Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c504ace167
commit
d664ed5637
+21
-29
@@ -1,11 +1,12 @@
|
||||
// Bout en bout : une règle de collision "quete" doit réellement faire
|
||||
// Bout en bout : une règle de collision "dialogue" doit réellement faire
|
||||
// apparaître/avancer/disparaître la boîte de dialogue posée sur la scène
|
||||
// — pas seulement chaque brique testée isolément (voir
|
||||
// collision-rules-controller.test.js et dialogue-box-controller.test.js).
|
||||
// Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle
|
||||
// "à la collision -> déclencher la quête « test »", une quête au statut
|
||||
// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur
|
||||
// la même scène.
|
||||
// "à la collision -> déclencher ce dialogue (avec mark_completed)", un
|
||||
// widget "💬 Boîte de dialogue" posé sur la même scène — plus aucun écran
|
||||
// d'offre automatique, le dialogue s'affiche directement et se marque
|
||||
// terminé à la fin.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -22,7 +23,6 @@ const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); //
|
||||
const {
|
||||
forgeCollisionRuleTick,
|
||||
} = require('../collision-rules-controller.js');
|
||||
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeEl(style) {
|
||||
const classes = new Set();
|
||||
@@ -43,11 +43,17 @@ function fakeEl(style) {
|
||||
};
|
||||
}
|
||||
|
||||
test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => {
|
||||
test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const mrPinguin = {
|
||||
id: 2, kind: 'personnage',
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }],
|
||||
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
|
||||
collision_rules: [{
|
||||
trigger: 'collision',
|
||||
action: {
|
||||
type: 'dialogue', mark_completed: true,
|
||||
dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const box = { id: 3, kind: 'dialogue_box' };
|
||||
|
||||
@@ -61,18 +67,10 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
boxWidget._footer = { innerHTML: '' };
|
||||
|
||||
global.window.currentScreenId = 1;
|
||||
global.window.forgeCompletedDialogueIds = {};
|
||||
global.gameData = {
|
||||
personnage_animations: {},
|
||||
quests: [{
|
||||
id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin',
|
||||
dialogues: {
|
||||
nouvelle: [
|
||||
{ speaker: 'Mr. Pinguin', text: 'Bonjour !' },
|
||||
{ speaker: 'Charlie', text: 'Salut !' },
|
||||
],
|
||||
en_cours: [], terminee: [],
|
||||
},
|
||||
}],
|
||||
completable_dialogue_ids: ['d_intro'],
|
||||
};
|
||||
|
||||
const previousDoc = global.document;
|
||||
@@ -94,12 +92,12 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
try {
|
||||
const screenData = { elements: [player, mrPinguin, box] };
|
||||
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche la quête
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche le dialogue
|
||||
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin');
|
||||
assert.equal(boxWidget._body.textContent, 'Bonjour !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas la quête
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget)
|
||||
@@ -107,14 +105,8 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
assert.equal(boxWidget._body.textContent, 'Salut !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie');
|
||||
assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin');
|
||||
assert.match(boxWidget._footer.innerHTML, /Accepter/);
|
||||
|
||||
forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter"
|
||||
assert.equal(forgeQuestById(99).status, 'en_cours');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Moteur d'exécution des règles "🧩 Collision" (voir
|
||||
// Moteur d'exécution des déclencheurs (voir
|
||||
// static/js/play/collision-rules-controller.js) : déclenche une action une
|
||||
// seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que
|
||||
// la condition reste vraie — et gère "interagir" à part (bulle + attente
|
||||
@@ -14,6 +14,7 @@ global.heldKeys = new Set();
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
|
||||
const {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
@@ -86,54 +87,117 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true);
|
||||
});
|
||||
|
||||
test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => {
|
||||
const objRect = rect(0, 0, 10, 10); // centre (5,5)
|
||||
const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true);
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => {
|
||||
const calls = [];
|
||||
global.window.dispatchGameEvent = id => calls.push(id);
|
||||
forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 });
|
||||
assert.deepEqual(calls, [42]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => {
|
||||
test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js) avec le nom de l\'objet, les répliques DE CETTE ACTION et mark_completed', () => {
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 });
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }];
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [42]);
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]);
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
|
||||
global.window.dispatchGameEvent = () => {};
|
||||
test('forgeRunCollisionRuleAction — "dialogue" sans dialogue (référence orpheline) : transmet une liste vide plutôt que de planter', () => {
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [[undefined, 'Crokine', [], undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_vrai', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '1' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_vrai', 'Crokine', vraiLines, undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_faux, jamais si_vrai', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '0' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } },
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_faux', 'Crokine', fauxLines, undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien faire" (null) ne fait rien', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '0' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } };
|
||||
forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '5');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } };
|
||||
const action = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' },
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
assert.equal(gameData.variables.vies.value, '2');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeShowDialogueBox (exécuté APRÈS le dialogue, pas ici)', () => {
|
||||
global.gameData = { variables: {} };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
const playerEl = fakeEl();
|
||||
const enemyEl = fakeEl();
|
||||
enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
const npcEl = fakeEl();
|
||||
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 1: playerEl, 2: enemyEl });
|
||||
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
|
||||
global.gameData = { personnage_animations: {} };
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
const calls = [];
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
try {
|
||||
const screenData = { elements: [player, enemy] };
|
||||
let attackCalls = 0;
|
||||
const originalRun = global.runSpriteAnimation;
|
||||
global.runSpriteAnimation = () => { attackCalls++; };
|
||||
const screenData = { elements: [player, npc] };
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||
global.runSpriteAnimation = originalRun;
|
||||
// "attaque" ne joue une animation que si le personnage a une pose
|
||||
// "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0
|
||||
// appel réel à runSpriteAnimation, mais le test vérifie surtout
|
||||
// l'absence de double déclenchement plutôt que l'appel lui-même.
|
||||
assert.equal(attackCalls, 0);
|
||||
} finally { global.document = previousDoc; }
|
||||
assert.deepEqual(calls, ['d_7']);
|
||||
} finally { global.document = previousDoc; global.forgeShowDialogueBox = previousShow; }
|
||||
});
|
||||
|
||||
test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas le cadre brut du sprite', () => {
|
||||
@@ -148,7 +212,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
// Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas
|
||||
// (offset_y positif = décalée vers le bas, voir collision_settings.py).
|
||||
collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 },
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 1 } } }],
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }],
|
||||
};
|
||||
const parent = fakeParentEl();
|
||||
const playerEl = fakeEl(parent);
|
||||
@@ -170,7 +234,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
|
||||
test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] };
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] };
|
||||
const parentNear = fakeParentEl();
|
||||
const parentFar = fakeParentEl();
|
||||
const playerElNear = fakeEl(parentNear);
|
||||
@@ -198,5 +262,5 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col
|
||||
|
||||
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir
|
||||
// static/js/play/dialogue-box-controller.js) : affiche la réplique en
|
||||
// cours du dialogue d'une quête (gameData.quests) correspondant à son
|
||||
// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin.
|
||||
// static/js/play/dialogue-box-controller.js) : affiche les répliques
|
||||
// D'UNE ACTION PRÉCISE (transmises en paramètre par collision-rules-
|
||||
// controller.js, chaque occurrence de "Déclencher ce dialogue" porte les
|
||||
// siennes, voir screens/rendering/collision_rules.py), avance au clic sur
|
||||
// "Suivant", se masque à la fin — sans écran d'offre automatique ni
|
||||
// notion de quête : le nom affiché (en-tête du quiz, xAPI) est celui de
|
||||
// l'objet déclencheur.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -9,10 +13,9 @@ global.window = global.window || {};
|
||||
global.gameData = {};
|
||||
|
||||
const {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
|
||||
forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm,
|
||||
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
@@ -43,50 +46,37 @@ test.beforeEach(() => {
|
||||
// fuiraient sur le calcul de statut final du test suivant.
|
||||
global.window.forgeQuizTotalAnswered = 0;
|
||||
global.window.forgeQuizCorrectAnswered = 0;
|
||||
global.gameData = {
|
||||
quests: [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
|
||||
{ id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } },
|
||||
{ id: 8, status: 'terminee', dialogues: { nouvelle: [], en_cours: [], terminee: [{ speaker: 'PNJ', text: 'Merci !' }] } },
|
||||
],
|
||||
};
|
||||
global.window.forgeCompletedDialogueIds = {};
|
||||
global.gameData = { completable_dialogue_ids: [] };
|
||||
});
|
||||
|
||||
test('forgeQuestById retrouve une quête par id', () => {
|
||||
assert.equal(forgeQuestById(5).id, 5);
|
||||
assert.equal(forgeQuestById(999), undefined);
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => {
|
||||
test('forgeShowDialogueBox — sans dialogueId : aucun effet (pas de plantage)', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(999));
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(null, 'PNJ', []));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => {
|
||||
test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle"
|
||||
forgeShowDialogueBox('d_6', 'PNJ', []);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => {
|
||||
global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} });
|
||||
test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dialogue terminé immédiatement, sans rien afficher', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(7);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu');
|
||||
assert.equal(widget._roles.body.textContent, 'Trouver le trésor');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
forgeShowDialogueBox('d_6', 'PNJ', [], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -94,16 +84,16 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(5));
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => {
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(8); // statut "terminee" -> pas d'offre à la fin, se masque normalement
|
||||
forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]);
|
||||
assert.equal(widget._roles.header.textContent, 'PNJ');
|
||||
assert.equal(widget._roles.body.textContent, 'Merci !');
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
@@ -113,48 +103,68 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => {
|
||||
test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : La quête du sorcier');
|
||||
assert.equal(widget._roles.body.textContent, 'Parler au sorcier');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxAcceptQuest — fait passer la quête "en_cours" (en mémoire seulement) et masque la boîte', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(forgeQuestById(5).status, 'en_cours');
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => {
|
||||
test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propage vers le SCORM', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.completable_dialogue_ids = ['d_5']; // seul dialogue connu
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxDeclineQuest(42);
|
||||
assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiatement l\'action chaînée (rien à exhiber avant)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousRun = global.forgeRunCollisionRuleAction;
|
||||
const calls = [];
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
assert.equal(calls[0][2].name, 'Crokine');
|
||||
} finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; }
|
||||
});
|
||||
|
||||
test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la dernière réplique, jamais avant', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousRun = global.forgeRunCollisionRuleAction;
|
||||
const calls = [];
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction);
|
||||
assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
} finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
@@ -206,36 +216,30 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
};
|
||||
}
|
||||
|
||||
const QUIZ_LINES = [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
];
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.gameData.quests.push({
|
||||
id: 10, title: 'Le quiz du sorcier', status: 'en_cours',
|
||||
dialogues: {
|
||||
nouvelle: [],
|
||||
en_cours: [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
],
|
||||
terminee: [],
|
||||
},
|
||||
});
|
||||
window.forgeQuizScore = 0;
|
||||
});
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal
|
||||
|
||||
forgeDialogueBoxAdvance(42); // -> ligne "question"
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
assert.equal(quizWidget._roles.header.textContent, 'Quête : Le quiz du sorcier');
|
||||
assert.equal(quizWidget._roles.header.textContent, 'Le sorcier');
|
||||
assert.equal(quizWidget._roles.question.textContent, '2+2 ?');
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/);
|
||||
@@ -248,7 +252,7 @@ test('mauvaise réponse au quiz : aucun point, MAIS avance quand même sur "Cont
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
|
||||
assert.equal(window.forgeQuizScore, 0); // aucun point
|
||||
@@ -271,7 +275,7 @@ test('un second clic pendant la révélation (avant "Continuer →") est ignoré
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
|
||||
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré
|
||||
@@ -287,7 +291,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES, true);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 1); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 5);
|
||||
@@ -298,8 +302,8 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), true);
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee"
|
||||
assert.equal(forgeQuestById(10).status, 'terminee');
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -313,7 +317,7 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante
|
||||
assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse
|
||||
@@ -333,9 +337,9 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
|
||||
});
|
||||
|
||||
// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ----------
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les quêtes devraient
|
||||
// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu
|
||||
// construit uniquement avec des quêtes/quiz ne touchait jamais
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
|
||||
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
|
||||
// un jeu construit uniquement avec des dialogues/quiz ne touchait jamais
|
||||
// gameData.scoring, laissant Score/Completion perpétuellement "unknown"
|
||||
// dans le LMS même après avoir fini le jeu.
|
||||
|
||||
@@ -366,42 +370,40 @@ test('forgeAddQuizScore ne rétrograde jamais un statut déjà plus avancé que
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('accepter une quête fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => {
|
||||
test('afficher un dialogue fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.scoring = undefined;
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]);
|
||||
assert.equal(gameData.scoring.status, 'en_cours');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => {
|
||||
global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }];
|
||||
test('forgeSyncAllDialoguesCompletionToScorm — passe "termine" seulement quand TOUS les dialogues completable sont completed', () => {
|
||||
global.gameData.completable_dialogue_ids = ['d_1', 'd_2'];
|
||||
window.forgeCompletedDialogueIds = { d_1: true };
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies
|
||||
|
||||
gameData.quests[1].status = 'terminee';
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
window.forgeCompletedDialogueIds.d_2 = true;
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
});
|
||||
|
||||
test('la dernière ligne d\'un dialogue "en_cours" propage la complétion à gameData.scoring si c\'est la seule quête', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.quests = [
|
||||
{ id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } },
|
||||
];
|
||||
test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () => {
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyDialogueCompleted = (id, name) => calls.push({ id, name });
|
||||
global.gameData.completable_dialogue_ids = ['d_20'];
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(20);
|
||||
forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee"
|
||||
assert.equal(forgeQuestById(20).status, 'terminee');
|
||||
forgeCompleteDialogue('d_20', 'Crokine');
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
} finally { global.document = previousDoc; }
|
||||
} finally { delete global.forgeXapiNotifyDialogueCompleted; }
|
||||
});
|
||||
|
||||
// ---------- Seuil de réussite (voir db/games/get_success_threshold.py) ----------
|
||||
@@ -433,31 +435,18 @@ test('forgeComputeFinalScormStatus — seuil par défaut (70%) si FORGE_SUCCESS_
|
||||
assert.equal(forgeComputeFinalScormStatus(), 'reussi');
|
||||
});
|
||||
|
||||
test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => {
|
||||
global.gameData.quests = [{ id: 1, status: 'terminee' }];
|
||||
test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "termine") une fois tous les dialogues finis', () => {
|
||||
global.gameData.completable_dialogue_ids = ['d_1'];
|
||||
window.forgeCompletedDialogueIds = { d_1: true };
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
global.window.forgeQuizTotalAnswered = 4;
|
||||
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
|
||||
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'echoue');
|
||||
});
|
||||
|
||||
// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ----------
|
||||
|
||||
test('accepter une quête notifie xAPI (verbe "attempted" ciblant CETTE quête)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.scoring = undefined;
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestAccepted = quest => calls.push(quest);
|
||||
try {
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].id, 5);
|
||||
} finally { global.document = previousDoc; delete global.forgeXapiNotifyQuestAccepted; }
|
||||
});
|
||||
// ---------- Granularité xAPI (dialogue terminé, question répondue) ----------
|
||||
|
||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
@@ -467,21 +456,19 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
global.gameData.quests = [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
|
||||
];
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestionAnswered = (quest, index, text, correct, score) => calls.push({ quest, index, text, correct, score });
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score });
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].quest.id, 5);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
assert.equal(calls[0].objectName, 'Le Sorcier');
|
||||
assert.equal(calls[0].correct, true);
|
||||
assert.equal(calls[0].score, 5);
|
||||
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique)
|
||||
@@ -496,22 +483,19 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
global.gameData.quests = [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
|
||||
];
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ quest, index, text, correct, choiceIndex, correctIndex });
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].quest.id, 5);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
assert.equal(calls[0].correct, false);
|
||||
assert.equal(calls[0].choiceIndex, 1);
|
||||
assert.equal(calls[0].correctIndex, 0);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// ---------- Éditeur de collision — moteur d'exécution en jeu ----------
|
||||
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
|
||||
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut
|
||||
// porter des règles "déclencheur -> action" posées dans l'onglet
|
||||
// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier
|
||||
// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre
|
||||
// la position du joueur, déclenche l'action une seule fois par entrée
|
||||
// (pas en continu tant que la condition reste vraie), même cadence que
|
||||
// personnage-controller.js (50ms) et réutilise ses mêmes briques
|
||||
// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||
// porter des règles "déclencheur -> action", posées depuis l'onglet
|
||||
// "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard).
|
||||
// Ce fichier les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque
|
||||
// règle contre la position du joueur, déclenche l'action une seule fois
|
||||
// par entrée (pas en continu tant que la condition reste vraie), même
|
||||
// cadence que personnage-controller.js (50ms) et réutilise ses mêmes
|
||||
// briques (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
|
||||
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
||||
// branchement FORGE_OFFLINE nécessaire ici.
|
||||
@@ -62,16 +62,10 @@ function forgeExpandRect(rect, margin) {
|
||||
|
||||
// "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour
|
||||
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
|
||||
// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes
|
||||
// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant
|
||||
// pour une portée approximative, cohérent avec le reste du moteur qui ne
|
||||
// vise pas une physique précise).
|
||||
// ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne
|
||||
// servait à rien pour l'instant, demande explicite).
|
||||
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
||||
if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
||||
var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2;
|
||||
var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2;
|
||||
var dist = Math.hypot(ocx - pcx, ocy - pcy);
|
||||
return dist <= (rule.perimetre_px || 0);
|
||||
return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
||||
}
|
||||
|
||||
// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un
|
||||
@@ -118,27 +112,50 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
// Exécute une action (top-level ou sub_action d'un "interagir") — jamais
|
||||
// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick
|
||||
// qui gère "interagir" à part (affichage de bulle + attente de touche).
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
// (affichage de bulle + attente de touche).
|
||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
if (action.type === 'evenement') {
|
||||
window.dispatchGameEvent(action.evenement_id);
|
||||
return;
|
||||
}
|
||||
if (action.type === 'attaque') {
|
||||
var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null;
|
||||
if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
||||
return;
|
||||
}
|
||||
if (action.type === 'quete') {
|
||||
// Affiche la réplique en cours du dialogue de cette quête, selon son
|
||||
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur
|
||||
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans
|
||||
// effet si aucun widget n'est posé ou si la quête n'a aucune
|
||||
// réplique pour son statut, comme le reste de ce moteur.
|
||||
forgeShowDialogueBox(action.quete_id);
|
||||
return;
|
||||
if (action.type === 'dialogue') {
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
||||
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
||||
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
|
||||
// de dialogue" posé sur l'écran (voir static/js/play/
|
||||
// dialogue-box-controller.js) — sans effet si aucun widget n'est
|
||||
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
|
||||
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
||||
// Informations") — plus de "quête" pour le porter. mark_completed
|
||||
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
||||
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||
// controller.js une fois le dialogue épuisé.
|
||||
var lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
var dialogueId = action.dialogue && action.dialogue.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
} else if (action.type === 'variable') {
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en
|
||||
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
|
||||
// philosophie que le reste de ce fichier). Instantané : `then`
|
||||
// s'exécute tout de suite, contrairement à un dialogue.
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'condition') {
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
|
||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
||||
var isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +253,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
||||
global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause;
|
||||
const personnageMod = require('./personnage-controller.js');
|
||||
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||
|
||||
@@ -1,55 +1,58 @@
|
||||
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
|
||||
// Fait le lien entre l'éditeur de collision (action "quete", voir
|
||||
// Fait le lien entre l'éditeur de déclencheurs (action "dialogue", voir
|
||||
// static/js/play/collision-rules-controller.js) et les widgets d'interface
|
||||
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||
// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la
|
||||
// quête (gameData.quests, voir full_game_payload.py) ligne par ligne —
|
||||
// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton
|
||||
// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir
|
||||
// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si
|
||||
// posée sur la scène — sinon ignorée silencieusement, la conversation
|
||||
// avance sans elle) : cliquer la BONNE réponse crédite le score du
|
||||
// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la
|
||||
// question reste affichée (le joueur peut réessayer).
|
||||
// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
|
||||
// (action.dialogue.lines, transmises telles quelles par
|
||||
// collision-rules-controller.js — chaque occurrence de "Déclencher ce
|
||||
// dialogue" porte son propre dialogue, voir screens/rendering/
|
||||
// collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche
|
||||
// dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne
|
||||
// "question" ("❓ Question", voir static/js/triggers/trigger-editor.js)
|
||||
// s'affiche dans la boîte à QUIZ (si posée sur la scène — sinon ignorée
|
||||
// silencieusement, la conversation avance sans elle) : cliquer la BONNE
|
||||
// réponse crédite le score du montant prévu et avance ; une mauvaise
|
||||
// réponse ne fait RIEN, la question reste affichée (le joueur peut
|
||||
// réessayer).
|
||||
//
|
||||
// Une fois le dialogue "nouvelle" épuisé : la boîte bascule en écran
|
||||
// d'OFFRE ("Quête : <titre>" / objectif / boutons Accepter-Refuser) au
|
||||
// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser
|
||||
// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle",
|
||||
// la prochaine interaction rejoue exactement le même dialogue). Une fois
|
||||
// le dialogue "en_cours" épuisé (donc, s'il contenait des questions,
|
||||
// toutes répondues correctement), la quête passe "terminee" — le joueur
|
||||
// peut alors quitter la scène normalement (aucun blocage, ces widgets ne
|
||||
// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours
|
||||
// (gameData.quests n'est qu'un instantané par joueur, voir
|
||||
// full_game_payload.py) : jamais persisté en base, qui garde le statut
|
||||
// de DÉPART pour toute nouvelle partie.
|
||||
// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
|
||||
// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
|
||||
// globales et conditions (voir screens/rendering/collision_rules.py::
|
||||
// "condition") — le dialogue choisi s'affiche directement. Une action
|
||||
// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
|
||||
// (ou immédiatement s'il est vide), son id est ajouté à
|
||||
// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
|
||||
// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
|
||||
// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
|
||||
// dialogues `mark_completed` du jeu joués, voir
|
||||
// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
|
||||
// lui-même reste un réglage du créateur totalement indépendant
|
||||
// (db/games/get_success_threshold.py), inchangé ici.
|
||||
//
|
||||
// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié
|
||||
// à ce système de quête/quiz — distinct du score "classique" du moteur
|
||||
// à ce système de dialogue/quiz — distinct du score "classique" du moteur
|
||||
// document (db/scoring/, action de flow "Modifier un score"), qui
|
||||
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors
|
||||
// de propos pour ce sous-système entièrement client (voir
|
||||
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
|
||||
// serveur").
|
||||
//
|
||||
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
|
||||
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur.
|
||||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||
// l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||
// uniquement à calculer un taux de réussite global (voir
|
||||
// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore
|
||||
// forgeComputeFinalScormStatus), distinct de window.forgeQuizScore
|
||||
// qui ne compte que les points gagnés.
|
||||
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
|
||||
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
|
||||
|
||||
function forgeQuestById(questId) {
|
||||
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
||||
}
|
||||
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
|
||||
// forgeSyncAllDialoguesCompletionToScorm.
|
||||
window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
@@ -78,9 +81,8 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
// "Suivant →", voir render_scene_object.py) — nécessaire pour effacer
|
||||
// les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer
|
||||
// un nouveau dialogue dans la même boîte.
|
||||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||
// affichée (voir forgeShowCurrentConversationLine).
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
@@ -110,11 +112,11 @@ function forgeUpdateAllScoreWidgets() {
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
// apply-actions.js, même forme {score, status}) — bug signalé : "les
|
||||
// indicateurs qu'on utilise pour les quêtes devraient aussi servir aux
|
||||
// indicateurs qu'on utilise pour les dialogues devraient aussi servir aux
|
||||
// indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que
|
||||
// static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser
|
||||
// cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y
|
||||
// toucher, un jeu construit uniquement avec des quêtes/quiz (jamais
|
||||
// toucher, un jeu construit uniquement avec des dialogues/quiz (jamais
|
||||
// l'action de flow "Modifier un score") laissait Score/Completion
|
||||
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
|
||||
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
|
||||
@@ -124,32 +126,36 @@ function forgeSyncQuizScoreToScorm() {
|
||||
gameData.scoring.score = window.forgeQuizScore;
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
|
||||
// quête/quiz contourne static/js/play/offline/apply-actions.js (aucune
|
||||
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
|
||||
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
|
||||
// dialogue/quiz contourne static/js/play/offline/apply-actions.js
|
||||
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||
// SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Une quête acceptée = la partie a commencé, même sans le moindre point
|
||||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||||
// encore gagné — sans cet appel, Score restait "0" mais Completion
|
||||
// restait "unknown" tant qu'aucune question n'avait encore été résolue.
|
||||
function forgeSyncQuestStartedToScorm() {
|
||||
// Appelé depuis forgeShowDialogueBox.
|
||||
function forgeSyncDialogueStartedToScorm() {
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
}
|
||||
|
||||
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues
|
||||
// sont "terminee" — règle simple et prévisible plutôt que de deviner
|
||||
// laquelle des quêtes créées "compte" pour la fin du jeu.
|
||||
// Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||||
// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
|
||||
// marqués `mark_completed` (voir gameData.completable_dialogue_ids,
|
||||
// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
|
||||
// été joués (voir forgeAdvanceConversation) — règle simple et
|
||||
// prévisible plutôt que de deviner lequel des dialogues créés "compte"
|
||||
// pour la fin du jeu. Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||||
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
|
||||
// build_scorm_package.py) : demande utilisateur — "il manque
|
||||
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
|
||||
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
|
||||
// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les
|
||||
// quêtes terminées" qui est déjà la seule condition de fin de partie.
|
||||
// Aucune question posée du tout (jeu fait uniquement de dialogues) :
|
||||
// reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
|
||||
// les dialogues terminés" qui est déjà la seule condition de fin de
|
||||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
function forgeComputeFinalScormStatus() {
|
||||
var total = window.forgeQuizTotalAnswered || 0;
|
||||
if (total === 0) return 'termine';
|
||||
@@ -158,9 +164,10 @@ function forgeComputeFinalScormStatus() {
|
||||
return percent >= threshold ? 'reussi' : 'echoue';
|
||||
}
|
||||
|
||||
function forgeSyncAllQuestsCompletionToScorm() {
|
||||
var quests = gameData.quests || [];
|
||||
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
|
||||
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
|
||||
if (!allDone) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
var finalStatus = forgeComputeFinalScormStatus();
|
||||
gameData.scoring.status = finalStatus;
|
||||
@@ -187,7 +194,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line, state.index);
|
||||
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
|
||||
return;
|
||||
}
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
@@ -197,30 +204,54 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
||||
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
||||
// comme le reste du moteur 2D).
|
||||
function forgeShowDialogueBox(questId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (!quest) return;
|
||||
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
|
||||
// Appelée par l'action "dialogue" (collision-rules-controller.js) —
|
||||
// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
|
||||
// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
|
||||
// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
|
||||
// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
|
||||
// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
|
||||
// le porter. `markCompleted` : voir forgeAdvanceConversation ci-dessous.
|
||||
// `thenAction` (chaînage, voir screens/rendering/collision_rules.py::
|
||||
// "then") : l'action suivante, exécutée seulement une fois ce dialogue
|
||||
// épuisé — jamais avant, contrairement à une action "variable" qui,
|
||||
// elle, chaîne immédiatement (voir collision-rules-controller.js).
|
||||
function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) {
|
||||
if (!dialogueId) return;
|
||||
lines = lines || [];
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
// Aucune ligne posée pour ce statut : une quête "nouvelle" propose
|
||||
// quand même directement l'offre (accepter/refuser), les autres
|
||||
// statuts restent silencieux (rien à montrer).
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
// immédiatement (rien à exhiber avant), sinon silencieux.
|
||||
if (markCompleted) forgeCompleteDialogue(dialogueId, objectName);
|
||||
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
||||
forgeDialogueBoxState[objectId] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(objectId);
|
||||
}
|
||||
|
||||
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
|
||||
// le créateur pilote lui-même quand, via la case "Marquer ce dialogue
|
||||
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
|
||||
function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
window.forgeCompletedDialogueIds[dialogueId] = true;
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
}
|
||||
|
||||
// Avance d'une ligne (fin de dialogue -> question, fin de question
|
||||
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par
|
||||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer).
|
||||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer). À l'épuisement des
|
||||
// lignes : markCompleted PUIS thenAction (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite : "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||||
// dialogue est fini", jamais avant.
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
@@ -228,20 +259,8 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
var quest = forgeQuestById(state.questId);
|
||||
if (quest && quest.status === 'nouvelle') {
|
||||
if (widget) forgeShowQuestOffer(widget, dialogueObjectId, quest);
|
||||
return;
|
||||
}
|
||||
// Tout le dialogue "en_cours" épuisé (donc, s'il contenait des
|
||||
// questions, toutes répondues correctement) -> quête terminée. Voir
|
||||
// demande utilisateur : "quand le joueur a répondu à toutes les
|
||||
// questions la quête passe au statut terminé".
|
||||
if (quest && quest.status === 'en_cours') {
|
||||
quest.status = 'terminee';
|
||||
if (typeof forgeXapiNotifyQuestCompleted === "function") forgeXapiNotifyQuestCompleted(quest);
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
}
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[dialogueObjectId];
|
||||
return;
|
||||
@@ -260,23 +279,23 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
}
|
||||
|
||||
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||||
// l'écran — header = "Quête : <titre>", corps = la question + ses choix
|
||||
// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz,
|
||||
// la question est ignorée (avance directement) plutôt que de bloquer la
|
||||
// conversation pour toujours en attendant une réponse qu'aucun widget ne
|
||||
// peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, quest, line, questionIndex) {
|
||||
// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
|
||||
// Informations"), corps = la question + ses choix — si l'auteur n'a posé
|
||||
// aucune boîte à quiz, la question est ignorée (avance directement)
|
||||
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||||
// réponse qu'aucun widget ne peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
|
||||
quest: quest, questionIndex: questionIndex, questionText: line.text,
|
||||
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
|
||||
};
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (header) header.textContent = 'Quête : ' + (quest ? quest.title : '');
|
||||
if (header) header.textContent = objectName || '';
|
||||
if (questionEl) questionEl.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
@@ -320,13 +339,13 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
forgeAddQuizScore(state.rewardAmount);
|
||||
}
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) {
|
||||
forgeXapiNotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function" && state.quest) {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
@@ -354,58 +373,16 @@ function forgeQuizBoxContinue(quizObjectId) {
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
}
|
||||
|
||||
// ---------- Offre de quête (Accepter/Refuser) ----------
|
||||
|
||||
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
||||
// Accepter/Refuser au lieu du "Suivant" habituel.
|
||||
function forgeShowQuestOffer(widget, objectId, quest) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (header) header.textContent = 'Quête : ' + quest.title;
|
||||
if (body) body.textContent = quest.objectif || '';
|
||||
if (footer) {
|
||||
footer.innerHTML =
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxAcceptBtn" onclick="forgeDialogueBoxAcceptQuest(' + quest.id + ', ' + objectId + ')">Accepter</button>' +
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxDeclineBtn" onclick="forgeDialogueBoxDeclineQuest(' + objectId + ')">Refuser</button>';
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 };
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Fait passer la quête "en_cours" — UNIQUEMENT dans l'instantané en
|
||||
// mémoire (gameData.quests), jamais persisté en base : voir la note en
|
||||
// tête de fichier (le statut en base est celui de DÉPART pour toute
|
||||
// nouvelle partie, pas un état de partie en cours).
|
||||
function forgeDialogueBoxAcceptQuest(questId, objectId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (quest) quest.status = 'en_cours';
|
||||
forgeSyncQuestStartedToScorm();
|
||||
if (quest && typeof forgeXapiNotifyQuestAccepted === "function") forgeXapiNotifyQuestAccepted(quest);
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// Referme la boîte SANS toucher au statut de la quête (reste "nouvelle")
|
||||
// — la prochaine interaction rejoue donc le même dialogue depuis le
|
||||
// début : impossible d'avancer la quête sans un jour l'accepter.
|
||||
function forgeDialogueBoxDeclineQuest(objectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que les autres
|
||||
// contrôleurs de ce dossier.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0);
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'quest:5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.type'], 'choice');
|
||||
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?');
|
||||
@@ -134,7 +134,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0);
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
@@ -147,5 +147,5 @@ test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004
|
||||
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
|
||||
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
|
||||
// ceci lèverait en tentant d'appeler .GetValue sur null.
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0));
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered('d_1', 'x', 0, 'x', true, 0, 0));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la
|
||||
// granularité par quête/question demandée par l'utilisateur (quête
|
||||
// acceptée/terminée, réponse à une question), en plus du score/statut
|
||||
// global déjà couvert par dialogue-box-controller.test.js.
|
||||
// granularité par dialogue/question demandée par l'utilisateur (dialogue
|
||||
// terminé, réponse à une question), en plus du score/statut global déjà
|
||||
// couvert par dialogue-box-controller.test.js.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -10,8 +10,8 @@ global.window.addEventListener = global.window.addEventListener || function () {
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject,
|
||||
} = require('../xapi-client.js');
|
||||
|
||||
test.beforeEach(() => {
|
||||
@@ -21,45 +21,35 @@ test.beforeEach(() => {
|
||||
};
|
||||
});
|
||||
|
||||
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
|
||||
const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
|
||||
test('forgeXapiDialogueObject construit un id de sous-activité dérivé de l\'activité du jeu, nommé d\'après l\'objet déclencheur', () => {
|
||||
const obj = forgeXapiDialogueObject('d_5', 'Le sorcier');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'Le sorcier');
|
||||
});
|
||||
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2');
|
||||
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => {
|
||||
test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant le dialogue (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(calls.length, 1);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 1, 'Qui es-tu ?', true, 5);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5:question:1');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
|
||||
assert.equal(body.result.success, true);
|
||||
assert.equal(body.result.score.raw, 5);
|
||||
});
|
||||
@@ -67,7 +57,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 0);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.result.success, false);
|
||||
assert.equal(body.result.score.raw, 0);
|
||||
@@ -76,18 +66,17 @@ test('une mauvaise réponse envoie quand même un statement, avec success=false
|
||||
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.display['en-US'], 'attempted');
|
||||
assert.equal(body.verb.display['fr-FR'], 'commencé');
|
||||
assert.equal(body.verb.display['en-US'], 'completed');
|
||||
assert.equal(body.verb.display['fr-FR'], 'terminé');
|
||||
});
|
||||
|
||||
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
|
||||
global.window.FORGE_XAPI_CONFIG = null;
|
||||
let called = false;
|
||||
global.fetch = () => { called = true; return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'x');
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function forgeScormInit() {
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/quests/reward_budget.py) : sans eux, le LMS n'a que le score BRUT
|
||||
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
|
||||
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
|
||||
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
|
||||
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
|
||||
|
||||
@@ -93,12 +93,12 @@ function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(quest, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
if (!forgeScorm2004Api) return;
|
||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||
if (isNaN(n)) n = 0;
|
||||
var prefix = "cmi.interactions." + n + ".";
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "quest:" + quest.id + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "type", "choice");
|
||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
||||
|
||||
@@ -73,26 +73,28 @@ function forgeXapiObject() {
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
function forgeXapiQuestObject(quest) {
|
||||
// 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) {
|
||||
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" },
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(quest, questionIndex, questionText) {
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex,
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") },
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
@@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(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) {
|
||||
// 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",
|
||||
{ completion: true }, forgeXapiQuestObject(quest)
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) {
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, 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)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +193,7 @@ if (typeof window !== "undefined") {
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user