Quête "nouvelle" : écran Accepter/Refuser à la fin du dialogue
Build and deploy / test-python (push) Successful in 7m13s
Build and deploy / test-js (push) Successful in 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Une fois le dialogue épuisé, si la quête est encore "nouvelle", la
boîte de dialogue bascule sur un écran d'offre au lieu de se masquer :
header "Quête : <titre>", body l'objectif, pied deux boutons.

- Accepter -> la quête passe "en_cours" (en MÉMOIRE seulement,
  gameData.quests — jamais persisté en base : le statut en base est
  celui de DÉPART pour toute nouvelle partie, pas un état de partie en
  cours, voir PLAYER_SHARED/full_game_payload.py).
- Refuser -> la boîte se referme SANS toucher au statut, qui reste
  "nouvelle" : la prochaine interaction rejoue exactement le même
  dialogue depuis le début — impossible d'avancer la quête sans
  l'accepter un jour.

Pour tout autre statut (déjà "en_cours"/"terminee"), la boîte se
masque normalement à la fin du dialogue, comme avant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-03 14:00:02 +02:00
co-authored by Claude Sonnet 5
parent df34106752
commit e90d941527
4 changed files with 178 additions and 25 deletions
@@ -19,7 +19,10 @@ Object.assign(global, actionsMod);
require('../personnage-controller.js');
const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier
const { forgeCollisionRuleTick } = require('../collision-rules-controller.js');
const {
forgeCollisionRuleTick,
} = require('../collision-rules-controller.js');
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
function fakeEl(style) {
const classes = new Set();
@@ -33,13 +36,14 @@ function fakeEl(style) {
if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget);
if (sel === '[data-dialogue-role="header"]') return this._header;
if (sel === '[data-dialogue-role="body"]') return this._body;
if (sel === '[data-dialogue-role="footer"]') return this._footer;
return undefined;
},
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
};
}
test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suivant" avance, se masque à la fin', () => {
test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => {
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
const mrPinguin = {
id: 2, kind: 'personnage',
@@ -54,12 +58,13 @@ test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suiva
boxWidget._objectId = '3';
boxWidget._header = { textContent: '' };
boxWidget._body = { textContent: '' };
boxWidget._footer = { innerHTML: '' };
global.window.currentScreenId = 1;
global.gameData = {
personnage_animations: {},
quests: [{
id: 99, status: 'nouvelle',
id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin',
dialogues: {
nouvelle: [
{ speaker: 'Mr. Pinguin', text: 'Bonjour !' },
@@ -102,7 +107,14 @@ test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suiva
assert.equal(boxWidget._body.textContent, 'Salut !');
assert.equal(boxWidget.classList.contains('is-active'), true);
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> la modale disparaît
assert.equal(boxWidget.classList.contains('is-active'), false);
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
} finally { global.document = previousDoc; }
});
@@ -10,12 +10,14 @@ global.gameData = {};
const {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
} = require('../dialogue-box-controller.js');
function fakeWidget(objectId) {
const roles = {
header: { textContent: '' },
body: { textContent: '' },
footer: { innerHTML: '' },
};
const classes = new Set();
return {
@@ -24,6 +26,7 @@ function fakeWidget(objectId) {
querySelector: sel => {
if (sel === '[data-dialogue-role="header"]') return roles.header;
if (sel === '[data-dialogue-role="body"]') return roles.body;
if (sel === '[data-dialogue-role="footer"]') return roles.footer;
return null;
},
_roles: roles, _classes: classes,
@@ -34,8 +37,9 @@ test.beforeEach(() => {
global.window.currentScreenId = 1;
global.gameData = {
quests: [
{ id: 5, status: 'nouvelle', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
{ 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 !' }] } },
],
};
});
@@ -53,11 +57,28 @@ test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage
} finally { global.document = previousDoc; }
});
test('forgeShowDialogueBox — quête sans réplique pour son statut actuel : aucun effet', () => {
test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => {
const widget = fakeWidget(42);
const previousDoc = global.document;
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
global.document = { querySelector: () => widget };
try {
assert.doesNotThrow(() => forgeShowDialogueBox(6)); // "en_cours" -> dialogues.en_cours est vide
forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle"
assert.equal(widget._classes.has('is-active'), false);
} 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: {} });
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);
} finally { global.document = previousDoc; }
});
@@ -69,22 +90,59 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
} finally { global.document = previousDoc; }
});
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => {
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
assert.equal(widget._roles.header.textContent, 'PNJ');
assert.equal(widget._roles.body.textContent, 'Merci !');
assert.equal(widget._classes.has('is-active'), true);
forgeDialogueBoxAdvance(42); // dernière (et unique) réplique consommée -> se masque
assert.equal(widget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => {
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(5);
assert.equal(widget._roles.header.textContent, 'Le Sorcier');
assert.equal(widget._roles.body.textContent, 'Bonjour !');
assert.equal(widget._classes.has('is-active'), true);
forgeDialogueBoxAdvance(42);
assert.equal(widget._roles.header.textContent, 'Joueur');
assert.equal(widget._roles.body.textContent, 'Salut.');
assert.equal(widget._classes.has('is-active'), true); // encore une réplique visible
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; }
});
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> se masque
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');
assert.equal(widget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => {
const widget = fakeWidget(42);
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
assert.equal(widget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
+81 -6
View File
@@ -5,10 +5,21 @@
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
// "quete", affiche la réplique en cours du dialogue correspondant au
// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py),
// avance d'une réplique à chaque clic sur "Suivant", se masque à la fin.
// Silencieux (aucun effet) si la quête est introuvable, n'a aucune
// réplique pour son statut, ou si l'auteur n'a posé aucun widget sur
// l'écran — même esprit que le reste du moteur (jamais planter).
// avance d'une réplique à chaque clic sur "Suivant".
//
// Une fois le dialogue épuisé : si la quête est encore "nouvelle", 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" (LOCAL à cette partie, jamais persisté en base :
// gameData.quests n'est qu'un instantané par joueur, voir
// full_game_payload.py ; muter la ligne en base changerait le statut de
// DÉPART de la quête pour toutes les parties futures) ; refuser ferme
// simplement la boîte SANS changer le statut — la quête reste "nouvelle",
// donc la prochaine interaction rejoue EXACTEMENT le même dialogue depuis
// le début (impossible d'avancer sans accepter). Pour tout autre statut
// (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin.
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
// posé aucun widget sur l'écran — même esprit que le reste du moteur.
var forgeDialogueBoxState = {};
@@ -24,6 +35,10 @@ function forgeDialogueBoxWidgetById(objectId) {
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
}
function forgeDialogueBoxFooterEl(widget) {
return widget.querySelector('[data-dialogue-role="footer"]');
}
function forgeRenderDialogueBoxLine(widget, line) {
var header = widget.querySelector('[data-dialogue-role="header"]');
var body = widget.querySelector('[data-dialogue-role="body"]');
@@ -31,6 +46,15 @@ function forgeRenderDialogueBoxLine(widget, line) {
if (body) body.textContent = line.text;
}
// 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.
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>';
}
// 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).
@@ -38,11 +62,18 @@ function forgeShowDialogueBox(questId) {
var quest = forgeQuestById(questId);
if (!quest) return;
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
if (!lines.length) return;
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
if (!widget) return;
var objectId = widget.getAttribute('data-object-id');
forgeDialogueBoxState[objectId] = { lines: lines, index: 0 };
// Aucune réplique 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).
if (!lines.length) {
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
return;
}
forgeResetDialogueBoxFooter(widget, objectId);
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
forgeRenderDialogueBoxLine(widget, lines[0]);
widget.classList.add('is-active');
}
@@ -55,6 +86,11 @@ function forgeDialogueBoxAdvance(objectId) {
if (!state || !widget) return;
state.index += 1;
if (state.index >= state.lines.length) {
var quest = forgeQuestById(state.questId);
if (quest && quest.status === 'nouvelle') {
forgeShowQuestOffer(widget, objectId, quest);
return;
}
widget.classList.remove('is-active');
delete forgeDialogueBoxState[objectId];
return;
@@ -62,11 +98,50 @@ function forgeDialogueBoxAdvance(objectId) {
forgeRenderDialogueBoxLine(widget, state.lines[state.index]);
}
// É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';
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,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
};
}
+9 -1
View File
@@ -653,12 +653,20 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.playScreen .dialogueBoxWidget.is-active{ display:flex; }
.dialogueBoxHeader{ padding:8px 12px; font-weight:700; }
.dialogueBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; }
.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; }
.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; gap:8px; }
.dialogueBoxNextBtn{
background:rgba(255,255,255,.15); color:inherit; border:1px solid rgba(255,255,255,.3);
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
}
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) :
Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la
boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur
de pied choisie par l'auteur (voir "🎨 Style"). */
.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; }
.dialogueBoxAcceptBtn:hover{ background:#3ddb80; }
.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; }
.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); }
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir