diff --git a/db/__init__.py b/db/__init__.py index 884dd607..c7f0f0fd 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -84,17 +84,7 @@ from .custom_events.get_custom_event import get_custom_event from .custom_events.create_custom_event import create_custom_event from .custom_events.update_custom_event import update_custom_event -from .quests.constants import ( - QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, -) -from .quests.list_quests import list_quests -from .quests.get_quest import get_quest -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, QUESTION_REWARD_TYPES -from .quests.reward_budget import quest_dialogues_total_reward +from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", @@ -113,7 +103,5 @@ __all__ = [ "update_global_variable_value", "update_global_variable", "delete_global_variable", "delete_global_variable_by_id", "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", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward", + "sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards", ] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/dialogue_lines.py similarity index 57% rename from db/quests/sanitize_quest_dialogues.py rename to db/dialogue_lines.py index 2f026047..c5a542ef 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/dialogue_lines.py @@ -1,9 +1,7 @@ -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) : +# Question à choix (voir "❓ Question", static/js/triggers/trigger-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_*/ @@ -61,22 +59,31 @@ def _sanitize_line(raw_line): return _sanitize_dialogue_line(raw_line) -def sanitize_quest_dialogues(raw_dialogues): - """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 - for status in QUEST_STATUS_CHOICES: - raw_lines = raw_dialogues.get(status) - if not isinstance(raw_lines, list): - continue - 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 +def sanitize_dialogue_lines(raw_lines): + """Sanitize une LISTE 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. Les lignes d'UN + dialogue embarqué dans une action "dialogue" (voir + screens/rendering/collision_rules.py) : chaque occurrence de + "Déclencher ce dialogue" porte le sien.""" + if not isinstance(raw_lines, list): + return [] + cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]] + return [line for line in cleaned if line] + + +def sum_question_rewards(lines): + """Somme des récompenses de toutes les questions ("❓ Question" ci- + dessus) d'UNE liste de répliques/questions — utilisée pour calculer le + score max possible du quiz à l'export SCORM, sur TOUTES les lignes de + TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines, + publish/build_scorm_package.py).""" + total = 0 + for line in (lines or []): + if isinstance(line, dict) and line.get("type") == "question": + total += line.get("reward_amount", 0) or 0 + return total diff --git a/db/quests/constants.py b/db/quests/constants.py deleted file mode 100644 index d01ef3fe..00000000 --- a/db/quests/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"] -QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"} -QUEST_RESULT_CHOICES = ["reussi", "echoue"] -QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"} diff --git a/db/quests/create_quest.py b/db/quests/create_quest.py deleted file mode 100644 index e58748a5..00000000 --- a/db/quests/create_quest.py +++ /dev/null @@ -1,18 +0,0 @@ -from ..connection import connect -from .ensure_quests_schema import ensure_quests_schema -from .get_quest import get_quest - - -def create_quest(slug, title="Nouvelle quête"): - """Titre par défaut plutôt qu'un refus — créée d'un clic depuis la - liste (voir l'onglet "🗺️ Quêtes", templates/scene_edit.html) ou depuis l'assistant "+ Action" - de l'éditeur de collision (voir collision-rules-editor.js), avant - même que l'auteur ait choisi un titre.""" - ensure_quests_schema(slug) - title = (title or "").strip() or "Nouvelle quête" - conn = connect(slug) - conn.execute("INSERT INTO _quests (title) VALUES (?)", (title,)) - new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] - conn.commit() - conn.close() - return get_quest(slug, new_id) diff --git a/db/quests/delete_quest.py b/db/quests/delete_quest.py deleted file mode 100644 index 80e19b75..00000000 --- a/db/quests/delete_quest.py +++ /dev/null @@ -1,10 +0,0 @@ -from ..connection import connect -from .ensure_quests_schema import ensure_quests_schema - - -def delete_quest(slug, quest_id): - ensure_quests_schema(slug) - conn = connect(slug) - conn.execute("DELETE FROM _quests WHERE id = ?", (quest_id,)) - conn.commit() - conn.close() diff --git a/db/quests/ensure_quests_schema.py b/db/quests/ensure_quests_schema.py deleted file mode 100644 index 8013cd81..00000000 --- a/db/quests/ensure_quests_schema.py +++ /dev/null @@ -1,29 +0,0 @@ -from ..connection import connect - - -def ensure_quests_schema(slug): - """Migration légère (même principe que ensure_custom_events_schema.py) : - crée _quests si absente. Une quête vit pour TOUT le jeu (pas par écran), - référencée par id technique depuis l'action "quete" d'une règle de - collision (voir screens/rendering/collision_rules.py, quete_id). Les - dialogues (voir static/js/quests/quest-editor.js — 3 colonnes - nouvelle/en_cours/terminee) sont stockés en JSON plutôt qu'une table à - part : jamais interrogés/filtrés côté SQL, toujours lus/réécrits en - bloc pour UNE quête à la fois (même convention que _scene_objects.attributes).""" - conn = connect(slug) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS _quests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - objectif TEXT DEFAULT '', - recompense_score INTEGER DEFAULT 0, - status TEXT DEFAULT 'nouvelle', - resultat TEXT, - dialogues_json TEXT DEFAULT '{}', - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ); - """ - ) - conn.commit() - conn.close() diff --git a/db/quests/get_quest.py b/db/quests/get_quest.py deleted file mode 100644 index 634d6e70..00000000 --- a/db/quests/get_quest.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..connection import connect -from .ensure_quests_schema import ensure_quests_schema -from .list_quests import _row_to_quest - - -def get_quest(slug, quest_id): - ensure_quests_schema(slug) - conn = connect(slug) - row = conn.execute("SELECT * FROM _quests WHERE id = ?", (quest_id,)).fetchone() - conn.close() - return _row_to_quest(row) if row else None diff --git a/db/quests/list_quests.py b/db/quests/list_quests.py deleted file mode 100644 index 734a160a..00000000 --- a/db/quests/list_quests.py +++ /dev/null @@ -1,22 +0,0 @@ -import json - -from ..connection import connect -from .ensure_quests_schema import ensure_quests_schema - - -def _row_to_quest(row): - quest = dict(row) - try: - quest["dialogues"] = json.loads(quest.pop("dialogues_json") or "{}") - except (TypeError, ValueError): - quest["dialogues"] = {} - quest.pop("dialogues_json", None) - return quest - - -def list_quests(slug): - ensure_quests_schema(slug) - conn = connect(slug) - rows = conn.execute("SELECT * FROM _quests ORDER BY created_at DESC, id DESC").fetchall() - conn.close() - return [_row_to_quest(r) for r in rows] diff --git a/db/quests/reward_budget.py b/db/quests/reward_budget.py deleted file mode 100644 index 68d66601..00000000 --- a/db/quests/reward_budget.py +++ /dev/null @@ -1,15 +0,0 @@ -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 diff --git a/db/quests/set_quest_dialogues.py b/db/quests/set_quest_dialogues.py deleted file mode 100644 index 62a4ed65..00000000 --- a/db/quests/set_quest_dialogues.py +++ /dev/null @@ -1,21 +0,0 @@ -import json - -from ..connection import connect -from .ensure_quests_schema import ensure_quests_schema -from .get_quest import get_quest -from .sanitize_quest_dialogues import sanitize_quest_dialogues - - -def set_quest_dialogues(slug, quest_id, raw_dialogues): - """Toujours les 3 colonnes ENTIÈRES (nouvelle/en_cours/terminee) en un - bloc — même convention "replace-all" que set_scene_object_collision_rules.py, - jamais une réplique isolée.""" - ensure_quests_schema(slug) - if get_quest(slug, quest_id) is None: - return None - dialogues = sanitize_quest_dialogues(raw_dialogues) - conn = connect(slug) - conn.execute("UPDATE _quests SET dialogues_json = ? WHERE id = ?", (json.dumps(dialogues), quest_id)) - conn.commit() - conn.close() - return dialogues diff --git a/db/quests/update_quest.py b/db/quests/update_quest.py deleted file mode 100644 index f8fec251..00000000 --- a/db/quests/update_quest.py +++ /dev/null @@ -1,42 +0,0 @@ -from ..connection import connect -from .constants import QUEST_STATUS_CHOICES, QUEST_RESULT_CHOICES -from .ensure_quests_schema import ensure_quests_schema -from .get_quest import get_quest - - -def update_quest(slug, quest_id, fields): - """Met à jour uniquement les clés reconnues et valides de `fields` — - ignore tout le reste plutôt que de lever, même esprit que - sanitize_collision_rules (jamais confiance dans ce qu'envoie le - client). title vide => ignoré (garde l'ancien plutôt qu'un titre vide).""" - ensure_quests_schema(slug) - if get_quest(slug, quest_id) is None: - return None - updates = {} - if "title" in fields: - title = (fields["title"] or "").strip() - if title: - updates["title"] = title - if "objectif" in fields: - updates["objectif"] = (fields["objectif"] or "").strip() - if "recompense_score" in fields: - try: - updates["recompense_score"] = int(fields["recompense_score"]) - except (TypeError, ValueError): - pass - if "status" in fields and fields["status"] in QUEST_STATUS_CHOICES: - updates["status"] = fields["status"] - if "resultat" in fields: - resultat = fields["resultat"] - if resultat in QUEST_RESULT_CHOICES: - updates["resultat"] = resultat - elif resultat is None: - updates["resultat"] = None - if not updates: - return get_quest(slug, quest_id) - conn = connect(slug) - set_clause = ", ".join(f"{key} = ?" for key in updates) - conn.execute(f"UPDATE _quests SET {set_clause} WHERE id = ?", (*updates.values(), quest_id)) - conn.commit() - conn.close() - return get_quest(slug, quest_id) diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py index fbc4934f..cec1234a 100644 --- a/publish/build_scorm_package.py +++ b/publish/build_scorm_package.py @@ -277,17 +277,18 @@ def build_scorm_zip(slug): else "static/js/play/offline/scorm-api.js" ) - # Score max possible (voir db/quests/reward_budget.py) : sans lui, un - # LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de points brut, - # sans barème) et l'affiche à tort avec un "%" — demande utilisateur - # "5% pour 1.2 mais 100% pour 2004 avec 1 seule question, c'est un - # défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant aussi - # min/max (voir static/js/play/offline/scorm-api.js), le LMS peut + # Score max possible (voir db/dialogue_lines.py::sum_question_rewards) : + # sans lui, un LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de + # points brut, sans barème) et l'affiche à tort avec un "%" — demande + # utilisateur "5% pour 1.2 mais 100% pour 2004 avec 1 seule question, + # c'est un défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant + # aussi min/max (voir static/js/play/offline/scorm-api.js), le LMS peut # calculer raw/max lui-même, un vrai pourcentage au lieu du score brut - # mal étiqueté. - max_quiz_score = sum( - db.quest_dialogues_total_reward(quest["dialogues"]) for quest in db.list_quests(slug) - ) + # mal étiqueté. Les dialogues vivent dans les règles de collision des + # objets de scène (voir screens/rendering/collision_rules.py:: + # "dialogue".dialogue), retrouvés via screens.collect_all_dialogue_lines. + all_dialogue_lines = [line for lines in screens.collect_all_dialogue_lines(slug) for line in lines] + max_quiz_score = db.sum_question_rewards(all_dialogue_lines) # gameData.icon_data_uris (voir forgeRenderIcone, # static/js/play/offline/render-special-widgets.js) : ajouté au diff --git a/routes/__init__.py b/routes/__init__.py index b25fb67d..f71ff2b9 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests +from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, triggers diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py index 37158678..146d8b10 100644 --- a/routes/global_vars/create_global_var.py +++ b/routes/global_vars/create_global_var.py @@ -7,12 +7,13 @@ from core.flask_app import app @app.route("/game//variables/new", methods=["POST"]) def create_global_var(slug): - """Depuis la page de gestion des variables (game_variables.html) — - idempotent par nom (voir db.create_global_variable), donc sans risque en - cas de double soumission.""" + """Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html) + — idempotent par nom (voir db.create_global_variable), donc sans + risque en cas de double soumission.""" name = (request.form.get("name") or "").strip() var_type = request.form.get("type") or "texte" default_value = request.form.get("default_value") or "" + screen_id = request.form.get("screen_id", type=int) # État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE # explicitement rend la variable partagée par tous les joueurs, voir # db/global_vars/create_global_variable.py) — absent du formulaire @@ -20,4 +21,6 @@ def create_global_var(slug): per_player = request.form.get("per_player", "1") == "1" if name: db.create_global_variable(slug, name, var_type, default_value, per_player=per_player) - return redirect(url_for("game_dashboard", slug=slug, tab="variables")) + if screen_id: + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables")) + return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/global_vars/global_var_delete.py b/routes/global_vars/global_var_delete.py index 9a89f507..46e4a056 100644 --- a/routes/global_vars/global_var_delete.py +++ b/routes/global_vars/global_var_delete.py @@ -1,4 +1,4 @@ -from flask import redirect, url_for +from flask import request, redirect, url_for import db @@ -7,5 +7,8 @@ from core.flask_app import app @app.route("/game//variables//delete", methods=["POST"]) def global_var_delete(slug, var_id): + screen_id = request.form.get("screen_id", type=int) db.delete_global_variable_by_id(slug, var_id) - return redirect(url_for("game_dashboard", slug=slug, tab="variables")) + if screen_id: + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables")) + return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/global_vars/global_var_edit.py b/routes/global_vars/global_var_edit.py index edf1cd63..98733b6f 100644 --- a/routes/global_vars/global_var_edit.py +++ b/routes/global_vars/global_var_edit.py @@ -9,5 +9,8 @@ from core.flask_app import app def global_var_edit(slug, var_id): var_type = request.form.get("type") or "texte" value = request.form.get("value") or "" + screen_id = request.form.get("screen_id", type=int) db.update_global_variable(slug, var_id, var_type, value) - return redirect(url_for("game_dashboard", slug=slug, tab="variables")) + if screen_id: + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables")) + return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/quests/__init__.py b/routes/quests/__init__.py deleted file mode 100644 index 95519fe5..00000000 --- a/routes/quests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues diff --git a/routes/quests/quest_delete.py b/routes/quests/quest_delete.py deleted file mode 100644 index c726589d..00000000 --- a/routes/quests/quest_delete.py +++ /dev/null @@ -1,11 +0,0 @@ -from flask import jsonify - -import db - -from core.flask_app import app - - -@app.route("/game//quests//delete", methods=["POST"]) -def quest_delete(slug, quest_id): - db.delete_quest(slug, quest_id) - return jsonify({"ok": True}) diff --git a/routes/quests/quest_dialogues.py b/routes/quests/quest_dialogues.py deleted file mode 100644 index 379ba347..00000000 --- a/routes/quests/quest_dialogues.py +++ /dev/null @@ -1,34 +0,0 @@ -from flask import jsonify, request - -import db - -from core.flask_app import app - - -@app.route("/game//quests//dialogues", methods=["POST"]) -def quest_dialogues(slug, quest_id): - """Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) — - le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet - complet à chaque modification plutôt qu'une réplique isolée. - - Refusé (400) si la somme des récompenses des questions ("❓ Question") - 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 - 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}) diff --git a/routes/quests/quest_new.py b/routes/quests/quest_new.py deleted file mode 100644 index a7b33ebc..00000000 --- a/routes/quests/quest_new.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import jsonify, request - -import db - -from core.flask_app import app - - -@app.route("/game//quests/new", methods=["POST"]) -def quest_new(slug): - body = request.get_json(silent=True) or {} - quest = db.create_quest(slug, body.get("title")) - return jsonify({"quest": quest}) diff --git a/routes/quests/quest_update.py b/routes/quests/quest_update.py deleted file mode 100644 index 46f5384f..00000000 --- a/routes/quests/quest_update.py +++ /dev/null @@ -1,36 +0,0 @@ -from flask import jsonify, request - -import db - -from core.flask_app import app - - -@app.route("/game//quests/", methods=["POST"]) -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 {} - 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) - if quest is None: - return jsonify({"error": "quête introuvable"}), 404 - return jsonify({"quest": quest}) diff --git a/routes/quests/quests_list_json.py b/routes/quests/quests_list_json.py deleted file mode 100644 index 35fd9167..00000000 --- a/routes/quests/quests_list_json.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import jsonify - -import db - -from core.flask_app import app - - -@app.route("/game//quests/list.json") -def quests_list_json(slug): - """Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir - collision-rules-editor.js) pour lister les quêtes existantes sans - quitter la modale déjà ouverte (l'onglet "🗺️ Quêtes" lui-même reçoit - déjà sa liste via QUESTS_JSON, voir routes/scenes/scene_edit_view.py — - cette route ne sert qu'à la RAFRAÎCHIR depuis le contexte collision).""" - return jsonify({"quests": db.list_quests(slug)}) diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index 70e01809..decff8e4 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -6,9 +6,7 @@ from . import ( scene_object_commands, scene_object_role, scene_object_collision, - scene_object_collision_rules, scene_object_name, scene_object_dialogue_box_style, - scene_collision_rule_objects, scene_object_names_list, ) diff --git a/routes/scenes/scene_collision_rule_objects.py b/routes/scenes/scene_collision_rule_objects.py deleted file mode 100644 index 1835b65b..00000000 --- a/routes/scenes/scene_collision_rule_objects.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask import jsonify - -import screens - -from core.flask_app import app - -from .collision_rule_objects import build_collision_rule_objects - - -@app.route("/game//screens//collision-rule-objects", methods=["GET"]) -def scene_collision_rule_objects(slug, screen_id): - """Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS, - templates/scene_edit.html) — appelée en AJAX après un ajout/suppression - d'objet, ou un changement de rôle/nom (voir static/js/scenes/ - scene-editor.js) qui peut faire apparaître/disparaître un objet de - cette liste (rôle passé à/depuis "joueur", voir - build_collision_rule_objects) ou changer son libellé affiché (nom). - Recalculée entièrement plutôt que diffée : la liste reste petite (un - objet de scène par carte, jamais un "fond"/widget d'UI) et cette même - fonction est déjà appelée à chaque rendu complet de la page.""" - objects = screens.list_scene_objects(slug, screen_id) - for o in objects: - o["rendered_html"] = screens.render_scene_object(o) - return jsonify(build_collision_rule_objects(objects)) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 8e098a6c..2ce0bdea 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -5,8 +5,6 @@ from flask import render_template, request, abort, g import db import screens -from .collision_rule_objects import build_collision_rule_objects - def render_scene_edit(slug, screen_id, game): """Vue de l'éditeur de scène 2D — appelée par routes/screens/ @@ -63,12 +61,6 @@ def render_scene_edit(slug, screen_id, game): o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage" } - # "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/ - # collision_rule_objects.py, factorisé pour être réutilisé par - # l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/ - # suppression/changement de rôle ou de nom sans recharger la page). - collision_rule_objects = build_collision_rule_objects(objects) - flow_blocks = screens.list_flow_blocks(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_edges = screens.list_flow_edges(slug, screen_id) @@ -125,24 +117,15 @@ def render_scene_edit(slug, screen_id, game): flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view, animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips), animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), - global_variables=global_variables, + global_variables=global_variables, global_variable_types=db.GLOBAL_VARIABLE_TYPES, custom_events=custom_events, custom_event_usages=custom_event_usages, custom_events_map_json=json.dumps(custom_events_map), definitions=db.list_definitions(slug), definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}), definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}), - collision_rule_objects_json=json.dumps(collision_rule_objects), - collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, - collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, - # Éditeur de quêtes (voir db/quests/) : préchargées ici pour que - # collisionRuleActionSummary (collision-rules-editor.js) affiche - # tout de suite le TITRE de la quête référencée par une règle - # existante, sans attendre l'ouverture de l'assistant "+ Action" - # (seul moment où questPickerHtml irait la chercher sinon). - quests_json=json.dumps(db.list_quests(slug)), - # "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations", - # static/js/quests/quest-editor.js) : les noms donnés à N'IMPORTE - # QUEL objet de scène (personnage, décor, fond), toutes scènes du - # jeu confondues. + # "qui parle" dans un dialogue de déclencheur (voir "ℹ️ + # Informations", static/js/triggers/trigger-editor.js) : les noms + # donnés à N'IMPORTE QUEL objet de scène (personnage, décor, + # fond), toutes scènes du jeu confondues. scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)), ) diff --git a/routes/scenes/scene_object_collision_rules.py b/routes/scenes/scene_object_collision_rules.py deleted file mode 100644 index d7c44159..00000000 --- a/routes/scenes/scene_object_collision_rules.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import request, jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//scene-objects//collision-rules", methods=["POST"]) -def scene_object_collision_rules(slug, object_id): - """Liste complète des règles "déclencheur -> action" d'un objet (voir - "🧩 Collision" dans templates/scene_edit.html) — le client poste - toujours la liste ENTIÈRE (ajout/suppression déjà appliqués côté - client), jamais une règle isolée, même patron que - scene_object_commands.py pour "extra".""" - body = request.get_json(silent=True) or {} - obj = screens.get_scene_object(slug, object_id) - if not obj: - return jsonify({"error": "objet introuvable"}), 404 - rules = screens.sanitize_collision_rules(body.get("rules")) - screens.set_scene_object_collision_rules(slug, object_id, rules) - return jsonify({"ok": True, "rules": rules}) diff --git a/routes/scenes/scene_object_names_list.py b/routes/scenes/scene_object_names_list.py index 92f63072..8faa883c 100644 --- a/routes/scenes/scene_object_names_list.py +++ b/routes/scenes/scene_object_names_list.py @@ -9,12 +9,13 @@ from core.flask_app import app def scene_object_names_list(slug): """Noms d'objets de scène à jour, pour repeupler le sélecteur "qui parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON, - templates/scene_edit.html, questSpeakerOptions() dans static/js/ - quests/quest-editor.js) — cette variable n'est posée qu'une fois au - chargement complet de la page (HORS de #builder3, jamais rafraîchie - par une sélection d'objet sans rechargement, voir goToSelected()) : - sans cet endpoint, renommer un personnage puis rouvrir la modale de - dialogue dans la MÊME session ne faisait jamais apparaître son nouveau - nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre - temps — appelée en AJAX depuis saveSceneObjectName().""" + templates/scene_edit.html, triggerSpeakerOptions() dans static/js/ + triggers/trigger-editor.js) — cette variable n'est posée qu'une fois + au chargement complet de la page (HORS de #builder3, jamais + rafraîchie par une sélection d'objet sans rechargement, voir + goToSelected()) : sans cet endpoint, renommer un personnage puis + rouvrir un dialogue de déclencheur dans la MÊME session ne faisait + jamais apparaître son nouveau nom, tant qu'aucun vrai rechargement de + page n'avait eu lieu entre temps — appelée en AJAX depuis + saveSceneObjectName().""" return jsonify(screens.list_named_scene_object_names(slug)) diff --git a/routes/triggers/__init__.py b/routes/triggers/__init__.py new file mode 100644 index 00000000..15629daa --- /dev/null +++ b/routes/triggers/__init__.py @@ -0,0 +1 @@ +from . import triggers diff --git a/routes/triggers/triggers.py b/routes/triggers/triggers.py new file mode 100644 index 00000000..d6f89647 --- /dev/null +++ b/routes/triggers/triggers.py @@ -0,0 +1,112 @@ +from flask import jsonify, request + +import screens + +from core.flask_app import app + + +@app.route("/game//triggers/objects-catalog", methods=["GET"]) +def triggers_objects_catalog(slug): + """Catalogue des objets de scène éligibles à un déclencheur, pour la + grille "choisis un objet" de la modale d'ajout (voir + screens.list_all_collision_objects).""" + return jsonify(screens.list_all_collision_objects(slug)) + + +@app.route("/game//triggers", methods=["GET"]) +def triggers_get(slug): + """TOUS les déclencheurs du jeu (voir screens.list_all_triggers) — plus + de recherche inversée par quête, la logique est portée directement par + chaque déclencheur.""" + return jsonify(screens.list_all_triggers(slug)) + + +@app.route("/game//triggers", methods=["POST"]) +def triggers_add(slug): + """Ajoute un déclencheur sur l'objet choisi — body {object_id, rule}, + `rule` construite par l'assistant "déclencheur -> action" (voir + openTriggerWizard, static/js/scenes/collision-rules-editor.js) : + n'importe quel type de déclencheur/action qu'il propose passe donc + déjà tel quel, sans changement à faire ici si de nouveaux types + apparaissent un jour. Même contrat "liste ENTIÈRE" que l'éditeur de + collision (routes/scenes/scene_object_collision_rules.py) : on + recharge les règles existantes de l'objet, on y ajoute la nouvelle, on + sauvegarde la liste complète.""" + body = request.get_json(silent=True) or {} + try: + object_id = int(body.get("object_id")) + except (TypeError, ValueError): + return jsonify({"error": "object_id invalide"}), 400 + obj = screens.get_scene_object(slug, object_id) + if obj is None: + return jsonify({"error": "objet introuvable"}), 404 + new_rule = body.get("rule") + if not isinstance(new_rule, dict): + return jsonify({"error": "rule invalide"}), 400 + + rules = screens.resolve_collision_rules(obj) + rules.append(new_rule) + sanitized = screens.sanitize_collision_rules(rules) + screens.set_scene_object_collision_rules(slug, object_id, sanitized) + return jsonify({"ok": True}) + + +@app.route("/game//triggers/", methods=["DELETE"]) +def triggers_delete(slug, object_id): + """Retire TOUTES les règles de collision de CET objet — sans quête + pour distinguer "cette règle-ci" d'une autre, un objet ne porte de + toute façon en pratique qu'un seul déclencheur à la fois (posé par + l'assistant, jamais cumulé).""" + obj = screens.get_scene_object(slug, object_id) + if obj is None: + return jsonify({"error": "objet introuvable"}), 404 + screens.set_scene_object_collision_rules(slug, object_id, []) + return jsonify({"ok": True}) + + +@app.route("/game//triggers//dialogue", methods=["POST"]) +def trigger_dialogue_update(slug, object_id): + """Édition EN LIGNE des répliques d'UN dialogue précis (voir + screens/rendering/collision_rules.py::"dialogue".dialogue) — body + {dialogue_id, lines}. 404 si `dialogue_id` ne correspond à aucun + dialogue de CET objet (règle supprimée entre-temps, référence + orpheline).""" + obj = screens.get_scene_object(slug, object_id) + if obj is None: + return jsonify({"error": "objet introuvable"}), 404 + body = request.get_json(silent=True) or {} + dialogue_id = body.get("dialogue_id") + if not isinstance(dialogue_id, str) or not dialogue_id.strip(): + return jsonify({"error": "dialogue_id invalide"}), 400 + + rules = screens.resolve_collision_rules(obj) + if not screens.set_dialogue_lines_in_rules(rules, dialogue_id, body.get("lines")): + return jsonify({"error": "dialogue introuvable"}), 404 + sanitized = screens.sanitize_collision_rules(rules) + screens.set_scene_object_collision_rules(slug, object_id, sanitized) + return jsonify({"ok": True}) + + +@app.route("/game//triggers//append-action", methods=["POST"]) +def trigger_append_action(slug, object_id): + """"+ Ajouter une action" en bout de chaîne (voir screens/rendering/ + collision_rules.py::set_then_action_in_rules, static/js/triggers/ + trigger-editor.js) — body {after_id, action} : `after_id` est le + dialogue.id (feuille "dialogue") ou id (feuille "variable") de la + DERNIÈRE feuille d'une chaîne. 404 si `after_id` introuvable ou si + cette feuille a déjà un "then" (jamais écrasé par cette route — un + nouvel appel doit d'abord recharger la chaîne à jour).""" + obj = screens.get_scene_object(slug, object_id) + if obj is None: + return jsonify({"error": "objet introuvable"}), 404 + body = request.get_json(silent=True) or {} + after_id = body.get("after_id") + if not isinstance(after_id, str) or not after_id.strip(): + return jsonify({"error": "after_id invalide"}), 400 + + rules = screens.resolve_collision_rules(obj) + if not screens.set_then_action_in_rules(rules, after_id, body.get("action")): + return jsonify({"error": "action précédente introuvable ou déjà chaînée"}), 404 + sanitized = screens.sanitize_collision_rules(rules) + screens.set_scene_object_collision_rules(slug, object_id, sanitized) + return jsonify({"ok": True}) diff --git a/screens/__init__.py b/screens/__init__.py index 96c7ac40..6c283f73 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -54,8 +54,7 @@ from .rendering.personnage_data import resolve_personnage_data, resolve_personna from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES from .rendering.collision_rules import ( - resolve_collision_rules, sanitize_collision_rules, - ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX, + resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules, ) from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .rendering.scene_object_names import list_named_scene_object_names @@ -87,6 +86,11 @@ from .scenes.set_scene_object_collision_rules import set_scene_object_collision_ from .scenes.set_scene_object_name import set_scene_object_name from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style from .scenes.render_scene_object import render_scene_object +from .scenes.build_collision_rule_objects import build_collision_rule_objects +from .rendering.trigger_graph import ( + list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines, + list_completable_dialogue_ids, +) from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES from .flow.ensure_flow_schema import ensure_flow_schema diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 47e98396..4b22ab94 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -10,6 +10,7 @@ from ..rendering.personnage_role import resolve_personnage_role from ..rendering.collision_settings import resolve_collision_settings from ..rendering.collision_rules import resolve_collision_rules from ..rendering.dialogue_box_style import resolve_dialogue_box_style +from ..rendering.trigger_graph import list_completable_dialogue_ids from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -156,18 +157,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # ci-dessus sont déjà indexés par id, jamais par nom. definition_names = {str(d["id"]): d["name"] for d in definitions} - # Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais - # rattachées à un écran comme "elements" ci-dessus — nécessaires ici - # pour que static/js/play/dialogue-box-controller.js puisse retrouver, - # à l'exécution d'une action "quete" (collision-rules-controller.js), - # le dialogue de la colonne correspondant au STATUT ACTUEL de la - # quête (nouvelle/en_cours/terminee) sans aller-retour serveur. - quests = db.list_quests(slug) + # Déclencheurs "terminés" (voir screens/rendering/collision_rules.py:: + # "dialogue".mark_completed) : GAME-WIDE, la liste des id de dialogue + # que le créateur a marqués comme concluant la partie — nécessaire à + # static/js/play/dialogue-box-controller.js:: + # forgeSyncAllDialoguesCompletionToScorm pour savoir quand TOUS ont + # été joués, sans plus aucune notion de quête. + completable_dialogue_ids = list_completable_dialogue_ids(slug) return { "screens": payload_screens, "flows": flows, "animations": animations, "data": data, "fields_meta": fields_meta, "variables": variables, "personnage_animations": personnage_animations, "definition_names": definition_names, - "quests": quests, + "completable_dialogue_ids": completable_dialogue_ids, } diff --git a/screens/rendering/collision_rules.py b/screens/rendering/collision_rules.py index 330f60a2..f8b85950 100644 --- a/screens/rendering/collision_rules.py +++ b/screens/rendering/collision_rules.py @@ -1,42 +1,166 @@ import json -# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir -# templates/scene_edit.html) : chaque objet de scène (hors le personnage -# "joueur" — voir screens/rendering/personnage_role.py — et un "fond", -# jamais une cible/source de collision) porte une LISTE de règles -# "déclencheur -> action", construites carte par carte dans l'éditeur. +from db.dialogue_lines import sanitize_dialogue_lines +from screens.flow.constants import CONDITION_OPERATORS +from screens.labels.data_operations import DATA_OPERATION_LABELS + +# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js:: +# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque +# objet de scène (hors le personnage "joueur" — voir screens/rendering/ +# personnage_role.py — et un "fond", jamais une cible/source de collision) +# porte une LISTE de règles "déclencheur -> action". # # Déclencheur : "collision" (chevauchement direct, voir -# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le -# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). +# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur +# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant, +# demande explicite). # -# Action (une seule par règle) : -# - "quete" : quete_id (id technique d'une quête — voir db/quests/, -# choisie/créée via l'assistant "+ Action" qui ouvre -# directement l'éditeur de quête, static/js/quests/ -# quest-editor.js) — une quête inexistante/supprimée -# depuis reste une référence orpheline plutôt qu'une -# contrainte SQL, comme le reste de ce moteur (voir -# evenement_id ci-dessous, même principe). -# - "attaque" : joue la pose "attack" du personnage si disponible (voir -# personnage-controller.js) — pas de système de combat -# (dégâts/PV...) pour l'instant, effet visuel seul. -# - "evenement" : evenement_id — réutilise TEL QUEL le système -# d'événements personnalisés existant (db.list_custom_events, -# window.dispatchGameEvent), la logique de flow d'un écran -# document peut donc déjà écouter ce déclenchement. -# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du -# joueur, voir personnage_commands.py) tant que la -# collision/le périmètre est actif, puis exécute -# `sub_action` (même forme qu'une action, SAUF -# "interagir" à nouveau — UN seul niveau d'imbrication, -# pas une récursion illimitée) à l'appui de la touche. -ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"] -TRIGGER_TYPES = ["collision", "perimetre"] -DEFAULT_PERIMETRE_PX = 5 +# Action : +# - "dialogue" : la logique n'est plus portée par une "quête" mais +# DIRECTEMENT par le déclencheur — cette action EST un +# dialogue, pas une référence à autre chose. `dialogue` +# (optionnel) : {id, lines} — `id` généré côté client +# (voir static/js/scenes/collision-rules-editor.js), +# `lines` une liste de répliques/questions (voir +# db/dialogue_lines.py) — chaque occurrence de +# "Déclencher ce dialogue" (directe, sous "interagir", +# ou dans une branche de "condition") porte SON PROPRE +# dialogue, édité en ligne dans l'onglet "Déclencheurs". +# `mark_completed` (optionnel, bool) : le créateur +# marque explicitement ce déclencheur comme "terminé" +# une fois ce dialogue joué (voir static/js/play/ +# dialogue-box-controller.js:: +# forgeSyncAllDialoguesCompletionToScorm). +# - "variable" : modifie une variable globale — MÊME vocabulaire que +# l'action "Modifier une variable" du graphe de logique +# document (voir screens/data_actions/apply_variable_action.py, +# screens/labels/data_operations.py::DATA_OPERATION_LABELS, +# réutilisé tel quel plutôt que réinventé) : +# `target_variable` (nom), `data_operation` (une clé de +# DATA_OPERATION_LABELS), `data_value` (optionnel, absent +# pour definir_bool_vrai/definir_bool_faux/basculer_bool +# qui n'ont besoin d'aucune valeur). `id` généré côté +# client, comme `dialogue.id` — sert à adresser CETTE +# feuille précise pour y attacher un "then" plus tard +# (voir set_then_action_in_rules). +# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" +# du joueur, voir personnage_commands.py) tant que la +# collision est active, puis exécute `sub_action` (même +# forme qu'une action, SAUF "interagir" à nouveau — UN +# seul niveau d'imbrication, pas une récursion illimitée) +# à l'appui de la touche. +# - "condition" : Si (voir +# screens/flow/constants.py::CONDITION_OPERATORS, même +# vocabulaire que le nœud Condition de l'éditeur de +# logique) -> exécute `si_vrai` ou `si_faux` (chacun soit +# None — "ne rien faire", soit une FEUILLE — "dialogue" +# ou "variable", voir maquette validée, JAMAIS une +# nouvelle "interagir"/"condition" imbriquée : une +# branche reste une feuille). +# +# CHAÎNAGE : une feuille ("dialogue" ou "variable") peut porter un champ +# optionnel "then" — l'action suivante à exécuter une fois celle-ci +# terminée, elle-même une feuille, qui peut à son tour avoir un "then" +# (borné par _MAX_THEN_CHAIN) — demande explicite : "pouvoir modifier un +# déclencheur pour lui ajouter des actions" (plusieurs actions chaînées, +# pas juste remplacer l'action actuelle). +# +# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant, +# demande explicite.) +ACTION_TYPES = ["dialogue", "variable", "interagir", "condition"] +LEAF_ACTION_TYPES = ("dialogue", "variable") +TRIGGER_TYPES = ["collision"] +CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS} # Profondeur max d'imbrication ("interagir" -> sous-action) — évite une # structure sans fin construite par un JSON posté à la main. _MAX_ACTION_DEPTH = 2 +# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH. +_MAX_THEN_CHAIN = 4 + + +_MAX_ACTION_ID_LENGTH = 40 +# Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/ +# data_operations.py::DATA_OPERATIONS) — data_value est toujours omis +# pour celles-ci, quoi que le client envoie (même logique que +# static/js/scenes/collision-rules-editor.js::COLLISION_NO_VALUE_OPERATIONS, +# qui masque le champ correspondant côté formulaire). +_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool") + + +def _sanitize_dialogue(raw_dialogue): + if not isinstance(raw_dialogue, dict): + return None + dialogue_id = raw_dialogue.get("id") + if not isinstance(dialogue_id, str) or not dialogue_id.strip(): + return None + return { + "id": dialogue_id.strip()[:_MAX_ACTION_ID_LENGTH], + "lines": sanitize_dialogue_lines(raw_dialogue.get("lines")), + } + + +def _sanitize_dialogue_action(raw_action): + action = {"type": "dialogue"} + dialogue = _sanitize_dialogue(raw_action.get("dialogue")) + if dialogue: + action["dialogue"] = dialogue + if raw_action.get("mark_completed") is True: + action["mark_completed"] = True + return action + + +def _sanitize_variable_action(raw_action): + target_variable = raw_action.get("target_variable") + if not isinstance(target_variable, str) or not target_variable.strip(): + return None + data_operation = raw_action.get("data_operation") + if data_operation not in DATA_OPERATION_LABELS: + return None + action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation} + action_id = raw_action.get("id") + if isinstance(action_id, str) and action_id.strip(): + action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH] + if data_operation not in _NO_VALUE_OPERATIONS: + raw_value = raw_action.get("data_value") + if isinstance(raw_value, str) and raw_value.strip(): + action["data_value"] = raw_value.strip() + elif isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool): + action["data_value"] = str(raw_value) + return action + + +def _sanitize_leaf_action(raw_action, depth=1): + """Sanitize une FEUILLE ("dialogue" ou "variable") et son éventuelle + chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une + feuille est attendue : action de premier niveau, sous "interagir", ou + branche Vrai/Faux de "condition".""" + if not isinstance(raw_action, dict): + return None + action_type = raw_action.get("type") + if action_type == "dialogue": + action = _sanitize_dialogue_action(raw_action) + elif action_type == "variable": + action = _sanitize_variable_action(raw_action) + else: + return None + if not action: + return None + if depth < _MAX_THEN_CHAIN: + then_action = _sanitize_leaf_action(raw_action.get("then"), depth=depth + 1) + if then_action: + action["then"] = then_action + return action + + +def _sanitize_condition_branch(raw_branch): + """Une branche Vrai/Faux de "condition" : soit None ("ne rien + faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou + "variable", éventuellement chaînée via "then") — jamais "interagir"/ + "condition" à nouveau, pour garder la structure bornée (voir maquette + validée : chaque branche n'a que ces choix).""" + if raw_branch is None: + return None + return _sanitize_leaf_action(raw_branch) def _sanitize_action(raw_action, depth=1): @@ -47,31 +171,33 @@ def _sanitize_action(raw_action, depth=1): return None if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH: return None # un seul niveau d'imbrication autorisé - action = {"type": action_type} - if action_type == "quete": - try: - action["quete_id"] = int(raw_action.get("quete_id")) - except (TypeError, ValueError): - return None - elif action_type == "evenement": - try: - action["evenement_id"] = int(raw_action.get("evenement_id")) - except (TypeError, ValueError): - return None - elif action_type == "interagir": + if action_type in LEAF_ACTION_TYPES: + return _sanitize_leaf_action(raw_action) + if action_type == "interagir": sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1) if not sub_action: return None - action["sub_action"] = sub_action - # "attaque" : aucun champ supplémentaire. - return action + return {"type": "interagir", "sub_action": sub_action} + # "condition" + variable = (raw_action.get("variable") or "").strip() + operateur = raw_action.get("operateur") + if not variable or operateur not in CONDITION_OPERATOR_KEYS: + return None + return { + "type": "condition", + "variable": variable, + "operateur": operateur, + "valeur": str(raw_action.get("valeur") if raw_action.get("valeur") is not None else ""), + "si_vrai": _sanitize_condition_branch(raw_action.get("si_vrai")), + "si_faux": _sanitize_condition_branch(raw_action.get("si_faux")), + } def sanitize_collision_rules(raw_rules): """Valide/nettoie une liste de règles postée par le client (voir - routes/scenes/scene_object_collision_rules.py) — une règle invalide - (déclencheur/action inconnus, imbrication trop profonde...) est - IGNORÉE plutôt que de faire planter la sauvegarde entière.""" + routes/triggers/) — une règle invalide (déclencheur/action inconnus, + imbrication trop profonde...) est IGNORÉE plutôt que de faire planter + la sauvegarde entière.""" if not isinstance(raw_rules, list): return [] cleaned = [] @@ -84,16 +210,96 @@ def sanitize_collision_rules(raw_rules): action = _sanitize_action(raw_rule.get("action")) if not action: continue - rule = {"trigger": trigger, "action": action} - if trigger == "perimetre": - try: - rule["perimetre_px"] = max(1, float(raw_rule.get("perimetre_px", DEFAULT_PERIMETRE_PX))) - except (TypeError, ValueError): - rule["perimetre_px"] = DEFAULT_PERIMETRE_PX - cleaned.append(rule) + cleaned.append({"trigger": trigger, "action": action}) return cleaned +def _apply_dialogue_lines(action, dialogue_id, lines): + if not isinstance(action, dict): + return False + action_type = action.get("type") + if action_type == "dialogue": + dialogue = action.get("dialogue") + if isinstance(dialogue, dict) and dialogue.get("id") == dialogue_id: + action["dialogue"] = {"id": dialogue_id, "lines": lines} + return True + return _apply_dialogue_lines(action.get("then"), dialogue_id, lines) + if action_type == "variable": + return _apply_dialogue_lines(action.get("then"), dialogue_id, lines) + if action_type == "interagir": + return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines) + if action_type == "condition": + return ( + _apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines) + or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines) + ) + return False + + +def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines): + """Met à jour EN PLACE les répliques du dialogue `dialogue_id`, + où qu'il se trouve dans l'arbre d'action d'une des règles (direct, + chaîné via "then", sous "interagir", ou dans une branche de + "condition") — utilisé par routes/triggers/ pour l'édition en ligne + d'un dialogue depuis l'onglet "Déclencheurs". Retourne False sans + rien modifier si `dialogue_id` n'existe dans aucune règle (référence + orpheline, ex. règle supprimée entre-temps).""" + lines = sanitize_dialogue_lines(raw_lines) + for rule in rules: + if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines): + return True + return False + + +def _leaf_id(action): + if action.get("type") == "dialogue": + dialogue = action.get("dialogue") + return dialogue.get("id") if isinstance(dialogue, dict) else None + if action.get("type") == "variable": + return action.get("id") + return None + + +def _apply_then_action(action, leaf_id, then_action): + if not isinstance(action, dict): + return False + action_type = action.get("type") + if action_type in LEAF_ACTION_TYPES: + if _leaf_id(action) == leaf_id: + if "then" in action: + return False # jamais écraser un chaînage déjà posé + action["then"] = then_action + return True + return _apply_then_action(action.get("then"), leaf_id, then_action) + if action_type == "interagir": + return _apply_then_action(action.get("sub_action"), leaf_id, then_action) + if action_type == "condition": + return ( + _apply_then_action(action.get("si_vrai"), leaf_id, then_action) + or _apply_then_action(action.get("si_faux"), leaf_id, then_action) + ) + return False + + +def set_then_action_in_rules(rules, leaf_id, raw_then_action): + """Attache EN PLACE une NOUVELLE action `then` sur la feuille + ("dialogue" ou "variable") identifiée par `leaf_id` (dialogue.id pour + une feuille dialogue, id pour une feuille variable) — n'importe où + dans l'arbre d'action d'une des règles. Utilisé par routes/triggers/ + pour "+ Ajouter une action" en bout de chaîne (voir static/js/triggers/ + trigger-editor.js). Retourne False sans rien modifier si `leaf_id` + est introuvable, OU si la feuille trouvée a DÉJÀ un "then" (on + n'écrase jamais un chaînage existant par cette route — seulement + ajouter à la fin réelle de la chaîne).""" + then_action = _sanitize_leaf_action(raw_then_action) + if not then_action: + return False + for rule in rules: + if _apply_then_action(rule.get("action"), leaf_id, then_action): + return True + return False + + def resolve_collision_rules(obj): """Décode attributes._collision_rules en liste Python, jamais None — un attribut absent/corrompu retombe sur aucune règle (comportement diff --git a/screens/rendering/dialogue_box_style.py b/screens/rendering/dialogue_box_style.py index 40aa6059..4a4e9900 100644 --- a/screens/rendering/dialogue_box_style.py +++ b/screens/rendering/dialogue_box_style.py @@ -3,7 +3,7 @@ import json # Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface", # templates/scene_edit.html, screens/scenes/add_scene_object.py kind= # "dialogue_box") : affiche AUTOMATIQUEMENT, en jeu, la réplique en cours -# d'une quête déclenchée par une règle de collision "quete" (voir +# d'un dialogue déclenché par une règle de collision "dialogue" (voir # static/js/play/dialogue-box-controller.js) — header (nom du # personnage qui parle), corps (texte), pied (bouton "Suivant"), # entièrement stylisable par l'auteur. diff --git a/screens/rendering/scene_object_names.py b/screens/rendering/scene_object_names.py index 97f06551..f49cd52f 100644 --- a/screens/rendering/scene_object_names.py +++ b/screens/rendering/scene_object_names.py @@ -11,10 +11,10 @@ _EXCLUDED_KINDS = {"dialogue_box"} def list_named_scene_object_names(slug): """Noms distincts de tous les objets de scène nommés (voir "ℹ️ Informations", templates/scene_edit.html) — n'importe quel kind - (personnage, décor, fond), toutes scènes 2D du jeu confondues : une - quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre - en scène un objet posé sur une autre scène que celle où la règle de - collision se déclenche. Ordre alphabétique, jamais de doublon.""" + (personnage, décor, fond), toutes scènes 2D du jeu confondues : un + déclencheur est GAME-WIDE, son dialogue peut donc mettre en scène un + objet posé sur une autre scène que celle où la règle de collision se + déclenche. Ordre alphabétique, jamais de doublon.""" names = set() for s in list_screens(slug): if s["kind"] != "jeu_2d": diff --git a/screens/rendering/trigger_graph.py b/screens/rendering/trigger_graph.py new file mode 100644 index 00000000..09d01909 --- /dev/null +++ b/screens/rendering/trigger_graph.py @@ -0,0 +1,199 @@ +def _game_objects_with_thumbnails(slug, screens_mod): + """Objets de TOUTES les scènes "jeu_2d" du jeu (contrairement à + screens.list_scene_objects, qui ne prend qu'un screen_id) — + rendered_html ajouté comme l'exigent screens.build_collision_rule_objects + (voir routes/scenes/scene_edit_view.py pour le même patron, une seule + scène).""" + result = [] + for s in screens_mod.list_screens(slug): + if s["kind"] != "jeu_2d": + continue + objects = screens_mod.list_scene_objects(slug, s["id"]) + for o in objects: + o["rendered_html"] = screens_mod.render_scene_object(o) + o["screen_id"] = s["id"] + o["screen_name"] = s["name"] + result.extend(objects) + return result + + +def _describe_leaf(action): + """Une seule feuille ("dialogue" ou "variable", voir + screens/rendering/collision_rules.py) décrite pour l'affichage, + SANS son éventuel "then" (voir _describe_chain, qui aplatit la + chaîne complète).""" + if action.get("type") == "dialogue": + return {"type": "dialogue", "dialogue": action.get("dialogue")} + return { + "type": "variable", + "id": action.get("id"), + "target_variable": action.get("target_variable"), + "data_operation": action.get("data_operation"), + "data_value": action.get("data_value"), + } + + +def _describe_chain(action): + """Aplatit une feuille + ses "then" successifs en une LISTE, dans + l'ordre d'exécution — voir maquette validée ("plusieurs actions + chaînées"). Liste vide si `action` n'est ni "dialogue" ni "variable".""" + chain = [] + node = action + while isinstance(node, dict) and node.get("type") in ("dialogue", "variable"): + chain.append(_describe_leaf(node)) + node = node.get("then") + return chain + + +def _branch_info(branch): + if not isinstance(branch, dict) or branch.get("type") not in ("dialogue", "variable"): + return None + return {"chain": _describe_chain(branch)} + + +def _resolve_action_trigger(action, has_interagir=False): + """Décrit ENTIÈREMENT un arbre d'action (voir screens/rendering/ + collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" — + chaque feuille ("dialogue"/"variable") affiche DIRECTEMENT son propre + contenu, où qu'elle soit dans l'arbre (y compris CHAQUE branche d'une + "condition", indépendamment l'une de l'autre), et sa chaîne "then" + complète (voir _describe_chain). Retourne {has_interagir, chain, + condition} — `chain` est une LISTE (jamais None, vide si l'action ne + se termine par aucune feuille reconnue) ; `condition` est None sauf + pour une action "condition", auquel cas il porte {variable, operateur, + valeur, si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne + rien faire") ou {chain: [...]}.""" + if not isinstance(action, dict): + return {"has_interagir": has_interagir, "chain": [], "condition": None} + action_type = action.get("type") + if action_type in ("dialogue", "variable"): + return {"has_interagir": has_interagir, "chain": _describe_chain(action), "condition": None} + if action_type == "interagir": + return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True) + if action_type == "condition": + return { + "has_interagir": has_interagir, "chain": [], + "condition": { + "variable": action.get("variable"), "operateur": action.get("operateur"), + "valeur": action.get("valeur"), + "si_vrai": _branch_info(action.get("si_vrai")), + "si_faux": _branch_info(action.get("si_faux")), + }, + } + return {"has_interagir": has_interagir, "chain": [], "condition": None} + + +def list_all_triggers(slug): + """TOUS les déclencheurs du jeu, un par occurrence de "À la collision" + posée sur un objet de scène — remplace l'ancienne recherche inversée + par quête : plus de filtrage par id, chaque déclencheur affiche + directement sa propre logique (voir onglet "Déclencheurs", + static/js/triggers/trigger-editor.js).""" + import screens as screens_mod + + objects = _game_objects_with_thumbnails(slug, screens_mod) + rule_objects = screens_mod.build_collision_rule_objects(objects) + + result = [] + for obj in rule_objects: + for rule in obj["rules"]: + info = _resolve_action_trigger(rule["action"]) + result.append({ + "object_id": obj["id"], + "object_name": obj["name"], + "thumbnail_html": obj["thumbnail_html"], + "has_interagir": info["has_interagir"], + "trigger": rule["trigger"], + "condition": info["condition"], + "chain": info["chain"], + }) + return result + + +def _collect_dialogue_lines_from_action(action, acc): + if not isinstance(action, dict): + return + action_type = action.get("type") + if action_type == "dialogue": + dialogue = action.get("dialogue") + if isinstance(dialogue, dict): + acc.append(dialogue.get("lines") or []) + _collect_dialogue_lines_from_action(action.get("then"), acc) + elif action_type == "variable": + _collect_dialogue_lines_from_action(action.get("then"), acc) + elif action_type == "interagir": + _collect_dialogue_lines_from_action(action.get("sub_action"), acc) + elif action_type == "condition": + _collect_dialogue_lines_from_action(action.get("si_vrai"), acc) + _collect_dialogue_lines_from_action(action.get("si_faux"), acc) + + +def collect_all_dialogue_lines(slug): + """Toutes les répliques/questions de TOUS les dialogues embarqués dans + les règles de collision de TOUS les objets de scène du jeu (voir + screens/rendering/collision_rules.py::"dialogue".dialogue), y compris + au fil d'une chaîne "then" — une liste de listes de lignes (une par + dialogue trouvé), jamais fusionnée ici. Utilisée pour calculer le + score max possible du quiz à l'export SCORM (voir + publish/build_scorm_package.py).""" + import screens as screens_mod + + acc = [] + for s in screens_mod.list_screens(slug): + if s["kind"] != "jeu_2d": + continue + for obj in screens_mod.list_scene_objects(slug, s["id"]): + for rule in screens_mod.resolve_collision_rules(obj): + _collect_dialogue_lines_from_action(rule.get("action"), acc) + return acc + + +def _collect_completable_ids_from_action(action, acc): + if not isinstance(action, dict): + return + action_type = action.get("type") + if action_type == "dialogue": + dialogue = action.get("dialogue") + if action.get("mark_completed") and isinstance(dialogue, dict) and dialogue.get("id"): + acc.append(dialogue["id"]) + _collect_completable_ids_from_action(action.get("then"), acc) + elif action_type == "variable": + _collect_completable_ids_from_action(action.get("then"), acc) + elif action_type == "interagir": + _collect_completable_ids_from_action(action.get("sub_action"), acc) + elif action_type == "condition": + _collect_completable_ids_from_action(action.get("si_vrai"), acc) + _collect_completable_ids_from_action(action.get("si_faux"), acc) + + +def list_completable_dialogue_ids(slug): + """Tous les id de dialogue du jeu dont l'action porte + `mark_completed` (voir screens/rendering/collision_rules.py), y + compris au fil d'une chaîne "then" — utilisée par static/js/play/ + dialogue-box-controller.js::forgeSyncAllDialoguesCompletionToScorm + pour savoir quand la partie est terminée (TOUS joués), sans plus + aucune notion de quête.""" + import screens as screens_mod + + acc = [] + for s in screens_mod.list_screens(slug): + if s["kind"] != "jeu_2d": + continue + for obj in screens_mod.list_scene_objects(slug, s["id"]): + for rule in screens_mod.resolve_collision_rules(obj): + _collect_completable_ids_from_action(rule.get("action"), acc) + return acc + + +def list_all_collision_objects(slug): + """Catalogue de TOUS les objets éligibles à une règle de collision, + toutes scènes "jeu_2d" confondues — pour peupler le sélecteur d'objet + de la modale d'ajout de déclencheur (voir routes/triggers/).""" + import screens as screens_mod + + objects = _game_objects_with_thumbnails(slug, screens_mod) + rule_objects = screens_mod.build_collision_rule_objects(objects) + return [ + {"id": o["id"], "name": o["name"], "kind": o["kind"], "thumbnail_html": o["thumbnail_html"]} + for o in rule_objects + ] diff --git a/routes/scenes/collision_rule_objects.py b/screens/scenes/build_collision_rule_objects.py similarity index 88% rename from routes/scenes/collision_rule_objects.py rename to screens/scenes/build_collision_rule_objects.py index 8fd72911..994799cf 100644 --- a/routes/scenes/collision_rule_objects.py +++ b/screens/scenes/build_collision_rule_objects.py @@ -10,9 +10,11 @@ def build_collision_rule_objects(objects): TOUJOURS les AUTRES objets qui réagissent à SA présence, voir screens/rendering/collision_rules.py) — factorisé ici plutôt que dupliqué : utilisé au premier rendu de la page (routes/scenes/ - scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après - ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/ - scene-editor.js) pour reconstruire la liste SANS recharger la page. + scene_edit_view.py), par l'endpoint AJAX scene_collision_rule_objects.py + (appelé après ajout/suppression/changement de rôle ou de nom, voir + static/js/scenes/scene-editor.js) ET par le listage de TOUS les + déclencheurs du jeu (screens/rendering/trigger_graph.py) — vit dans + screens/ (pas routes/) pour être appelable depuis les deux. `objects` doit déjà porter "rendered_html" (voir screens.render_scene_object) sur chaque élément. diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index b787ecdf..d86da49d 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -47,7 +47,7 @@ def render_scene_object(obj): # body/footer sont remplis EN JEU (data-dialogue-role sert de # sélecteur), masqué par défaut au jeu (voir static/style.css, # `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de - # collision "quete" ait un dialogue à afficher. Toujours VISIBLE + # collision "dialogue" ait un dialogue à afficher. Toujours VISIBLE # dans l'éditeur (avec un texte d'exemple) pour pouvoir le # positionner/styliser. style = resolve_dialogue_box_style(obj) diff --git a/screens/scenes/set_scene_object_name.py b/screens/scenes/set_scene_object_name.py index 45acf2c6..892e6ab0 100644 --- a/screens/scenes/set_scene_object_name.py +++ b/screens/scenes/set_scene_object_name.py @@ -9,8 +9,8 @@ def set_scene_object_name(slug, object_id, name): partout ailleurs, mais jamais éditable avant le panneau "ℹ️ Informations", templates/scene_edit.html). Pour un personnage, ce nom est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue - de quête (voir screens/rendering/scene_object_names.py, static/js/ - quests/quest-editor.js).""" + d'un déclencheur (voir screens/rendering/scene_object_names.py, + static/js/triggers/trigger-editor.js).""" ensure_scene_schema(slug) conn = db.connect(slug) conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id)) diff --git a/static/js/play/__tests__/collision-quete-dialogue-integration.test.js b/static/js/play/__tests__/collision-dialogue-integration.test.js similarity index 73% rename from static/js/play/__tests__/collision-quete-dialogue-integration.test.js rename to static/js/play/__tests__/collision-dialogue-integration.test.js index 2cec7214..f1980acd 100644 --- a/static/js/play/__tests__/collision-quete-dialogue-integration.test.js +++ b/static/js/play/__tests__/collision-dialogue-integration.test.js @@ -1,11 +1,12 @@ -// Bout en bout : une règle de collision "quete" doit réellement faire +// Bout en bout : une règle de collision "dialogue" doit réellement faire // apparaître/avancer/disparaître la boîte de dialogue posée sur la scène // — pas seulement chaque brique testée isolément (voir // collision-rules-controller.test.js et dialogue-box-controller.test.js). // Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle -// "à la collision -> déclencher la quête « test »", une quête au statut -// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur -// la même scène. +// "à la collision -> déclencher ce dialogue (avec mark_completed)", un +// widget "💬 Boîte de dialogue" posé sur la même scène — plus aucun écran +// d'offre automatique, le dialogue s'affiche directement et se marque +// terminé à la fin. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -22,7 +23,6 @@ const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // const { forgeCollisionRuleTick, } = require('../collision-rules-controller.js'); -const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js'); function fakeEl(style) { const classes = new Set(); @@ -43,11 +43,17 @@ function fakeEl(style) { }; } -test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => { +test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => { const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' }; const mrPinguin = { - id: 2, kind: 'personnage', - collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }], + id: 2, kind: 'personnage', name: 'Mr. Pinguin', + collision_rules: [{ + trigger: 'collision', + action: { + type: 'dialogue', mark_completed: true, + dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] }, + }, + }], }; const box = { id: 3, kind: 'dialogue_box' }; @@ -61,18 +67,10 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer boxWidget._footer = { innerHTML: '' }; global.window.currentScreenId = 1; + global.window.forgeCompletedDialogueIds = {}; global.gameData = { personnage_animations: {}, - quests: [{ - id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin', - dialogues: { - nouvelle: [ - { speaker: 'Mr. Pinguin', text: 'Bonjour !' }, - { speaker: 'Charlie', text: 'Salut !' }, - ], - en_cours: [], terminee: [], - }, - }], + completable_dialogue_ids: ['d_intro'], }; const previousDoc = global.document; @@ -94,12 +92,12 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer try { const screenData = { elements: [player, mrPinguin, box] }; - forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche la quête + forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche le dialogue assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); assert.equal(boxWidget._body.textContent, 'Bonjour !'); assert.equal(boxWidget.classList.contains('is-active'), true); - forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas la quête + forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget) @@ -107,14 +105,8 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer assert.equal(boxWidget._body.textContent, 'Salut !'); assert.equal(boxWidget.classList.contains('is-active'), true); - forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué - assert.equal(boxWidget.classList.contains('is-active'), true); - assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie'); - assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin'); - assert.match(boxWidget._footer.innerHTML, /Accepter/); - - forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter" - assert.equal(forgeQuestById(99).status, 'en_cours'); - assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît + forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée + assert.equal(boxWidget.classList.contains('is-active'), false); + assert.equal(window.forgeCompletedDialogueIds['d_intro'], true); } finally { global.document = previousDoc; } }); diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js index d11e01ec..d09b036a 100644 --- a/static/js/play/__tests__/collision-rules-controller.test.js +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -1,4 +1,4 @@ -// Moteur d'exécution des règles "🧩 Collision" (voir +// Moteur d'exécution des déclencheurs (voir // static/js/play/collision-rules-controller.js) : déclenche une action une // seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que // la condition reste vraie — et gère "interagir" à part (bulle + attente @@ -14,6 +14,7 @@ global.heldKeys = new Set(); const actionsMod = require('../actions.js'); Object.assign(global, actionsMod); require('../personnage-controller.js'); +global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline; const { forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, @@ -86,54 +87,117 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true); }); -test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => { - const objRect = rect(0, 0, 10, 10); // centre (5,5) - const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10 - assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true); - assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false); -}); - -test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => { - const calls = []; - global.window.dispatchGameEvent = id => calls.push(id); - forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 }); - assert.deepEqual(calls, [42]); -}); - -test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => { +test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js) avec le nom de l\'objet, les répliques DE CETTE ACTION et mark_completed', () => { const calls = []; const previousShow = global.forgeShowDialogueBox; - global.forgeShowDialogueBox = id => calls.push(id); - forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 }); + global.forgeShowDialogueBox = (...args) => calls.push(args); + const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }]; + forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' }); global.forgeShowDialogueBox = previousShow; - assert.deepEqual(calls, [42]); + assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]); }); -test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => { - global.window.dispatchGameEvent = () => {}; +test('forgeRunCollisionRuleAction — "dialogue" sans dialogue (référence orpheline) : transmet une liste vide plutôt que de planter', () => { + const calls = []; + const previousShow = global.forgeShowDialogueBox; + global.forgeShowDialogueBox = (...args) => calls.push(args); + forgeRunCollisionRuleAction({ type: 'dialogue' }, fakeEl(), { id: 1, name: 'Crokine' }); + global.forgeShowDialogueBox = previousShow; + assert.deepEqual(calls, [[undefined, 'Crokine', [], undefined, undefined]]); +}); + +test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_vrai', () => { + global.gameData = { variables: { inventaire_cle: { value: '1' } } }; + const calls = []; + const previousShow = global.forgeShowDialogueBox; + global.forgeShowDialogueBox = (...args) => calls.push(args); + const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }]; + const action = { + type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0', + si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null, + }; + forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' }); + global.forgeShowDialogueBox = previousShow; + assert.deepEqual(calls, [['d_vrai', 'Crokine', vraiLines, undefined, undefined]]); +}); + +test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_faux, jamais si_vrai', () => { + global.gameData = { variables: { inventaire_cle: { value: '0' } } }; + const calls = []; + const previousShow = global.forgeShowDialogueBox; + global.forgeShowDialogueBox = (...args) => calls.push(args); + const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }]; + const action = { + type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0', + si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, + si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } }, + }; + forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' }); + global.forgeShowDialogueBox = previousShow; + assert.deepEqual(calls, [['d_faux', 'Crokine', fauxLines, undefined, undefined]]); +}); + +test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien faire" (null) ne fait rien', () => { + global.gameData = { variables: { inventaire_cle: { value: '0' } } }; + const calls = []; + const previousShow = global.forgeShowDialogueBox; + global.forgeShowDialogueBox = (...args) => calls.push(args); + const action = { + type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0', + si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null, + }; + forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' }); + global.forgeShowDialogueBox = previousShow; + assert.deepEqual(calls, []); +}); + +test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => { + global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } }; + forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' }); + assert.equal(gameData.variables.score.value, '5'); +}); + +test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => { + global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } }; + const action = { + type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1', + then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' }, + }; + forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' }); + assert.equal(gameData.variables.score.value, '1'); + assert.equal(gameData.variables.vies.value, '2'); +}); + +test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeShowDialogueBox (exécuté APRÈS le dialogue, pas ici)', () => { + global.gameData = { variables: {} }; + const calls = []; + const previousShow = global.forgeShowDialogueBox; + global.forgeShowDialogueBox = (...args) => calls.push(args); + const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' }; + forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' }); + global.forgeShowDialogueBox = previousShow; + assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]); +}); + +test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => { const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } }; - const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }; + const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] }; const playerEl = fakeEl(); - const enemyEl = fakeEl(); - enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20) + const npcEl = fakeEl(); + npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20) const previousDoc = global.document; - global.document = fakeDocument({ 1: playerEl, 2: enemyEl }); + global.document = fakeDocument({ 1: playerEl, 2: npcEl }); global.gameData = { personnage_animations: {} }; + const previousShow = global.forgeShowDialogueBox; + const calls = []; + global.forgeShowDialogueBox = id => calls.push(id); try { - const screenData = { elements: [player, enemy] }; - let attackCalls = 0; - const originalRun = global.runSpriteAnimation; - global.runSpriteAnimation = () => { attackCalls++; }; + const screenData = { elements: [player, npc] }; forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas - global.runSpriteAnimation = originalRun; - // "attaque" ne joue une animation que si le personnage a une pose - // "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0 - // appel réel à runSpriteAnimation, mais le test vérifie surtout - // l'absence de double déclenchement plutôt que l'appel lui-même. - assert.equal(attackCalls, 0); - } finally { global.document = previousDoc; } + assert.deepEqual(calls, ['d_7']); + } finally { global.document = previousDoc; global.forgeShowDialogueBox = previousShow; } }); test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas le cadre brut du sprite', () => { @@ -148,7 +212,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas // Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas // (offset_y positif = décalée vers le bas, voir collision_settings.py). collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 }, - collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 1 } } }], + collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }], }; const parent = fakeParentEl(); const playerEl = fakeEl(parent); @@ -170,7 +234,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => { const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } }; - const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] }; + const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] }; const parentNear = fakeParentEl(); const parentFar = fakeParentEl(); const playerElNear = fakeEl(parentNear); @@ -198,5 +262,5 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => { assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] })); - assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] })); + assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] })); }); diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index 09f06253..33291672 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -1,7 +1,11 @@ // Widget "💬 Boîte de dialogue" — moteur d'exécution (voir -// static/js/play/dialogue-box-controller.js) : affiche la réplique en -// cours du dialogue d'une quête (gameData.quests) correspondant à son -// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin. +// static/js/play/dialogue-box-controller.js) : affiche les répliques +// D'UNE ACTION PRÉCISE (transmises en paramètre par collision-rules- +// controller.js, chaque occurrence de "Déclencher ce dialogue" porte les +// siennes, voir screens/rendering/collision_rules.py), avance au clic sur +// "Suivant", se masque à la fin — sans écran d'offre automatique ni +// notion de quête : le nom affiché (en-tête du quiz, xAPI) est celui de +// l'objet déclencheur. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -9,10 +13,9 @@ global.window = global.window || {}; global.gameData = {}; const { - forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, - forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, + forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets, - forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm, + forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm, forgeComputeFinalScormStatus, } = require('../dialogue-box-controller.js'); @@ -43,50 +46,37 @@ test.beforeEach(() => { // fuiraient sur le calcul de statut final du test suivant. global.window.forgeQuizTotalAnswered = 0; global.window.forgeQuizCorrectAnswered = 0; - global.gameData = { - quests: [ - { id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } }, - { id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } }, - { id: 8, status: 'terminee', dialogues: { nouvelle: [], en_cours: [], terminee: [{ speaker: 'PNJ', text: 'Merci !' }] } }, - ], - }; + global.window.forgeCompletedDialogueIds = {}; + global.gameData = { completable_dialogue_ids: [] }; }); -test('forgeQuestById retrouve une quête par id', () => { - assert.equal(forgeQuestById(5).id, 5); - assert.equal(forgeQuestById(999), undefined); -}); - -test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => { +test('forgeShowDialogueBox — sans dialogueId : aucun effet (pas de plantage)', () => { const previousDoc = global.document; global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } }; try { - assert.doesNotThrow(() => forgeShowDialogueBox(999)); + assert.doesNotThrow(() => forgeShowDialogueBox(null, 'PNJ', [])); } finally { global.document = previousDoc; } }); -test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => { +test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet', () => { const widget = fakeWidget(42); const previousDoc = global.document; global.document = { querySelector: () => widget }; try { - forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle" + forgeShowDialogueBox('d_6', 'PNJ', []); assert.equal(widget._classes.has('is-active'), false); + assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined); } finally { global.document = previousDoc; } }); -test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => { - global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} }); +test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dialogue terminé immédiatement, sans rien afficher', () => { const widget = fakeWidget(42); const previousDoc = global.document; global.document = { querySelector: () => widget }; try { - forgeShowDialogueBox(7); - assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu'); - assert.equal(widget._roles.body.textContent, 'Trouver le trésor'); - assert.match(widget._roles.footer.innerHTML, /Accepter/); - assert.match(widget._roles.footer.innerHTML, /Refuser/); - assert.equal(widget._classes.has('is-active'), true); + forgeShowDialogueBox('d_6', 'PNJ', [], true); + assert.equal(widget._classes.has('is-active'), false); + assert.equal(window.forgeCompletedDialogueIds['d_6'], true); } finally { global.document = previousDoc; } }); @@ -94,16 +84,16 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet', const previousDoc = global.document; global.document = { querySelector: () => null }; try { - assert.doesNotThrow(() => forgeShowDialogueBox(5)); + assert.doesNotThrow(() => forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }])); } finally { global.document = previousDoc; } }); -test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => { +test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => { const widget = fakeWidget(42); const previousDoc = global.document; global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; try { - forgeShowDialogueBox(8); // statut "terminee" -> pas d'offre à la fin, se masque normalement + forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]); assert.equal(widget._roles.header.textContent, 'PNJ'); assert.equal(widget._roles.body.textContent, 'Merci !'); assert.equal(widget._classes.has('is-active'), true); @@ -113,48 +103,68 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré } finally { global.document = previousDoc; } }); -test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => { +test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => { const widget = fakeWidget(42); const previousDoc = global.document; - global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; + global.document = { querySelector: () => widget }; try { - forgeShowDialogueBox(5); + forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]); forgeDialogueBoxAdvance(42); - forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué - assert.equal(widget._classes.has('is-active'), true); - assert.equal(widget._roles.header.textContent, 'Quête : La quête du sorcier'); - assert.equal(widget._roles.body.textContent, 'Parler au sorcier'); - assert.match(widget._roles.footer.innerHTML, /Accepter/); - assert.match(widget._roles.footer.innerHTML, /Refuser/); - } finally { global.document = previousDoc; } -}); - -test('forgeDialogueBoxAcceptQuest — fait passer la quête "en_cours" (en mémoire seulement) et masque la boîte', () => { - const widget = fakeWidget(42); - const previousDoc = global.document; - global.document = { querySelector: () => widget }; - try { - forgeShowDialogueBox(5); - widget._classes.add('is-active'); - forgeDialogueBoxAcceptQuest(5, 42); - assert.equal(forgeQuestById(5).status, 'en_cours'); + forgeDialogueBoxAdvance(42); // dernière réplique consommée + assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined); assert.equal(widget._classes.has('is-active'), false); } finally { global.document = previousDoc; } }); -test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => { +test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propage vers le SCORM', () => { const widget = fakeWidget(42); + global.gameData.completable_dialogue_ids = ['d_5']; // seul dialogue connu + global.gameData.scoring = { score: 0, status: 'en_cours' }; const previousDoc = global.document; global.document = { querySelector: () => widget }; try { - forgeShowDialogueBox(5); - widget._classes.add('is-active'); - forgeDialogueBoxDeclineQuest(42); - assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début + forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true); + forgeDialogueBoxAdvance(42); + forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé + assert.equal(window.forgeCompletedDialogueIds['d_5'], true); assert.equal(widget._classes.has('is-active'), false); + assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété } finally { global.document = previousDoc; } }); +test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiatement l\'action chaînée (rien à exhiber avant)', () => { + const widget = fakeWidget(42); + const previousDoc = global.document; + const previousRun = global.forgeRunCollisionRuleAction; + const calls = []; + global.forgeRunCollisionRuleAction = (...args) => calls.push(args); + global.document = { querySelector: () => widget }; + try { + const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' }; + forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], thenAction); + assert.equal(calls[0][2].name, 'Crokine'); + } finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; } +}); + +test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la dernière réplique, jamais avant', () => { + const widget = fakeWidget(42); + const previousDoc = global.document; + const previousRun = global.forgeRunCollisionRuleAction; + const calls = []; + global.forgeRunCollisionRuleAction = (...args) => calls.push(args); + global.document = { querySelector: () => widget }; + try { + const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' }; + forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction); + assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé + forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant + assert.equal(calls.length, 1); + assert.equal(calls[0][0], thenAction); + } finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; } +}); + test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => { const previousDoc = global.document; global.document = { querySelector: () => null }; @@ -206,36 +216,30 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) { }; } +const QUIZ_LINES = [ + { type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' }, + { type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 }, + { type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' }, +]; + test.beforeEach(() => { - global.gameData.quests.push({ - id: 10, title: 'Le quiz du sorcier', status: 'en_cours', - dialogues: { - nouvelle: [], - en_cours: [ - { type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' }, - { type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 }, - { type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' }, - ], - terminee: [], - }, - }); window.forgeQuizScore = 0; }); -test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête, corps = question+choix), pas la boîte de dialogue', () => { +test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => { const dialogueWidget = fakeWidget(42); dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null); const quizWidget = fakeQuizWidget(99, 2); const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { - forgeShowDialogueBox(10); + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal forgeDialogueBoxAdvance(42); // -> ligne "question" assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question assert.equal(quizWidget._classes.has('is-active'), true); - assert.equal(quizWidget._roles.header.textContent, 'Quête : Le quiz du sorcier'); + assert.equal(quizWidget._roles.header.textContent, 'Le sorcier'); assert.equal(quizWidget._roles.question.textContent, '2+2 ?'); assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/); assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/); @@ -248,7 +252,7 @@ test('mauvaise réponse au quiz : aucun point, MAIS avance quand même sur "Cont const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { - forgeShowDialogueBox(10); + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); forgeDialogueBoxAdvance(42); // -> question forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1) assert.equal(window.forgeQuizScore, 0); // aucun point @@ -271,7 +275,7 @@ test('un second clic pendant la révélation (avant "Continuer →") est ignoré const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { - forgeShowDialogueBox(10); + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); forgeDialogueBoxAdvance(42); forgeQuizBoxAnswer(99, 0); // 1er clic (faux) forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré @@ -287,7 +291,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { - forgeShowDialogueBox(10); + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES, true); forgeDialogueBoxAdvance(42); // -> question forgeQuizBoxAnswer(99, 1); // bonne réponse assert.equal(window.forgeQuizScore, 5); @@ -298,8 +302,8 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue assert.equal(dialogueWidget._classes.has('is-active'), true); assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); - forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee" - assert.equal(forgeQuestById(10).status, 'terminee'); + forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé + assert.equal(window.forgeCompletedDialogueIds['d_10'], true); assert.equal(dialogueWidget._classes.has('is-active'), false); } finally { global.document = previousDoc; } }); @@ -313,7 +317,7 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz try { - forgeShowDialogueBox(10); + forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES); forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse @@ -333,9 +337,9 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a }); // ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ---------- -// Bug signalé : "les indicateurs qu'on utilise pour les quêtes devraient -// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu -// construit uniquement avec des quêtes/quiz ne touchait jamais +// Bug signalé : "les indicateurs qu'on utilise pour les dialogues +// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — +// un jeu construit uniquement avec des dialogues/quiz ne touchait jamais // gameData.scoring, laissant Score/Completion perpétuellement "unknown" // dans le LMS même après avoir fini le jeu. @@ -366,42 +370,40 @@ test('forgeAddQuizScore ne rétrograde jamais un statut déjà plus avancé que } finally { global.document = previousDoc; } }); -test('accepter une quête fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => { +test('afficher un dialogue fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => { const widget = fakeWidget(42); global.gameData.scoring = undefined; const previousDoc = global.document; global.document = { querySelector: () => widget }; try { - forgeDialogueBoxAcceptQuest(5, 42); + forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]); assert.equal(gameData.scoring.status, 'en_cours'); } finally { global.document = previousDoc; } }); -test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => { - global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }]; +test('forgeSyncAllDialoguesCompletionToScorm — passe "termine" seulement quand TOUS les dialogues completable sont completed', () => { + global.gameData.completable_dialogue_ids = ['d_1', 'd_2']; + window.forgeCompletedDialogueIds = { d_1: true }; global.gameData.scoring = { score: 0, status: 'en_cours' }; - forgeSyncAllQuestsCompletionToScorm(); + forgeSyncAllDialoguesCompletionToScorm(); assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies - gameData.quests[1].status = 'terminee'; - forgeSyncAllQuestsCompletionToScorm(); + window.forgeCompletedDialogueIds.d_2 = true; + forgeSyncAllDialoguesCompletionToScorm(); assert.equal(gameData.scoring.status, 'termine'); }); -test('la dernière ligne d\'un dialogue "en_cours" propage la complétion à gameData.scoring si c\'est la seule quête', () => { - const widget = fakeWidget(42); - global.gameData.quests = [ - { id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } }, - ]; +test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () => { + const calls = []; + global.forgeXapiNotifyDialogueCompleted = (id, name) => calls.push({ id, name }); + global.gameData.completable_dialogue_ids = ['d_20']; global.gameData.scoring = { score: 0, status: 'en_cours' }; - const previousDoc = global.document; - global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; try { - forgeShowDialogueBox(20); - forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee" - assert.equal(forgeQuestById(20).status, 'terminee'); + forgeCompleteDialogue('d_20', 'Crokine'); + assert.equal(window.forgeCompletedDialogueIds['d_20'], true); + assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]); assert.equal(gameData.scoring.status, 'termine'); - } finally { global.document = previousDoc; } + } finally { delete global.forgeXapiNotifyDialogueCompleted; } }); // ---------- Seuil de réussite (voir db/games/get_success_threshold.py) ---------- @@ -433,31 +435,18 @@ test('forgeComputeFinalScormStatus — seuil par défaut (70%) si FORGE_SUCCESS_ assert.equal(forgeComputeFinalScormStatus(), 'reussi'); }); -test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => { - global.gameData.quests = [{ id: 1, status: 'terminee' }]; +test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "termine") une fois tous les dialogues finis', () => { + global.gameData.completable_dialogue_ids = ['d_1']; + window.forgeCompletedDialogueIds = { d_1: true }; global.gameData.scoring = { score: 0, status: 'en_cours' }; global.window.forgeQuizTotalAnswered = 4; global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%) delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT; - forgeSyncAllQuestsCompletionToScorm(); + forgeSyncAllDialoguesCompletionToScorm(); assert.equal(gameData.scoring.status, 'echoue'); }); -// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ---------- - -test('accepter une quête notifie xAPI (verbe "attempted" ciblant CETTE quête)', () => { - const widget = fakeWidget(42); - global.gameData.scoring = undefined; - const previousDoc = global.document; - global.document = { querySelector: () => widget }; - const calls = []; - global.forgeXapiNotifyQuestAccepted = quest => calls.push(quest); - try { - forgeDialogueBoxAcceptQuest(5, 42); - assert.equal(calls.length, 1); - assert.equal(calls[0].id, 5); - } finally { global.document = previousDoc; delete global.forgeXapiNotifyQuestAccepted; } -}); +// ---------- Granularité xAPI (dialogue terminé, question répondue) ---------- test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => { const dialogueWidget = fakeWidget(42); @@ -467,21 +456,19 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }), querySelectorAll: () => [], }; - global.gameData.quests = [ - { id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } }, - ]; const previousDoc = global.document; global.document = { querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget), querySelectorAll: () => [], }; const calls = []; - global.forgeXapiNotifyQuestionAnswered = (quest, index, text, correct, score) => calls.push({ quest, index, text, correct, score }); + global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score }); try { - forgeShowDialogueBox(5); + forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]); forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0) assert.equal(calls.length, 1); - assert.equal(calls[0].quest.id, 5); + assert.equal(calls[0].dialogueId, 'd_5'); + assert.equal(calls[0].objectName, 'Le Sorcier'); assert.equal(calls[0].correct, true); assert.equal(calls[0].score, 5); forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique) @@ -496,22 +483,19 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n. querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }), querySelectorAll: () => [], }; - global.gameData.quests = [ - { id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } }, - ]; const previousDoc = global.document; global.document = { querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget), querySelectorAll: () => [], }; const calls = []; - global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) => - calls.push({ quest, index, text, correct, choiceIndex, correctIndex }); + global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) => + calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex }); try { - forgeShowDialogueBox(5); + forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]); forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1) assert.equal(calls.length, 1); - assert.equal(calls[0].quest.id, 5); + assert.equal(calls[0].dialogueId, 'd_5'); assert.equal(calls[0].correct, false); assert.equal(calls[0].choiceIndex, 1); assert.equal(calls[0].correctIndex, 0); diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js index f1cc605f..6e25a278 100644 --- a/static/js/play/collision-rules-controller.js +++ b/static/js/play/collision-rules-controller.js @@ -1,13 +1,13 @@ // ---------- Éditeur de collision — moteur d'exécution en jeu ---------- // Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le // personnage "joueur" — voir screens/rendering/collision_rules.py) peut -// porter des règles "déclencheur -> action" posées dans l'onglet -// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier -// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre -// la position du joueur, déclenche l'action une seule fois par entrée -// (pas en continu tant que la condition reste vraie), même cadence que -// personnage-controller.js (50ms) et réutilise ses mêmes briques -// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js). +// porter des règles "déclencheur -> action", posées depuis l'onglet +// "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard). +// Ce fichier les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque +// règle contre la position du joueur, déclenche l'action une seule fois +// par entrée (pas en continu tant que la condition reste vraie), même +// cadence que personnage-controller.js (50ms) et réutilise ses mêmes +// briques (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js). // Pur JS client, sans le moindre appel serveur — fonctionne à l'identique // en ligne et dans un export SCORM (voir static/js/play/offline/), aucun // branchement FORGE_OFFLINE nécessaire ici. @@ -62,16 +62,10 @@ function forgeExpandRect(rect, margin) { // "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour // détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN -// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes -// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant -// pour une portée approximative, cohérent avec le reste du moteur qui ne -// vise pas une physique précise). +// ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne +// servait à rien pour l'instant, demande explicite). function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { - if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect); - var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2; - var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2; - var dist = Math.hypot(ocx - pcx, ocy - pcy); - return dist <= (rule.perimetre_px || 0); + return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect); } // La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un @@ -118,27 +112,50 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) { if (bubble) bubble.style.display = 'none'; } -// Exécute une action (top-level ou sub_action d'un "interagir") — jamais -// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick -// qui gère "interagir" à part (affichage de bulle + attente de touche). +// Exécute une action (top-level, sub_action d'un "interagir", ou branche +// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir" +// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part +// (affichage de bulle + attente de touche). function forgeRunCollisionRuleAction(action, targetEl, obj) { - if (action.type === 'evenement') { - window.dispatchGameEvent(action.evenement_id); - return; - } - if (action.type === 'attaque') { - var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null; - if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false })); - return; - } - if (action.type === 'quete') { - // Affiche la réplique en cours du dialogue de cette quête, selon son - // statut actuel, dans le widget "💬 Boîte de dialogue" posé sur - // l'écran (voir static/js/play/dialogue-box-controller.js) — sans - // effet si aucun widget n'est posé ou si la quête n'a aucune - // réplique pour son statut, comme le reste de ce moteur. - forgeShowDialogueBox(action.quete_id); - return; + if (action.type === 'dialogue') { + // Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines + // — chaque occurrence de "Déclencher ce dialogue" porte le sien, + // voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte + // de dialogue" posé sur l'écran (voir static/js/play/ + // dialogue-box-controller.js) — sans effet si aucun widget n'est + // posé, comme le reste de ce moteur. Le nom affiché (en-tête du + // quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️ + // Informations") — plus de "quête" pour le porter. mark_completed + // (optionnel) : marque ce dialogue terminé une fois épuisé. + // `action.then` (chaînage, voir maquette validée : "pouvoir modifier + // un déclencheur pour lui ajouter des actions") NE PEUT PAS + // s'exécuter immédiatement — un dialogue attend des clics du joueur + // — transmis à forgeShowDialogueBox, exécuté par dialogue-box- + // controller.js une fois le dialogue épuisé. + var lines = (action.dialogue && action.dialogue.lines) || []; + var dialogueId = action.dialogue && action.dialogue.id; + forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then); + } else if (action.type === 'variable') { + // Modifie une variable globale — même vocabulaire/calcul que l'action + // "Modifier une variable" du graphe de logique document (voir + // screens/data_actions/apply_variable_action.py), portée ici par + // forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js, + // TOUJOURS chargée — voir templates/play.html — pas seulement en + // export, donc aucun branchement FORGE_OFFLINE nécessaire, même + // philosophie que le reste de ce fichier). Instantané : `then` + // s'exécute tout de suite, contrairement à un dialogue. + forgeApplyVariableActionOffline(gameData, action); + if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); + } else if (action.type === 'condition') { + // Réutilise evaluateConditionClause (static/js/play/conditions.js), + // même comparaison qu'un nœud Condition de l'éditeur de logique, sur + // une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet + // ici (voir maquette validée : le losange ne teste qu'une variable). + var isTrue = evaluateConditionClause({ + source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur, + }); + var branch = isTrue ? action.si_vrai : action.si_faux; + if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj); } } @@ -236,6 +253,7 @@ if (typeof module !== 'undefined' && module.exports) { const conditionsMod = require('./conditions.js'); global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox; global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap; + global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause; const personnageMod = require('./personnage-controller.js'); global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists; global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8; diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index 33d10f08..0aaab103 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -1,55 +1,58 @@ // ---------- 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 déclencheurs (action "dialogue", voir // static/js/play/collision-rules-controller.js) et les widgets d'interface // posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, // screens/rendering/dialogue_box_style.py) : à l'exécution d'une action -// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la -// quête (gameData.quests, voir full_game_payload.py) ligne par ligne — -// 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). +// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE +// (action.dialogue.lines, transmises telles quelles par +// collision-rules-controller.js — chaque occurrence de "Déclencher ce +// dialogue" porte son propre dialogue, voir screens/rendering/ +// collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche +// dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne +// "question" ("❓ Question", voir static/js/triggers/trigger-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 : " / 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. +// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) : +// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables +// globales et conditions (voir screens/rendering/collision_rules.py:: +// "condition") — le dialogue choisi s'affiche directement. Une action +// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé +// (ou immédiatement s'il est vide), son id est ajouté à +// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en +// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir +// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les +// dialogues `mark_completed` du jeu joués, voir +// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM +// lui-même reste un réglage du créateur totalement indépendant +// (db/games/get_success_threshold.py), inchangé ici. // // 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 +// à ce système de dialogue/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"). // -// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a -// posé aucun widget requis sur l'écran — même esprit que le reste du moteur. +// Silencieux (aucun effet) si le widget requis n'est pas posé sur +// l'écran — même esprit que le reste du moteur. var forgeDialogueBoxState = {}; var forgeQuizBoxState = {}; window.forgeQuizScore = window.forgeQuizScore || 0; // Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert // uniquement à calculer un taux de réussite global (voir -// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore +// forgeComputeFinalScormStatus), distinct de window.forgeQuizScore // qui ne compte que les points gagnés. window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0; window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0; - -function forgeQuestById(questId) { - return (gameData.quests || []).find(function (q) { return q.id === questId; }); -} +// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir +// forgeSyncAllDialoguesCompletionToScorm. +window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {}; function forgeEscapeHtml(str) { return String(str == null ? '' : str) @@ -78,9 +81,8 @@ function forgeRenderDialogueBoxLine(widget, line) { } // 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. +// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne +// affichée (voir forgeShowCurrentConversationLine). function forgeResetDialogueBoxFooter(widget, objectId) { var footer = forgeDialogueBoxFooterEl(widget); if (footer) footer.innerHTML = ''; @@ -110,11 +112,11 @@ function forgeUpdateAllScoreWidgets() { // Alimente aussi gameData.scoring (voir static/js/play/offline/ // apply-actions.js, même forme {score, status}) — bug signalé : "les -// indicateurs qu'on utilise pour les quêtes devraient aussi servir aux +// indicateurs qu'on utilise pour les dialogues devraient aussi servir aux // indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que // static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser // cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y -// toucher, un jeu construit uniquement avec des quêtes/quiz (jamais +// toucher, un jeu construit uniquement avec des dialogues/quiz (jamais // l'action de flow "Modifier un score") laissait Score/Completion // perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué // jusqu'au bout. Vocabulaire de statut identique à db/scoring/ @@ -124,32 +126,36 @@ function forgeSyncQuizScoreToScorm() { gameData.scoring.score = window.forgeQuizScore; if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours'; // xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin - // quête/quiz contourne static/js/play/offline/apply-actions.js (aucune - // action de flow "Modifier un score" ici), donc jamais notifié sans cet - // appel direct — même bug que le commentaire ci-dessus pour SCORM. + // dialogue/quiz contourne static/js/play/offline/apply-actions.js + // (aucune action de flow "Modifier un score" ici), donc jamais notifié + // sans cet appel direct — même bug que le commentaire ci-dessus pour + // SCORM. if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score); } -// Une quête acceptée = la partie a commencé, même sans le moindre point +// Un dialogue affiché = la partie a commencé, même sans le moindre point // encore gagné — sans cet appel, Score restait "0" mais Completion // restait "unknown" tant qu'aucune question n'avait encore été résolue. -function forgeSyncQuestStartedToScorm() { +// Appelé depuis forgeShowDialogueBox. +function forgeSyncDialogueStartedToScorm() { gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours'; } -// Le jeu entier est considéré terminé quand TOUTES les quêtes connues -// sont "terminee" — règle simple et prévisible plutôt que de deviner -// laquelle des quêtes créées "compte" pour la fin du jeu. -// Réussi/Échoué (voir db/games/get_success_threshold.py, +// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu +// marqués `mark_completed` (voir gameData.completable_dialogue_ids, +// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont +// été joués (voir forgeAdvanceConversation) — règle simple et +// prévisible plutôt que de deviner lequel des dialogues créés "compte" +// pour la fin du jeu. Réussi/Échoué (voir db/games/get_success_threshold.py, // window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/ // build_scorm_package.py) : demande utilisateur — "il manque // l'indicateur succès/échec sur le SCORM". Calculé sur le taux de // bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered) -// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les -// quêtes terminées" qui est déjà la seule condition de fin de partie. -// Aucune question posée du tout (jeu fait uniquement de dialogues) : -// reste "termine" (ni réussi ni échoué, rien à évaluer). +// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous +// les dialogues terminés" qui est déjà la seule condition de fin de +// partie. Aucune question posée du tout (jeu fait uniquement de +// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer). function forgeComputeFinalScormStatus() { var total = window.forgeQuizTotalAnswered || 0; if (total === 0) return 'termine'; @@ -158,9 +164,10 @@ function forgeComputeFinalScormStatus() { return percent >= threshold ? 'reussi' : 'echoue'; } -function forgeSyncAllQuestsCompletionToScorm() { - var quests = gameData.quests || []; - if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return; +function forgeSyncAllDialoguesCompletionToScorm() { + var completableIds = (gameData.completable_dialogue_ids || []); + var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; }); + if (!allDone) return; gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; var finalStatus = forgeComputeFinalScormStatus(); gameData.scoring.status = finalStatus; @@ -187,7 +194,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) { var widget = forgeDialogueBoxWidgetById(dialogueObjectId); if (line.type === 'question') { if (widget) widget.classList.remove('is-active'); - forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line, state.index); + forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index); return; } forgeHideQuizBoxOnCurrentScreen(); @@ -197,30 +204,54 @@ function forgeShowCurrentConversationLine(dialogueObjectId) { widget.classList.add('is-active'); } -// 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). -function forgeShowDialogueBox(questId) { - var quest = forgeQuestById(questId); - if (!quest) return; - var lines = (quest.dialogues && quest.dialogues[quest.status]) || []; +// Appelée par l'action "dialogue" (collision-rules-controller.js) — +// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la +// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE +// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête). +// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"), +// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour +// le porter. `markCompleted` : voir forgeAdvanceConversation ci-dessous. +// `thenAction` (chaînage, voir screens/rendering/collision_rules.py:: +// "then") : l'action suivante, exécutée seulement une fois ce dialogue +// épuisé — jamais avant, contrairement à une action "variable" qui, +// elle, chaîne immédiatement (voir collision-rules-controller.js). +function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) { + if (!dialogueId) return; + lines = lines || []; var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); - // Aucune ligne 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). + forgeSyncDialogueStartedToScorm(); if (!lines.length) { - if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest); + // Rien à jouer : applique quand même markCompleted PUIS thenAction + // immédiatement (rien à exhiber avant), sinon silencieux. + if (markCompleted) forgeCompleteDialogue(dialogueId, objectName); + if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName }); return; } - forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 }; + forgeDialogueBoxState[objectId] = { + dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0, + markCompleted: !!markCompleted, thenAction: thenAction, + }; forgeShowCurrentConversationLine(objectId); } +// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté — +// le créateur pilote lui-même quand, via la case "Marquer ce dialogue +// comme terminé" de l'assistant) et propage vers le SCORM/xAPI. +function forgeCompleteDialogue(dialogueId, objectName) { + window.forgeCompletedDialogueIds[dialogueId] = true; + if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName); + forgeSyncAllDialoguesCompletionToScorm(); +} + // 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). +// une bonne réponse au quiz (voir forgeQuizBoxAnswer). À l'épuisement des +// lignes : markCompleted PUIS thenAction (voir screens/rendering/ +// collision_rules.py::"then", demande explicite : "pouvoir modifier un +// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce +// dialogue est fini", jamais avant. function forgeAdvanceConversation(dialogueObjectId) { var state = forgeDialogueBoxState[dialogueObjectId]; if (!state) return; @@ -228,20 +259,8 @@ function forgeAdvanceConversation(dialogueObjectId) { 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 (typeof forgeXapiNotifyQuestCompleted === "function") forgeXapiNotifyQuestCompleted(quest); - forgeSyncAllQuestsCompletionToScorm(); - } + if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName); + if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName }); if (widget) widget.classList.remove('is-active'); delete forgeDialogueBoxState[dialogueObjectId]; return; @@ -260,23 +279,23 @@ function forgeDialogueBoxAdvance(objectId) { } // Affiche une question ("❓ Question") dans la boîte à quiz posée sur -// l'écran — header = "Quête : ", corps = la question + ses choix -// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz, -// 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, quest, line, questionIndex) { +// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️ +// Informations"), corps = la question + ses choix — si l'auteur n'a posé +// aucune boîte à quiz, 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, dialogueId, objectName, line, questionIndex) { 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, resolved: false, - quest: quest, questionIndex: questionIndex, questionText: line.text, + dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text, }; var header = widget.querySelector('[data-quiz-role="header"]'); var questionEl = widget.querySelector('[data-quiz-role="question"]'); var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); - if (header) header.textContent = 'Quête : ' + (quest ? quest.title : ''); + if (header) header.textContent = objectName || ''; if (questionEl) questionEl.textContent = line.text; if (choicesEl) { choicesEl.innerHTML = line.choices.map(function (choice, i) { @@ -320,13 +339,13 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { window.forgeQuizCorrectAnswered += 1; forgeAddQuizScore(state.rewardAmount); } - if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) { - forgeXapiNotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0); + if (typeof forgeXapiNotifyQuestionAnswered === "function") { + forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0); } // cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) : // no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null). - if (typeof forgeScorm2004NotifyQuestionAnswered === "function" && state.quest) { - forgeScorm2004NotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex); + if (typeof forgeScorm2004NotifyQuestionAnswered === "function") { + forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex); } // RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul // après un délai fixe (700ms/1400ms), jamais assez pour un lecteur @@ -354,58 +373,16 @@ function forgeQuizBoxContinue(quizObjectId) { forgeAdvanceConversation(state.dialogueObjectId); } -// ---------- Offre de quête (Accepter/Refuser) ---------- - -// Écran d'offre : header = "Quête : ", 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 = - '' + - ''; - } - 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'; - forgeSyncQuestStartedToScorm(); - if (quest && typeof forgeXapiNotifyQuestAccepted === "function") forgeXapiNotifyQuestAccepted(quest); - 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, + forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, - forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, + forgeResetDialogueBoxFooter, forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, - forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm, + forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm, forgeComputeFinalScormStatus, }; } diff --git a/static/js/play/offline/__tests__/scorm2004-api.test.js b/static/js/play/offline/__tests__/scorm2004-api.test.js index a06da907..a5171fec 100644 --- a/static/js/play/offline/__tests__/scorm2004-api.test.js +++ b/static/js/play/offline/__tests__/scorm2004-api.test.js @@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.* global.window.API_1484_11 = api; try { forgeScorm2004Init(); - forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0); + forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0); const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]])); - assert.equal(sets['cmi.interactions.2.id'], 'quest:5:question:1'); + assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1'); assert.equal(sets['cmi.interactions.2.type'], 'choice'); assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?'); @@ -134,7 +134,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor global.window.API_1484_11 = api; try { forgeScorm2004Init(); - forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0); + forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0); const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]])); assert.equal(sets['cmi.interactions.0.result'], 'incorrect'); assert.equal(sets['cmi.interactions.0.learner_response'], '1'); @@ -147,5 +147,5 @@ test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004 forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null // Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent, // ceci lèverait en tentant d'appeler .GetValue sur null. - assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0)); + assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered('d_1', 'x', 0, 'x', true, 0, 0)); }); diff --git a/static/js/play/offline/__tests__/xapi-client.test.js b/static/js/play/offline/__tests__/xapi-client.test.js index e16535a9..98156781 100644 --- a/static/js/play/offline/__tests__/xapi-client.test.js +++ b/static/js/play/offline/__tests__/xapi-client.test.js @@ -1,7 +1,7 @@ // Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la -// granularité par quête/question demandée par l'utilisateur (quête -// acceptée/terminée, réponse à une question), en plus du score/statut -// global déjà couvert par dialogue-box-controller.test.js. +// granularité par dialogue/question demandée par l'utilisateur (dialogue +// terminé, réponse à une question), en plus du score/statut global déjà +// couvert par dialogue-box-controller.test.js. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -10,8 +10,8 @@ global.window.addEventListener = global.window.addEventListener || function () { global.btoa = global.btoa || (str => Buffer.from(str).toString('base64')); const { - forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered, - forgeXapiQuestObject, forgeXapiQuestionObject, + forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered, + forgeXapiDialogueObject, forgeXapiQuestionObject, } = require('../xapi-client.js'); test.beforeEach(() => { @@ -21,45 +21,35 @@ test.beforeEach(() => { }; }); -test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => { - const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' }); - assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5'); - assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier'); +test('forgeXapiDialogueObject construit un id de sous-activité dérivé de l\'activité du jeu, nommé d\'après l\'objet déclencheur', () => { + const obj = forgeXapiDialogueObject('d_5', 'Le sorcier'); + assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5'); + assert.equal(obj.definition.name['fr-FR'], 'Le sorcier'); }); -test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => { - const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?'); - assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2'); +test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => { + const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?'); + assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2'); assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?'); }); -test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => { +test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant le dialogue (result.completion=true)', () => { const calls = []; global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; - forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' }); - assert.equal(calls.length, 1); - const body = JSON.parse(calls[0].opts.body); - assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted'); - assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5'); -}); - -test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => { - const calls = []; - global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; - forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' }); + forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier'); const body = JSON.parse(calls[0].opts.body); assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed'); - assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5'); + assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5'); assert.equal(body.result.completion, true); }); test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => { const calls = []; global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; - forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 1, 'Qui es-tu ?', true, 5); + forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5); const body = JSON.parse(calls[0].opts.body); assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered'); - assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5:question:1'); + assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1'); assert.equal(body.result.success, true); assert.equal(body.result.score.raw, 5); }); @@ -67,7 +57,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => { const calls = []; global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; - forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0); + forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 0); const body = JSON.parse(calls[0].opts.body); assert.equal(body.result.success, false); assert.equal(body.result.score.raw, 0); @@ -76,18 +66,17 @@ test('une mauvaise réponse envoie quand même un statement, avec success=false test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => { const calls = []; global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; - forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' }); + forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier'); const body = JSON.parse(calls[0].opts.body); - assert.equal(body.verb.display['en-US'], 'attempted'); - assert.equal(body.verb.display['fr-FR'], 'commencé'); + assert.equal(body.verb.display['en-US'], 'completed'); + assert.equal(body.verb.display['fr-FR'], 'terminé'); }); test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => { global.window.FORGE_XAPI_CONFIG = null; let called = false; global.fetch = () => { called = true; return Promise.resolve(); }; - forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' }); - forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' }); - forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0); + forgeXapiNotifyDialogueCompleted('d_5', 'x'); + forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0); assert.equal(called, false); }); diff --git a/static/js/play/offline/scorm-api.js b/static/js/play/offline/scorm-api.js index 85478aa9..36c588cd 100644 --- a/static/js/play/offline/scorm-api.js +++ b/static/js/play/offline/scorm-api.js @@ -24,7 +24,7 @@ function forgeScormInit() { if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence forgeScormApi.LMSInitialize(""); // cmi.core.score.min/max (voir publish/build_scorm_package.py, - // db/quests/reward_budget.py) : sans eux, le LMS n'a que le score BRUT + // db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT // (cmi.core.score.raw, un total de points sans barème) et l'affiche // parfois à tort avec un "%" — en fournissant aussi min/max une seule // fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage. diff --git a/static/js/play/offline/scorm2004-api.js b/static/js/play/offline/scorm2004-api.js index 64d3ffaa..0291e603 100644 --- a/static/js/play/offline/scorm2004-api.js +++ b/static/js/play/offline/scorm2004-api.js @@ -93,12 +93,12 @@ function forgeScorm2004Timestamp() { return new Date().toISOString().replace(/\.\d+Z$/, ""); } -function forgeScorm2004NotifyQuestionAnswered(quest, questionIndex, questionText, correct, choiceIndex, correctIndex) { +function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) { if (!forgeScorm2004Api) return; var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10); if (isNaN(n)) n = 0; var prefix = "cmi.interactions." + n + "."; - forgeScorm2004Api.SetValue(prefix + "id", "quest:" + quest.id + ":question:" + questionIndex); + forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex); forgeScorm2004Api.SetValue(prefix + "type", "choice"); forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp()); forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || "")); diff --git a/static/js/play/offline/xapi-client.js b/static/js/play/offline/xapi-client.js index c1bad15a..e61c6aba 100644 --- a/static/js/play/offline/xapi-client.js +++ b/static/js/play/offline/xapi-client.js @@ -73,26 +73,28 @@ function forgeXapiObject() { } // Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) : -// une quête/question n'est PAS l'activité de premier niveau du jeu +// un dialogue/question n'est PAS l'activité de premier niveau du jeu // (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un // id dérivé — assez pour que le LRS les distingue sans configuration -// supplémentaire côté créateur du jeu. -function forgeXapiQuestObject(quest) { +// supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet +// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de +// "quête" pour le porter. +function forgeXapiDialogueObject(dialogueId, objectName) { var config = window.FORGE_XAPI_CONFIG; return { objectType: "Activity", - id: config.activity_id + ":quest:" + quest.id, - definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" }, + id: config.activity_id + ":dialogue:" + dialogueId, + definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" }, }; } -function forgeXapiQuestionObject(quest, questionIndex, questionText) { +function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) { var config = window.FORGE_XAPI_CONFIG; return { objectType: "Activity", - id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex, + id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex, definition: { - name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") }, + name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") }, description: { "fr-FR": questionText || "" }, type: "http://adlnet.gov/expapi/activities/cmi.interaction", interactionType: "choice", @@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(newScore) { ); } -// Granularité quête/quiz (voir dialogue-box-controller.js) : demande -// explicite "il faut savoir quand une quête a été acceptée, à telle -// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ -// par évènement, distinct du score/statut global (forgeXapiNotify -// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de -// premier niveau). -function forgeXapiNotifyQuestAccepted(quest) { - if (!window.FORGE_XAPI_CONFIG) return; - // "attempted" (ADL standard) : pas de verbe dédié "accepted" dans le - // vocabulaire ADL de base — attempted rend fidèlement "le joueur s'est - // engagé sur cette quête". - forgeXapiSendStatement( - "http://adlnet.gov/expapi/verbs/attempted", - null, forgeXapiQuestObject(quest) - ); -} - -function forgeXapiNotifyQuestCompleted(quest) { +// Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande +// explicite "il faut savoir [...] à telle question quel point obtenu, +// bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du +// score/statut global (forgeXapiNotifyScoreChanged/StatusChanged +// ci-dessus, qui restent l'activité de premier niveau). +function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( "http://adlnet.gov/expapi/verbs/completed", - { completion: true }, forgeXapiQuestObject(quest) + { completion: true }, forgeXapiDialogueObject(dialogueId, objectName) ); } -function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) { +function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) { if (!window.FORGE_XAPI_CONFIG) return; forgeXapiSendStatement( "http://adlnet.gov/expapi/verbs/answered", { success: correct, score: { raw: scoreAwarded || 0 } }, - forgeXapiQuestionObject(quest, questionIndex, questionText) + forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) ); } @@ -203,7 +193,7 @@ if (typeof window !== "undefined") { if (typeof module !== "undefined" && module.exports) { module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB, - forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered, - forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY, + forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered, + forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY, }; } diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js deleted file mode 100644 index cb2fa855..00000000 --- a/static/js/quests/quest-editor.js +++ /dev/null @@ -1,588 +0,0 @@ -// ---------- Éditeur de quêtes ---------- -// Onglet "🗺️ Quêtes" à côté de "🧩 Collision" (templates/scene_edit.html, -// jamais une page à part) : une LISTE (mêmes tableau/lignes que l'onglet -// "📣 Événements") plutôt qu'une grille de cartes. Un clic sur une ligne -// ouvre la modale de FICHE (titre/objectif/récompense/statut/résultat — -// "je crée"), qui ouvre à son tour, sur un bouton dédié, la modale -// PLEIN ÉCRAN d'arbre de dialogue ("j'ajoute les dialogues") — deux -// étapes séparées, jamais le même formulaire. Les deux modales sont -// aussi ouvertes depuis l'assistant "+ Action" de l'éditeur de collision -// (voir static/js/scenes/collision-rules-editor.js) quand l'auteur -// choisit "Déclencher une quête" — créées dynamiquement (ensureQuest*Modal) -// plutôt que dupliquées dans deux endroits. -// -// Une quête (voir db/quests/) : id, title, objectif, recompense_score, -// status (nouvelle/en_cours/terminee — statut COURANT, informatif), -// resultat (reussi/echoue), dialogues (une liste de répliques PAR -// STATUT — 3 colonnes dans la modale : quel dialogue jouer selon l'état -// de la quête au moment où le joueur parle au PNJ). - -var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' }; -var QUEST_RESULT_LABELS = { reussi: 'Réussi', echoue: 'Échoué' }; -var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; - -// Quêtes connues côté client — préremplies par QUESTS_JSON (voir -// templates/scene_edit.html), toujours présentes puisque l'onglet -// Quêtes vit dans le même éditeur que l'onglet Collision qui en a -// besoin (collisionRuleActionSummary). -var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; - -// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/ -// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas -// seulement un personnage — proposés comme "qui parle" pour une réplique, -// en plus de "Joueur" (toujours disponible, jamais un objet de scène). -var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : []; - -// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/ -// .questBubble-pnj, static/style.css) même si le nom affiché est -// n'importe quel objet nommé — "Joueur" en vert, n'importe -// quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles -// du pnj et du joueur sont d'une couleur différente"). -function questBubbleKind(speaker) { - return speaker === 'Joueur' ? 'joueur' : 'pnj'; -} - -function questById(questId) { - return QUESTS.find(function (q) { return q.id === questId; }); -} - -function questListRowHtml(quest) { - var resultatHtml = quest.resultat ? '' + QUEST_RESULT_LABELS[quest.resultat] + '' : '—'; - return ( - '' + - '' + (quest.title || '').replace(/' + - '' + (quest.objectif ? quest.objectif.replace(/—') + '' + - '+' + (quest.recompense_score || 0) + '' + - '' + QUEST_STATUS_LABELS[quest.status] + '' + - '' + resultatHtml + '' + - '' + - '' + - '' + - '' - ); -} - -function renderQuestListPage() { - var container = document.getElementById('questsTabPanel'); - if (!container) return; - var wrap = document.getElementById('questsListWrap'); - if (!QUESTS.length) { - wrap.innerHTML = '

Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.

