Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Build and deploy / test-python (push) Successful in 8m45s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-09-07 22:05:06 +02:00
co-authored by Claude Sonnet 5
parent c504ace167
commit d664ed5637
62 changed files with 3129 additions and 2266 deletions
@@ -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);