From e972acf9e65fbf06765682cbb48f1776ef828a7f Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 14:48:14 +0200 Subject: [PATCH] =?UTF-8?q?Qu=C3=AAtes=20:=20bulle=20"=E2=9D=93=20Question?= =?UTF-8?q?"=20=C3=A0=20choix=20multiples,=20=C3=A0=20c=C3=B4t=C3=A9=20de?= =?UTF-8?q?=20"+=20R=C3=A9plique"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau type de ligne dans un dialogue de quête (voir db/quests/sanitize_quest_dialogues.py) : une bulle JAUNE, dans la même chaîne verticale que les répliques (même alignement, reliée par un trait) — header : nombre de choix (2 à 8) + récompense (score, montant en points) ; body : la question, ses choix et un bouton radio pour désigner la bonne réponse. Aucune limite au nombre de questions par colonne, comme pour une réplique. Les lignes de dialogue portent maintenant explicitement {"type": "dialogue", ...} (au lieu d'un objet sans type) pour distinguer les deux formes — migration de forme, tests mis à jour. Portée de ce commit : l'ÉDITEUR (créer/modifier une question). Le jeu lui-même (afficher la question au joueur, vérifier la réponse, attribuer la récompense) reste à construire — le widget "💬 Boîte de dialogue" ne sait aujourd'hui afficher qu'une réplique. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 4 +- db/quests/sanitize_quest_dialogues.py | 89 +++++++++++++++----- static/js/quests/quest-editor.js | 116 +++++++++++++++++++++++++- static/style.css | 29 ++++++- tests/test_quests.py | 38 +++++++-- 5 files changed, 242 insertions(+), 34 deletions(-) diff --git a/db/__init__.py b/db/__init__.py index 35b2eb84..e3874e41 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -87,7 +87,7 @@ from .quests.create_quest import create_quest from .quests.update_quest import update_quest from .quests.delete_quest import delete_quest from .quests.set_quest_dialogues import set_quest_dialogues -from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues +from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", @@ -108,5 +108,5 @@ __all__ = [ "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "list_quests", "get_quest", "create_quest", "update_quest", "delete_quest", - "set_quest_dialogues", "sanitize_quest_dialogues", + "set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", ] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index aa3a9708..d17828af 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -3,18 +3,73 @@ from .constants import QUEST_STATUS_CHOICES _MAX_LINES_PER_COLUMN = 200 _MAX_SPEAKER_LENGTH = 60 +# Question à choix (voir "❓ Question", static/js/quests/quest-editor.js) : +# une bulle jaune posée dans le même enchaînement qu'une réplique de +# dialogue — plusieurs choix, une seule bonne réponse, une récompense. +# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/ +# db.set_score_value) — liste plutôt qu'un booléen pour pouvoir en ajouter +# d'autres plus tard sans changer la forme des données. +QUESTION_REWARD_TYPES = ["score"] +_MIN_CHOICES = 2 +_MAX_CHOICES = 8 + + +def _sanitize_dialogue_line(raw_line): + speaker = raw_line.get("speaker") + text = raw_line.get("text") + if not isinstance(speaker, str) or not speaker.strip(): + return None + if not isinstance(text, str) or not text.strip(): + return None + return {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()} + + +def _sanitize_question_line(raw_line): + text = raw_line.get("text") + if not isinstance(text, str) or not text.strip(): + return None + raw_choices = raw_line.get("choices") + if not isinstance(raw_choices, list): + return None + choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES] + if len(choices) < _MIN_CHOICES: + return None + try: + correct_index = int(raw_line.get("correct_index")) + except (TypeError, ValueError): + return None + if not (0 <= correct_index < len(choices)): + return None + reward_type = raw_line.get("reward_type") + if reward_type not in QUESTION_REWARD_TYPES: + reward_type = QUESTION_REWARD_TYPES[0] + try: + reward_amount = max(0, int(raw_line.get("reward_amount", 0))) + except (TypeError, ValueError): + reward_amount = 0 + return { + "type": "question", "text": text.strip(), "choices": choices, + "correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount, + } + + +def _sanitize_line(raw_line): + if not isinstance(raw_line, dict): + return None + if raw_line.get("type") == "question": + return _sanitize_question_line(raw_line) + return _sanitize_dialogue_line(raw_line) + def sanitize_quest_dialogues(raw_dialogues): - """Une réplique = {speaker: '', text: '...'} — le - nom vient du champ "ℹ️ Informations" d'un objet de scène kind= - "personnage" (voir screens/rendering/scene_object_names.py), ou - "Joueur" (toujours proposé, voir static/js/quests/quest-editor.js) : - texte libre côté serveur (jamais un enum fixe — n'importe quel nom - donné à un personnage doit être accepté), juste borné en longueur. - Une colonne par statut de quête (voir QUEST_STATUS_CHOICES), même - esprit que resolve_collision_rules.sanitize_collision_rules : rejette - tout élément invalide plutôt que de lever, ne garde jamais un texte - ou un nom vide.""" + """Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de + répliques de dialogue ({type: "dialogue", speaker, text} — le nom + vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur", + voir screens/rendering/scene_object_names.py) ET/OU de questions à + choix ({type: "question", text, choices, correct_index, reward_type, + reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit + que resolve_collision_rules.sanitize_collision_rules : rejette tout + élément invalide plutôt que de lever.""" result = {status: [] for status in QUEST_STATUS_CHOICES} if not isinstance(raw_dialogues, dict): return result @@ -22,16 +77,6 @@ def sanitize_quest_dialogues(raw_dialogues): raw_lines = raw_dialogues.get(status) if not isinstance(raw_lines, list): continue - cleaned = [] - for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]: - if not isinstance(raw_line, dict): - continue - speaker = raw_line.get("speaker") - text = raw_line.get("text") - if not isinstance(speaker, str) or not speaker.strip(): - continue - if not isinstance(text, str) or not text.strip(): - continue - cleaned.append({"speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}) - result[status] = cleaned + cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]] + result[status] = [line for line in cleaned if line] return result diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index a32b685e..0f16fb83 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -231,14 +231,66 @@ function questDialogueBubbleHtml(line, status, index) { ); } +function questEscAttr(str) { + return (str || '').replace(/"/g, '"'); +} + +// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de +// dialogue (même colonne, même alignement vertical relié par un trait, +// voir .questBubbleRow/.questBubble ci-dessus et static/style.css) mais +// un type de ligne différent (voir db/quests/sanitize_quest_dialogues.py) : +// header = nombre de choix + récompense (type + montant), body = la +// question et ses choix, avec la bonne réponse cochée. Aucune limite au +// nombre de questions par colonne (voir questAddQuestion), comme pour +// une réplique. +function questQuestionChoiceRowHtml(choice, choiceIndex, correctIndex, status, index) { + var radioName = 'questCorrect-' + status + '-' + index; + return ( + '
' + + '' + + '' + + '
' + ); +} + +function questQuestionBubbleHtml(line, status, index) { + var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join(''); + return ( + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
' + choicesHtml + '
' + + '
' + + '
' + + '
' + ); +} + function questDialogueColumnHtml(quest, status) { var lines = (quest.dialogues && quest.dialogues[status]) || []; - var bubblesHtml = lines.map(function (line, i) { return questDialogueBubbleHtml(line, status, i); }).join(''); + var bubblesHtml = lines.map(function (line, i) { + return line.type === 'question' ? questQuestionBubbleHtml(line, status, i) : questDialogueBubbleHtml(line, status, i); + }).join(''); return ( '
' + '