'; - return; - } - wrap.innerHTML = - '
' + - '' + - '' + QUESTS.map(questListRowHtml).join('') + '' + - '
TitreObjectifRécompenseStatutRésultat
'; -} - -function createQuestFromListPage() { - fetch('/game/' + GAME_SLUG + '/quests/new', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), - }) - .then(function (r) { return r.json(); }) - .then(function (json) { - QUESTS.unshift(json.quest); - renderQuestListPage(); - openQuestFieldsModal(json.quest.id); - }); -} - -function questDeleteFromList(questId) { - var quest = questById(questId); - if (!quest || !confirm('Supprimer la quête « ' + quest.title + ' » ? Toute règle de collision qui la référence perdra sa cible.')) return; - fetch('/game/' + GAME_SLUG + '/quests/' + questId + '/delete', { method: 'POST' }) - .then(function () { - QUESTS = QUESTS.filter(function (q) { return q.id !== questId; }); - renderQuestListPage(); - }); -} - -// ---------- Deux modales distinctes ---------- -// 1. #questFieldsModal (taille normale) : "je crée" — titre/objectif/ -// récompense/statut/résultat, + un bouton qui ouvre... -// 2. #questDialogueModal (PLEIN ÉCRAN) : "j'ajoute les dialogues" — les -// 3 colonnes de répliques, rien d'autre. Étapes séparées demandées -// explicitement par l'utilisateur (pas un seul gros formulaire). -// Créées une seule fois chacune (au premier appel), réutilisées ensuite — -// partagées avec l'assistant "+ Action" de l'éditeur de collision, -// jamais dupliquées dans les templates. -var _questModalOnUse = null; -var _questModalCurrentId = null; - -function ensureQuestFieldsModal() { - var modal = document.getElementById('questFieldsModal'); - if (modal) return modal; - modal = document.createElement('div'); - modal.className = 'modal'; - modal.id = 'questFieldsModal'; - modal.innerHTML = - '' + - ''; - document.body.appendChild(modal); - return modal; -} - -// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...) -// pour finaliser la règle de collision avec cette quête, affiche alors un -// bouton "✓ Utiliser cette quête" dans les deux modales. -// -// Masque #collisionRuleModal s'il est ouvert, SANS effacer _collisionWizard -// (contrairement à closeCollisionRuleModal(), static/js/scenes/ -// collision-rules-editor.js) : appelée depuis questPickerChoose()/ -// questPickerCreate() (assistant "+ Action" de l'onglet Collision, action -// "quete" — voir renderQuestPickerStep()), CETTE modale et -// #collisionRuleModal partagent le même z-index (.modal de base) — sans la -// masquer, les deux restaient actives en même temps, empilées dans l'ordre -// du DOM plutôt que l'une à la place de l'autre : le bouton "+ Action" -// semblait ne plus rien faire ensuite (il rouvrait bien #collisionRuleModal, -// mais caché derrière celle-ci restée active). _collisionWizard doit -// SURVIVRE à ce masquage : "✓ Utiliser cette quête" (questUseThisQuest) -// finalise la règle en cours via collisionWizardFinalizeAction(), qui en a -// encore besoin (objectId, trigger, pendingInteragir pour "interagir"). -function _hideCollisionRuleModalKeepingWizardState() { - var modal = document.getElementById('collisionRuleModal'); - if (modal) modal.classList.remove('is-active'); -} - -function openQuestFieldsModal(questId, onUse) { - _hideCollisionRuleModalKeepingWizardState(); - _questModalOnUse = onUse || null; - _questModalCurrentId = questId; - var modal = ensureQuestFieldsModal(); - modal.classList.add('is-active'); - renderQuestFieldsModalBody(questId); -} - -// _questModalOnUse/_questModalCurrentId ne sont PAS remis à null ici : -// openQuestDialogueModal() ferme cette modale en s'ouvrant (voir plus -// bas) et a besoin de les garder pour son propre bouton "✓ Utiliser -// cette quête" — chaque point d'entrée (openQuestFieldsModal) les fixe -// de toute façon fraîchement à chaque ouverture. -function closeQuestFieldsModal() { - var modal = document.getElementById('questFieldsModal'); - if (modal) modal.classList.remove('is-active'); -} - -function questUseThisQuest(questId) { - var quest = questById(questId); - if (_questModalOnUse && quest) _questModalOnUse(quest); - closeQuestDialogueModal(); - closeQuestFieldsModal(); - _questModalOnUse = null; - _questModalCurrentId = null; -} - -function questFieldRowHtml(quest) { - return ( - '
' + - '" + - '" + - '" + - '' + - '' + - '
' - ); -} - -function renderQuestFieldsModalBody(questId) { - var quest = questById(questId); - var body = document.getElementById('questFieldsModalBody'); - if (!quest || !body) return; - var useBtn = _questModalOnUse - ? '' - : ''; - body.innerHTML = - '
' + - '

