Files
Forge-Engine/static/js/play/offline/xapi-client.js
T
william c504ace167
Build and deploy / test-python (push) Successful in 5m56s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Enrichit le reporting SCORM/xAPI et met en conformité RGAA le player
SCORM/xAPI :
- Ajoute l'export SCORM 2004 (3rd/4th edition) au choix, en plus du 1.2
  par défaut : sépare completion_status/success_status (un échec reste
  "completed" au lieu de retomber à tort en "incomplete" comme force la
  1.2), remonte aussi cmi.interactions.n.* par question répondue.
- Fournit cmi.core.score.min/max (1.2 et 2004), calculé depuis les
  récompenses de quiz du jeu, pour que le LMS affiche un vrai pourcentage
  au lieu du score brut à tort étiqueté "%".
- Libellés de verbes xAPI en français en plus de l'anglais.

Accessibilité (RGAA/WCAG 2.1 AA) sur le player :
- Navigation clavier des objets de scène "au clic"/"au survol"
  (tabindex, role, Entrée/Espace, focus/blur).
- alt sur les images (nom auteur ou décoratif), aria-hidden sur les
  icônes seules, role="dialog"/aria-live sur les boîtes de dialogue/quiz.
- Landmark <main> + titre de page, respect de prefers-reduced-motion.
- Le quiz n'avance plus automatiquement après un délai fixe : bouton
  "Continuer →" explicite (RGAA 2.2.1).
- Avertissement de contraste dans l'éditeur de style de dialogue.
- Déclaration d'accessibilité téléchargeable depuis la modale d'export.
2026-09-06 00:17:51 +02:00

210 lines
9.1 KiB
JavaScript

// 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,
};
}