Quiz jouable : budget de récompense, boîte à quiz, widget score
- 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:
co-authored by
Claude Sonnet 5
parent
e972acf9e6
commit
b5546df044
+2
-1
@@ -88,6 +88,7 @@ from .quests.update_quest import update_quest
|
|||||||
from .quests.delete_quest import delete_quest
|
from .quests.delete_quest import delete_quest
|
||||||
from .quests.set_quest_dialogues import set_quest_dialogues
|
from .quests.set_quest_dialogues import set_quest_dialogues
|
||||||
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES
|
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES
|
||||||
|
from .quests.reward_budget import quest_dialogues_total_reward
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||||
@@ -108,5 +109,5 @@ __all__ = [
|
|||||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
||||||
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
|
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
|
||||||
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
||||||
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES",
|
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
def quest_dialogues_total_reward(dialogues):
|
||||||
|
"""Somme des récompenses de toutes les questions ("❓ Question", voir
|
||||||
|
sanitize_quest_dialogues.py), TOUTES colonnes/statuts confondus — une
|
||||||
|
quête n'a qu'UNE SEULE récompense totale (quest["recompense_score"]),
|
||||||
|
jamais une par statut. Utilisée pour empêcher d'enregistrer plus de
|
||||||
|
points répartis entre les questions que la quête n'en offre (voir
|
||||||
|
routes/quests/quest_dialogues.py et quest_update.py)."""
|
||||||
|
total = 0
|
||||||
|
for lines in (dialogues or {}).values():
|
||||||
|
if not isinstance(lines, list):
|
||||||
|
continue
|
||||||
|
for line in lines:
|
||||||
|
if isinstance(line, dict) and line.get("type") == "question":
|
||||||
|
total += line.get("reward_amount", 0) or 0
|
||||||
|
return total
|
||||||
@@ -11,7 +11,7 @@ _MAX_SPEAKER_LENGTH = 60
|
|||||||
# d'autres plus tard sans changer la forme des données.
|
# d'autres plus tard sans changer la forme des données.
|
||||||
QUESTION_REWARD_TYPES = ["score"]
|
QUESTION_REWARD_TYPES = ["score"]
|
||||||
_MIN_CHOICES = 2
|
_MIN_CHOICES = 2
|
||||||
_MAX_CHOICES = 8
|
_MAX_CHOICES = 4
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_dialogue_line(raw_line):
|
def _sanitize_dialogue_line(raw_line):
|
||||||
|
|||||||
@@ -9,9 +9,26 @@ from core.flask_app import app
|
|||||||
def quest_dialogues(slug, quest_id):
|
def quest_dialogues(slug, quest_id):
|
||||||
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
|
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
|
||||||
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
|
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
|
||||||
complet à chaque modification plutôt qu'une réplique isolée."""
|
complet à chaque modification plutôt qu'une réplique isolée.
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
|
Refusé (400) si la somme des récompenses des questions ("❓ Question")
|
||||||
if dialogues is None:
|
dépasse la récompense totale de la quête (recompense_score) — voir
|
||||||
|
db.quest_dialogues_total_reward. Validé ICI (avant persistance)
|
||||||
|
plutôt que dans db.set_quest_dialogues, pour renvoyer un message
|
||||||
|
d'erreur explicite au lieu d'enregistrer silencieusement un état
|
||||||
|
incohérent."""
|
||||||
|
quest = db.get_quest(slug, quest_id)
|
||||||
|
if quest is None:
|
||||||
return jsonify({"error": "quête introuvable"}), 404
|
return jsonify({"error": "quête introuvable"}), 404
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
sanitized = db.sanitize_quest_dialogues(body.get("dialogues"))
|
||||||
|
allocated = db.quest_dialogues_total_reward(sanitized)
|
||||||
|
if allocated > quest["recompense_score"]:
|
||||||
|
return jsonify({
|
||||||
|
"error": (
|
||||||
|
f"La somme des récompenses des questions ({allocated} pts) dépasse "
|
||||||
|
f"la récompense totale de la quête ({quest['recompense_score']} pts)."
|
||||||
|
),
|
||||||
|
}), 400
|
||||||
|
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
|
||||||
return jsonify({"dialogues": dialogues})
|
return jsonify({"dialogues": dialogues})
|
||||||
|
|||||||
@@ -7,7 +7,29 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
|
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
|
||||||
def quest_update(slug, quest_id):
|
def quest_update(slug, quest_id):
|
||||||
|
"""Refusé (400) si "recompense_score" est abaissé SOUS le total déjà
|
||||||
|
réparti entre les questions ("❓ Question") du dialogue de cette
|
||||||
|
quête — voir db.quest_dialogues_total_reward, même contrainte que
|
||||||
|
routes/quests/quest_dialogues.py mais dans l'autre sens (ici c'est le
|
||||||
|
budget total qui bouge, pas la répartition)."""
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
if "recompense_score" in body:
|
||||||
|
quest = db.get_quest(slug, quest_id)
|
||||||
|
if quest is None:
|
||||||
|
return jsonify({"error": "quête introuvable"}), 404
|
||||||
|
try:
|
||||||
|
new_score = int(body["recompense_score"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
new_score = None
|
||||||
|
if new_score is not None:
|
||||||
|
allocated = db.quest_dialogues_total_reward(quest["dialogues"])
|
||||||
|
if new_score < allocated:
|
||||||
|
return jsonify({
|
||||||
|
"error": (
|
||||||
|
f"La récompense totale ({new_score} pts) est inférieure aux "
|
||||||
|
f"{allocated} pts déjà répartis entre les questions du dialogue."
|
||||||
|
),
|
||||||
|
}), 400
|
||||||
quest = db.update_quest(slug, quest_id, body)
|
quest = db.update_quest(slug, quest_id, body)
|
||||||
if quest is None:
|
if quest is None:
|
||||||
return jsonify({"error": "quête introuvable"}), 404
|
return jsonify({"error": "quête introuvable"}), 404
|
||||||
|
|||||||
@@ -28,14 +28,15 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
collision_settings = None
|
collision_settings = None
|
||||||
dialogue_box_style = None
|
dialogue_box_style = None
|
||||||
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
|
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
|
||||||
|
_UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget")
|
||||||
if selected_obj:
|
if selected_obj:
|
||||||
if selected_obj["kind"] != "dialogue_box":
|
if selected_obj["kind"] not in _UI_WIDGET_KINDS:
|
||||||
collision_settings = screens.resolve_collision_settings(selected_obj)
|
collision_settings = screens.resolve_collision_settings(selected_obj)
|
||||||
if selected_obj["kind"] == "personnage":
|
if selected_obj["kind"] == "personnage":
|
||||||
personnage_data = screens.resolve_personnage_data(selected_obj)
|
personnage_data = screens.resolve_personnage_data(selected_obj)
|
||||||
personnage_commands = screens.resolve_personnage_commands(selected_obj)
|
personnage_commands = screens.resolve_personnage_commands(selected_obj)
|
||||||
personnage_role = screens.resolve_personnage_role(selected_obj)
|
personnage_role = screens.resolve_personnage_role(selected_obj)
|
||||||
elif selected_obj["kind"] == "dialogue_box":
|
elif selected_obj["kind"] in _UI_WIDGET_KINDS:
|
||||||
dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj)
|
dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj)
|
||||||
|
|
||||||
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||||
@@ -56,7 +57,7 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
|
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
|
||||||
collision_rule_objects = []
|
collision_rule_objects = []
|
||||||
for o in objects:
|
for o in objects:
|
||||||
if o["kind"] in ("fond", "dialogue_box"):
|
if o["kind"] in ("fond", "dialogue_box", "quiz_box", "score_widget"):
|
||||||
continue
|
continue
|
||||||
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
|
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
|
||||||
if role == "joueur":
|
if role == "joueur":
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
if o["kind"] == "personnage":
|
if o["kind"] == "personnage":
|
||||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||||
o["personnage_role"] = resolve_personnage_role(o)
|
o["personnage_role"] = resolve_personnage_role(o)
|
||||||
if o["kind"] == "dialogue_box":
|
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
|
||||||
# Widget d'interface (voir "🖥️ Interface",
|
# Widgets d'interface (voir "🖥️ Interface",
|
||||||
# screens/rendering/dialogue_box_style.py) : ni
|
# screens/rendering/dialogue_box_style.py) : ni
|
||||||
# collision, ni cible/source de règle — juste son
|
# collision, ni cible/source de règle — juste leur
|
||||||
# style, lu par static/js/play/dialogue-box-controller.js.
|
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||||
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import db
|
|||||||
from .ensure_scene_schema import ensure_scene_schema
|
from .ensure_scene_schema import ensure_scene_schema
|
||||||
|
|
||||||
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
||||||
_KINDS = {"personnage", "decor", "fond", "dialogue_box"}
|
_KINDS = {"personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"}
|
||||||
# Widget d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) :
|
# Widgets d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) :
|
||||||
# assez grand pour lire une réplique sans redimensionner, jamais confondu
|
# assez grands pour lire une réplique/question sans redimensionner,
|
||||||
# visuellement avec un personnage/décor (128×128 par défaut).
|
# jamais confondus visuellement avec un personnage/décor (128×128 par
|
||||||
|
# défaut). "score_widget" reste petit — juste un nombre.
|
||||||
_DIALOGUE_BOX_DEFAULT_SIZE = (320, 150)
|
_DIALOGUE_BOX_DEFAULT_SIZE = (320, 150)
|
||||||
|
_QUIZ_BOX_DEFAULT_SIZE = (340, 240)
|
||||||
|
_SCORE_WIDGET_DEFAULT_SIZE = (140, 50)
|
||||||
|
|
||||||
|
|
||||||
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
|
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
|
||||||
@@ -46,6 +49,10 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
|||||||
width, height = background["width"], background["height"]
|
width, height = background["width"], background["height"]
|
||||||
elif kind == "dialogue_box":
|
elif kind == "dialogue_box":
|
||||||
width, height = _DIALOGUE_BOX_DEFAULT_SIZE
|
width, height = _DIALOGUE_BOX_DEFAULT_SIZE
|
||||||
|
elif kind == "quiz_box":
|
||||||
|
width, height = _QUIZ_BOX_DEFAULT_SIZE
|
||||||
|
elif kind == "score_widget":
|
||||||
|
width, height = _SCORE_WIDGET_DEFAULT_SIZE
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
if kind == "fond":
|
if kind == "fond":
|
||||||
z_index = -1000
|
z_index = -1000
|
||||||
|
|||||||
@@ -65,6 +65,50 @@ def render_scene_object(obj):
|
|||||||
f'<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance({obj["id"]})">Suivant →</button>'
|
f'<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance({obj["id"]})">Suivant →</button>'
|
||||||
f'</div></div>'
|
f'</div></div>'
|
||||||
)
|
)
|
||||||
|
if obj["kind"] == "quiz_box":
|
||||||
|
# Widget "❓ Boîte à quiz" (voir "🖥️ Interface") — réutilise le
|
||||||
|
# MÊME style que "💬 Boîte de dialogue" (resolve_dialogue_box_style/
|
||||||
|
# set_scene_object_dialogue_box_style, une seule "🎨 Style" pour
|
||||||
|
# les deux) : header = la question, corps = les choix de réponse
|
||||||
|
# (boutons remplis EN JEU par static/js/play/dialogue-box-controller.js,
|
||||||
|
# data-quiz-role sert de sélecteur) — pas de pied (une question se
|
||||||
|
# résout au clic sur un choix, pas de "Suivant"). Masqué par
|
||||||
|
# défaut au jeu, comme la boîte de dialogue.
|
||||||
|
style = resolve_dialogue_box_style(obj)
|
||||||
|
box_style = (
|
||||||
|
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
||||||
|
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
|
||||||
|
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
|
||||||
|
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'<div class="quizBoxWidget" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||||
|
f'<div class="quizBoxHeader" data-quiz-role="header" style="background:{html_lib.escape(style["header_bg"])}">La question s\'affiche ici.</div>'
|
||||||
|
f'<div class="quizBoxChoices" data-quiz-role="choices" style="background:{html_lib.escape(style["body_bg"])}">'
|
||||||
|
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
|
||||||
|
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
|
||||||
|
f'</div></div>'
|
||||||
|
)
|
||||||
|
if obj["kind"] == "score_widget":
|
||||||
|
# Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une
|
||||||
|
# fois posé (pas de collision/déclencheur, contrairement aux deux
|
||||||
|
# widgets ci-dessus) : affiche en continu le score de la partie en
|
||||||
|
# cours (voir static/js/play/dialogue-box-controller.js::
|
||||||
|
# forgeUpdateAllScoreWidgets). Même style que les deux autres
|
||||||
|
# widgets, header_bg sert de fond unique (pas de zone body/footer).
|
||||||
|
style = resolve_dialogue_box_style(obj)
|
||||||
|
box_style = (
|
||||||
|
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
||||||
|
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
|
||||||
|
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
|
||||||
|
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])}; "
|
||||||
|
f"background:{html_lib.escape(style['header_bg'])};"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'<div class="scoreWidget" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||||
|
f'🏆 <span data-score-role="value">0</span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
if obj["kind"] == "personnage":
|
if obj["kind"] == "personnage":
|
||||||
src = resolve_personnage_idle_frame(obj)
|
src = resolve_personnage_idle_frame(obj)
|
||||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ global.gameData = {};
|
|||||||
const {
|
const {
|
||||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
||||||
|
forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets,
|
||||||
} = require('../dialogue-box-controller.js');
|
} = require('../dialogue-box-controller.js');
|
||||||
|
|
||||||
function fakeWidget(objectId) {
|
function fakeWidget(objectId) {
|
||||||
@@ -154,3 +155,126 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
|
|||||||
assert.doesNotThrow(() => forgeDialogueBoxAdvance(999));
|
assert.doesNotThrow(() => forgeDialogueBoxAdvance(999));
|
||||||
} finally { global.document = previousDoc; }
|
} 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; }
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,32 +1,57 @@
|
|||||||
// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ----------
|
// ---------- 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 collision (action "quete", voir
|
||||||
// static/js/play/collision-rules-controller.js) et le widget d'interface
|
// static/js/play/collision-rules-controller.js) et les widgets d'interface
|
||||||
// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||||
// "quete", affiche la réplique en cours du dialogue correspondant au
|
// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la
|
||||||
// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py),
|
// quête (gameData.quests, voir full_game_payload.py) ligne par ligne —
|
||||||
// avance d'une réplique à chaque clic sur "Suivant".
|
// 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).
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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").
|
||||||
//
|
//
|
||||||
// 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
|
// 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.
|
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur.
|
||||||
|
|
||||||
var forgeDialogueBoxState = {};
|
var forgeDialogueBoxState = {};
|
||||||
|
var forgeQuizBoxState = {};
|
||||||
|
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||||
|
|
||||||
function forgeQuestById(questId) {
|
function forgeQuestById(questId) {
|
||||||
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeEscapeHtml(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Boîte de dialogue ----------
|
||||||
|
|
||||||
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
||||||
}
|
}
|
||||||
@@ -55,6 +80,57 @@ function forgeResetDialogueBoxFooter(widget, objectId) {
|
|||||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Boîte à quiz ----------
|
||||||
|
|
||||||
|
function forgeQuizBoxWidgetOnCurrentScreen() {
|
||||||
|
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeQuizBoxWidgetById(objectId) {
|
||||||
|
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeHideQuizBoxOnCurrentScreen() {
|
||||||
|
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||||
|
if (widget) widget.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Score ----------
|
||||||
|
|
||||||
|
function forgeUpdateAllScoreWidgets() {
|
||||||
|
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||||
|
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeAddQuizScore(amount) {
|
||||||
|
window.forgeQuizScore += (amount || 0);
|
||||||
|
forgeUpdateAllScoreWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Enchaînement dialogue/question ----------
|
||||||
|
|
||||||
|
// Affiche la ligne COURANTE de l'état de conversation `dialogueObjectId`
|
||||||
|
// (voir forgeDialogueBoxState) — une ligne "dialogue" dans la boîte de
|
||||||
|
// dialogue, une ligne "question" dans la boîte à quiz (voir
|
||||||
|
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
|
||||||
|
// n'est posée : impossible de bloquer la conversation indéfiniment).
|
||||||
|
function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||||
|
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||||
|
if (!state) return;
|
||||||
|
var line = state.lines[state.index];
|
||||||
|
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||||
|
if (line.type === 'question') {
|
||||||
|
if (widget) widget.classList.remove('is-active');
|
||||||
|
forgeShowQuizBox(dialogueObjectId, line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forgeHideQuizBoxOnCurrentScreen();
|
||||||
|
if (!widget) return;
|
||||||
|
forgeResetDialogueBoxFooter(widget, dialogueObjectId);
|
||||||
|
forgeRenderDialogueBoxLine(widget, line);
|
||||||
|
widget.classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
||||||
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
||||||
// comme le reste du moteur 2D).
|
// comme le reste du moteur 2D).
|
||||||
@@ -65,39 +141,95 @@ function forgeShowDialogueBox(questId) {
|
|||||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
var objectId = widget.getAttribute('data-object-id');
|
var objectId = widget.getAttribute('data-object-id');
|
||||||
// Aucune réplique posée pour ce statut : une quête "nouvelle" propose
|
// Aucune ligne posée pour ce statut : une quête "nouvelle" propose
|
||||||
// quand même directement l'offre (accepter/refuser), les autres
|
// quand même directement l'offre (accepter/refuser), les autres
|
||||||
// statuts restent silencieux (rien à montrer).
|
// statuts restent silencieux (rien à montrer).
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
forgeResetDialogueBoxFooter(widget, objectId);
|
|
||||||
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
||||||
forgeRenderDialogueBoxLine(widget, lines[0]);
|
forgeShowCurrentConversationLine(objectId);
|
||||||
widget.classList.add('is-active');
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
function forgeAdvanceConversation(dialogueObjectId) {
|
||||||
|
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||||
|
if (!state) return;
|
||||||
|
state.index += 1;
|
||||||
|
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 (widget) widget.classList.remove('is-active');
|
||||||
|
delete forgeDialogueBoxState[dialogueObjectId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forgeShowCurrentConversationLine(dialogueObjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
|
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
|
||||||
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)").
|
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)") — sans
|
||||||
|
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
|
||||||
|
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||||
function forgeDialogueBoxAdvance(objectId) {
|
function forgeDialogueBoxAdvance(objectId) {
|
||||||
var state = forgeDialogueBoxState[objectId];
|
var state = forgeDialogueBoxState[objectId];
|
||||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||||||
if (!state || !widget) return;
|
forgeAdvanceConversation(objectId);
|
||||||
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;
|
|
||||||
}
|
|
||||||
forgeRenderDialogueBoxLine(widget, state.lines[state.index]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||||||
|
// l'écran — si l'auteur n'en a posé aucune, 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, line) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||||
|
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||||
|
if (header) header.textContent = line.text;
|
||||||
|
if (choicesEl) {
|
||||||
|
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||||
|
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
widget.classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appelée par le clic sur un choix (voir render_scene_object.py côté
|
||||||
|
// éditeur — les vrais choix sont injectés EN JEU par forgeShowQuizBox
|
||||||
|
// ci-dessus, jamais rendus côté serveur). Mauvaise réponse : rien, la
|
||||||
|
// question reste affichée. Bonne réponse : crédite le score, masque la
|
||||||
|
// boîte, avance la conversation.
|
||||||
|
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||||
|
var state = forgeQuizBoxState[quizObjectId];
|
||||||
|
if (!state) return;
|
||||||
|
if (choiceIndex !== state.correctIndex) return;
|
||||||
|
forgeAddQuizScore(state.rewardAmount);
|
||||||
|
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||||
|
if (widget) widget.classList.remove('is-active');
|
||||||
|
var dialogueObjectId = state.dialogueObjectId;
|
||||||
|
delete forgeQuizBoxState[quizObjectId];
|
||||||
|
forgeAdvanceConversation(dialogueObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Offre de quête (Accepter/Refuser) ----------
|
||||||
|
|
||||||
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
||||||
// Accepter/Refuser au lieu du "Suivant" habituel.
|
// Accepter/Refuser au lieu du "Suivant" habituel.
|
||||||
function forgeShowQuestOffer(widget, objectId, quest) {
|
function forgeShowQuestOffer(widget, objectId, quest) {
|
||||||
@@ -143,5 +275,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
||||||
|
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||||
|
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
|||||||
// se déplace lui-même.
|
// se déplace lui-même.
|
||||||
function forgeSolidObstacles(screenData, movingId) {
|
function forgeSolidObstacles(screenData, movingId) {
|
||||||
return (screenData.elements || []).filter(function (o) {
|
return (screenData.elements || []).filter(function (o) {
|
||||||
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false);
|
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ function showScreen(id) {
|
|||||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||||
|
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||||
|
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||||
|
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||||
|
// joueur ait déjà marqué des points ailleurs resterait figé à "0"
|
||||||
|
// (valeur d'exemple rendue côté serveur) jusqu'au prochain point gagné.
|
||||||
|
if (typeof forgeUpdateAllScoreWidgets === 'function') forgeUpdateAllScoreWidgets();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
|
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ function questQuestionBubbleHtml(line, status, index) {
|
|||||||
'<div class="questBubbleRow">' +
|
'<div class="questBubbleRow">' +
|
||||||
'<div class="questBubble questBubble-question">' +
|
'<div class="questBubble questBubble-question">' +
|
||||||
'<div class="questBubbleHeader">' +
|
'<div class="questBubbleHeader">' +
|
||||||
'<label class="questQuestionHeaderField" title="Nombre de choix">🔢 <input type="number" min="2" max="8" value="' + line.choices.length + '" ' +
|
'<label class="questQuestionHeaderField" title="Nombre de choix">🔢 <input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
|
||||||
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
|
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
|
||||||
'<label class="questQuestionHeaderField" title="Récompense (points de score)">🏆 <input type="number" min="0" value="' + line.reward_amount + '" ' +
|
'<label class="questQuestionHeaderField" title="Récompense (points de score)">🏆 <input type="number" min="0" value="' + line.reward_amount + '" ' +
|
||||||
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
|
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
|
||||||
@@ -362,10 +362,15 @@ function questUpdateField(questId, field, value) {
|
|||||||
fetch('/game/' + GAME_SLUG + '/quests/' + questId, {
|
fetch('/game/' + GAME_SLUG + '/quests/' + questId, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||||
.then(function (json) {
|
.then(function (res) {
|
||||||
if (!json.quest) return;
|
// Refusé (400) : recompense_score abaissé sous le total déjà
|
||||||
Object.assign(quest, json.quest);
|
// réparti entre les questions (voir routes/quests/quest_update.py)
|
||||||
|
// — message d'erreur demandé explicitement, et le champ est
|
||||||
|
// remis à sa valeur RÉELLE (inchangée côté serveur).
|
||||||
|
if (!res.ok) { alert(res.json.error || "Impossible d'enregistrer."); renderQuestFieldsModalBody(questId); return; }
|
||||||
|
if (!res.json.quest) return;
|
||||||
|
Object.assign(quest, res.json.quest);
|
||||||
renderQuestListPage();
|
renderQuestListPage();
|
||||||
renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables
|
renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables
|
||||||
});
|
});
|
||||||
@@ -385,7 +390,15 @@ function questUpdateField(questId, field, value) {
|
|||||||
function questSaveDialogues(quest) {
|
function questSaveDialogues(quest) {
|
||||||
fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', {
|
fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }),
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }),
|
||||||
});
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
// Refusé (400) : la somme des récompenses des questions dépasse la
|
||||||
|
// récompense totale de la quête (voir routes/quests/quest_dialogues.py)
|
||||||
|
// — message d'erreur demandé explicitement. La bulle en trop reste
|
||||||
|
// affichée localement (non enregistrée) : l'auteur baisse son
|
||||||
|
// montant ou celui d'une autre question pour repasser sous le budget.
|
||||||
|
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de
|
// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de
|
||||||
@@ -437,7 +450,7 @@ function questSetQuestionChoiceCount(status, index, value) {
|
|||||||
var quest = questById(_currentQuestModalId());
|
var quest = questById(_currentQuestModalId());
|
||||||
if (!quest) return;
|
if (!quest) return;
|
||||||
var line = quest.dialogues[status][index];
|
var line = quest.dialogues[status][index];
|
||||||
var count = Math.max(2, Math.min(8, parseInt(value, 10) || 2));
|
var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2));
|
||||||
while (line.choices.length < count) line.choices.push('');
|
while (line.choices.length < count) line.choices.push('');
|
||||||
while (line.choices.length > count) line.choices.pop();
|
while (line.choices.length > count) line.choices.pop();
|
||||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||||
|
|||||||
@@ -668,6 +668,34 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
|||||||
.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; }
|
.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; }
|
||||||
.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); }
|
.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); }
|
||||||
|
|
||||||
|
/* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py,
|
||||||
|
static/js/play/dialogue-box-controller.js) : même squelette que
|
||||||
|
"💬 Boîte de dialogue" (header/corps, même "🎨 Style") mais pas de pied
|
||||||
|
— la question se résout au clic sur un choix. Masquée par défaut au
|
||||||
|
jeu, comme la boîte de dialogue. */
|
||||||
|
.quizBoxWidget{
|
||||||
|
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
|
||||||
|
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
|
||||||
|
}
|
||||||
|
.playScreen .quizBoxWidget{ display:none; }
|
||||||
|
.playScreen .quizBoxWidget.is-active{ display:flex; }
|
||||||
|
.quizBoxHeader{ padding:8px 12px; font-weight:700; }
|
||||||
|
.quizBoxChoices{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:8px; }
|
||||||
|
.quizBoxChoiceBtn{
|
||||||
|
background:rgba(255,255,255,.12); color:inherit; border:1px solid rgba(255,255,255,.3);
|
||||||
|
border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; text-align:left; cursor:pointer;
|
||||||
|
transition:background .15s ease, transform .1s ease;
|
||||||
|
}
|
||||||
|
.quizBoxChoiceBtn:hover{ background:rgba(255,255,255,.25); transform:translateX(2px); }
|
||||||
|
|
||||||
|
/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/
|
||||||
|
déclencheur, contrairement aux deux widgets ci-dessus), voir
|
||||||
|
forgeUpdateAllScoreWidgets(). */
|
||||||
|
.scoreWidget{
|
||||||
|
display:flex; align-items:center; justify-content:center; gap:4px;
|
||||||
|
border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
|
/* 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
|
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir
|
||||||
sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/
|
sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/
|
||||||
|
|||||||
+4
-4
@@ -84,13 +84,13 @@
|
|||||||
uniquement si un objet kind="fond" plus grand que la scène est posé
|
uniquement si un objet kind="fond" plus grand que la scène est posé
|
||||||
(caméra qui suit le personnage). -->
|
(caméra qui suit le personnage). -->
|
||||||
<div class="sceneWorld" data-scene-width="{{ s.scene_width }}" data-scene-height="{{ s.scene_height }}">
|
<div class="sceneWorld" data-scene-width="{{ s.scene_width }}" data-scene-height="{{ s.scene_height }}">
|
||||||
{% for obj in s.elements if obj.kind != 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %}
|
{% for obj in s.elements if obj.kind not in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<!-- .sceneUI : widgets d'interface FIXES au viewport, jamais dans
|
<!-- .sceneUI : widgets d'interface FIXES au viewport, jamais dans
|
||||||
.sceneWorld ci-dessus (voir static/style.css) — pour l'instant
|
.sceneWorld ci-dessus (voir static/style.css) — "💬 Boîte de
|
||||||
seulement "💬 Boîte de dialogue" (screens/rendering/dialogue_box_style.py). -->
|
dialogue"/"❓ Boîte à quiz"/"🏆 Score" (screens/rendering/dialogue_box_style.py). -->
|
||||||
<div class="sceneUI">
|
<div class="sceneUI">
|
||||||
{% for obj in s.elements if obj.kind == 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %}
|
{% for obj in s.elements if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
+18
-11
@@ -17,7 +17,9 @@
|
|||||||
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
||||||
/* Même correctif que ci-dessus, pour le widget "💬 Boîte de dialogue"
|
/* Même correctif que ci-dessus, pour le widget "💬 Boîte de dialogue"
|
||||||
(screens/scenes/render_scene_object.py, un <div>, pas un <img>). */
|
(screens/scenes/render_scene_object.py, un <div>, pas un <img>). */
|
||||||
.canvasElementInner > .dialogueBoxWidget{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
.canvasElementInner > .dialogueBoxWidget, .canvasElementInner > .quizBoxWidget, .canvasElementInner > .scoreWidget{
|
||||||
|
position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -100,8 +102,10 @@
|
|||||||
<details class="propGroup">
|
<details class="propGroup">
|
||||||
<summary>🖥️ Interface</summary>
|
<summary>🖥️ Interface</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('dialogue_box')">💬 Boîte de dialogue</button>
|
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('dialogue_box')">💬 Boîte de dialogue</button>
|
||||||
<p class="help">Affiche automatiquement le dialogue de la quête en cours (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posée.</p>
|
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('quiz_box')">❓ Boîte à quiz</button>
|
||||||
|
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('score_widget')">🏆 Score</button>
|
||||||
|
<p class="help">Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +128,7 @@
|
|||||||
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
||||||
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box') %}
|
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
||||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
||||||
aperçu visuel + réglable directement au glisser — seulement pour
|
aperçu visuel + réglable directement au glisser — seulement pour
|
||||||
l'objet SÉLECTIONNÉ (celui dont le panneau "🧱 Collision" est ouvert),
|
l'objet SÉLECTIONNÉ (celui dont le panneau "🧱 Collision" est ouvert),
|
||||||
@@ -261,8 +265,10 @@
|
|||||||
{% if not selected_id or not obj %}
|
{% if not selected_id or not obj %}
|
||||||
<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% set _WIDGET_ICONS = {'dialogue_box': '💬', 'quiz_box': '❓', 'score_widget': '🏆'} %}
|
||||||
|
{% set _WIDGET_LABELS = {'dialogue_box': 'Boîte de dialogue', 'quiz_box': 'Boîte à quiz', 'score_widget': 'Score'} %}
|
||||||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
<h2 class="subtitle is-6" style="margin:0;">{{ '🏃' if obj.kind == 'personnage' else ('💬' if obj.kind == 'dialogue_box' else '🖼️') }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else ('Boîte de dialogue' if obj.kind == 'dialogue_box' else 'Décor')) }}</h2>
|
<h2 class="subtitle is-6" style="margin:0;">{{ '🏃' if obj.kind == 'personnage' else _WIDGET_ICONS.get(obj.kind, '🖼️') }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else _WIDGET_LABELS.get(obj.kind, 'Décor')) }}</h2>
|
||||||
<form method="post" action="{{ url_for('scene_object_delete', slug=game.slug, object_id=obj.id) }}" onsubmit="return confirm('Supprimer cet objet ?');">
|
<form method="post" action="{{ url_for('scene_object_delete', slug=game.slug, object_id=obj.id) }}" onsubmit="return confirm('Supprimer cet objet ?');">
|
||||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -315,11 +321,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if obj.kind == 'dialogue_box' %}
|
{% if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %}
|
||||||
<!-- "🎨 Style" (voir screens/rendering/dialogue_box_style.py) : le
|
<!-- "🎨 Style" (voir screens/rendering/dialogue_box_style.py) —
|
||||||
widget "💬 Boîte de dialogue" n'a ni collision ni animation,
|
PARTAGÉ par les 3 widgets d'interface : aucun n'a de collision
|
||||||
juste une apparence — police, couleur du texte, couleur de
|
ni d'animation, juste une apparence — police, couleur du
|
||||||
chaque zone (header/body/footer). -->
|
texte, couleur de chaque zone (header/body/footer, "header_bg"
|
||||||
|
sert de fond unique pour "🏆 Score", qui n'a pas de body/footer). -->
|
||||||
<details class="propGroup" open>
|
<details class="propGroup" open>
|
||||||
<summary>🎨 Style</summary>
|
<summary>🎨 Style</summary>
|
||||||
<div class="propGroupBody" data-element-id="{{ obj.id }}">
|
<div class="propGroupBody" data-element-id="{{ obj.id }}">
|
||||||
@@ -373,7 +380,7 @@
|
|||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if obj.kind not in ('fond', 'dialogue_box') %}
|
{% if obj.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
||||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
|
||||||
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
|
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
|
||||||
sur tout objet (= sa boîte visuelle par défaut, tant que rien
|
sur tout objet (= sa boîte visuelle par défaut, tant que rien
|
||||||
|
|||||||
@@ -152,3 +152,66 @@ def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_
|
|||||||
screens.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur")
|
screens.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur")
|
||||||
|
|
||||||
assert screens.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"]
|
assert screens.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Widgets "❓ Boîte à quiz" / "🏆 Score" ----------
|
||||||
|
|
||||||
|
def test_add_quiz_box_has_sensible_default_size(tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||||
|
obj = screens.get_scene_object(slug, obj_id)
|
||||||
|
assert obj["kind"] == "quiz_box"
|
||||||
|
assert obj["width"] == 340
|
||||||
|
assert obj["height"] == 240
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_score_widget_has_sensible_default_size(tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id, kind="score_widget")
|
||||||
|
obj = screens.get_scene_object(slug, obj_id)
|
||||||
|
assert obj["kind"] == "score_widget"
|
||||||
|
assert obj["width"] == 140
|
||||||
|
assert obj["height"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||||
|
obj = screens.get_scene_object(slug, obj_id)
|
||||||
|
html = screens.render_scene_object(obj)
|
||||||
|
assert 'data-quiz-role="header"' in html
|
||||||
|
assert 'data-quiz-role="choices"' in html
|
||||||
|
assert f'data-object-id="{obj_id}"' in html
|
||||||
|
assert "quizBoxFooter" not in html # pas de pied, une question se résout au clic
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_score_widget_shows_zero_by_default(tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id, kind="score_widget")
|
||||||
|
obj = screens.get_scene_object(slug, obj_id)
|
||||||
|
html = screens.render_scene_object(obj)
|
||||||
|
assert 'data-score-role="value"' in html
|
||||||
|
assert f'data-object-id="{obj_id}"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_quiz_box_and_score_widget_share_the_same_style_system_as_dialogue_box(tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||||
|
quiz_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||||
|
style = screens.set_scene_object_dialogue_box_style(slug, quiz_id, {"header_bg": "#abcdef"})
|
||||||
|
assert style["header_bg"] == "#abcdef"
|
||||||
|
obj = screens.get_scene_object(slug, quiz_id)
|
||||||
|
assert screens.resolve_dialogue_box_style(obj)["header_bg"] == "#abcdef"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quiz_box_and_score_widget_excluded_from_collision_rules_payload(tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||||
|
screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||||
|
screens.add_scene_object(slug, screen_id, kind="score_widget")
|
||||||
|
payload = screens.full_game_payload(slug)
|
||||||
|
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||||||
|
quiz = next(o for o in scene["elements"] if o["kind"] == "quiz_box")
|
||||||
|
score = next(o for o in scene["elements"] if o["kind"] == "score_widget")
|
||||||
|
for widget in (quiz, score):
|
||||||
|
assert "collision_rules" not in widget
|
||||||
|
assert "collision" not in widget
|
||||||
|
assert widget["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ et un dialogue par statut (3 colonnes — voir static/js/quests/
|
|||||||
quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le
|
quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le
|
||||||
jeu, jamais par écran (mêmes conventions que les événements
|
jeu, jamais par écran (mêmes conventions que les événements
|
||||||
personnalisés, db/custom_events/)."""
|
personnalisés, db/custom_events/)."""
|
||||||
|
import json
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
|
||||||
|
|
||||||
@@ -192,3 +194,71 @@ def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_clea
|
|||||||
def test_quest_dialogues_route_404s_for_missing_quest(client, game):
|
def test_quest_dialogues_route_404s_for_missing_quest(client, game):
|
||||||
resp = client.post(f"/game/{game}/quests/999999/dialogues", data='{"dialogues": {}}', content_type="application/json")
|
resp = client.post(f"/game/{game}/quests/999999/dialogues", data='{"dialogues": {}}', content_type="application/json")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def _question_line(reward_amount, correct_index=0):
|
||||||
|
return {
|
||||||
|
"type": "question", "text": "2+2 ?", "choices": ["3", "4"],
|
||||||
|
"correct_index": correct_index, "reward_type": "score", "reward_amount": reward_amount,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_dialogues_route_rejects_questions_totaling_more_than_the_reward(client, tmp_game_slug_cleanup):
|
||||||
|
slug = _make_game(tmp_game_slug_cleanup)
|
||||||
|
quest = db.create_quest(slug, "Trouver la clé")
|
||||||
|
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||||
|
data=json.dumps({"dialogues": {"nouvelle": [_question_line(3), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "pts" in resp.get_json()["error"]
|
||||||
|
# Rien n'a été enregistré — la répartition invalide n'est jamais persistée.
|
||||||
|
assert db.get_quest(slug, quest["id"])["dialogues"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_dialogues_route_accepts_questions_totaling_exactly_the_reward(client, tmp_game_slug_cleanup):
|
||||||
|
slug = _make_game(tmp_game_slug_cleanup)
|
||||||
|
quest = db.create_quest(slug, "Trouver la clé")
|
||||||
|
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||||
|
data=json.dumps({"dialogues": {"nouvelle": [_question_line(2), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_update_route_rejects_lowering_reward_below_already_allocated_questions(client, tmp_game_slug_cleanup):
|
||||||
|
slug = _make_game(tmp_game_slug_cleanup)
|
||||||
|
quest = db.create_quest(slug, "Trouver la clé")
|
||||||
|
db.update_quest(slug, quest["id"], {"recompense_score": 10})
|
||||||
|
db.set_quest_dialogues(slug, quest["id"], {"nouvelle": [_question_line(6)], "en_cours": [], "terminee": []})
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/quests/{quest['id']}",
|
||||||
|
data=json.dumps({"recompense_score": 3}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "pts" in resp.get_json()["error"]
|
||||||
|
assert db.get_quest(slug, quest["id"])["recompense_score"] == 10 # inchangé
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_quest_dialogues_caps_choices_at_four():
|
||||||
|
raw = {
|
||||||
|
"nouvelle": [{"type": "question", "text": "Q", "choices": ["a", "b", "c", "d", "e"], "correct_index": 4, "reward_type": "score", "reward_amount": 1}],
|
||||||
|
"en_cours": [], "terminee": [],
|
||||||
|
}
|
||||||
|
cleaned = db.sanitize_quest_dialogues(raw)
|
||||||
|
# Coupé à 4 choix -> l'index 4 (qui pointait sur le 5e, retiré) sort des bornes -> ligne rejetée.
|
||||||
|
assert cleaned["nouvelle"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_dialogues_total_reward_sums_questions_across_all_statuses():
|
||||||
|
dialogues = {
|
||||||
|
"nouvelle": [_question_line(2)],
|
||||||
|
"en_cours": [_question_line(3), {"type": "dialogue", "speaker": "pnj", "text": "x"}],
|
||||||
|
"terminee": [],
|
||||||
|
}
|
||||||
|
assert db.quest_dialogues_total_reward(dialogues) == 5
|
||||||
|
|||||||
Reference in New Issue
Block a user