// 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. // 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). var FORGE_TO_XAPI_VERB = { termine: { id: "http://adlnet.gov/expapi/verbs/completed" }, reussi: { id: "http://adlnet.gov/expapi/verbs/passed" }, echoue: { id: "http://adlnet.gov/expapi/verbs/failed" }, }; // 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. var FORGE_XAPI_VERB_DISPLAY = { "http://adlnet.gov/expapi/verbs/initialized": { "en-US": "initialized", "fr-FR": "initialisé" }, "http://adlnet.gov/expapi/verbs/terminated": { "en-US": "terminated", "fr-FR": "quitté" }, "http://adlnet.gov/expapi/verbs/completed": { "en-US": "completed", "fr-FR": "terminé" }, "http://adlnet.gov/expapi/verbs/passed": { "en-US": "passed", "fr-FR": "réussi" }, "http://adlnet.gov/expapi/verbs/failed": { "en-US": "failed", "fr-FR": "échoué" }, "http://adlnet.gov/expapi/verbs/scored": { "en-US": "scored", "fr-FR": "noté" }, "http://adlnet.gov/expapi/verbs/attempted": { "en-US": "attempted", "fr-FR": "commencé" }, "http://adlnet.gov/expapi/verbs/answered": { "en-US": "answered", "fr-FR": "répondu" }, }; var 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) { var name = forgeScormApi.LMSGetValue("cmi.core.student_name"); var 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 */ } return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" }; } function forgeXapiObject() { var 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) : // une quête/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. function forgeXapiQuestObject(quest) { var config = window.FORGE_XAPI_CONFIG; return { objectType: "Activity", id: config.activity_id + ":quest:" + quest.id, definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" }, }; } function forgeXapiQuestionObject(quest, questionIndex, questionText) { var config = window.FORGE_XAPI_CONFIG; return { objectType: "Activity", id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex, definition: { name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") }, description: { "fr-FR": questionText || "" }, type: "http://adlnet.gov/expapi/activities/cmi.interaction", 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) { var config = window.FORGE_XAPI_CONFIG; if (!config || !config.endpoint) return; var 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; var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements"; 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(function (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", { score: { raw: newScore } } ); } // Granularité quête/quiz (voir dialogue-box-controller.js) : demande // explicite "il faut savoir quand une quête a été acceptée, à telle // question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ // par évènement, distinct du score/statut global (forgeXapiNotify // ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de // premier niveau). function forgeXapiNotifyQuestAccepted(quest) { if (!window.FORGE_XAPI_CONFIG) return; // "attempted" (ADL standard) : pas de verbe dédié "accepted" dans le // vocabulaire ADL de base — attempted rend fidèlement "le joueur s'est // engagé sur cette quête". forgeXapiSendStatement( "http://adlnet.gov/expapi/verbs/attempted", null, forgeXapiQuestObject(quest) ); } function forgeXapiNotifyQuestCompleted(quest) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( "http://adlnet.gov/expapi/verbs/completed", { completion: true }, forgeXapiQuestObject(quest) ); } function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( "http://adlnet.gov/expapi/verbs/answered", { success: correct, score: { raw: scoreAwarded || 0 } }, forgeXapiQuestionObject(quest, questionIndex, questionText) ); } function forgeXapiNotifyStatusChanged(newStatus) { if (!window.FORGE_XAPI_CONFIG) return; var 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", function () { if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized"); }); window.addEventListener("beforeunload", function () { if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated"); }); } if (typeof module !== "undefined" && module.exports) { module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB, forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered, forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY, }; }