' + (quest.title || '').replace(/' + - '' + - '

' + - questFieldRowHtml(quest) + - '
' + - '' + - useBtn + - '
'; -} - -// Options du ' + speakerSelect + '' + - '' + - '' + - '
' + - '' + - '
' + - '' + - '' - ); -} - -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 line.type === 'question' ? questQuestionBubbleHtml(line, status, i) : questDialogueBubbleHtml(line, status, i); - }).join(''); - return ( - '
' + - '

' + QUEST_STATUS_LABELS[status] + '

' + - '
' + bubblesHtml + '
' + - '
' + - '' + - '' + - '
' + - '
' - ); -} - -// Modale de dialogue — PLEIN ÉCRAN (voir .questDialogueModal en CSS), -// ouverte DEPUIS la modale de fiche (bouton "💬 Ajouter les dialogues"), -// jamais les deux à la fois : referme la fiche en s'ouvrant plutôt que de -// les empiler, "← Fiche de la quête" y ramène. -function ensureQuestDialogueModal() { - var modal = document.getElementById('questDialogueModal'); - if (modal) return modal; - modal = document.createElement('div'); - modal.className = 'modal questDialogueModal'; - modal.id = 'questDialogueModal'; - modal.innerHTML = - '' + - ''; - document.body.appendChild(modal); - return modal; -} - -function openQuestDialogueModal(questId) { - _questModalCurrentId = questId; - closeQuestFieldsModal(); - var modal = ensureQuestDialogueModal(); - modal.classList.add('is-active'); - renderQuestDialogueModalBody(questId); -} - -function closeQuestDialogueModal() { - var modal = document.getElementById('questDialogueModal'); - if (modal) modal.classList.remove('is-active'); -} - -function questBackToFieldsModal(questId) { - closeQuestDialogueModal(); - openQuestFieldsModal(questId, _questModalOnUse); -} - -function renderQuestDialogueModalBody(questId) { - var quest = questById(questId); - var body = document.getElementById('questDialogueModalBody'); - if (!quest || !body) return; - _questModalCurrentId = questId; - var useBtn = _questModalOnUse - ? '' - : ''; - body.innerHTML = - '
' + - '
' + - '' + - '

