- Lot 1 (S8786, ReDoS) : 5 sites documentes NOSONAR apres preuve empirique (script reproductible docs/redos_probe_s8786.js), aucune reecriture defensive necessaire. - Lot 2 (S2703, variable globale implicite) : bug reel trouve et corrige (SCENE_OBJECT_NAMES en const au lieu de let, cassait la reassignation cross-script depuis scene-editor.js) + test de non-regression ; 4 autres sites confirmes surs et documentes. - Lot 3 (S2486, exceptions avalees) : 6 sites confirmes surs et documentes ; 2 sites (config sprite JSON invalide) corriges avec un console.warn devtools, comportement joueur inchange, couverts par un nouveau test. - Retrait du job CI sonarqube (.gitea/workflows/deploy.yml) et du service prod sonarqube/sonar-postgres (docker-compose.prod.yml) : acces dashboard bloque par des soucis d'infrastructure reseau (WSL2/pare-feu Hyper-V en local, reseau Docker partage avec Caddy pas en place en prod), sans lien avec le code du moteur - mis de cote plutot que de continuer a bloquer sur de l'infra. Les lots 4+ de modernisation JS dependent de scores Sonar exacts et sont donc egalement en pause (voir CODE_QUALITY.md). SKIP=djlint : H021 (styles inline, 49 occurrences) est un backlog deja documente et assume (CODE_QUALITY.md section 6), sur des templates non touches par ce commit - deja exclu de la CI pour la meme raison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
217 lines
10 KiB
JavaScript
217 lines
10 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.
|
||
//
|
||
// 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,
|
||
};
|
||
}
|