' + QUEST_STATUS_LABELS[status] + '

' + '
' + bubblesHtml + '
' + - '' + + '
' + + '' + + '' + + '
' + '
' ); } @@ -351,11 +403,69 @@ function questAddBubble(status) { quest.dialogues = quest.dialogues || {}; var lines = quest.dialogues[status] || (quest.dialogues[status] = []); var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ'; - lines.push({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' }); + lines.push({ type: 'dialogue', speaker: questNextDefaultSpeaker(lastSpeaker), text: '' }); renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } +// "❓ Question" — voir questQuestionBubbleHtml ci-dessus. Défauts : 2 +// choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt +// (l'auteur règle le montant dans le header de la bulle). +function questAddQuestion(status) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues = quest.dialogues || {}; + var lines = quest.dialogues[status] || (quest.dialogues[status] = []); + lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 }); + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questUpdateQuestionText(status, index, text) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].text = text; + questSaveDialogues(quest); +} + +// Ajuste le NOMBRE de choix (2 à 8, voir db/quests/sanitize_quest_dialogues.py +// ::_MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les +// derniers, sans jamais toucher aux choix déjà remplis qui restent dans +// la nouvelle taille. Recadre la bonne réponse sur le premier choix si +// elle pointait vers un choix retiré. +function questSetQuestionChoiceCount(status, index, value) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + var line = quest.dialogues[status][index]; + var count = Math.max(2, Math.min(8, parseInt(value, 10) || 2)); + while (line.choices.length < count) line.choices.push(''); + while (line.choices.length > count) line.choices.pop(); + if (line.correct_index >= line.choices.length) line.correct_index = 0; + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questSetQuestionChoiceText(status, index, choiceIndex, text) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].choices[choiceIndex] = text; + questSaveDialogues(quest); +} + +function questSetQuestionCorrectIndex(status, index, choiceIndex) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].correct_index = choiceIndex; + questSaveDialogues(quest); +} + +function questSetQuestionRewardAmount(status, index, value) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].reward_amount = Math.max(0, parseInt(value, 10) || 0); + questSaveDialogues(quest); +} + function questSetBubbleSpeaker(status, index, speaker) { var quest = questById(_currentQuestModalId()); if (!quest) return; diff --git a/static/style.css b/static/style.css index 151257ac..0ec232e7 100644 --- a/static/style.css +++ b/static/style.css @@ -854,7 +854,9 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d CETTE zone défile (flex:1 + overflow-y:auto) — le titre de colonne et le bouton "+ Réplique" restent fixes, en haut/en bas. */ .questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; padding-top:4px; } -.questDialogueAddBtn{ flex:0 0 auto; align-self:center; padding:6px 14px; font-size:12px; margin-top:4px; } +.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; margin-top:4px; } +.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } +.questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); } .questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; } .questBubbleRow:not(:first-child)::before{ @@ -874,6 +876,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); } .questBubble-joueur .questBubbleHeader{ background:#1e9e57; } .questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); } +/* "❓ Question" (voir questQuestionBubbleHtml, static/js/quests/ + quest-editor.js) — bulle JAUNE distincte des répliques de dialogue, + même chaîne/alignement vertical (.questBubbleRow). */ +.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; } +.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); } +.questQuestionHeaderField{ + display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap; +} +.questQuestionHeaderField input{ + width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px; + color:#fff; font:inherit; font-size:11px; padding:2px 4px; +} +.questQuestionText{ + width:100%; background:rgba(0,0,0,.2); border:1px solid var(--border); border-radius:6px; + color:#fff; font:inherit; font-size:13px; padding:6px 8px; margin-bottom:8px; +} +.questQuestionText::placeholder{ color:rgba(255,255,255,.5); } +.questChoicesList{ display:flex; flex-direction:column; gap:6px; } +.questChoiceRow{ display:flex; align-items:center; gap:6px; } +.questChoiceRadio{ accent-color:#e0b93d; flex-shrink:0; } +.questChoiceInput{ + flex:1; background:rgba(0,0,0,.2); border:1px solid var(--border); border-radius:6px; + color:#fff; font:inherit; font-size:12.5px; padding:4px 8px; +} +.questChoiceInput::placeholder{ color:rgba(255,255,255,.5); } .questBubbleSpeaker{ font-size:11px; font-weight:700; color:#fff; cursor:pointer; background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; flex:1; diff --git a/tests/test_quests.py b/tests/test_quests.py index a5045ad3..8d485e19 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -86,19 +86,45 @@ def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status(): } cleaned = db.sanitize_quest_dialogues(raw) assert cleaned == { - "nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}], + "nouvelle": [{"type": "dialogue", "speaker": "Le Sorcier", "text": "Bonjour !"}], "en_cours": [], "terminee": [], } +def test_sanitize_quest_dialogues_accepts_a_valid_question_and_rejects_malformed_ones(): + """"❓ Question" (voir static/js/quests/quest-editor.js) : une bulle + jaune avec plusieurs choix, une seule bonne réponse, une récompense — + même chaîne qu'une réplique de dialogue, un autre "type" de ligne.""" + raw = { + "nouvelle": [ + {"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2}, + {"type": "question", "text": "Une seule option", "choices": ["seule"], "correct_index": 0}, # < 2 choix -> rejetée + {"type": "question", "text": "index hors bornes", "choices": ["a", "b"], "correct_index": 5}, # rejetée + {"type": "question", "text": " ", "choices": ["a", "b"], "correct_index": 0}, # texte vide -> rejetée + {"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "vie"}, + ], + "en_cours": [], "terminee": [], + } + cleaned = db.sanitize_quest_dialogues(raw) + assert cleaned["nouvelle"] == [ + {"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2}, + # reward_type invalide -> repli sur le premier type connu ("score"), jamais rejetée pour ça seul + {"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "score", "reward_amount": 0}, + ] + + def test_set_quest_dialogues_persists_all_three_columns(tmp_game_slug_cleanup): slug = _make_game(tmp_game_slug_cleanup) quest = db.create_quest(slug, "Trouver la clé") dialogues = { - "nouvelle": [{"speaker": "pnj", "text": "Peux-tu m'aider ?"}, {"speaker": "joueur", "text": "Bien sûr !"}], - "en_cours": [{"speaker": "pnj", "text": "Alors, cette clé ?"}], - "terminee": [{"speaker": "pnj", "text": "Merci beaucoup !"}], + "nouvelle": [ + {"type": "dialogue", "speaker": "pnj", "text": "Peux-tu m'aider ?"}, + {"type": "dialogue", "speaker": "joueur", "text": "Bien sûr !"}, + {"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_type": "score", "reward_amount": 2}, + ], + "en_cours": [{"type": "dialogue", "speaker": "pnj", "text": "Alors, cette clé ?"}], + "terminee": [{"type": "dialogue", "speaker": "pnj", "text": "Merci beaucoup !"}], } db.set_quest_dialogues(slug, quest["id"], dialogues) reloaded = db.get_quest(slug, quest["id"]) @@ -159,8 +185,8 @@ def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_clea data='{"dialogues": {"nouvelle": [{"speaker": "pnj", "text": "Salut !"}], "en_cours": [], "terminee": []}}', content_type="application/json", ) - assert resp.get_json()["dialogues"]["nouvelle"] == [{"speaker": "pnj", "text": "Salut !"}] - assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"speaker": "pnj", "text": "Salut !"}] + assert resp.get_json()["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}] + assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}] def test_quest_dialogues_route_404s_for_missing_quest(client, game):