Files
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

217 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/game/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/game/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/game/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,
};
}