Ajoute un suivi xAPI optionnel (bolt-on) au paquet SCORM exporté
Greffe l'envoi de statements xAPI vers un LRS configurable par le créateur du jeu, en parallèle du reporting SCORM existant : réglages stockés en base (_meta), formulaire dans la modale d'export, injection dans le paquet exporté. Relié aussi bien à l'action de flow "Modifier un score/statut" qu'au parcours quête/quiz (qui alimentait déjà le SCORM classique via un chemin séparé). L'export ne se lance plus automatiquement à l'ouverture de la modale, pour laisser le temps d'enregistrer les réglages xAPI avant de générer le paquet.
This commit is contained in:
@@ -62,6 +62,10 @@ function forgeApplyScoreActionOffline(gameData, action) {
|
||||
return false;
|
||||
}
|
||||
gameData.scoring.score = newValue;
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
|
||||
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
|
||||
// ou réglage xAPI jamais configuré pour ce jeu).
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,6 +74,7 @@ function forgeApplyStatusActionOffline(gameData, action) {
|
||||
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
gameData.scoring.status = status;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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", display: "completed" },
|
||||
reussi: { id: "http://adlnet.gov/expapi/verbs/passed", display: "passed" },
|
||||
echoue: { id: "http://adlnet.gov/expapi/verbs/failed", display: "failed" },
|
||||
};
|
||||
|
||||
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 } },
|
||||
};
|
||||
}
|
||||
|
||||
// verbId/verbDisplay : voir FORGE_TO_XAPI_VERB, ou directement les
|
||||
// verbes "initialized"/"terminated" (posés une seule fois chacun, voir
|
||||
// plus bas) — result : objet xAPI "result" optionnel (score/completion/
|
||||
// success), fusionné tel quel dans le statement.
|
||||
function forgeXapiSendStatement(verbId, verbDisplay, result) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
if (!config || !config.endpoint) return;
|
||||
var statement = {
|
||||
actor: forgeXapiActor(),
|
||||
verb: { id: verbId, display: { "en-US": verbDisplay } },
|
||||
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", "scored",
|
||||
{ score: { raw: newScore } }
|
||||
);
|
||||
}
|
||||
|
||||
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, verb.display, {
|
||||
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", "initialized");
|
||||
});
|
||||
window.addEventListener("beforeunload", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated", "terminated");
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB };
|
||||
}
|
||||
Reference in New Issue
Block a user