' + (quest.title || '').replace(/' + - '

' + - '
' + useBtn + - '' + - '
' + - '
' + - '

Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :

' + - '
' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '
'; -} - -function questUpdateField(questId, field, value) { - var quest = questById(questId); - if (!quest) return; - var body = {}; - body[field] = field === 'recompense_score' ? Number(value) : (value || null); - fetch('/game/' + GAME_SLUG + '/quests/' + questId, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }) - .then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); }) - .then(function (res) { - // Refusé (400) : recompense_score abaissé sous le total déjà - // 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(); - renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables - }); -} - -// Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir -// db/quests/set_quest_dialogues.py) — jamais une réplique isolée. -// N'ADOPTE JAMAIS la réponse du serveur comme nouvel état local (bug -// corrigé : sanitize_quest_dialogues.py rejette toute réplique au texte -// encore vide — juste ajoutée, pas encore tapée — donc chaque -// enregistrement renvoyait un tableau amputé de la bulle en cours -// d'écriture ; l'adopter effaçait silencieusement la bulle que l'auteur -// venait d'ajouter, et le clic suivant sur "+ Réplique" semblait la -// "remplacer" au lieu d'en ajouter une seconde). L'état local, construit -// par des mutations client explicites, est déjà la seule source de -// vérité pendant l'édition. -function questSaveDialogues(quest) { - fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/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 -// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations") -// — juste un défaut de départ, l'auteur choisit ensuite librement dans -// le menu déroulant de la réplique. -function questNextDefaultSpeaker(lastSpeaker) { - if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ'; - return 'Joueur'; -} - -function questAddBubble(status) { - var quest = questById(_currentQuestModalId()); - if (!quest) return; - quest.dialogues = quest.dialogues || {}; - var lines = quest.dialogues[status] || (quest.dialogues[status] = []); - var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ'; - 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(4, 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; - quest.dialogues[status][index].speaker = speaker; - renderQuestDialogueModalBody(quest.id); - questSaveDialogues(quest); -} - -function questUpdateBubbleText(status, index, text) { - var quest = questById(_currentQuestModalId()); - if (!quest) return; - quest.dialogues[status][index].text = text; - questSaveDialogues(quest); -} - -function questDeleteBubble(status, index) { - var quest = questById(_currentQuestModalId()); - if (!quest) return; - quest.dialogues[status].splice(index, 1); - renderQuestDialogueModalBody(quest.id); - questSaveDialogues(quest); -} - -function _currentQuestModalId() { - return _questModalCurrentId; -} - -// ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ---------- -// Affiche des cartes + "+ Nouvelle quête" DANS la modale de collision -// déjà ouverte (voir renderCollisionWizardActionStep) ; choisir/créer une -// quête ouvre ENSUITE la modale de FICHE (celle-ci par-dessus), avec un -// bouton "✓ Utiliser cette quête" (disponible aussi depuis la modale de -// dialogue) qui referme tout et finalise la règle — exactement le -// comportement demandé : cliquer "Déclencher une quête" ouvre l'éditeur -// de quête, pas un simple champ texte. -function questPickerHtml(quests) { - if (!quests.length) { - return '

Aucune quête pour l\'instant.

'; - } - return '
' + quests.map(function (q, i) { - return ''; - }).join('') + '
'; -} - -var _questPickerOnUse = null; - -function renderQuestPickerStep(onUse) { - _questPickerOnUse = onUse; - var body = collisionWizardBodyEl(); - body.innerHTML = '

Quelle quête déclencher ?

Chargement…
' + - ''; - fetch('/game/' + GAME_SLUG + '/quests/list.json') - .then(function (r) { return r.json(); }) - .then(function (json) { - QUESTS = json.quests; - var list = document.getElementById('questPickerList'); - if (list) list.innerHTML = questPickerHtml(QUESTS); - }); -} - -function questPickerChoose(questId) { - openQuestFieldsModal(questId, _questPickerOnUse); -} - -function questPickerCreate() { - fetch('/game/' + GAME_SLUG + '/quests/new', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), - }) - .then(function (r) { return r.json(); }) - .then(function (json) { - QUESTS.unshift(json.quest); - openQuestFieldsModal(json.quest.id, _questPickerOnUse); - }); -} - -// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par -// un -{% if offline_mode %} +{% if offline_mode %} diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 45bf0a94..ba3c0936 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -115,7 +115,7 @@ -

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.

+

Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "Déclencheurs") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.

