// Export Web/SCORM — bolt-on xAPI (voir publish/build_scorm_package.py, // db/games/get_xapi_settings.py) : EN PLUS du SCORM classique // (scorm-api.js, jamais touché par ce fichier), envoie des statements // xAPI directement au LRS configuré par le créateur du jeu. Chargé // TOUJOURS dans le paquet exporté (même sans réglage xAPI — voir // templates/play.html), mais ne fait RIEN tant que // window.FORGE_XAPI_CONFIG est `null` (réglage jamais renseigné). // // Volontairement PAS le protocole cmi5 (pas de négociation de jeton au // lancement, pas de découverte d'endpoint via l'URL du LMS) : l'URL du // LRS et les identifiants sont saisis une fois pour toutes par le // créateur (voir routes/publish/xapi_settings.py) et embarqués tels // quels dans le paquet — un LRS quelconque suffit, jamais besoin que le // LMS du client supporte cmi5 lui-même. // // Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS // bloquer ni ralentir la partie du joueur. // // NOSONAR S5332 (toutes occurrences "http://adlnet.gov/..." de ce fichier // + les 2 assertions de __tests__/xapi-client.test.js) : identifiants du // vocabulaire xAPI standard ADL, jamais dérérencés/appelés en réseau (de // simples chaînes comparées/embarquées) — le "http://" fait partie de la // chaîne fixée par la spec, jamais "https://" (changer casserait // l'interopérabilité avec n'importe quel LRS). Le VRAI endpoint réseau // (voir forgeXapiSendStatement plus bas) est toujours `config.endpoint`, // saisi par le créateur (routes/publish/xapi_settings.py), jamais un // littéral de ce fichier. // Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts // qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne // génèrent rien ici (bruit inutile pour un suivi volontairement léger, // voir le plan). const FORGE_TO_XAPI_VERB = { termine: { id: 'http://adlnet.gov/expapi/verbs/completed' }, // NOSONAR S5332 reussi: { id: 'http://adlnet.gov/expapi/verbs/passed' }, // NOSONAR S5332 echoue: { id: 'http://adlnet.gov/expapi/verbs/failed' }, // NOSONAR S5332 }; // Libellé humain par verbe — l'id xAPI (http://adlnet.gov/expapi/verbs/...) // reste TOUJOURS en anglais (identifiant technique fixé par le // vocabulaire ADL, jamais traduit), mais le libellé affiché par le LRS // peut porter plusieurs langues : demande utilisateur "c'est possible en // français ?" — un LRS qui préfère le français (ou n'importe quelle // langue ici) affichera la bonne entrée au lieu de rester bloqué sur // l'anglais. const FORGE_XAPI_VERB_DISPLAY = { 'http://adlnet.gov/expapi/verbs/initialized': { 'en-US': 'initialized', 'fr-FR': 'initialisé' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/terminated': { 'en-US': 'terminated', 'fr-FR': 'quitté' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/completed': { 'en-US': 'completed', 'fr-FR': 'terminé' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/passed': { 'en-US': 'passed', 'fr-FR': 'réussi' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/failed': { 'en-US': 'failed', 'fr-FR': 'échoué' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/scored': { 'en-US': 'scored', 'fr-FR': 'noté' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/attempted': { 'en-US': 'attempted', 'fr-FR': 'commencé' }, // NOSONAR S5332 'http://adlnet.gov/expapi/verbs/answered': { 'en-US': 'answered', 'fr-FR': 'répondu' }, // NOSONAR S5332 }; let forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement // Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par // scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas, // notre propre handler "load" s'enregistre APRÈS le sien, donc after lui) // si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique. function forgeXapiActor() { try { if (typeof forgeScormApi !== 'undefined' && forgeScormApi) { const name = forgeScormApi.LMSGetValue('cmi.core.student_name'); const id = forgeScormApi.LMSGetValue('cmi.core.student_id'); if (id) { return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } }; } } } catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ } // NOSONAR S2486, CODE_QUALITY.md return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' }; } function forgeXapiObject() { const config = window.FORGE_XAPI_CONFIG; return { objectType: 'Activity', id: config.activity_id, definition: { name: { 'fr-FR': config.activity_name } }, }; } // Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) : // un dialogue/question n'est PAS l'activité de premier niveau du jeu // (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un // id dérivé — assez pour que le LRS les distingue sans configuration // supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet // de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de // "quête" pour le porter. function forgeXapiDialogueObject(dialogueId, objectName) { const config = window.FORGE_XAPI_CONFIG; return { objectType: 'Activity', id: `${config.activity_id}:dialogue:${dialogueId}`, definition: { name: { 'fr-FR': objectName || 'Dialogue' }, type: 'http://adlnet.gov/expapi/activities/simulation' }, // NOSONAR S5332 }; } function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) { const config = window.FORGE_XAPI_CONFIG; return { objectType: 'Activity', id: `${config.activity_id}:dialogue:${dialogueId}:question:${questionIndex}`, definition: { name: { 'fr-FR': `Question ${questionIndex + 1} — ${objectName || 'Dialogue'}` }, description: { 'fr-FR': questionText || '' }, type: 'http://adlnet.gov/expapi/activities/cmi.interaction', // NOSONAR S5332 interactionType: 'choice', }, }; } // verbId : voir FORGE_TO_XAPI_VERB/FORGE_XAPI_VERB_DISPLAY (le libellé // affiché est dérivé automatiquement du verbe, jamais passé en argument) // — result : objet xAPI "result" optionnel (score/completion/success), // fusionné tel quel dans le statement. object : l'activité visée par CE // statement — l'activité de premier niveau du jeu par défaut // (forgeXapiObject), ou une sous-activité quête/question // (forgeXapiQuestObject/forgeXapiQuestionObject) pour un suivi plus fin. function forgeXapiSendStatement(verbId, result, object) { const config = window.FORGE_XAPI_CONFIG; if (!config || !config.endpoint) return; const statement = { actor: forgeXapiActor(), verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { 'en-US': verbId } }, object: object || forgeXapiObject(), timestamp: new Date().toISOString(), }; if (result) statement.result = result; const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`; // NOSONAR S8786 - un seul quantificateur sur 1 litteral, teste jusqu'a 1M car. sans blowup (voir CODE_QUALITY.md) try { fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3', Authorization: `Basic ${btoa(`${config.login}:${config.password}`)}`, }, body: JSON.stringify(statement), }).catch((err) => { console.warn('xAPI: envoi du statement échoué (LRS injoignable ?)', err); }); } catch (err) { console.warn("xAPI: impossible d'envoyer le statement", err); } } // Rappelées directement par static/js/play/offline/apply-actions.js après // chaque mutation RÉELLE (jamais un sondage périodique, contrairement à // scorm-api.js::forgeScormSyncScore) — un seul statement par changement. function forgeXapiNotifyScoreChanged(newScore) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( 'http://adlnet.gov/expapi/verbs/scored', // NOSONAR S5332 { score: { raw: newScore } }, ); } // Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande // explicite "il faut savoir [...] à telle question quel point obtenu, // bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du // score/statut global (forgeXapiNotifyScoreChanged/StatusChanged // ci-dessus, qui restent l'activité de premier niveau). function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( 'http://adlnet.gov/expapi/verbs/completed', // NOSONAR S5332 { completion: true }, forgeXapiDialogueObject(dialogueId, objectName), ); } function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( 'http://adlnet.gov/expapi/verbs/answered', // NOSONAR S5332 { success: correct, score: { raw: scoreAwarded || 0 } }, forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText), ); } function forgeXapiNotifyStatusChanged(newStatus) { if (!window.FORGE_XAPI_CONFIG) return; const verb = FORGE_TO_XAPI_VERB[newStatus]; if (!verb || newStatus === forgeXapiLastStatusSent) return; forgeXapiLastStatusSent = newStatus; forgeXapiSendStatement(verb.id, { completion: true, success: newStatus !== 'echoue', }); } if (typeof window !== 'undefined') { window.addEventListener('load', () => { if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/initialized'); // NOSONAR S5332 }); window.addEventListener('beforeunload', () => { if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/terminated'); // NOSONAR S5332 }); } if (typeof module !== 'undefined' && module.exports) { module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB, forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered, forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY, }; }