Quiz jouable : budget de récompense, boîte à quiz, widget score
Build and deploy / test-python (push) Failing after 1m34s
Build and deploy / test-js (push) Successful in 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme
  des points des "❓ Question" ne peut plus dépasser recompense_score
  (routes/quests/quest_dialogues.py à l'enregistrement des dialogues,
  quest_update.py si on abaisse la récompense sous ce qui est déjà
  réparti) — message d'erreur explicite (400) dans les deux sens,
  affiché via alert() côté éditeur (quest-editor.js), jamais enregistré
  silencieusement dans un état incohérent.
- Nombre de choix par question plafonné à 4 (au lieu de 8).

- Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte
  de dialogue" — screens/rendering/dialogue_box_style.py, même panneau
  "🎨 Style") :
  - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une
    question se résout au clic sur un choix, pas de "Suivant").
  - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible
    une fois posé (contrairement aux boîtes de dialogue/quiz, masquées
    par défaut).

- Moteur d'exécution (static/js/play/dialogue-box-controller.js,
  refonte) : une conversation de quête alterne maintenant répliques
  (boîte de dialogue) et questions (boîte à quiz) selon le type de
  chaque ligne. Bonne réponse -> crédite le score (compteur runtime
  dédié, jamais lié au score du moteur document) et avance ; mauvaise
  réponse -> rien, la question reste affichée pour réessayer ; aucune
  boîte à quiz posée -> la question est ignorée plutôt que de bloquer
  la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait
  des questions, toutes répondues) fait passer la quête "terminee" (en
  mémoire seulement, comme le reste de ce système — voir accepter/
  refuser une offre de quête) : le joueur peut alors quitter la scène
  normalement, ces widgets n'étant jamais des obstacles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-03 15:42:42 +02:00
co-authored by Claude Sonnet 5
parent e972acf9e6
commit b5546df044
19 changed files with 628 additions and 76 deletions
@@ -11,6 +11,7 @@ global.gameData = {};
const {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets,
} = require('../dialogue-box-controller.js');
function fakeWidget(objectId) {
@@ -154,3 +155,126 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
assert.doesNotThrow(() => forgeDialogueBoxAdvance(999));
} finally { global.document = previousDoc; }
});
// ---------- Enchaînement dialogue + question ("❓ Question") ----------
function fakeQuizWidget(objectId) {
const roles = { header: { textContent: '' }, choices: { innerHTML: '' } };
const classes = new Set();
return {
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
querySelector: sel => {
if (sel === '[data-quiz-role="header"]') return roles.header;
if (sel === '[data-quiz-role="choices"]') return roles.choices;
return null;
},
_roles: roles, _classes: classes,
};
}
function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
return {
querySelector(sel) {
if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null;
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null; // forgeQuizBoxWidgetById
if (sel.indexOf('.dialogueBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return dialogueWidget;
if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById
return null;
},
querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests
};
}
test.beforeEach(() => {
global.gameData.quests.push({
id: 10, 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, pas la boîte de dialogue', () => {
const dialogueWidget = fakeWidget(42);
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
const quizWidget = fakeQuizWidget(99);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
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, '2+2 ?');
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/);
} finally { global.document = previousDoc; }
});
test('mauvaise réponse au quiz : aucun effet, la question reste affichée', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
forgeDialogueBoxAdvance(42); // -> question
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
assert.equal(quizWidget._classes.has('is-active'), true); // reste affichée
assert.equal(window.forgeQuizScore, 0); // aucun point
} finally { global.document = previousDoc; }
});
test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la ligne suivante', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
forgeDialogueBoxAdvance(42); // -> question
forgeQuizBoxAnswer(99, 1); // bonne réponse
assert.equal(window.forgeQuizScore, 5);
assert.equal(quizWidget._classes.has('is-active'), false);
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');
assert.equal(dialogueWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => {
const dialogueWidget = fakeWidget(42);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
try {
forgeShowDialogueBox(10);
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
} finally { global.document = previousDoc; }
});
test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => {
const values = [{ textContent: '' }, { textContent: '' }];
const previousDoc = global.document;
window.forgeQuizScore = 42;
global.document = { querySelectorAll: () => values };
try {
forgeUpdateAllScoreWidgets();
assert.equal(values[0].textContent, 42);
assert.equal(values[1].textContent, 42);
} finally { global.document = previousDoc; }
});