@@ -124,15 +124,16 @@
- - + + {% if game.onboarding_type != 'rpg' %} + moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le + seul point d'entrée qui le permettait (l'action "evenement" de + l'ancien éditeur de collision) a été retiré (demande explicite, + ne servait à rien). game.onboarding_type est de toute façon + TOUJOURS "rpg" désormais (voir la suppression des types Quiz/ + Embranchement) — cet onglet ne s'affiche donc plus jamais. --> {% endif %}
@@ -179,55 +180,82 @@

Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).

- -
-
-

Collision

-
-

Pour chaque objet (jamais le joueur lui-même), choisis ce qui doit se passer à son contact — la carte se construit au fil de tes choix, comme des pièces qui s'emboîtent.

-
+ +
+
- - - - -
+ +
-

Quêtes

- +

Variables

+
-

Titre, objectif, récompense, statut, résultat — clique sur une ligne pour écrire son enchaînement de dialogue.

-
+

Accessibles depuis n'importe quelle scène (conditions, actions "Modifier une variable"). Le nom ne peut plus changer une fois créé.

+ + + {% if global_variables %} + {% for v in global_variables %} +
+ +
+ {% endfor %} +
+ + + + {% for v in global_variables %} + + + + + + + + {% endfor %} + +
NomTypeValeurPar joueur
{{ v.name }} + + {{ 'Oui' if v.per_player else 'Non (partagée)' }} + +
+ + +
+
+
+ {% else %} +

Aucune variable pour l'instant — crée la première ci-dessus.

+ {% endif %}
{% if game.onboarding_type != 'rpg' %} @@ -311,9 +339,9 @@ screens/scenes/set_scene_object_name.py) — n'importe quel kind (personnage, décor, fond), pas seulement un personnage — c'est CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur - de dialogue de quête (voir "🗺️ Quêtes", static/js/quests/ - quest-editor.js) : un dialogue peut mettre en scène n'importe - quel objet, pas seulement un personnage. --> + de dialogue d'un déclencheur (voir "Déclencheurs", static/js/ + triggers/trigger-editor.js) : un dialogue peut mettre en scène + n'importe quel objet, pas seulement un personnage. -->
Informations
@@ -602,18 +630,15 @@ var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }}; var SPRITE_LIBRARY = {{ sprite_library_json|safe }}; var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }}; - var COLLISION_RULE_OBJECTS = {{ collision_rule_objects_json|safe }}; - var COLLISION_ACTION_TYPES = {{ collision_action_types|tojson }}; - var COLLISION_TRIGGER_TYPES = {{ collision_trigger_types|tojson }}; - var COLLISION_DEFAULT_PERIMETRE_PX = {{ collision_default_perimetre_px }}; - // Éditeur de quêtes (voir static/js/quests/quest-editor.js) — chargé - // avant collision-rules-editor.js : l'action "quete" de l'assistant - // "+ Action" ouvre directement cet éditeur (renderQuestPickerStep). - var QUESTS_JSON = {{ quests_json|safe }}; // "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" + - // static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe - // quel objet de scène nommé, pas seulement un personnage. + // static/js/triggers/trigger-editor.js::triggerSpeakerOptions) — + // n'importe quel objet de scène nommé, pas seulement un personnage. var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }}; + // Assistant "condition" d'un déclencheur (voir static/js/scenes/ + // collision-rules-editor.js::renderCollisionWizardConditionStep) — les + // noms des variables globales du jeu (voir l'onglet "Variables" + // ci-dessus), pour peupler son sélecteur de variable. + var GLOBAL_VARIABLE_NAMES = {{ global_variables | map(attribute='name') | list | tojson }}; @@ -621,6 +646,6 @@ - + {% endblock %} diff --git a/tests/test_collision_rules.py b/tests/test_collision_rules.py index 886754d3..103164a7 100644 --- a/tests/test_collision_rules.py +++ b/tests/test_collision_rules.py @@ -1,12 +1,11 @@ -"""Éditeur de collision (onglet "🧩 Collision", voir -screens/rendering/collision_rules.py) : une règle "déclencheur (collision/ -périmètre) -> action (quête/attaque/événement/interagir)" par carte -objet — toujours les AUTRES objets qui réagissent à la présence du -joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js -construit l'assistant "+ Action" ; ce fichier teste la couche serveur -(défauts, validation, exposition dans full_game_payload).""" -import re - +"""Règles de collision (voir screens/rendering/collision_rules.py) : une +règle "déclencheur (collision) -> action (dialogue/interagir/condition)" +par objet de scène — toujours les AUTRES objets qui réagissent à la +présence du joueur, jamais l'inverse. Posées depuis l'onglet +"Déclencheurs" (voir tests/test_triggers.py pour le parcours complet côté +route) — ce fichier teste la couche de validation/lecture elle-même +(sanitize_collision_rules/resolve_collision_rules), plus l'exposition +dans full_game_payload.""" import db import screens @@ -19,6 +18,7 @@ def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rul def _add_personnage(client, slug, screen_id, role=None): + import re resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) assert resp.status_code == 302, resp.data obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) @@ -27,13 +27,6 @@ def _add_personnage(client, slug, screen_id, role=None): return obj_id -def _add_custom_event(client, slug, name="NEED_HELP"): - resp = client.post(f"/game/{slug}/events/new", data={"name": name}, follow_redirects=False) - assert resp.status_code == 302, resp.data - events = screens.list_custom_events(slug) if hasattr(screens, "list_custom_events") else __import__("db").list_custom_events(slug) - return next(e["id"] for e in events if e["name"] == name) - - def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") @@ -41,93 +34,293 @@ def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_clea assert screens.resolve_collision_rules(obj) == [] -def test_rules_route_persists_a_collision_trigger_attack_action(client, tmp_game_slug_cleanup): - slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") - - resp = client.post( - f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", - data='{"rules": [{"trigger": "collision", "action": {"type": "attaque"}}]}', - content_type="application/json", - ) - assert resp.status_code == 200 - assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "attaque"}}] - obj = screens.get_scene_object(slug, enemy_id) - assert screens.resolve_collision_rules(obj) == [{"trigger": "collision", "action": {"type": "attaque"}}] +def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup): + slug = tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize")) + rules = screens.sanitize_collision_rules([ + {"trigger": "collision", "action": {"type": "dialogue"}}, + ]) + assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}] -def test_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp_game_slug_cleanup): - slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") - - resp = client.post( - f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", - data='{"rules": [{"trigger": "perimetre", "perimetre_px": 40, "action": {"type": "attaque"}}]}', - content_type="application/json", - ) - rules = resp.get_json()["rules"] - assert rules == [{"trigger": "perimetre", "perimetre_px": 40.0, "action": {"type": "attaque"}}] - - -def test_rules_route_persists_a_quest_action_referencing_a_quest_id(client, tmp_game_slug_cleanup): - slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - pnj_id = _add_personnage(client, slug, screen_id, role="pnj") - quest = db.create_quest(slug, "Trouver la clé") - - resp = client.post( - f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", - data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": ' + str(quest["id"]) + '}}]}', - content_type="application/json", - ) - assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": quest["id"]}}] - - -def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(client, tmp_game_slug_cleanup): +def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting(): """"interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action à l'appui — un seul niveau d'imbrication autorisé (voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH).""" - slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - pnj_id = _add_personnage(client, slug, screen_id, role="pnj") - - resp = client.post( - f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", - data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}}]}', - content_type="application/json", - ) - assert resp.get_json()["rules"] == [ - {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}}, + rules = screens.sanitize_collision_rules([ + {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}, + ]) + assert rules == [ + {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}, ] -def test_rules_route_rejects_interagir_nested_inside_interagir(client, tmp_game_slug_cleanup): +def test_sanitize_collision_rules_rejects_interagir_nested_inside_interagir(): """Une imbrication trop profonde (interagir -> interagir -> ...) est ignorée plutôt que d'accepter une structure sans fin postée à la main.""" - slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - pnj_id = _add_personnage(client, slug, screen_id, role="pnj") - - resp = client.post( - f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", - data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "attaque"}}}}]}', - content_type="application/json", - ) - assert resp.get_json()["rules"] == [] # la règle entière est rejetée + rules = screens.sanitize_collision_rules([ + {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}}, + ]) + assert rules == [] # la règle entière est rejetée -def test_rules_route_drops_malformed_rules(client, tmp_game_slug_cleanup): - slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") - - resp = client.post( - f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", - data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}', - content_type="application/json", - ) - assert resp.get_json()["rules"] == [] +def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches(): + """"condition" (losange à embranchement, maquette validée) : Vrai/Faux + peuvent chacun valoir None ("ne rien faire") ou une action "dialogue".""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0", + "si_vrai": {"type": "dialogue"}, "si_faux": None, + }, + }]) + assert rules == [{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0", + "si_vrai": {"type": "dialogue"}, "si_faux": None, + }, + }] -def test_rules_route_404s_for_missing_object(client, game): - resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json") - assert resp.status_code == 404 +def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator(): + rules = screens.sanitize_collision_rules([ + {"trigger": "collision", "action": {"type": "condition", "variable": "", "operateur": "superieur", "valeur": "0", "si_vrai": None, "si_faux": None}}, + {"trigger": "collision", "action": {"type": "condition", "variable": "score", "operateur": "sorcellerie", "valeur": "0", "si_vrai": None, "si_faux": None}}, + ]) + assert rules == [] + + +def test_sanitize_collision_rules_rejects_interagir_nested_inside_a_condition_branch(): + """Une branche Vrai/Faux reste une FEUILLE — jamais une nouvelle + "interagir"/"condition" imbriquée (voir maquette validée : chaque + branche n'a que "Déclencher ce dialogue" ou "Ne rien faire").""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", + "si_vrai": {"type": "interagir", "sub_action": {"type": "dialogue"}}, "si_faux": None, + }, + }]) + assert rules == [{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", + "si_vrai": None, "si_faux": None, + }, + }] + + +def test_sanitize_collision_rules_keeps_interagir_wrapping_a_condition(): + """interagir -> condition -> dialogue (maquette validée : "Interagir avec + une touche" PUIS un test avant de déclencher le dialogue).""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "interagir", + "sub_action": { + "type": "condition", "variable": "score", "operateur": "superieur_egal", "valeur": "10", + "si_vrai": {"type": "dialogue"}, "si_faux": {"type": "dialogue"}}, + }, + }]) + assert rules[0]["action"]["sub_action"]["type"] == "condition" + assert rules[0]["action"]["sub_action"]["si_vrai"] == {"type": "dialogue"} + + +def test_sanitize_collision_rules_keeps_a_valid_dialogue_and_mark_completed(): + """Chaque occurrence de "Déclencher ce dialogue" porte SON PROPRE + dialogue ({id, lines}, voir maquette validée : "un dialogue est un + ensemble de répliques regroupées par un id").""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "dialogue", "mark_completed": True, + "dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}, {"speaker": "x", "text": " "}]}, + }, + }]) + assert rules == [{ + "trigger": "collision", + "action": { + "type": "dialogue", "mark_completed": True, + "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}, + }, + }] + + +def test_sanitize_collision_rules_drops_a_dialogue_without_a_valid_id(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": " ", "lines": [{"speaker": "pnj", "text": "Salut !"}]}}, + }]) + assert "dialogue" not in rules[0]["action"] + + +def test_sanitize_collision_rules_keeps_a_dialogue_on_a_condition_branch(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", + "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}, "si_faux": None, + }, + }]) + assert rules[0]["action"]["si_vrai"]["dialogue"] == {"id": "d_vrai", "lines": []} + + +def test_set_dialogue_lines_in_rules_updates_the_matching_dialogue_wherever_it_is(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "interagir", + "sub_action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", + "si_vrai": None, "si_faux": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }, + }, + }]) + updated = screens.set_dialogue_lines_in_rules(rules, "d_1", [{"speaker": "pnj", "text": "Trouvé !"}]) + assert updated is True + assert rules[0]["action"]["sub_action"]["si_faux"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}] + + +def test_set_dialogue_lines_in_rules_returns_false_for_an_unknown_dialogue_id(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + assert screens.set_dialogue_lines_in_rules(rules, "d_absent", [{"speaker": "pnj", "text": "x"}]) is False + assert rules[0]["action"]["dialogue"]["lines"] == [] # inchangé + + +def test_sanitize_collision_rules_keeps_a_variable_action(): + """"Modifier une variable" (même vocabulaire que l'action du graphe de + logique document, voir screens/labels/data_operations.py) — utilisable + partout où une feuille est attendue : ici directement après une + collision.""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "variable", "id": "d_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "5"}, + }]) + assert rules == [{ + "trigger": "collision", + "action": {"type": "variable", "id": "d_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "5"}, + }] + + +def test_sanitize_collision_rules_drops_a_variable_action_without_a_target_or_with_an_unknown_operation(): + rules = screens.sanitize_collision_rules([ + {"trigger": "collision", "action": {"type": "variable", "target_variable": "", "data_operation": "incrementer"}}, + {"trigger": "collision", "action": {"type": "variable", "target_variable": "score", "data_operation": "sorcellerie"}}, + ]) + assert rules == [] + + +def test_sanitize_collision_rules_omits_data_value_for_operations_that_need_none(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "variable", "target_variable": "actif", "data_operation": "basculer_bool", "data_value": "peu importe"}, + }]) + assert "data_value" not in rules[0]["action"] + + +def test_sanitize_collision_rules_keeps_a_variable_action_as_a_condition_branch(): + """Une branche Vrai/Faux peut être une feuille "variable" tout autant + qu'une feuille "dialogue" (voir maquette validée : chaque branche + reste une feuille, peu importe laquelle).""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", + "si_vrai": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"}, "si_faux": None, + }, + }]) + assert rules[0]["action"]["si_vrai"]["type"] == "variable" + + +def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves(): + """Chaînage (demande explicite : "pouvoir modifier un déclencheur pour + lui ajouter des actions") : dialogue -> then variable -> then dialogue, + plusieurs actions à la suite sur le MÊME déclencheur.""" + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, + "then": { + "type": "variable", "id": "d_2", "target_variable": "score", "data_operation": "incrementer", "data_value": "1", + "then": {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}}, + }, + }, + }]) + action = rules[0]["action"] + assert action["then"]["type"] == "variable" + assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}} + + +def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long(): + """_MAX_THEN_CHAIN borne la chaîne — une chaîne trop longue postée à la + main est coupée plutôt que rejetée entièrement (même tolérance que le + reste de ce moteur).""" + chain = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"} + node = chain + for _ in range(10): # largement au-delà de _MAX_THEN_CHAIN (4) + node["then"] = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"} + node = node["then"] + rules = screens.sanitize_collision_rules([{"trigger": "collision", "action": chain}]) + depth = 0 + node = rules[0]["action"] + while node: + depth += 1 + node = node.get("then") + assert depth == 4 # _MAX_THEN_CHAIN, jamais plus + + +def test_set_then_action_in_rules_attaches_a_new_leaf_at_the_end_of_a_dialogue_chain(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + attached = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}) + assert attached is True + assert rules[0]["action"]["then"] == {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"} + + +def test_set_then_action_in_rules_addresses_a_variable_leaf_by_its_own_id(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, + }]) + attached = screens.set_then_action_in_rules(rules, "v_1", {"type": "dialogue", "dialogue": {"id": "d_2", "lines": []}}) + assert attached is True + assert rules[0]["action"]["then"]["dialogue"]["id"] == "d_2" + + +def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + assert screens.set_then_action_in_rules(rules, "d_absent", {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}) is False + + +def test_set_then_action_in_rules_never_overwrites_an_existing_then(): + rules = screens.sanitize_collision_rules([{ + "trigger": "collision", + "action": { + "type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, + "then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, + }, + }]) + attached = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"}) + assert attached is False + assert rules[0]["action"]["then"]["target_variable"] == "score" # inchangé + + +def test_sanitize_collision_rules_drops_malformed_or_unknown_rules(): + rules = screens.sanitize_collision_rules([ + {"trigger": "diagonale", "action": {"type": "dialogue"}}, + {"trigger": "collision", "action": {"type": "sorcellerie"}}, + {"trigger": "perimetre", "action": {"type": "dialogue"}}, # "perimetre" retiré (demande explicite) + "pas un objet", + ]) + assert rules == [] def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(client, tmp_game_slug_cleanup): @@ -148,17 +341,34 @@ def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_obje assert enemy_payload["collision_rules"] == [] -def test_scene_edit_view_lists_eligible_objects_only(client, tmp_game_slug_cleanup): +def test_build_collision_rule_objects_excludes_the_player(client, tmp_game_slug_cleanup): + """screens.build_collision_rule_objects (voir screens/rendering/ + trigger_graph.py pour son autre appelant, le listage de tous les + déclencheurs du jeu) : "Joueur" (le seul rôle exclu) est désormais + toujours un choix explicite.""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) - # Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) : - # "Joueur" (le seul rôle exclu de "🧩 Collision") est désormais toujours - # un choix explicite. player_id = _add_personnage(client, slug, screen_id, role="joueur") enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") - html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) - idx = html.find("var COLLISION_RULE_OBJECTS") - end = html.find(";", idx) - snippet = html[idx:end] - assert f'"id": {enemy_id}' in snippet - assert f'"id": {player_id}' not in snippet + objects = screens.list_scene_objects(slug, screen_id) + for o in objects: + o["rendered_html"] = screens.render_scene_object(o) + rule_objects = screens.build_collision_rule_objects(objects) + ids = [o["id"] for o in rule_objects] + assert enemy_id in ids + assert player_id not in ids + + +def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(client, tmp_game_slug_cleanup): + """Bug corrigé : compute-operation.js/apply-actions.js (qui portent + forgeApplyVariableActionOffline, appelée par collision-rules-controller.js + pour l'action "Modifier une variable") n'étaient chargés QUE derrière + `{% if offline_mode %}` dans templates/play.html — en aperçu créateur + (/game//play, jamais offline_mode), l'action ne faisait donc + RIEN (fonction absente). Les deux scripts doivent être chargés + inconditionnellement (voir aussi test_export_scorm.py, qui vérifie + qu'ils restent bien présents dans le paquet exporté).""" + slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup) + html = client.get(f"/game/{slug}/play").data.decode() + assert 'src="/static/js/play/offline/compute-operation.js"' in html + assert 'src="/static/js/play/offline/apply-actions.js"' in html diff --git a/tests/test_dialogue_box.py b/tests/test_dialogue_box.py index 5e621baa..0c4b3e6c 100644 --- a/tests/test_dialogue_box.py +++ b/tests/test_dialogue_box.py @@ -1,11 +1,11 @@ """Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en -cours d'une quête déclenchée par une règle de collision "quete" (voir +cours d'un dialogue déclenché par une règle de collision "dialogue" (voir static/js/play/dialogue-box-controller.js) — header/body/footer stylisables. Et le nom d'un personnage ("ℹ️ Informations", screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans -l'éditeur de dialogue de quête (screens/rendering/scene_object_names.py).""" +l'éditeur de dialogue d'un déclencheur (screens/rendering/scene_object_names.py).""" import db import screens @@ -44,10 +44,8 @@ def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup): assert f'data-object-id="{obj_id}"' in html # Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul # son bouton "Suivant" interne l'avait, sous "next-btn") — le JS - # (forgeShowQuestOffer, static/js/play/dialogue-box-controller.js) le - # cherche par ce sélecteur pour y injecter Accepter/Refuser en fin de - # dialogue d'une quête "nouvelle" ; sans lui, l'écran d'offre gardait - # silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons. + # (forgeResetDialogueBoxFooter, static/js/play/dialogue-box-controller.js) + # le cherche par ce sélecteur pour y (re)poser le bouton "Suivant". assert 'data-dialogue-role="header"' in html assert 'data-dialogue-role="body"' in html assert 'data-dialogue-role="footer"' in html @@ -95,11 +93,16 @@ def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanu assert box["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE -def test_full_game_payload_exposes_quests_game_wide(tmp_game_slug_cleanup): +def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) - db.create_quest(slug, "Trouver la clé") + obj_id = screens.add_scene_object(slug, screen_id, kind="personnage") + rules = [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True}, + }] + screens.set_scene_object_collision_rules(slug, obj_id, screens.sanitize_collision_rules(rules)) payload = screens.full_game_payload(slug) - assert [q["title"] for q in payload["quests"]] == ["Trouver la clé"] + assert payload["completable_dialogue_ids"] == ["d_1"] # ---------- Nom d'un personnage ("ℹ️ Informations") ---------- @@ -179,7 +182,7 @@ def test_render_quiz_box_has_header_and_choices(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 # "Quête : ", rempli en jeu + assert 'data-quiz-role="header"' in html # nom de l'objet déclencheur, rempli en jeu assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps assert 'data-quiz-role="choices"' in html assert f'data-object-id="{obj_id}"' in html diff --git a/tests/test_export_scorm.py b/tests/test_export_scorm.py index a5f71b85..3480d69d 100644 --- a/tests/test_export_scorm.py +++ b/tests/test_export_scorm.py @@ -6,9 +6,11 @@ vérifient l'assemblage (imsmanifest.xml, index.html en FORGE_OFFLINE, JS hors ligne présent), jamais un vrai import dans un LMS réel (voir le plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite automatisée).""" +import re import zipfile import db +import screens from tests.test_auth import anon_client # noqa: F401 @@ -125,7 +127,7 @@ def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client, game): def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game): - """Sans quête/question : max=0 (SCORM 1.2/2004 n'ont alors rien à + """Sans déclencheur/question : max=0 (SCORM 1.2/2004 n'ont alors rien à barémer, voir static/js/play/offline/scorm-api.js).""" resp = client.post(f"/game/{game}/export-scorm") import io @@ -133,20 +135,62 @@ def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game): html = zf.read("index.html").decode("utf-8") assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html - quest = db.create_quest(game, "Une quête") - db.set_quest_dialogues(game, quest["id"], { - "nouvelle": [], "terminee": [], - "en_cours": [ - {"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5}, - {"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7}, - ], - }) + # Les dialogues vivent dans les règles de collision d'un objet de + # scène (voir screens/rendering/collision_rules.py::"dialogue".dialogue). + screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d") + resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + rules = [{ + "trigger": "collision", + "action": { + "type": "dialogue", + "dialogue": {"id": "d_1", "lines": [ + {"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5}, + {"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7}, + ]}, + }, + }] + screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules)) resp = client.post(f"/game/{game}/export-scorm") with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: html = zf.read("index.html").decode("utf-8") assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html +def test_export_scorm_index_html_embeds_the_max_quiz_score_across_a_then_chain(client, game): + """Chaînage (demande explicite : "pouvoir modifier un déclencheur pour + lui ajouter des actions") : un dialogue -> then variable -> then + dialogue — les DEUX dialogues de la chaîne comptent pour le score max, + la feuille "variable" au milieu n'en interrompt pas la collecte.""" + screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d") + resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + rules = [{ + "trigger": "collision", + "action": { + "type": "dialogue", + "dialogue": {"id": "d_1", "lines": [ + {"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5}, + ]}, + "then": { + "type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1", + "then": { + "type": "dialogue", + "dialogue": {"id": "d_2", "lines": [ + {"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7}, + ]}, + }, + }, + }, + }] + screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules)) + resp = client.post(f"/game/{game}/export-scorm") + import io + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + html = zf.read("index.html").decode("utf-8") + assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html + + def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client): """Même garde d'accès que /game//publish (core/auth_guard.py) — aucune vérification supplémentaire écrite pour cette route.""" diff --git a/tests/test_global_variables_tab.py b/tests/test_global_variables_tab.py new file mode 100644 index 00000000..c0ef6e78 --- /dev/null +++ b/tests/test_global_variables_tab.py @@ -0,0 +1,74 @@ +"""Onglet "Variables" de l'éditeur de scène (templates/scene_edit.html) — +CRUD classique (formulaires POST/redirect, voir l'onglet "Événements" +pour le même patron) sur les fonctions déjà existantes de db/global_vars/ +(list/create/update/delete). Le nom est la clé stable lue par +static/js/play/conditions.js::readVariableValue — jamais modifiable après +création (voir db/global_vars/update_global_variable.py).""" +import re + +import db + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def test_create_global_var_route_persists_and_redirects_to_the_variables_tab(client, game): + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/variables/new", + data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables") + variables = db.list_global_variables(game) + assert [v["name"] for v in variables] == ["score_total"] + assert variables[0]["type"] == "nombre_entier" + assert variables[0]["value"] == "0" + + +def test_create_global_var_is_idempotent_by_name(client, game): + screen_id = _create_screen(client, game) + client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id}) + client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id}) + assert len(db.list_global_variables(game)) == 1 + + +def test_global_var_edit_route_updates_type_and_value(client, game): + screen_id = _create_screen(client, game) + client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id}) + var_id = db.list_global_variables(game)[0]["id"] + + resp = client.post( + f"/game/{game}/variables/{var_id}/edit", + data={"type": "texte", "value": "beaucoup", "screen_id": screen_id}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables") + variable = db.list_global_variables(game)[0] + assert variable["type"] == "texte" + assert variable["value"] == "beaucoup" + assert variable["name"] == "score_total" # le nom ne change jamais + + +def test_global_var_delete_route_removes_it(client, game): + screen_id = _create_screen(client, game) + client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id}) + var_id = db.list_global_variables(game)[0]["id"] + + resp = client.post(f"/game/{game}/variables/{var_id}/delete", data={"screen_id": screen_id}, follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables") + assert db.list_global_variables(game) == [] + + +def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, game): + screen_id = _create_screen(client, game) + client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "5", "screen_id": screen_id}) + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True) + assert "score_total" in html + assert 'data-tab="variables"' in html diff --git a/tests/test_quests.py b/tests/test_quests.py deleted file mode 100644 index 7e504b04..00000000 --- a/tests/test_quests.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Éditeur de quêtes (voir db/quests/, routes/quests/, onglet "🗺️ Quêtes" -de l'éditeur de scène 2D, templates/scene_edit.html) : id, titre, objectif, récompense -(score), statut (nouvelle/en_cours/terminee), résultat (reussi/echoue), -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 -jeu, jamais par écran (mêmes conventions que les événements -personnalisés, db/custom_events/).""" -import json - -import db - - -def _make_game(tmp_game_slug_cleanup, name="pytest_quests"): - return tmp_game_slug_cleanup(db.create_game(name)) - - -def test_create_quest_has_sensible_defaults(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - assert quest["title"] == "Trouver la clé" - assert quest["objectif"] == "" - assert quest["recompense_score"] == 0 - assert quest["status"] == "nouvelle" - assert quest["resultat"] is None - assert quest["dialogues"] == {} - - -def test_create_quest_falls_back_to_default_title_when_blank(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, " ") - assert quest["title"] == "Nouvelle quête" - - -def test_list_quests_orders_most_recent_first(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - first = db.create_quest(slug, "Première") - second = db.create_quest(slug, "Seconde") - ids = [q["id"] for q in db.list_quests(slug)] - assert ids[:2] == [second["id"], first["id"]] - - -def test_update_quest_only_applies_known_valid_fields(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - updated = db.update_quest(slug, quest["id"], { - "title": "Trouver la clé dorée", "objectif": "Fouiller le donjon", - "recompense_score": 50, "status": "en_cours", "resultat": "reussi", - "unknown_field": "ignoré", "id": 999999, - }) - assert updated["title"] == "Trouver la clé dorée" - assert updated["objectif"] == "Fouiller le donjon" - assert updated["recompense_score"] == 50 - assert updated["status"] == "en_cours" - assert updated["resultat"] == "reussi" - assert updated["id"] == quest["id"] # "id" dans fields est ignoré, jamais modifiable - - -def test_update_quest_rejects_invalid_status_and_blank_title(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - updated = db.update_quest(slug, quest["id"], {"status": "en_pause", "title": " "}) - assert updated["status"] == "nouvelle" # inchangé, valeur invalide ignorée - assert updated["title"] == "Trouver la clé" # inchangé, titre vide ignoré - - -def test_update_quest_returns_none_for_missing_quest(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - assert db.update_quest(slug, 999999, {"title": "x"}) is None - - -def test_delete_quest_removes_it(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - db.delete_quest(slug, quest["id"]) - assert db.get_quest(slug, quest["id"]) is None - - -def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status(): - """speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations", - screens/rendering/scene_object_names.py) — "Le Sorcier" est donc un nom - valide comme n'importe quel autre, seul un speaker/texte VIDE ou un - élément mal formé est rejeté.""" - raw = { - "nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}], - "en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"], - "terminee": "pas une liste", - "statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}], - } - cleaned = db.sanitize_quest_dialogues(raw) - assert cleaned == { - "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": [ - {"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"]) - assert reloaded["dialogues"] == dialogues - - -def test_set_quest_dialogues_returns_none_for_missing_quest(tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - assert db.set_quest_dialogues(slug, 999999, {}) is None - - -# ---------- Routes ---------- - -def test_quest_new_route_creates_and_returns_json(client, tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - resp = client.post(f"/game/{slug}/quests/new", data='{"title": "Nouvelle quête du donjon"}', content_type="application/json") - assert resp.status_code == 200 - quest = resp.get_json()["quest"] - assert quest["title"] == "Nouvelle quête du donjon" - assert db.get_quest(slug, quest["id"]) is not None - - -def test_quests_list_json_route_returns_all_quests(client, tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - db.create_quest(slug, "A") - db.create_quest(slug, "B") - resp = client.get(f"/game/{slug}/quests/list.json") - titles = {q["title"] for q in resp.get_json()["quests"]} - assert titles == {"A", "B"} - - -def test_quest_update_route_persists_fields(client, tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - resp = client.post(f"/game/{slug}/quests/{quest['id']}", data='{"recompense_score": 100, "status": "terminee"}', content_type="application/json") - assert resp.get_json()["quest"]["recompense_score"] == 100 - assert resp.get_json()["quest"]["status"] == "terminee" - - -def test_quest_update_route_404s_for_missing_quest(client, game): - resp = client.post(f"/game/{game}/quests/999999", data="{}", content_type="application/json") - assert resp.status_code == 404 - - -def test_quest_delete_route_removes_it(client, tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - resp = client.post(f"/game/{slug}/quests/{quest['id']}/delete") - assert resp.get_json() == {"ok": True} - assert db.get_quest(slug, quest["id"]) is None - - -def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - quest = db.create_quest(slug, "Trouver la clé") - resp = client.post( - f"/game/{slug}/quests/{quest['id']}/dialogues", - data='{"dialogues": {"nouvelle": [{"speaker": "pnj", "text": "Salut !"}], "en_cours": [], "terminee": []}}', - content_type="application/json", - ) - 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): - resp = client.post(f"/game/{game}/quests/999999/dialogues", data='{"dialogues": {}}', content_type="application/json") - 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 diff --git a/tests/test_scene_collision_rule_objects.py b/tests/test_scene_collision_rule_objects.py index eee8832c..164978b7 100644 --- a/tests/test_scene_collision_rule_objects.py +++ b/tests/test_scene_collision_rule_objects.py @@ -1,9 +1,12 @@ -"""Liste "🧩 Collision" à jour en AJAX (voir routes/scenes/ -scene_collision_rule_objects.py, refreshCollisionRuleObjects() dans -static/js/scenes/scene-editor.js) : appelée après ajout/suppression d'un -objet de scène ou changement de rôle/nom, pour ne jamais recharger la page -alors que cette liste doit refléter le changement (un objet apparaît/ -disparaît selon son rôle, ou change de libellé affiché).""" +"""screens.build_collision_rule_objects (voir screens/scenes/ +build_collision_rule_objects.py) : la liste d'objets éligibles à un +déclencheur de collision, réutilisée par le listage de TOUS les +déclencheurs du jeu (screens/rendering/trigger_graph.py, voir +tests/test_triggers.py) — un objet apparaît/disparaît selon son +rôle, ou change de libellé affiché selon son nom. L'ancien endpoint AJAX +dédié (routes/scenes/scene_collision_rule_objects.py) a été retiré avec +l'onglet "🧩 Collision" séparé — ce fichier teste directement les +fonctions Python plutôt qu'une route HTTP désormais inexistante.""" import screens @@ -21,16 +24,17 @@ def _add_ajax(client, slug, screen_id, **data): ).get_json() -def _list(client, slug, screen_id): - resp = client.get(f"/game/{slug}/screens/{screen_id}/collision-rule-objects") - assert resp.status_code == 200 - return resp.get_json() +def _list(slug, screen_id): + objects = screens.list_scene_objects(slug, screen_id) + for o in objects: + o["rendered_html"] = screens.render_scene_object(o) + return screens.build_collision_rule_objects(objects) def test_freshly_added_personnage_is_pnj_in_the_list(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] - entries = _list(client, slug, screen_id) + entries = _list(slug, screen_id) assert len(entries) == 1 assert entries[0]["id"] == obj_id assert entries[0]["role"] == "pnj" @@ -40,7 +44,7 @@ def test_role_change_to_joueur_removes_it_from_the_list(client, tmp_game_slug_cl slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json") - assert _list(client, slug, screen_id) == [] + assert _list(slug, screen_id) == [] def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cleanup): @@ -48,7 +52,7 @@ def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cle obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json") client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json") - entries = _list(client, slug, screen_id) + entries = _list(slug, screen_id) assert len(entries) == 1 assert entries[0]["role"] == "ennemie" @@ -57,7 +61,7 @@ def test_name_change_is_reflected_in_the_list(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json") - entries = _list(client, slug, screen_id) + entries = _list(slug, screen_id) assert entries[0]["name"] == "Le Sorcier" @@ -65,11 +69,11 @@ def test_deleted_object_is_removed_from_the_list(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", headers={"X-Requested-With": "fetch"}) - assert _list(client, slug, screen_id) == [] + assert _list(slug, screen_id) == [] def test_decor_has_no_role(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) _add_ajax(client, slug, screen_id, kind="decor") - entries = _list(client, slug, screen_id) + entries = _list(slug, screen_id) assert entries[0]["role"] is None diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 00000000..d688fd25 --- /dev/null +++ b/tests/test_triggers.py @@ -0,0 +1,356 @@ +"""Déclencheurs de collision — onglet "Déclencheurs" (voir +screens/rendering/trigger_graph.py, routes/triggers/triggers.py) : TOUS +les déclencheurs du jeu, plus aucune notion de quête pour les regrouper — +un créateur peut faire un simple quiz sans jamais poser de personnage. +Chaque occurrence de "Déclencher ce dialogue" porte son propre dialogue +({id, lines}, voir screens/rendering/collision_rules.py::"dialogue".dialogue), +édité en ligne (voir routes POST .../triggers//dialogue +ci-dessous). Source de données UNIQUE avec l'éditeur de collision : toute +règle ajoutée/retirée/modifiée ici doit rester visible/cohérente via +screens.resolve_collision_rules directement sur l'objet.""" +import re + +import screens +from tests.test_auth import anon_client # noqa: F401 + + +def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_triggers"): + resp = client.post("/games/new", data={"name": name}, follow_redirects=False) + slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + return slug, screen_id + + +def _add_personnage(client, slug, screen_id, role=None): + resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) + assert resp.status_code == 302, resp.data + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + if role: + client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json") + return obj_id + + +def _set_rules(slug, object_id, rules): + """Pose des règles directement (sans passer par une route HTTP — + l'ancienne route de sauvegarde, routes/scenes/scene_object_collision_rules.py, + a été retirée avec l'éditeur de collision séparé) pour préparer un + scénario de test.""" + screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules(rules)) + + +def test_mark_completed_is_kept_when_true(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": True}}]) + obj = screens.get_scene_object(slug, obj_id) + rules = screens.resolve_collision_rules(obj) + assert rules[0]["action"]["mark_completed"] is True + + +def test_mark_completed_is_dropped_when_false_or_absent(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": False}}]) + obj = screens.get_scene_object(slug, obj_id) + rules = screens.resolve_collision_rules(obj) + assert "mark_completed" not in rules[0]["action"] + + +def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + direct_id = _add_personnage(client, slug, screen_id, role="pnj") + nested_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, direct_id, [{"trigger": "collision", "action": {"type": "dialogue"}}]) + _set_rules(slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}]) + + result = screens.list_all_triggers(slug) + ids = [o["object_id"] for o in result] + assert direct_id in ids + assert nested_id in ids + direct_entry = next(o for o in result if o["object_id"] == direct_id) + assert direct_entry["has_interagir"] is False + nested_entry = next(o for o in result if o["object_id"] == nested_id) + assert nested_entry["has_interagir"] is True + + +def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(client, tmp_game_slug_cleanup): + """Losange à embranchement (maquette validée, lecture verticale) : + Vrai ET Faux peuvent chacun déclencher LEUR PROPRE dialogue, + indépendamment l'un de l'autre — plus de "branche qui mène à telle + quête" à choisir (voir static/js/triggers/trigger-editor.js:: + triggerConditionColumnHtml pour son affichage).""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0", + "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}, + "si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}, + }, + }]) + + result = screens.list_all_triggers(slug) + entry = next(o for o in result if o["object_id"] == obj_id) + assert entry["condition"]["variable"] == "inventaire_cle" + assert entry["condition"]["si_vrai"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}]} + assert entry["condition"]["si_faux"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}]} + + +def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "0", + "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, "si_faux": None, + }, + }]) + + result = screens.list_all_triggers(slug) + entry = next(o for o in result if o["object_id"] == obj_id) + assert entry["condition"]["si_faux"] is None + + +def test_triggers_delete_removes_all_rules_on_the_object(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + _set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue"}}]) + + resp = client.delete(f"/game/{slug}/triggers/{obj_id}") + assert resp.status_code == 200 + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_collision_rules(obj) == [] + + +def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/triggers", + json={ + "object_id": obj_id, + "rule": {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}}, + }, + ) + assert resp.status_code == 200 + + # Même donnée, visible directement via la couche collision (source unique). + obj = screens.get_scene_object(slug, obj_id) + rules = screens.resolve_collision_rules(obj) + assert rules == [{ + "trigger": "collision", + "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}, + }] + + resp = client.get(f"/game/{slug}/triggers") + result = resp.get_json() + assert [o["object_id"] for o in result] == [obj_id] + assert result[0]["has_interagir"] is True + + resp = client.delete(f"/game/{slug}/triggers/{obj_id}") + assert resp.status_code == 200 + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_collision_rules(obj) == [] + + +def test_list_all_triggers_surfaces_the_dialogue(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]}}, + }]) + result = screens.list_all_triggers(slug) + assert result[0]["chain"] == [{"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}}] + + +def test_list_all_triggers_surfaces_a_chain_of_actions(client, tmp_game_slug_cleanup): + """Chaînage (demande explicite : "pouvoir modifier un déclencheur pour + lui ajouter des actions") : dialogue -> then variable, la chaîne + ENTIÈRE est aplatie dans l'ordre d'exécution.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": { + "type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, + "then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, + }, + }]) + result = screens.list_all_triggers(slug) + assert result[0]["chain"] == [ + {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, + ] + + +def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + direct_id = _add_personnage(client, slug, screen_id, role="pnj") + cond_id = _add_personnage(client, slug, screen_id, role="pnj") + other_id = _add_personnage(client, slug, screen_id, role="pnj") + + _set_rules(slug, direct_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True}, + }]) + _set_rules(slug, cond_id, [{ + "trigger": "collision", + "action": { + "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", + "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True}, + "si_faux": None, + }, + }]) + _set_rules(slug, other_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}}, + }]) + + ids = screens.list_completable_dialogue_ids(slug) + assert set(ids) == {"d_direct", "d_cond"} + + +def test_trigger_dialogue_update_route_persists_the_new_lines(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + + resp = client.post( + f"/game/{slug}/triggers/{obj_id}/dialogue", + json={"dialogue_id": "d_1", "lines": [{"speaker": "pnj", "text": "Trouvé !"}]}, + ) + assert resp.status_code == 200 + obj = screens.get_scene_object(slug, obj_id) + rules = screens.resolve_collision_rules(obj) + assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}] + + +def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + + resp = client.post( + f"/game/{slug}/triggers/{obj_id}/dialogue", + json={"dialogue_id": "d_absent", "lines": [{"speaker": "pnj", "text": "x"}]}, + ) + assert resp.status_code == 404 + + +def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game): + resp = client.post( + f"/game/{game}/triggers/999999/dialogue", + json={"dialogue_id": "d_1", "lines": []}, + ) + assert resp.status_code == 404 + + +def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + + resp = client.post( + f"/game/{slug}/triggers/{obj_id}/append-action", + json={"after_id": "d_1", "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}}, + ) + assert resp.status_code == 200 + obj = screens.get_scene_object(slug, obj_id) + rules = screens.resolve_collision_rules(obj) + assert rules[0]["action"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"} + + +def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, + }]) + + resp = client.post( + f"/game/{slug}/triggers/{obj_id}/append-action", + json={"after_id": "d_absent", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}}, + ) + assert resp.status_code == 404 + + +def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id, role="pnj") + _set_rules(slug, obj_id, [{ + "trigger": "collision", + "action": { + "type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, + "then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, + }, + }]) + + resp = client.post( + f"/game/{slug}/triggers/{obj_id}/append-action", + json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}}, + ) + assert resp.status_code == 404 + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score" # inchangé + + +def test_trigger_append_action_route_404s_for_an_unknown_object(client, game): + resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}}) + assert resp.status_code == 404 + + +def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.get(f"/game/{slug}/triggers/objects-catalog") + assert resp.status_code == 200 + catalog = resp.get_json() + assert len(catalog) == 1 + assert catalog[0]["thumbnail_html"] + + +def test_triggers_route_is_isolated_like_other_game_routes(anon_client): + """Même garde d'accès que /game//publish (core/auth_guard.py) — + aucune vérification supplémentaire écrite pour ces routes.""" + from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project + + _register(anon_client, "triggersisolation@example.com") + _confirm_2fa(anon_client) + _complete_onboarding(anon_client) + try: + resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers") + assert resp.status_code == 403 + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers", json={}) + assert resp.status_code == 403 + resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1") + assert resp.status_code == 403 + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/dialogue", json={}) + assert resp.status_code == 403 + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/append-action", json={}) + assert resp.status_code == 403 + resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog") + assert resp.status_code == 403 + finally: + _cleanup_project("triggersisolation@example.com")