Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Build and deploy / test-python (push) Successful in 8m45s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-07 22:05:06 +02:00
co-authored by Claude Sonnet 5
parent c504ace167
commit d664ed5637
62 changed files with 3129 additions and 2266 deletions
+2 -14
View File
@@ -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.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event from .custom_events.update_custom_event import update_custom_event
from .quests.constants import ( from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
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
__all__ = [ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
@@ -113,7 +103,5 @@ __all__ = [
"update_global_variable_value", "update_global_variable", "delete_global_variable", "update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id", "delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
] ]
@@ -1,9 +1,7 @@
from .constants import QUEST_STATUS_CHOICES
_MAX_LINES_PER_COLUMN = 200 _MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60 _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 # 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. # 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_*/ # 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) return _sanitize_dialogue_line(raw_line)
def sanitize_quest_dialogues(raw_dialogues): def sanitize_dialogue_lines(raw_lines):
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de """Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
répliques de dialogue ({type: "dialogue", speaker, text} — le nom speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur", scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
voir screens/rendering/scene_object_names.py) ET/OU de questions à ET/OU de questions à choix ({type: "question", text, choices,
choix ({type: "question", text, choices, correct_index, reward_type, correct_index, reward_type, reward_amount}, voir QUESTION_REWARD_TYPES
reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit ci-dessus) — même esprit que resolve_collision_rules.sanitize_collision_rules :
que resolve_collision_rules.sanitize_collision_rules : rejette tout rejette tout élément invalide plutôt que de lever. Les lignes d'UN
élément invalide plutôt que de lever.""" dialogue embarqué dans une action "dialogue" (voir
result = {status: [] for status in QUEST_STATUS_CHOICES} screens/rendering/collision_rules.py) : chaque occurrence de
if not isinstance(raw_dialogues, dict): "Déclencher ce dialogue" porte le sien."""
return result if not isinstance(raw_lines, list):
for status in QUEST_STATUS_CHOICES: return []
raw_lines = raw_dialogues.get(status) cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
if not isinstance(raw_lines, list): return [line for line in cleaned if line]
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] def sum_question_rewards(lines):
return result """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
-4
View File
@@ -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é"}
-18
View File
@@ -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)
-10
View File
@@ -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()
-29
View File
@@ -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()
-11
View File
@@ -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
-22
View File
@@ -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]
-15
View File
@@ -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
-21
View File
@@ -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
-42
View File
@@ -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)
+11 -10
View File
@@ -277,17 +277,18 @@ def build_scorm_zip(slug):
else "static/js/play/offline/scorm-api.js" else "static/js/play/offline/scorm-api.js"
) )
# Score max possible (voir db/quests/reward_budget.py) : sans lui, un # Score max possible (voir db/dialogue_lines.py::sum_question_rewards) :
# LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de points brut, # sans lui, un LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de
# sans barème) et l'affiche à tort avec un "%" — demande utilisateur # points brut, sans barème) et l'affiche à tort avec un "%" — demande
# "5% pour 1.2 mais 100% pour 2004 avec 1 seule question, c'est un # utilisateur "5% pour 1.2 mais 100% pour 2004 avec 1 seule question,
# défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant aussi # c'est un défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant
# min/max (voir static/js/play/offline/scorm-api.js), le LMS peut # 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 # calculer raw/max lui-même, un vrai pourcentage au lieu du score brut
# mal étiqueté. # mal étiqueté. Les dialogues vivent dans les règles de collision des
max_quiz_score = sum( # objets de scène (voir screens/rendering/collision_rules.py::
db.quest_dialogues_total_reward(quest["dialogues"]) for quest in db.list_quests(slug) # "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, # gameData.icon_data_uris (voir forgeRenderIcone,
# static/js/play/offline/render-special-widgets.js) : ajouté au # static/js/play/offline/render-special-widgets.js) : ajouté au
+1 -1
View File
@@ -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
+7 -4
View File
@@ -7,12 +7,13 @@ from core.flask_app import app
@app.route("/game/<slug>/variables/new", methods=["POST"]) @app.route("/game/<slug>/variables/new", methods=["POST"])
def create_global_var(slug): def create_global_var(slug):
"""Depuis la page de gestion des variables (game_variables.html) — """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 — idempotent par nom (voir db.create_global_variable), donc sans
cas de double soumission.""" risque en cas de double soumission."""
name = (request.form.get("name") or "").strip() name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte" var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or "" 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 # É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 # explicitement rend la variable partagée par tous les joueurs, voir
# db/global_vars/create_global_variable.py) — absent du formulaire # 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" per_player = request.form.get("per_player", "1") == "1"
if name: if name:
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player) 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))
+5 -2
View File
@@ -1,4 +1,4 @@
from flask import redirect, url_for from flask import request, redirect, url_for
import db import db
@@ -7,5 +7,8 @@ from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"]) @app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
def global_var_delete(slug, var_id): 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) 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))
+4 -1
View File
@@ -9,5 +9,8 @@ from core.flask_app import app
def global_var_edit(slug, var_id): def global_var_edit(slug, var_id):
var_type = request.form.get("type") or "texte" var_type = request.form.get("type") or "texte"
value = request.form.get("value") or "" 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) 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))
-1
View File
@@ -1 +0,0 @@
from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues
-11
View File
@@ -1,11 +0,0 @@
from flask import jsonify
import db
from core.flask_app import app
@app.route("/game/<slug>/quests/<int:quest_id>/delete", methods=["POST"])
def quest_delete(slug, quest_id):
db.delete_quest(slug, quest_id)
return jsonify({"ok": True})
-34
View File
@@ -1,34 +0,0 @@
from flask import jsonify, request
import db
from core.flask_app import app
@app.route("/game/<slug>/quests/<int:quest_id>/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})
-12
View File
@@ -1,12 +0,0 @@
from flask import jsonify, request
import db
from core.flask_app import app
@app.route("/game/<slug>/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})
-36
View File
@@ -1,36 +0,0 @@
from flask import jsonify, request
import db
from core.flask_app import app
@app.route("/game/<slug>/quests/<int:quest_id>", 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})
-15
View File
@@ -1,15 +0,0 @@
from flask import jsonify
import db
from core.flask_app import app
@app.route("/game/<slug>/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)})
-2
View File
@@ -6,9 +6,7 @@ from . import (
scene_object_commands, scene_object_commands,
scene_object_role, scene_object_role,
scene_object_collision, scene_object_collision,
scene_object_collision_rules,
scene_object_name, scene_object_name,
scene_object_dialogue_box_style, scene_object_dialogue_box_style,
scene_collision_rule_objects,
scene_object_names_list, scene_object_names_list,
) )
@@ -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/<slug>/screens/<int:screen_id>/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))
+5 -22
View File
@@ -5,8 +5,6 @@ from flask import render_template, request, abort, g
import db import db
import screens import screens
from .collision_rule_objects import build_collision_rule_objects
def render_scene_edit(slug, screen_id, game): def render_scene_edit(slug, screen_id, game):
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/ """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" 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_blocks = screens.list_flow_blocks(slug, screen_id)
flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id)
flow_edges = screens.list_flow_edges(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, flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view,
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips), 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), 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=custom_events, custom_event_usages=custom_event_usages,
custom_events_map_json=json.dumps(custom_events_map), custom_events_map_json=json.dumps(custom_events_map),
definitions=db.list_definitions(slug), 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_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)}), 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), # "qui parle" dans un dialogue de déclencheur (voir "ℹ️
collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, # Informations", static/js/triggers/trigger-editor.js) : les noms
collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, # donnés à N'IMPORTE QUEL objet de scène (personnage, décor,
# Éditeur de quêtes (voir db/quests/) : préchargées ici pour que # fond), toutes scènes du jeu confondues.
# 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.
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)), scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
) )
@@ -1,21 +0,0 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/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})
+9 -8
View File
@@ -9,12 +9,13 @@ from core.flask_app import app
def scene_object_names_list(slug): def scene_object_names_list(slug):
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui """Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON, parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
templates/scene_edit.html, questSpeakerOptions() dans static/js/ templates/scene_edit.html, triggerSpeakerOptions() dans static/js/
quests/quest-editor.js) — cette variable n'est posée qu'une fois au triggers/trigger-editor.js) — cette variable n'est posée qu'une fois
chargement complet de la page (HORS de #builder3, jamais rafraîchie au chargement complet de la page (HORS de #builder3, jamais
par une sélection d'objet sans rechargement, voir goToSelected()) : rafraîchie par une sélection d'objet sans rechargement, voir
sans cet endpoint, renommer un personnage puis rouvrir la modale de goToSelected()) : sans cet endpoint, renommer un personnage puis
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau rouvrir un dialogue de déclencheur dans la MÊME session ne faisait
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre jamais apparaître son nouveau nom, tant qu'aucun vrai rechargement de
temps — appelée en AJAX depuis saveSceneObjectName().""" page n'avait eu lieu entre temps — appelée en AJAX depuis
saveSceneObjectName()."""
return jsonify(screens.list_named_scene_object_names(slug)) return jsonify(screens.list_named_scene_object_names(slug))
+1
View File
@@ -0,0 +1 @@
from . import triggers
+112
View File
@@ -0,0 +1,112 @@
from flask import jsonify, request
import screens
from core.flask_app import app
@app.route("/game/<slug>/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/<slug>/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/<slug>/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/<slug>/triggers/<int:object_id>", 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/<slug>/triggers/<int:object_id>/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/<slug>/triggers/<int:object_id>/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})
+6 -2
View File
@@ -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.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_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
from .rendering.collision_rules import ( from .rendering.collision_rules import (
resolve_collision_rules, sanitize_collision_rules, resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules,
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
) )
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands 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 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_name import set_scene_object_name
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style 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.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 .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .flow.ensure_flow_schema import ensure_flow_schema from .flow.ensure_flow_schema import ensure_flow_schema
+9 -8
View File
@@ -10,6 +10,7 @@ from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings from ..rendering.collision_settings import resolve_collision_settings
from ..rendering.collision_rules import resolve_collision_rules from ..rendering.collision_rules import resolve_collision_rules
from ..rendering.dialogue_box_style import resolve_dialogue_box_style 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.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object 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. # ci-dessus sont déjà indexés par id, jamais par nom.
definition_names = {str(d["id"]): d["name"] for d in definitions} definition_names = {str(d["id"]): d["name"] for d in definitions}
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais # Déclencheurs "terminés" (voir screens/rendering/collision_rules.py::
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici # "dialogue".mark_completed) : GAME-WIDE, la liste des id de dialogue
# pour que static/js/play/dialogue-box-controller.js puisse retrouver, # que le créateur a marqués comme concluant la partie — nécessaire à
# à l'exécution d'une action "quete" (collision-rules-controller.js), # static/js/play/dialogue-box-controller.js::
# le dialogue de la colonne correspondant au STATUT ACTUEL de la # forgeSyncAllDialoguesCompletionToScorm pour savoir quand TOUS ont
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur. # été joués, sans plus aucune notion de quête.
quests = db.list_quests(slug) completable_dialogue_ids = list_completable_dialogue_ids(slug)
return { return {
"screens": payload_screens, "flows": flows, "animations": animations, "screens": payload_screens, "flows": flows, "animations": animations,
"data": data, "fields_meta": fields_meta, "data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations, "variables": variables, "personnage_animations": personnage_animations,
"definition_names": definition_names, "definition_names": definition_names,
"quests": quests, "completable_dialogue_ids": completable_dialogue_ids,
} }
+262 -56
View File
@@ -1,42 +1,166 @@
import json import json
# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir from db.dialogue_lines import sanitize_dialogue_lines
# templates/scene_edit.html) : chaque objet de scène (hors le personnage from screens.flow.constants import CONDITION_OPERATORS
# "joueur" — voir screens/rendering/personnage_role.py — et un "fond", from screens.labels.data_operations import DATA_OPERATION_LABELS
# jamais une cible/source de collision) porte une LISTE de règles
# "déclencheur -> action", construites carte par carte dans l'éditeur. # É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 # Déclencheur : "collision" (chevauchement direct, voir
# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le # static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). # encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
# demande explicite).
# #
# Action (une seule par règle) : # Action :
# - "quete" : quete_id (id technique d'une quête — voir db/quests/, # - "dialogue" : la logique n'est plus portée par une "quête" mais
# choisie/créée via l'assistant "+ Action" qui ouvre # DIRECTEMENT par le déclencheur — cette action EST un
# directement l'éditeur de quête, static/js/quests/ # dialogue, pas une référence à autre chose. `dialogue`
# quest-editor.js) — une quête inexistante/supprimée # (optionnel) : {id, lines} — `id` généré côté client
# depuis reste une référence orpheline plutôt qu'une # (voir static/js/scenes/collision-rules-editor.js),
# contrainte SQL, comme le reste de ce moteur (voir # `lines` une liste de répliques/questions (voir
# evenement_id ci-dessous, même principe). # db/dialogue_lines.py) — chaque occurrence de
# - "attaque" : joue la pose "attack" du personnage si disponible (voir # "Déclencher ce dialogue" (directe, sous "interagir",
# personnage-controller.js) — pas de système de combat # ou dans une branche de "condition") porte SON PROPRE
# (dégâts/PV...) pour l'instant, effet visuel seul. # dialogue, édité en ligne dans l'onglet "Déclencheurs".
# - "evenement" : evenement_id — réutilise TEL QUEL le système # `mark_completed` (optionnel, bool) : le créateur
# d'événements personnalisés existant (db.list_custom_events, # marque explicitement ce déclencheur comme "terminé"
# window.dispatchGameEvent), la logique de flow d'un écran # une fois ce dialogue joué (voir static/js/play/
# document peut donc déjà écouter ce déclenchement. # dialogue-box-controller.js::
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du # forgeSyncAllDialoguesCompletionToScorm).
# joueur, voir personnage_commands.py) tant que la # - "variable" : modifie une variable globale — MÊME vocabulaire que
# collision/le périmètre est actif, puis exécute # l'action "Modifier une variable" du graphe de logique
# `sub_action` (même forme qu'une action, SAUF # document (voir screens/data_actions/apply_variable_action.py,
# "interagir" à nouveau — UN seul niveau d'imbrication, # screens/labels/data_operations.py::DATA_OPERATION_LABELS,
# pas une récursion illimitée) à l'appui de la touche. # réutilisé tel quel plutôt que réinventé) :
ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"] # `target_variable` (nom), `data_operation` (une clé de
TRIGGER_TYPES = ["collision", "perimetre"] # DATA_OPERATION_LABELS), `data_value` (optionnel, absent
DEFAULT_PERIMETRE_PX = 5 # 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 <variable> <opérateur> <valeur> (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 # Profondeur max d'imbrication ("interagir" -> sous-action) — évite une
# structure sans fin construite par un JSON posté à la main. # structure sans fin construite par un JSON posté à la main.
_MAX_ACTION_DEPTH = 2 _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): def _sanitize_action(raw_action, depth=1):
@@ -47,31 +171,33 @@ def _sanitize_action(raw_action, depth=1):
return None return None
if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH: if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH:
return None # un seul niveau d'imbrication autorisé return None # un seul niveau d'imbrication autorisé
action = {"type": action_type} if action_type in LEAF_ACTION_TYPES:
if action_type == "quete": return _sanitize_leaf_action(raw_action)
try: if action_type == "interagir":
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":
sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1) sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1)
if not sub_action: if not sub_action:
return None return None
action["sub_action"] = sub_action return {"type": "interagir", "sub_action": sub_action}
# "attaque" : aucun champ supplémentaire. # "condition"
return action 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): def sanitize_collision_rules(raw_rules):
"""Valide/nettoie une liste de règles postée par le client (voir """Valide/nettoie une liste de règles postée par le client (voir
routes/scenes/scene_object_collision_rules.py) — une règle invalide routes/triggers/) — une règle invalide (déclencheur/action inconnus,
(déclencheur/action inconnus, imbrication trop profonde...) est imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
IGNORÉE plutôt que de faire planter la sauvegarde entière.""" la sauvegarde entière."""
if not isinstance(raw_rules, list): if not isinstance(raw_rules, list):
return [] return []
cleaned = [] cleaned = []
@@ -84,16 +210,96 @@ def sanitize_collision_rules(raw_rules):
action = _sanitize_action(raw_rule.get("action")) action = _sanitize_action(raw_rule.get("action"))
if not action: if not action:
continue continue
rule = {"trigger": trigger, "action": action} cleaned.append({"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)
return cleaned 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): def resolve_collision_rules(obj):
"""Décode attributes._collision_rules en liste Python, jamais None — """Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement un attribut absent/corrompu retombe sur aucune règle (comportement
+1 -1
View File
@@ -3,7 +3,7 @@ import json
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface", # Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind= # templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
# "dialogue_box") : affiche AUTOMATIQUEMENT, en jeu, la réplique en cours # "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 # static/js/play/dialogue-box-controller.js) — header (nom du
# personnage qui parle), corps (texte), pied (bouton "Suivant"), # personnage qui parle), corps (texte), pied (bouton "Suivant"),
# entièrement stylisable par l'auteur. # entièrement stylisable par l'auteur.
+4 -4
View File
@@ -11,10 +11,10 @@ _EXCLUDED_KINDS = {"dialogue_box"}
def list_named_scene_object_names(slug): def list_named_scene_object_names(slug):
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️ """Noms distincts de tous les objets de scène nommés (voir "ℹ️
Informations", templates/scene_edit.html) — n'importe quel kind Informations", templates/scene_edit.html) — n'importe quel kind
(personnage, décor, fond), toutes scènes 2D du jeu confondues : une (personnage, décor, fond), toutes scènes 2D du jeu confondues : un
quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre déclencheur est GAME-WIDE, son dialogue peut donc mettre en scène un
en scène un objet posé sur une autre scène que celle où la règle de objet posé sur une autre scène que celle où la règle de collision se
collision se déclenche. Ordre alphabétique, jamais de doublon.""" déclenche. Ordre alphabétique, jamais de doublon."""
names = set() names = set()
for s in list_screens(slug): for s in list_screens(slug):
if s["kind"] != "jeu_2d": if s["kind"] != "jeu_2d":
+199
View File
@@ -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
]
@@ -10,9 +10,11 @@ def build_collision_rule_objects(objects):
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
screens/rendering/collision_rules.py) — factorisé ici plutôt que screens/rendering/collision_rules.py) — factorisé ici plutôt que
dupliqué : utilisé au premier rendu de la page (routes/scenes/ dupliqué : utilisé au premier rendu de la page (routes/scenes/
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après scene_edit_view.py), par l'endpoint AJAX scene_collision_rule_objects.py
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/ (appelé après ajout/suppression/changement de rôle ou de nom, voir
scene-editor.js) pour reconstruire la liste SANS recharger la page. 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 `objects` doit déjà porter "rendered_html" (voir
screens.render_scene_object) sur chaque élément. screens.render_scene_object) sur chaque élément.
+1 -1
View File
@@ -47,7 +47,7 @@ def render_scene_object(obj):
# body/footer sont remplis EN JEU (data-dialogue-role sert de # body/footer sont remplis EN JEU (data-dialogue-role sert de
# sélecteur), masqué par défaut au jeu (voir static/style.css, # sélecteur), masqué par défaut au jeu (voir static/style.css,
# `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de # `.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 # dans l'éditeur (avec un texte d'exemple) pour pouvoir le
# positionner/styliser. # positionner/styliser.
style = resolve_dialogue_box_style(obj) style = resolve_dialogue_box_style(obj)
+2 -2
View File
@@ -9,8 +9,8 @@ def set_scene_object_name(slug, object_id, name):
partout ailleurs, mais jamais éditable avant le panneau "ℹ️ partout ailleurs, mais jamais éditable avant le panneau "ℹ️
Informations", templates/scene_edit.html). Pour un personnage, ce nom Informations", templates/scene_edit.html). Pour un personnage, ce nom
est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue
de quête (voir screens/rendering/scene_object_names.py, static/js/ d'un déclencheur (voir screens/rendering/scene_object_names.py,
quests/quest-editor.js).""" static/js/triggers/trigger-editor.js)."""
ensure_scene_schema(slug) ensure_scene_schema(slug)
conn = db.connect(slug) conn = db.connect(slug)
conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id)) conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id))
@@ -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 // 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 // — pas seulement chaque brique testée isolément (voir
// collision-rules-controller.test.js et dialogue-box-controller.test.js). // collision-rules-controller.test.js et dialogue-box-controller.test.js).
// Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle // 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 // "à la collision -> déclencher ce dialogue (avec mark_completed)", un
// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur // widget "💬 Boîte de dialogue" posé sur la même scène — plus aucun écran
// la même scène. // d'offre automatique, le dialogue s'affiche directement et se marque
// terminé à la fin.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -22,7 +23,6 @@ const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); //
const { const {
forgeCollisionRuleTick, forgeCollisionRuleTick,
} = require('../collision-rules-controller.js'); } = require('../collision-rules-controller.js');
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
function fakeEl(style) { function fakeEl(style) {
const classes = new Set(); 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 player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
const mrPinguin = { const mrPinguin = {
id: 2, kind: 'personnage', id: 2, kind: 'personnage', name: 'Mr. Pinguin',
collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }], 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' }; 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: '' }; boxWidget._footer = { innerHTML: '' };
global.window.currentScreenId = 1; global.window.currentScreenId = 1;
global.window.forgeCompletedDialogueIds = {};
global.gameData = { global.gameData = {
personnage_animations: {}, personnage_animations: {},
quests: [{ completable_dialogue_ids: ['d_intro'],
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: [],
},
}],
}; };
const previousDoc = global.document; const previousDoc = global.document;
@@ -94,12 +92,12 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
try { try {
const screenData = { elements: [player, mrPinguin, box] }; 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._header.textContent, 'Mr. Pinguin');
assert.equal(boxWidget._body.textContent, 'Bonjour !'); assert.equal(boxWidget._body.textContent, 'Bonjour !');
assert.equal(boxWidget.classList.contains('is-active'), true); 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 assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique
forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget) 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._body.textContent, 'Salut !');
assert.equal(boxWidget.classList.contains('is-active'), true); assert.equal(boxWidget.classList.contains('is-active'), true);
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
assert.equal(boxWidget.classList.contains('is-active'), true); assert.equal(boxWidget.classList.contains('is-active'), false);
assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie'); assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin');
assert.match(boxWidget._footer.innerHTML, /Accepter/);
forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter"
assert.equal(forgeQuestById(99).status, 'en_cours');
assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît
} finally { global.document = previousDoc; } } finally { global.document = previousDoc; }
}); });
@@ -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 // 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 // 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 // la condition reste vraie — et gère "interagir" à part (bulle + attente
@@ -14,6 +14,7 @@ global.heldKeys = new Set();
const actionsMod = require('../actions.js'); const actionsMod = require('../actions.js');
Object.assign(global, actionsMod); Object.assign(global, actionsMod);
require('../personnage-controller.js'); require('../personnage-controller.js');
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
const { const {
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
@@ -86,54 +87,117 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true); assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true);
}); });
test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => { 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 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)', () => {
const calls = []; const calls = [];
const previousShow = global.forgeShowDialogueBox; const previousShow = global.forgeShowDialogueBox;
global.forgeShowDialogueBox = id => calls.push(id); global.forgeShowDialogueBox = (...args) => calls.push(args);
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 }); 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; 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', () => { test('forgeRunCollisionRuleAction — "dialogue" sans dialogue (référence orpheline) : transmet une liste vide plutôt que de planter', () => {
global.window.dispatchGameEvent = () => {}; 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 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 playerEl = fakeEl();
const enemyEl = fakeEl(); const npcEl = fakeEl();
enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20) npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
const previousDoc = global.document; const previousDoc = global.document;
global.document = fakeDocument({ 1: playerEl, 2: enemyEl }); global.document = fakeDocument({ 1: playerEl, 2: npcEl });
global.gameData = { personnage_animations: {} }; global.gameData = { personnage_animations: {} };
const previousShow = global.forgeShowDialogueBox;
const calls = [];
global.forgeShowDialogueBox = id => calls.push(id);
try { try {
const screenData = { elements: [player, enemy] }; const screenData = { elements: [player, npc] };
let attackCalls = 0;
const originalRun = global.runSpriteAnimation;
global.runSpriteAnimation = () => { attackCalls++; };
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
global.runSpriteAnimation = originalRun; assert.deepEqual(calls, ['d_7']);
// "attaque" ne joue une animation que si le personnage a une pose } finally { global.document = previousDoc; global.forgeShowDialogueBox = previousShow; }
// "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; }
}); });
test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas le cadre brut du sprite', () => { 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 // 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). // (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: { 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 parent = fakeParentEl();
const playerEl = fakeEl(parent); 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', () => { 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 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 parentNear = fakeParentEl();
const parentFar = fakeParentEl(); const parentFar = fakeParentEl();
const playerElNear = fakeEl(parentNear); 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é)', () => { 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', 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' } }] }] }));
}); });
@@ -1,7 +1,11 @@
// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir // Widget "💬 Boîte de dialogue" — moteur d'exécution (voir
// static/js/play/dialogue-box-controller.js) : affiche la réplique en // static/js/play/dialogue-box-controller.js) : affiche les répliques
// cours du dialogue d'une quête (gameData.quests) correspondant à son // D'UNE ACTION PRÉCISE (transmises en paramètre par collision-rules-
// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin. // 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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -9,10 +13,9 @@ global.window = global.window || {};
global.gameData = {}; global.gameData = {};
const { const {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm, forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus, forgeComputeFinalScormStatus,
} = require('../dialogue-box-controller.js'); } = require('../dialogue-box-controller.js');
@@ -43,50 +46,37 @@ test.beforeEach(() => {
// fuiraient sur le calcul de statut final du test suivant. // fuiraient sur le calcul de statut final du test suivant.
global.window.forgeQuizTotalAnswered = 0; global.window.forgeQuizTotalAnswered = 0;
global.window.forgeQuizCorrectAnswered = 0; global.window.forgeQuizCorrectAnswered = 0;
global.gameData = { global.window.forgeCompletedDialogueIds = {};
quests: [ global.gameData = { completable_dialogue_ids: [] };
{ 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 !' }] } },
],
};
}); });
test('forgeQuestById retrouve une quête par id', () => { test('forgeShowDialogueBox — sans dialogueId : aucun effet (pas de plantage)', () => {
assert.equal(forgeQuestById(5).id, 5);
assert.equal(forgeQuestById(999), undefined);
});
test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => {
const previousDoc = global.document; const previousDoc = global.document;
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } }; global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
try { try {
assert.doesNotThrow(() => forgeShowDialogueBox(999)); assert.doesNotThrow(() => forgeShowDialogueBox(null, 'PNJ', []));
} finally { global.document = previousDoc; } } 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 widget = fakeWidget(42);
const previousDoc = global.document; const previousDoc = global.document;
global.document = { querySelector: () => widget }; global.document = { querySelector: () => widget };
try { 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(widget._classes.has('is-active'), false);
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
} finally { global.document = previousDoc; } } finally { global.document = previousDoc; }
}); });
test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => { test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dialogue terminé immédiatement, sans rien afficher', () => {
global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} });
const widget = fakeWidget(42); const widget = fakeWidget(42);
const previousDoc = global.document; const previousDoc = global.document;
global.document = { querySelector: () => widget }; global.document = { querySelector: () => widget };
try { try {
forgeShowDialogueBox(7); forgeShowDialogueBox('d_6', 'PNJ', [], true);
assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu'); assert.equal(widget._classes.has('is-active'), false);
assert.equal(widget._roles.body.textContent, 'Trouver le trésor'); assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
assert.match(widget._roles.footer.innerHTML, /Accepter/);
assert.match(widget._roles.footer.innerHTML, /Refuser/);
assert.equal(widget._classes.has('is-active'), true);
} finally { global.document = previousDoc; } } finally { global.document = previousDoc; }
}); });
@@ -94,16 +84,16 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
const previousDoc = global.document; const previousDoc = global.document;
global.document = { querySelector: () => null }; global.document = { querySelector: () => null };
try { try {
assert.doesNotThrow(() => forgeShowDialogueBox(5)); assert.doesNotThrow(() => forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]));
} finally { global.document = previousDoc; } } 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 widget = fakeWidget(42);
const previousDoc = global.document; 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: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
try { 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.header.textContent, 'PNJ');
assert.equal(widget._roles.body.textContent, 'Merci !'); assert.equal(widget._roles.body.textContent, 'Merci !');
assert.equal(widget._classes.has('is-active'), true); assert.equal(widget._classes.has('is-active'), true);
@@ -113,48 +103,68 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
} finally { global.document = previousDoc; } } 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 widget = fakeWidget(42);
const previousDoc = global.document; 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 { try {
forgeShowDialogueBox(5); forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
forgeDialogueBoxAdvance(42); forgeDialogueBoxAdvance(42);
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué forgeDialogueBoxAdvance(42); // dernière réplique consommée
assert.equal(widget._classes.has('is-active'), true); assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
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');
assert.equal(widget._classes.has('is-active'), false); assert.equal(widget._classes.has('is-active'), false);
} finally { global.document = previousDoc; } } 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); 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; const previousDoc = global.document;
global.document = { querySelector: () => widget }; global.document = { querySelector: () => widget };
try { try {
forgeShowDialogueBox(5); forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
widget._classes.add('is-active'); forgeDialogueBoxAdvance(42);
forgeDialogueBoxDeclineQuest(42); forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
assert.equal(widget._classes.has('is-active'), false); 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; } } 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', () => { test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
const previousDoc = global.document; const previousDoc = global.document;
global.document = { querySelector: () => null }; 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(() => { 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; 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); const dialogueWidget = fakeWidget(42);
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null); dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
const quizWidget = fakeQuizWidget(99, 2); const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document; const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try { try {
forgeShowDialogueBox(10); forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal
forgeDialogueBoxAdvance(42); // -> ligne "question" forgeDialogueBoxAdvance(42); // -> ligne "question"
assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la 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._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.equal(quizWidget._roles.question.textContent, '2+2 ?');
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/); assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/); 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; const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try { try {
forgeShowDialogueBox(10); forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); // -> question forgeDialogueBoxAdvance(42); // -> question
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1) forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
assert.equal(window.forgeQuizScore, 0); // aucun point 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; const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try { try {
forgeShowDialogueBox(10); forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); forgeDialogueBoxAdvance(42);
forgeQuizBoxAnswer(99, 0); // 1er clic (faux) forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré 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; const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try { try {
forgeShowDialogueBox(10); forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES, true);
forgeDialogueBoxAdvance(42); // -> question forgeDialogueBoxAdvance(42); // -> question
forgeQuizBoxAnswer(99, 1); // bonne réponse forgeQuizBoxAnswer(99, 1); // bonne réponse
assert.equal(window.forgeQuizScore, 5); 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._classes.has('is-active'), true);
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee" forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
assert.equal(forgeQuestById(10).status, 'terminee'); assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
assert.equal(dialogueWidget._classes.has('is-active'), false); assert.equal(dialogueWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; } } 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; const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
try { try {
forgeShowDialogueBox(10); forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement 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(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante
assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse 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) ---------- // ---------- 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 // Bug signalé : "les indicateurs qu'on utilise pour les dialogues
// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu // devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
// construit uniquement avec des quêtes/quiz ne touchait jamais // un jeu construit uniquement avec des dialogues/quiz ne touchait jamais
// gameData.scoring, laissant Score/Completion perpétuellement "unknown" // gameData.scoring, laissant Score/Completion perpétuellement "unknown"
// dans le LMS même après avoir fini le jeu. // 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; } } 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); const widget = fakeWidget(42);
global.gameData.scoring = undefined; global.gameData.scoring = undefined;
const previousDoc = global.document; const previousDoc = global.document;
global.document = { querySelector: () => widget }; global.document = { querySelector: () => widget };
try { try {
forgeDialogueBoxAcceptQuest(5, 42); forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]);
assert.equal(gameData.scoring.status, 'en_cours'); assert.equal(gameData.scoring.status, 'en_cours');
} finally { global.document = previousDoc; } } finally { global.document = previousDoc; }
}); });
test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => { test('forgeSyncAllDialoguesCompletionToScorm — passe "termine" seulement quand TOUS les dialogues completable sont completed', () => {
global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }]; global.gameData.completable_dialogue_ids = ['d_1', 'd_2'];
window.forgeCompletedDialogueIds = { d_1: true };
global.gameData.scoring = { score: 0, status: 'en_cours' }; global.gameData.scoring = { score: 0, status: 'en_cours' };
forgeSyncAllQuestsCompletionToScorm(); forgeSyncAllDialoguesCompletionToScorm();
assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies
gameData.quests[1].status = 'terminee'; window.forgeCompletedDialogueIds.d_2 = true;
forgeSyncAllQuestsCompletionToScorm(); forgeSyncAllDialoguesCompletionToScorm();
assert.equal(gameData.scoring.status, 'termine'); 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', () => { test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () => {
const widget = fakeWidget(42); const calls = [];
global.gameData.quests = [ global.forgeXapiNotifyDialogueCompleted = (id, name) => calls.push({ id, name });
{ id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } }, global.gameData.completable_dialogue_ids = ['d_20'];
];
global.gameData.scoring = { score: 0, status: 'en_cours' }; 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 { try {
forgeShowDialogueBox(20); forgeCompleteDialogue('d_20', 'Crokine');
forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee" assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
assert.equal(forgeQuestById(20).status, 'terminee'); assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
assert.equal(gameData.scoring.status, 'termine'); 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) ---------- // ---------- 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'); assert.equal(forgeComputeFinalScormStatus(), 'reussi');
}); });
test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => { test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "termine") une fois tous les dialogues finis', () => {
global.gameData.quests = [{ id: 1, status: 'terminee' }]; global.gameData.completable_dialogue_ids = ['d_1'];
window.forgeCompletedDialogueIds = { d_1: true };
global.gameData.scoring = { score: 0, status: 'en_cours' }; global.gameData.scoring = { score: 0, status: 'en_cours' };
global.window.forgeQuizTotalAnswered = 4; global.window.forgeQuizTotalAnswered = 4;
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%) global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT; delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
forgeSyncAllQuestsCompletionToScorm(); forgeSyncAllDialoguesCompletionToScorm();
assert.equal(gameData.scoring.status, 'echoue'); assert.equal(gameData.scoring.status, 'echoue');
}); });
// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ---------- // ---------- Granularité xAPI (dialogue terminé, 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; }
});
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => { test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
const dialogueWidget = fakeWidget(42); 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: '' }), querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [], 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; const previousDoc = global.document;
global.document = { global.document = {
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget), querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelectorAll: () => [], querySelectorAll: () => [],
}; };
const calls = []; 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 { 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) forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
assert.equal(calls.length, 1); 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].correct, true);
assert.equal(calls[0].score, 5); assert.equal(calls[0].score, 5);
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique) 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: '' }), querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [], 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; const previousDoc = global.document;
global.document = { global.document = {
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget), querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelectorAll: () => [], querySelectorAll: () => [],
}; };
const calls = []; const calls = [];
global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) => global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
calls.push({ quest, index, text, correct, choiceIndex, correctIndex }); calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
try { 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) forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
assert.equal(calls.length, 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].correct, false);
assert.equal(calls[0].choiceIndex, 1); assert.equal(calls[0].choiceIndex, 1);
assert.equal(calls[0].correctIndex, 0); assert.equal(calls[0].correctIndex, 0);
+54 -36
View File
@@ -1,13 +1,13 @@
// ---------- Éditeur de collision — moteur d'exécution en jeu ---------- // ---------- Éditeur de collision — moteur d'exécution en jeu ----------
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le // Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut // personnage "joueur" — voir screens/rendering/collision_rules.py) peut
// porter des règles "déclencheur -> action" posées dans l'onglet // porter des règles "déclencheur -> action", posées depuis l'onglet
// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier // "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard).
// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre // Ce fichier les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque
// la position du joueur, déclenche l'action une seule fois par entrée // règle contre la position du joueur, déclenche l'action une seule fois
// (pas en continu tant que la condition reste vraie), même cadence que // par entrée (pas en continu tant que la condition reste vraie), même
// personnage-controller.js (50ms) et réutilise ses mêmes briques // cadence que personnage-controller.js (50ms) et réutilise ses mêmes
// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js). // briques (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique // Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun // en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
// branchement FORGE_OFFLINE nécessaire ici. // 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 // "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN // détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes // ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne
// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant // servait à rien pour l'instant, demande explicite).
// pour une portée approximative, cohérent avec le reste du moteur qui ne
// vise pas une physique précise).
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect); 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);
} }
// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un // 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'; if (bubble) bubble.style.display = 'none';
} }
// Exécute une action (top-level ou sub_action d'un "interagir") — jamais // Exécute une action (top-level, sub_action d'un "interagir", ou branche
// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick // si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
// qui gère "interagir" à part (affichage de bulle + attente de touche). // lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
// (affichage de bulle + attente de touche).
function forgeRunCollisionRuleAction(action, targetEl, obj) { function forgeRunCollisionRuleAction(action, targetEl, obj) {
if (action.type === 'evenement') { if (action.type === 'dialogue') {
window.dispatchGameEvent(action.evenement_id); // Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
return; // — chaque occurrence de "Déclencher ce dialogue" porte le sien,
} // voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
if (action.type === 'attaque') { // de dialogue" posé sur l'écran (voir static/js/play/
var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null; // dialogue-box-controller.js) — sans effet si aucun widget n'est
if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false })); // posé, comme le reste de ce moteur. Le nom affiché (en-tête du
return; // quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
} // Informations") — plus de "quête" pour le porter. mark_completed
if (action.type === 'quete') { // (optionnel) : marque ce dialogue terminé une fois épuisé.
// Affiche la réplique en cours du dialogue de cette quête, selon son // `action.then` (chaînage, voir maquette validée : "pouvoir modifier
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur // un déclencheur pour lui ajouter des actions") NE PEUT PAS
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans // s'exécuter immédiatement — un dialogue attend des clics du joueur
// effet si aucun widget n'est posé ou si la quête n'a aucune // — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
// réplique pour son statut, comme le reste de ce moteur. // controller.js une fois le dialogue épuisé.
forgeShowDialogueBox(action.quete_id); var lines = (action.dialogue && action.dialogue.lines) || [];
return; 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'); const conditionsMod = require('./conditions.js');
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox; global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap; global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause;
const personnageMod = require('./personnage-controller.js'); const personnageMod = require('./personnage-controller.js');
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists; global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8; global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
+113 -136
View File
@@ -1,55 +1,58 @@
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ---------- // ---------- 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 // static/js/play/collision-rules-controller.js) et les widgets d'interface
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, // posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action // screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la // "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
// quête (gameData.quests, voir full_game_payload.py) ligne par ligne — // (action.dialogue.lines, transmises telles quelles par
// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton // collision-rules-controller.js — chaque occurrence de "Déclencher ce
// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir // dialogue" porte son propre dialogue, voir screens/rendering/
// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si // collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche
// posée sur la scène — sinon ignorée silencieusement, la conversation // dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne
// avance sans elle) : cliquer la BONNE réponse crédite le score du // "question" ("❓ Question", voir static/js/triggers/trigger-editor.js)
// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la // s'affiche dans la boîte à QUIZ (si posée sur la scène — sinon ignorée
// question reste affichée (le joueur peut réessayer). // 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 // Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
// d'OFFRE ("Quête : <titre>" / objectif / boutons Accepter-Refuser) au // c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser // globales et conditions (voir screens/rendering/collision_rules.py::
// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle", // "condition") — le dialogue choisi s'affiche directement. Une action
// la prochaine interaction rejoue exactement le même dialogue). Une fois // "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
// le dialogue "en_cours" épuisé (donc, s'il contenait des questions, // (ou immédiatement s'il est vide), son id est ajouté à
// toutes répondues correctement), la quête passe "terminee" — le joueur // forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
// peut alors quitter la scène normalement (aucun blocage, ces widgets ne // base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours // forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
// (gameData.quests n'est qu'un instantané par joueur, voir // dialogues `mark_completed` du jeu joués, voir
// full_game_payload.py) : jamais persisté en base, qui garde le statut // gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
// de DÉPART pour toute nouvelle partie. // 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é // 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 // document (db/scoring/, action de flow "Modifier un score"), qui
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors // 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 // de propos pour ce sous-système entièrement client (voir
// collision-rules-controller.js : "Pur JS client, sans le moindre appel // collision-rules-controller.js : "Pur JS client, sans le moindre appel
// serveur"). // serveur").
// //
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a // Silencieux (aucun effet) si le widget requis n'est pas posé sur
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur. // l'écran — même esprit que le reste du moteur.
var forgeDialogueBoxState = {}; var forgeDialogueBoxState = {};
var forgeQuizBoxState = {}; var forgeQuizBoxState = {};
window.forgeQuizScore = window.forgeQuizScore || 0; window.forgeQuizScore = window.forgeQuizScore || 0;
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert // Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
// uniquement à calculer un taux de réussite global (voir // 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. // qui ne compte que les points gagnés.
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0; window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0; window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
function forgeQuestById(questId) { // forgeSyncAllDialoguesCompletionToScorm.
return (gameData.quests || []).find(function (q) { return q.id === questId; }); window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
}
function forgeEscapeHtml(str) { function forgeEscapeHtml(str) {
return String(str == null ? '' : 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 // Remet le pied de la boîte dans son état "dialogue" (un seul bouton
// "Suivant →", voir render_scene_object.py) — nécessaire pour effacer // "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
// les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer // affichée (voir forgeShowCurrentConversationLine).
// un nouveau dialogue dans la même boîte.
function forgeResetDialogueBoxFooter(widget, objectId) { function forgeResetDialogueBoxFooter(widget, objectId) {
var footer = forgeDialogueBoxFooterEl(widget); var footer = forgeDialogueBoxFooterEl(widget);
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>'; if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
@@ -110,11 +112,11 @@ function forgeUpdateAllScoreWidgets() {
// Alimente aussi gameData.scoring (voir static/js/play/offline/ // Alimente aussi gameData.scoring (voir static/js/play/offline/
// apply-actions.js, même forme {score, status}) — bug signalé : "les // 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 // 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 // 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 // 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 // l'action de flow "Modifier un score") laissait Score/Completion
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué // perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/ // jusqu'au bout. Vocabulaire de statut identique à db/scoring/
@@ -124,32 +126,36 @@ function forgeSyncQuizScoreToScorm() {
gameData.scoring.score = window.forgeQuizScore; gameData.scoring.score = window.forgeQuizScore;
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours'; if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin // 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 // dialogue/quiz contourne static/js/play/offline/apply-actions.js
// action de flow "Modifier un score" ici), donc jamais notifié sans cet // (aucune action de flow "Modifier un score" ici), donc jamais notifié
// appel direct — même bug que le commentaire ci-dessus pour SCORM. // sans cet appel direct — même bug que le commentaire ci-dessus pour
// SCORM.
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score); 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 // encore gagné — sans cet appel, Score restait "0" mais Completion
// restait "unknown" tant qu'aucune question n'avait encore été résolue. // 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' }; gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours'; if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
} }
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues // Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
// sont "terminee" — règle simple et prévisible plutôt que de deviner // marqués `mark_completed` (voir gameData.completable_dialogue_ids,
// laquelle des quêtes créées "compte" pour la fin du jeu. // screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
// Réussi/Échoué (voir db/games/get_success_threshold.py, // é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/ // window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
// build_scorm_package.py) : demande utilisateur — "il manque // build_scorm_package.py) : demande utilisateur — "il manque
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de // l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered) // bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les // SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
// quêtes terminées" qui est déjà la seule condition de fin de partie. // les dialogues terminés" qui est déjà la seule condition de fin de
// Aucune question posée du tout (jeu fait uniquement de dialogues) : // partie. Aucune question posée du tout (jeu fait uniquement de
// reste "termine" (ni réussi ni échoué, rien à évaluer). // dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
function forgeComputeFinalScormStatus() { function forgeComputeFinalScormStatus() {
var total = window.forgeQuizTotalAnswered || 0; var total = window.forgeQuizTotalAnswered || 0;
if (total === 0) return 'termine'; if (total === 0) return 'termine';
@@ -158,9 +164,10 @@ function forgeComputeFinalScormStatus() {
return percent >= threshold ? 'reussi' : 'echoue'; return percent >= threshold ? 'reussi' : 'echoue';
} }
function forgeSyncAllQuestsCompletionToScorm() { function forgeSyncAllDialoguesCompletionToScorm() {
var quests = gameData.quests || []; var completableIds = (gameData.completable_dialogue_ids || []);
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return; 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' }; gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
var finalStatus = forgeComputeFinalScormStatus(); var finalStatus = forgeComputeFinalScormStatus();
gameData.scoring.status = finalStatus; gameData.scoring.status = finalStatus;
@@ -187,7 +194,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
var widget = forgeDialogueBoxWidgetById(dialogueObjectId); var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (line.type === 'question') { if (line.type === 'question') {
if (widget) widget.classList.remove('is-active'); 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; return;
} }
forgeHideQuizBoxOnCurrentScreen(); forgeHideQuizBoxOnCurrentScreen();
@@ -197,30 +204,54 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
widget.classList.add('is-active'); widget.classList.add('is-active');
} }
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve // Appelée par l'action "dialogue" (collision-rules-controller.js) —
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois, // trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
// comme le reste du moteur 2D). // fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
function forgeShowDialogueBox(questId) { // ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
var quest = forgeQuestById(questId); // `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
if (!quest) return; // affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
var lines = (quest.dialogues && quest.dialogues[quest.status]) || []; // 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(); var widget = forgeDialogueBoxWidgetOnCurrentScreen();
if (!widget) return; if (!widget) return;
var objectId = widget.getAttribute('data-object-id'); var objectId = widget.getAttribute('data-object-id');
// Aucune ligne posée pour ce statut : une quête "nouvelle" propose forgeSyncDialogueStartedToScorm();
// quand même directement l'offre (accepter/refuser), les autres
// statuts restent silencieux (rien à montrer).
if (!lines.length) { 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; 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); 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 // Avance d'une ligne (fin de dialogue -> question, fin de question
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par // 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) { function forgeAdvanceConversation(dialogueObjectId) {
var state = forgeDialogueBoxState[dialogueObjectId]; var state = forgeDialogueBoxState[dialogueObjectId];
if (!state) return; if (!state) return;
@@ -228,20 +259,8 @@ function forgeAdvanceConversation(dialogueObjectId) {
if (state.index >= state.lines.length) { if (state.index >= state.lines.length) {
forgeHideQuizBoxOnCurrentScreen(); forgeHideQuizBoxOnCurrentScreen();
var widget = forgeDialogueBoxWidgetById(dialogueObjectId); var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
var quest = forgeQuestById(state.questId); if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
if (quest && quest.status === 'nouvelle') { if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
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 (widget) widget.classList.remove('is-active'); if (widget) widget.classList.remove('is-active');
delete forgeDialogueBoxState[dialogueObjectId]; delete forgeDialogueBoxState[dialogueObjectId];
return; return;
@@ -260,23 +279,23 @@ function forgeDialogueBoxAdvance(objectId) {
} }
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur // Affiche une question ("❓ Question") dans la boîte à quiz posée sur
// l'écran — header = "Quête : <titre>", corps = la question + ses choix // l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz, // Informations"), corps = la question + ses choix — si l'auteur n'a posé
// la question est ignorée (avance directement) plutôt que de bloquer la // aucune boîte à quiz, la question est ignorée (avance directement)
// conversation pour toujours en attendant une réponse qu'aucun widget ne // plutôt que de bloquer la conversation pour toujours en attendant une
// peut recueillir. // réponse qu'aucun widget ne peut recueillir.
function forgeShowQuizBox(dialogueObjectId, quest, line, questionIndex) { function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
var widget = forgeQuizBoxWidgetOnCurrentScreen(); var widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; } if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
var quizObjectId = widget.getAttribute('data-object-id'); var quizObjectId = widget.getAttribute('data-object-id');
forgeQuizBoxState[quizObjectId] = { forgeQuizBoxState[quizObjectId] = {
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false, 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 header = widget.querySelector('[data-quiz-role="header"]');
var questionEl = widget.querySelector('[data-quiz-role="question"]'); var questionEl = widget.querySelector('[data-quiz-role="question"]');
var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); 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 (questionEl) questionEl.textContent = line.text;
if (choicesEl) { if (choicesEl) {
choicesEl.innerHTML = line.choices.map(function (choice, i) { choicesEl.innerHTML = line.choices.map(function (choice, i) {
@@ -320,13 +339,13 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
window.forgeQuizCorrectAnswered += 1; window.forgeQuizCorrectAnswered += 1;
forgeAddQuizScore(state.rewardAmount); forgeAddQuizScore(state.rewardAmount);
} }
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) { if (typeof forgeXapiNotifyQuestionAnswered === "function") {
forgeXapiNotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0); forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
} }
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) : // cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null). // no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
if (typeof forgeScorm2004NotifyQuestionAnswered === "function" && state.quest) { if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
forgeScorm2004NotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex); 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 // 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 // après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
@@ -354,58 +373,16 @@ function forgeQuizBoxContinue(quizObjectId) {
forgeAdvanceConversation(state.dialogueObjectId); forgeAdvanceConversation(state.dialogueObjectId);
} }
// ---------- Offre de quête (Accepter/Refuser) ----------
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
// Accepter/Refuser au lieu du "Suivant" habituel.
function forgeShowQuestOffer(widget, objectId, quest) {
var header = widget.querySelector('[data-dialogue-role="header"]');
var body = widget.querySelector('[data-dialogue-role="body"]');
var footer = forgeDialogueBoxFooterEl(widget);
if (header) header.textContent = 'Quête : ' + quest.title;
if (body) body.textContent = quest.objectif || '';
if (footer) {
footer.innerHTML =
'<button type="button" class="dialogueBoxNextBtn dialogueBoxAcceptBtn" onclick="forgeDialogueBoxAcceptQuest(' + quest.id + ', ' + objectId + ')">Accepter</button>' +
'<button type="button" class="dialogueBoxNextBtn dialogueBoxDeclineBtn" onclick="forgeDialogueBoxDeclineQuest(' + objectId + ')">Refuser</button>';
}
forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 };
widget.classList.add('is-active');
}
// Fait passer la quête "en_cours" — UNIQUEMENT dans l'instantané en
// mémoire (gameData.quests), jamais persisté en base : voir la note en
// tête de fichier (le statut en base est celui de DÉPART pour toute
// nouvelle partie, pas un état de partie en cours).
function forgeDialogueBoxAcceptQuest(questId, objectId) {
var quest = forgeQuestById(questId);
if (quest) quest.status = 'en_cours';
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 // static/js/play/__tests__/ (node:test) — même garde que les autres
// contrôleurs de ce dossier. // contrôleurs de ce dossier.
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = { module.exports = {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, forgeResetDialogueBoxFooter,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm, forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus, forgeComputeFinalScormStatus,
}; };
} }
@@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
global.window.API_1484_11 = api; global.window.API_1484_11 = api;
try { try {
forgeScorm2004Init(); 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]])); 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.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.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 ?'); 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; global.window.API_1484_11 = api;
try { try {
forgeScorm2004Init(); 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]])); 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.result'], 'incorrect');
assert.equal(sets['cmi.interactions.0.learner_response'], '1'); 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 forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent, // Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
// ceci lèverait en tentant d'appeler .GetValue sur null. // 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));
}); });
@@ -1,7 +1,7 @@
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la // 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 // granularité par dialogue/question demandée par l'utilisateur (dialogue
// acceptée/terminée, réponse à une question), en plus du score/statut // terminé, réponse à une question), en plus du score/statut global déjà
// global déjà couvert par dialogue-box-controller.test.js. // couvert par dialogue-box-controller.test.js.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); 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')); global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
const { const {
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered, forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiQuestObject, forgeXapiQuestionObject, forgeXapiDialogueObject, forgeXapiQuestionObject,
} = require('../xapi-client.js'); } = require('../xapi-client.js');
test.beforeEach(() => { test.beforeEach(() => {
@@ -21,45 +21,35 @@ test.beforeEach(() => {
}; };
}); });
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => { 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 = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' }); const obj = forgeXapiDialogueObject('d_5', 'Le sorcier');
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5'); assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5');
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier'); 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', () => { test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => {
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?'); const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?');
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2'); assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2');
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?'); 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 = []; const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; 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');
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' });
const body = JSON.parse(calls[0].opts.body); const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed'); 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); assert.equal(body.result.completion, true);
}); });
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => { test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
const calls = []; const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; 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); const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered'); 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.success, true);
assert.equal(body.result.score.raw, 5); 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', () => { test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
const calls = []; const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; 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); const body = JSON.parse(calls[0].opts.body);
assert.equal(body.result.success, false); assert.equal(body.result.success, false);
assert.equal(body.result.score.raw, 0); 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)', () => { test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
const calls = []; const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); }; 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); const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.display['en-US'], 'attempted'); assert.equal(body.verb.display['en-US'], 'completed');
assert.equal(body.verb.display['fr-FR'], 'commencé'); assert.equal(body.verb.display['fr-FR'], 'terminé');
}); });
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => { test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
global.window.FORGE_XAPI_CONFIG = null; global.window.FORGE_XAPI_CONFIG = null;
let called = false; let called = false;
global.fetch = () => { called = true; return Promise.resolve(); }; global.fetch = () => { called = true; return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' }); forgeXapiNotifyDialogueCompleted('d_5', 'x');
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' }); forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0);
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
assert.equal(called, false); assert.equal(called, false);
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ function forgeScormInit() {
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScormApi.LMSInitialize(""); forgeScormApi.LMSInitialize("");
// cmi.core.score.min/max (voir publish/build_scorm_package.py, // 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 // (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 // 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. // fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
+2 -2
View File
@@ -93,12 +93,12 @@ function forgeScorm2004Timestamp() {
return new Date().toISOString().replace(/\.\d+Z$/, ""); 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; if (!forgeScorm2004Api) return;
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10); var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
if (isNaN(n)) n = 0; if (isNaN(n)) n = 0;
var prefix = "cmi.interactions." + n + "."; 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 + "type", "choice");
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp()); forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || "")); forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
+21 -31
View File
@@ -73,26 +73,28 @@ function forgeXapiObject() {
} }
// Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) : // 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 // (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un
// id dérivé — assez pour que le LRS les distingue sans configuration // id dérivé — assez pour que le LRS les distingue sans configuration
// supplémentaire côté créateur du jeu. // supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet
function forgeXapiQuestObject(quest) { // 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; var config = window.FORGE_XAPI_CONFIG;
return { return {
objectType: "Activity", objectType: "Activity",
id: config.activity_id + ":quest:" + quest.id, id: config.activity_id + ":dialogue:" + dialogueId,
definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" }, 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; var config = window.FORGE_XAPI_CONFIG;
return { return {
objectType: "Activity", objectType: "Activity",
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex, id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
definition: { definition: {
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") }, name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
description: { "fr-FR": questionText || "" }, description: { "fr-FR": questionText || "" },
type: "http://adlnet.gov/expapi/activities/cmi.interaction", type: "http://adlnet.gov/expapi/activities/cmi.interaction",
interactionType: "choice", interactionType: "choice",
@@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(newScore) {
); );
} }
// Granularité quête/quiz (voir dialogue-box-controller.js) : demande // Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande
// explicite "il faut savoir quand une quête a été acceptée, à telle // explicite "il faut savoir [...] à telle question quel point obtenu,
// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ // bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du
// par évènement, distinct du score/statut global (forgeXapiNotify // score/statut global (forgeXapiNotifyScoreChanged/StatusChanged
// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de // ci-dessus, qui restent l'activité de premier niveau).
// premier niveau). function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
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) {
if (!window.FORGE_XAPI_CONFIG) return; if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement( forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/completed", "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; if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement( forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/answered", "http://adlnet.gov/expapi/verbs/answered",
{ success: correct, score: { raw: scoreAwarded || 0 } }, { 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) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB, forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered, forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY, forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
}; };
} }
-588
View File
@@ -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 ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '<span class="hint">—</span>';
return (
'<tr class="questListRow" onclick="openQuestFieldsModal(' + quest.id + ')">' +
'<td><strong>' + (quest.title || '').replace(/</g, '&lt;') + '</strong></td>' +
'<td>' + (quest.objectif ? quest.objectif.replace(/</g, '&lt;') : '<span class="hint">—</span>') + '</td>' +
'<td><span class="questPill questPill-score">+' + (quest.recompense_score || 0) + '</span></td>' +
'<td><span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span></td>' +
'<td>' + resultatHtml + '</td>' +
'<td class="fieldsTableActions">' +
'<button type="button" class="button is-small is-danger" title="Supprimer" onclick="event.stopPropagation(); questDeleteFromList(' + quest.id + ')">🗑️</button>' +
'</td>' +
'</tr>'
);
}
function renderQuestListPage() {
var container = document.getElementById('questsTabPanel');
if (!container) return;
var wrap = document.getElementById('questsListWrap');
if (!QUESTS.length) {
wrap.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
return;
}
wrap.innerHTML =
'<div class="fieldsTableWrap"><table class="table is-fullwidth is-hoverable fieldsTable">' +
'<thead><tr><th>Titre</th><th>Objectif</th><th>Récompense</th><th>Statut</th><th>Résultat</th><th></th></tr></thead>' +
'<tbody>' + QUESTS.map(questListRowHtml).join('') + '</tbody>' +
'</table></div>';
}
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 =
'<div class="modal-background" onclick="closeQuestFieldsModal()"></div>' +
'<div class="modal-content questFieldsModalContent"><div class="box">' +
'<div id="questFieldsModalBody"></div>' +
'</div></div>';
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 (
'<div class="questFieldsGrid">' +
'<label class="questField"><span>Titre</span><input type="text" class="input" value="' + (quest.title || '').replace(/"/g, '&quot;') + '" onchange="questUpdateField(' + quest.id + ", 'title', this.value)\"></label>" +
'<label class="questField questField-wide"><span>Objectif</span><input type="text" class="input" value="' + (quest.objectif || '').replace(/"/g, '&quot;') + '" onchange="questUpdateField(' + quest.id + ", 'objectif', this.value)\"></label>" +
'<label class="questField"><span>Récompense (score)</span><input type="number" class="input" value="' + (quest.recompense_score || 0) + '" onchange="questUpdateField(' + quest.id + ", 'recompense_score', this.value)\"></label>" +
'<label class="questField"><span>Statut</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'status', this.value)\">" +
QUEST_STATUS_ORDER.map(function (s) { return '<option value="' + s + '"' + (quest.status === s ? ' selected' : '') + '>' + QUEST_STATUS_LABELS[s] + '</option>'; }).join('') +
'</select></label>' +
'<label class="questField"><span>Résultat</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'resultat', this.value)\">" +
'<option value=""' + (!quest.resultat ? ' selected' : '') + '>—</option>' +
'<option value="reussi"' + (quest.resultat === 'reussi' ? ' selected' : '') + '>Réussi</option>' +
'<option value="echoue"' + (quest.resultat === 'echoue' ? ' selected' : '') + '>Échoué</option>' +
'</select></label>' +
'</div>'
);
}
function renderQuestFieldsModalBody(questId) {
var quest = questById(questId);
var body = document.getElementById('questFieldsModalBody');
if (!quest || !body) return;
var useBtn = _questModalOnUse
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
: '';
body.innerHTML =
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '&lt;') + '</h2>' +
'<button type="button" class="button is-small" onclick="closeQuestFieldsModal()">✕</button>' +
'</div>' +
questFieldRowHtml(quest) +
'<div class="fieldRow" style="justify-content:space-between; align-items:center; margin-top:6px;">' +
'<button type="button" class="collisionRuleAddBtn" onclick="openQuestDialogueModal(' + quest.id + ')">Ajouter les dialogues</button>' +
useBtn +
'</div>';
}
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
// valeur déjà enregistrée.
function questSpeakerOptions(currentSpeaker) {
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
return options;
}
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
// distinctes demandées explicitement, pas un seul bloc. Toujours centrée
// dans sa colonne, 70% de la largeur (voir .questBubble, static/style.css) —
// aucune limite au nombre de bulles par colonne (voir questAddBubble).
function questDialogueBubbleHtml(line, status, index) {
var kind = questBubbleKind(line.speaker);
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
}).join('');
return (
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-' + kind + '">' +
'<div class="questBubbleHeader">' +
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '&lt;') + '</textarea>' +
'</div>' +
'</div>' +
'</div>'
);
}
function questEscAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
// "❓ 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 (
'<div class="questChoiceRow">' +
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
(choiceIndex === correctIndex ? 'checked' : '') +
' onchange="questSetQuestionCorrectIndex(\'' + status + '\', ' + index + ', ' + choiceIndex + ')">' +
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + questEscAttr(choice) + '" ' +
'onchange="questSetQuestionChoiceText(\'' + status + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
'</div>'
);
}
function questQuestionBubbleHtml(line, status, index) {
var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join('');
return (
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-question">' +
'<div class="questBubbleHeader">' +
'<label class="questQuestionHeaderField" title="Nombre de choix"><input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
'<label class="questQuestionHeaderField" title="Récompense (points de score)"><input type="number" min="0" value="' + line.reward_amount + '" ' +
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + questEscAttr(line.text) + '" ' +
'onchange="questUpdateQuestionText(\'' + status + '\', ' + index + ', this.value)">' +
'<div class="questChoicesList">' + choicesHtml + '</div>' +
'</div>' +
'</div>' +
'</div>'
);
}
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 (
'<div class="questDialogueColumn">' +
'<h4 class="questDialogueColumnTitle">' + QUEST_STATUS_LABELS[status] + '</h4>' +
'<div class="questDialogueColumnBubbles" id="questDialogueColumn-' + status + '">' + bubblesHtml + '</div>' +
'<div class="questDialogueColumnButtons">' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">Réplique</button>' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn questQuestionAddBtn" onclick="questAddQuestion(\'' + status + '\')">Question</button>' +
'</div>' +
'</div>'
);
}
// 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 =
'<div class="modal-background" onclick="closeQuestDialogueModal()"></div>' +
'<div class="modal-content questDialogueModalContent"><div class="box">' +
'<div id="questDialogueModalBody"></div>' +
'</div></div>';
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
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
: '';
body.innerHTML =
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
'<div style="display:flex; align-items:center; gap:10px;">' +
'<button type="button" class="button is-small" onclick="questBackToFieldsModal(' + quest.id + ')" title="Retour à la fiche">←</button>' +
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '&lt;') + ' — dialogues</h2>' +
'</div>' +
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
'</div>' +
'</div>' +
'<p class="hint mt-2 mb-2">Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :</p>' +
'<div class="questDialogueColumns">' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '</div>';
}
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 '<p class="hint mb-2">Aucune quête pour l\'instant.</p>';
}
return '<div class="collisionWizardChoices">' + quests.map(function (q, i) {
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="questPickerChoose(' + q.id + ')">' +
'<span>' + (q.title || '').replace(/</g, '&lt;') + '</span>' +
'</button>';
}).join('') + '</div>';
}
var _questPickerOnUse = null;
function renderQuestPickerStep(onUse) {
_questPickerOnUse = onUse;
var body = collisionWizardBodyEl();
body.innerHTML = '<p class="hint mb-2">Quelle quête déclencher ?</p><div id="questPickerList">Chargement…</div>' +
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">Nouvelle quête</button>';
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 <script src> posé à la fin du contenu de la page — les éléments dont
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
// redéclenche jamais après le tout premier chargement réel) — sans ça,
// l'onglet "🗺️ Quêtes" restait vide après un remplacement de <main> (ex.
// suppression d'un objet de scène, voir scene_object_delete.py) qui ne
// redéclenche jamais cet événement.
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
+263 -203
View File
@@ -1,185 +1,77 @@
// ---------- Éditeur de collision (nouvel onglet "🧩 Collision") ---------- // ---------- Assistant "déclencheur -> action" (fusionné dans l'onglet "Déclencheurs") ----------
// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage // L'onglet "🧩 Collision" séparé a été retiré (redondant une fois la
// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/ // vue/gestion des déclencheurs intégrée à l'onglet "Déclencheurs", voir
// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà // static/js/triggers/trigger-editor.js::openAddTriggerModal) — ce
// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais // fichier ne porte plus que le MOTEUR de l'assistant lui-même
// un vrai formulaire à plusieurs champs) : un clic choisit une option, la // (déclencheur -> action, un clic choisit une option, la carte suivante
// carte suivante s'emboîte aussitôt. Voir screens/rendering/ // s'emboîte aussitôt), invoqué exclusivement via openTriggerWizard()
// collision_rules.py pour la forme exacte d'une règle (côté serveur, où // ci-dessous. Voir screens/rendering/collision_rules.py pour la forme
// elle est validée une seconde fois avant d'être enregistrée). // exacte d'une règle (côté serveur, où elle est validée une seconde fois
// avant d'être enregistrée).
var COLLISION_ACTION_LABELS = { var COLLISION_ACTION_LABELS = {
quete: 'Déclencher une quête', attaque: 'Attaquer la cible', dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
evenement: 'Déclencher un événement', interagir: 'Interagir avec une touche', interagir: 'Interagir avec une touche', condition: 'Si une condition est vraie',
}; };
// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'interagir', 'condition'];
// moment, demande explicite) — server-side (screens/rendering/ var COLLISION_TRIGGER_LABELS = { collision: 'À la collision' };
// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles, // Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
// pour ne jamais casser une règle déjà enregistrée avec l'un des deux. // data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir']; // "Modifier une variable" (renderCollisionWizardVariableStep) se masque
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', perimetre: 'Dans un périmètre' }; // pour celles-ci.
var COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
function collisionRuleObjectLabel(obj) {
var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor';
return (obj.name || kindLabel);
}
// Un maillon = un segment de la "chaîne de pièces" affichée dans la carte
// (déclencheur, puis action, puis éventuellement sous-action) — texte
// seul, la logique de lecture vit dans collisionRuleActionSummary/
// collisionRuleTriggerSummary ci-dessous.
function collisionRuleTriggerSummary(rule) {
if (rule.trigger === 'perimetre') return COLLISION_TRIGGER_LABELS.perimetre + ' (' + rule.perimetre_px + 'px)';
return COLLISION_TRIGGER_LABELS.collision;
}
function collisionRuleActionSummary(action) {
if (action.type === 'quete') {
var quest = (typeof questById === 'function') ? questById(action.quete_id) : null;
return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »';
}
if (action.type === 'evenement') {
var ev = CUSTOM_EVENTS_MAP[action.evenement_id];
return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »';
}
return COLLISION_ACTION_LABELS[action.type] || action.type;
}
// Une règle = une chaîne de "maillons" en forme de pièces qui s'emboîtent
// (déclencheur -> action -> sous-action éventuelle), pas une liste plate —
// chaque maillon porte un data-kind (couleur en CSS) et un délai d'entrée
// en cascade (voir @keyframes collisionLinkIn, static/style.css).
function collisionRuleChainHtml(rule, ruleIndex, objectId) {
var links = [
{ kind: 'trigger', text: collisionRuleTriggerSummary(rule) },
{ kind: rule.action.type, text: collisionRuleActionSummary(rule.action), questId: rule.action.quete_id },
];
var action = rule.action;
while (action.type === 'interagir' && action.sub_action) {
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action), questId: action.sub_action.quete_id });
action = action.sub_action;
}
// Un maillon "quete" (kind==='quete') ouvre directement sa modale de
// dialogue (voir openQuestDialogueModal(), static/js/quests/
// quest-editor.js) au clic — demande explicite, plutôt qu'un simple
// texte informatif comme les autres maillons. Reste un <span> comme les
// autres (jamais un <button>, dont le style natif — apparence système,
// marges par défaut — grignotait la pointe en clip-path malgré les
// resets CSS) : un onclick sur un <span> suffit à le rendre cliquable,
// ".collisionRuleLink[data-kind='quete']" ajoute juste le curseur/hover
// qui le distingue visuellement (voir static/style.css).
var linksHtml = links.map(function (link, i) {
var clickable = link.kind === 'quete' && link.questId != null;
var onclick = clickable ? ' onclick="openQuestDialogueModal(' + link.questId + ')"' : '';
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms"' + onclick + '>' +
link.text.replace(/</g, '&lt;') + '</span>';
}).join('');
return (
'<div class="collisionRuleChain">' +
'<div class="collisionRuleChainLinks">' + linksHtml + '</div>' +
'<button type="button" class="collisionRuleChainDelete" title="Supprimer cette règle" ' +
'onclick="deleteCollisionRule(' + objectId + ', ' + ruleIndex + ')">🗑️</button>' +
'</div>'
);
}
// obj.thumbnail_html (screens.render_scene_object) porte un style inline // obj.thumbnail_html (screens.render_scene_object) porte un style inline
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir // PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
// screens/scenes/render_scene_object.py) — un personnage déplacé loin // screens/scenes/render_scene_object.py) — un personnage déplacé loin
// dans la scène (left/top de plusieurs centaines de px) ressortait donc // dans la scène (left/top de plusieurs centaines de px) ressortait donc
// hors du petit cadre 120×120 de cette miniature : la règle CSS // hors du petit cadre de la miniature : la règle CSS `.questTriggerObjectCard
// `.collisionRuleCardThumb img` (voir static/style.css) est censée le // img`/`.questTriggerObjectThumb img` (static/style.css) est censée le
// neutraliser (position:static !important etc.), mais s'appuyer sur un // neutraliser, mais s'appuyer sur un forçage CSS pour annuler un style
// forçage CSS pour annuler un style inline entier reste fragile. Plus // inline entier reste fragile. Plus robuste : reconstruire ici une balise
// robuste : reconstruire ici une balise <img> "propre", sans AUCUN style // <img> "propre", sans AUCUN style hérité du canevas — seul le src
// hérité du canevas — seul le src (l'image elle-même) est repris. // (l'image elle-même) est repris. Utilisée par static/js/triggers/
// trigger-editor.js (chaîne de bulles + grille de choix d'objet).
function collisionRuleThumbHtml(obj) { function collisionRuleThumbHtml(obj) {
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || ''); var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
return match ? '<img src="' + match[1] + '" alt="">' : ''; return match ? '<img src="' + match[1] + '" alt="">' : '';
} }
function collisionRuleCardHtml(obj) { // Id court généré CÔTÉ CLIENT pour une NOUVELLE feuille ("dialogue" ou
var rulesHtml = obj.rules.length // "variable", voir screens/rendering/collision_rules.py) — jamais
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('') // recalculé/réattribué côté serveur. Pour un dialogue, sert de
: '<p class="hint">Aucune règle pour l\'instant.</p>'; // dialogue.id (édité en ligne, voir trigger-editor.js) ; pour une
return ( // variable, sert d'id à la feuille elle-même — dans les deux cas,
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' + // permet d'adresser CETTE feuille précise pour y attacher un "then"
'<div class="collisionRuleCardHeader">' + // plus tard (voir "+ Ajouter une action", trigger-editor.js).
'<div class="collisionRuleCardThumb">' + collisionRuleThumbHtml(obj) + '</div>' + function collisionGenerateActionId() {
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '&lt;') + '</h3>' + return 'd_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
'</div>' +
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">Action</button>' +
'</div>'
);
} }
function renderCollisionRuleCards() { // ---------- Assistant "déclencheur -> action" ----------
var container = document.getElementById('collisionRulesCards');
if (!container) return;
if (!COLLISION_RULE_OBJECTS.length) {
container.innerHTML = '<p class="hint">Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).</p>';
return;
}
// Chaque carte entre en cascade (voir @keyframes collisionCardIn,
// static/style.css) plutôt que toutes en même temps.
container.innerHTML = COLLISION_RULE_OBJECTS.map(function (obj, i) {
return collisionRuleCardHtml(obj).replace('<div class="collisionRuleCard"', '<div style="animation-delay:' + (i * 50) + 'ms" class="collisionRuleCard"');
}).join('');
}
function collisionRuleObjectById(objectId) {
return COLLISION_RULE_OBJECTS.find(function (o) { return o.id === objectId; });
}
// Enregistre la liste COMPLÈTE des règles de cet objet (voir
// routes/scenes/scene_object_collision_rules.py — toujours la liste
// entière, jamais une règle isolée) puis redessine SA carte.
function saveCollisionRules(objectId) {
var obj = collisionRuleObjectById(objectId);
if (!obj) return;
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision-rules', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: obj.rules }),
})
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.rules) obj.rules = json.rules; // reflète la validation serveur (voir sanitize_collision_rules)
renderCollisionRuleCards();
});
}
function deleteCollisionRule(objectId, ruleIndex) {
var obj = collisionRuleObjectById(objectId);
if (!obj) return;
obj.rules.splice(ruleIndex, 1);
saveCollisionRules(objectId);
}
// ---------- Assistant "+ Action" ----------
// État du parcours en cours — un seul à la fois (la modale n'autorise // État du parcours en cours — un seul à la fois (la modale n'autorise
// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi, // qu'une règle en construction) : l'objet ciblé, le déclencheur choisi,
// et l'action en cours de construction (posée directement dans // et l'action en cours de construction (posée directement dans
// `action`, ou dans `action.sub_action` au second niveau pour "interagir"). // `action`, ou dans `action.sub_action` au second niveau pour "interagir").
// Plus de "quête cible" à connaître d'avance — la règle est posée sur
// l'objet, un déclencheur "dialogue" se suffit à lui-même.
var _collisionWizard = null; var _collisionWizard = null;
function openCollisionRuleModal(objectId) { // Appelée après avoir choisi l'objet dans la modale d'ajout de déclencheur
_collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX }; // (voir triggerSelectObject, trigger-editor.js).
document.getElementById('collisionRuleModal').classList.add('is-active'); function openTriggerWizard(objectId, bodyEl) {
_collisionWizard = { objectId: objectId, trigger: null, bodyEl: bodyEl };
renderCollisionWizardTriggerStep(); renderCollisionWizardTriggerStep();
} }
function closeCollisionRuleModal() {
document.getElementById('collisionRuleModal').classList.remove('is-active');
_collisionWizard = null;
}
function collisionWizardBodyEl() { function collisionWizardBodyEl() {
return document.getElementById('collisionRuleModalBody'); return _collisionWizard.bodyEl;
} }
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable, // Étape 1 — déclencheur (un seul choix pour l'instant, "collision") : la
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX). // carte à choix reste affichée telle quelle (plutôt que sautée
// automatiquement) pour rester extensible à de futurs types sans rien
// changer à la structure de l'assistant.
function renderCollisionWizardTriggerStep() { function renderCollisionWizardTriggerStep() {
collisionWizardBodyEl().innerHTML = collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">Cette règle se déclenche...</p>' + '<p class="hint mb-2">Cette règle se déclenche...</p>' +
@@ -187,22 +79,11 @@ function renderCollisionWizardTriggerStep() {
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' + '<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' + '<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
'</button>' + '</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
'<span>' + COLLISION_TRIGGER_LABELS.perimetre + '</span>' +
'<span class="fieldRow" style="align-items:center; gap:6px;" onclick="event.stopPropagation()">' +
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:64px;" class="input is-small">' +
'<span class="hint">px</span>' +
'</span>' +
'</button>' +
'</div>'; '</div>';
} }
function collisionWizardChooseTrigger(trigger) { function collisionWizardChooseTrigger(trigger) {
_collisionWizard.trigger = trigger; _collisionWizard.trigger = trigger;
if (trigger === 'perimetre') {
var pxEl = document.getElementById('collisionWizardPerimetrePx');
_collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX;
}
renderCollisionWizardActionStep(false); renderCollisionWizardActionStep(false);
} }
@@ -240,54 +121,233 @@ function collisionWizardFinalizeAction(action, nested) {
collisionWizardSaveRule(action); collisionWizardSaveRule(action);
} }
// Enregistre via la route /game/<slug>/triggers (routes/triggers/triggers.py)
// — même contrat "liste ENTIÈRE" que l'ancien éditeur de collision
// (recharge les règles existantes de l'objet, y ajoute la nouvelle,
// sauvegarde tout), géré côté serveur : le client ne poste que la
// nouvelle règle + l'id d'objet.
function collisionWizardSaveRule(action) { function collisionWizardSaveRule(action) {
var rule = { trigger: _collisionWizard.trigger, action: action }; var rule = { trigger: _collisionWizard.trigger, action: action };
if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx; closeTriggerModal();
var obj = collisionRuleObjectById(_collisionWizard.objectId); fetch('/game/' + GAME_SLUG + '/triggers', {
obj.rules.push(rule); method: 'POST', headers: { 'Content-Type': 'application/json' },
var objectId = _collisionWizard.objectId; body: JSON.stringify({ object_id: _collisionWizard.objectId, rule: rule }),
closeCollisionRuleModal(); }).then(function () { triggerLoadAll(); });
saveCollisionRules(objectId);
} }
// Chaque type d'action demande éventuellement une précision avant de // "dialogue"/"variable" (des FEUILLES, voir screens/rendering/
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir // collision_rules.py) enchaînent sur "Ensuite ?" (voir
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple // renderCollisionWizardChainStep — Marquer terminé + chaîner une autre
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de // action, demande explicite : "pouvoir modifier un déclencheur pour lui
// plus, se finalise directement. // ajouter des actions") ; "interagir" enchaîne sur une carte de plus
// (voir collisionWizardFinalizeAction) ; "condition" enchaîne sur son
// propre mini-formulaire (voir renderCollisionWizardConditionStep).
function collisionWizardChooseAction(type, nested) { function collisionWizardChooseAction(type, nested) {
if (type === 'attaque' || type === 'interagir') { if (type === 'interagir') {
collisionWizardFinalizeAction({ type: type }, nested); collisionWizardFinalizeAction({ type: type }, nested);
return; return;
} }
if (type === 'quete') { if (type === 'dialogue') {
renderQuestPickerStep(function (quest) { var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested); renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
return;
}
if (type === 'variable') {
renderCollisionWizardVariableStep(function (action) {
renderCollisionWizardChainStep(action, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
}); });
return; return;
} }
if (type === 'evenement') { if (type === 'condition') {
var eventIds = Object.keys(CUSTOM_EVENTS_MAP); renderCollisionWizardConditionStep(nested);
if (!eventIds.length) {
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "Événements", puis reviens ici.</p>';
return;
}
var buttons = eventIds.map(function (id, i) {
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">' +
'<span>' + CUSTOM_EVENTS_MAP[id].name.replace(/</g, '&lt;') + '</span>' +
'</button>';
}).join('');
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
} }
} }
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par // ---------- Étape "Modifier une variable" ----------
// un <script src> posé à la fin du contenu de la page — les éléments dont // Même vocabulaire EXACT que l'action "Modifier une variable" du graphe
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la // de logique document (GLOBAL_VARIABLE_NAMES et DATA_OPERATION_LABELS_MAP
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce // déjà posés par templates/scene_edit.html — ce dernier n'était utilisé
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se // nulle part avant cette fonctionnalité) : variable ciblée, opération,
// redéclenche jamais après le tout premier chargement réel) — sans ça, // valeur (masquée pour les opérations qui n'en ont pas besoin, voir
// l'onglet "🧩 Collision" restait vide après un remplacement de <main> // COLLISION_NO_VALUE_OPERATIONS). `onChoose(action)`.
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui function renderCollisionWizardVariableStep(onChoose) {
// ne redéclenche jamais cet événement. var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards(); .map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
var operationOptions = Object.keys(DATA_OPERATION_LABELS_MAP).map(function (key) {
return '<option value="' + key + '">' + DATA_OPERATION_LABELS_MAP[key] + '</option>';
}).join('');
collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">Modifier...</p>' +
'<div class="collisionWizardConditionForm">' +
'<select id="varWizVariable">' + variableOptions + '</select>' +
'<select id="varWizOperation" onchange="collisionWizardVariableOperationChanged()">' + operationOptions + '</select>' +
'<input type="text" id="varWizValue" placeholder="Valeur">' +
'</div>' +
'<button type="button" class="button primary mt-3" onclick="collisionWizardVariableNext()">Continuer</button>';
_collisionWizard.onVariableChosen = onChoose;
collisionWizardVariableOperationChanged();
}
function collisionWizardVariableOperationChanged() {
var operation = document.getElementById('varWizOperation').value;
var valueInput = document.getElementById('varWizValue');
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1 ? '' : 'none';
}
function collisionWizardVariableNext() {
var operation = document.getElementById('varWizOperation').value;
var action = {
type: 'variable', id: collisionGenerateActionId(),
target_variable: document.getElementById('varWizVariable').value,
data_operation: operation,
};
if (COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1) {
action.data_value = document.getElementById('varWizValue').value;
}
var onChoose = _collisionWizard.onVariableChosen;
_collisionWizard.onVariableChosen = null;
onChoose(action);
}
// ---------- Étape "Ensuite ?" (chaînage + Marquer terminé) ----------
// Après CHAQUE feuille construite (dialogue ou variable, au premier
// niveau, sous "interagir", ou dans une branche de condition) : la case
// "Marquer terminé" (seulement pour un dialogue) PUIS le choix de
// chaîner une autre action à la suite (voir screens/rendering/
// collision_rules.py::"then", demande explicite "pouvoir modifier un
// déclencheur pour lui ajouter des actions") — jamais bloqué à une seule
// action par déclencheur. `onDone(leafAction)` reçoit la PREMIÈRE
// feuille de la chaîne (les suivantes sont attachées via `.then`).
function renderCollisionWizardChainStep(leafAction, onDone) {
var completionHtml = leafAction.type === 'dialogue'
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">' +
'<input type="checkbox" id="condWizMarkCompleted"' + (leafAction.mark_completed ? ' checked' : '') + '> Marquer ce dialogue comme terminé une fois joué' +
'</label>'
: '';
collisionWizardBodyEl().innerHTML =
completionHtml +
'<p class="hint mb-2">Ensuite ?</p>' +
'<div class="collisionWizardChoices">' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChainDone(\'none\')"><span>Rien de plus</span></button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChainDone(\'dialogue\')"><span>Déclencher un dialogue</span></button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChainDone(\'variable\')"><span>Modifier une variable</span></button>' +
'</div>';
_collisionWizard.chainLeaf = leafAction;
_collisionWizard.onChainDone = onDone;
}
function collisionWizardChainDone(choice) {
var leafAction = _collisionWizard.chainLeaf;
var onDone = _collisionWizard.onChainDone;
if (leafAction.type === 'dialogue') {
if (document.getElementById('condWizMarkCompleted').checked) leafAction.mark_completed = true;
else delete leafAction.mark_completed;
}
if (choice === 'none') {
onDone(leafAction);
return;
}
if (choice === 'dialogue') {
var nextLeaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
leafAction.then = nextLeaf;
renderCollisionWizardChainStep(nextLeaf, onDone);
return;
}
// 'variable'
renderCollisionWizardVariableStep(function (action) {
leafAction.then = action;
renderCollisionWizardChainStep(action, onDone);
});
}
// Étape "condition" — variable/opérateur/valeur (même vocabulaire que le
// nœud Condition de l'éditeur de logique, voir screens/flow/constants.py
// ::CONDITION_OPERATORS ; GLOBAL_VARIABLE_NAMES et
// CONDITION_OPERATOR_LABELS_MAP posés par templates/scene_edit.html).
// `nested` (interagir -> condition) est reporté jusqu'à la finalisation,
// une fois les deux branches Vrai/Faux choisies ci-dessous.
function renderCollisionWizardConditionStep(nested) {
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
var operatorOptions = Object.keys(CONDITION_OPERATOR_LABELS_MAP).map(function (key) {
return '<option value="' + key + '">' + CONDITION_OPERATOR_LABELS_MAP[key] + '</option>';
}).join('');
collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">Si...</p>' +
'<div class="collisionWizardConditionForm">' +
'<select id="condWizVariable">' + variableOptions + '</select>' +
'<select id="condWizOperator">' + operatorOptions + '</select>' +
'<input type="text" id="condWizValue" placeholder="Valeur">' +
'</div>' +
'<button type="button" class="button primary mt-3" onclick="collisionWizardConditionNext(' + (nested ? 'true' : 'false') + ')">Continuer</button>';
}
function collisionWizardConditionNext(nested) {
_collisionWizard.pendingCondition = {
type: 'condition',
variable: document.getElementById('condWizVariable').value,
operateur: document.getElementById('condWizOperator').value,
valeur: document.getElementById('condWizValue').value,
si_vrai: null, si_faux: null,
};
_collisionWizard.pendingConditionNested = nested;
renderCollisionWizardBranchStep('si_vrai');
}
// Étape branche (Vrai puis Faux) — chaque branche est soit "Ne rien
// faire", soit une FEUILLE ("Déclencher un dialogue" ou "Modifier une
// variable", voir screens/rendering/collision_rules.py::
// _sanitize_condition_branch : une branche reste une feuille, jamais une
// nouvelle "interagir"/"condition" imbriquée) — les DEUX branches
// peuvent chacune déclencher LEUR PROPRE feuille (et sa propre chaîne),
// indépendamment l'une de l'autre.
function renderCollisionWizardBranchStep(branchKey) {
var label = branchKey === 'si_vrai' ? '✓ Si VRAI...' : '✕ Si FAUX...';
collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">' + label + '</p>' +
'<div class="collisionWizardChoices">' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'dialogue\')">' +
'<span>Déclencher un dialogue</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'variable\')">' +
'<span>Modifier une variable</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
'<span>Ne rien faire</span>' +
'</button>' +
'</div>';
}
function collisionWizardChooseBranch(branchKey, choice) {
if (choice === 'none') {
_collisionWizard.pendingCondition[branchKey] = null;
collisionWizardBranchDone(branchKey);
return;
}
if (choice === 'dialogue') {
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
renderCollisionWizardChainStep(leaf, function (finalLeaf) {
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
collisionWizardBranchDone(branchKey);
});
return;
}
// 'variable'
renderCollisionWizardVariableStep(function (action) {
renderCollisionWizardChainStep(action, function (finalLeaf) {
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
collisionWizardBranchDone(branchKey);
});
});
}
function collisionWizardBranchDone(branchKey) {
if (branchKey === 'si_vrai') {
renderCollisionWizardBranchStep('si_faux');
return;
}
var nested = _collisionWizard.pendingConditionNested;
var condition = _collisionWizard.pendingCondition;
_collisionWizard.pendingCondition = null;
collisionWizardFinalizeAction(condition, nested);
}
+17 -44
View File
@@ -51,7 +51,6 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
'<div class="canvasElementInner">' + json.rendered_html + '</div>' + '<div class="canvasElementInner">' + json.rendered_html + '</div>' +
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>'; '<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
canvas.appendChild(el); canvas.appendChild(el);
refreshCollisionRuleObjects();
if (json.animations) { if (json.animations) {
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations; ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
restartPersonnageIdlePreview(json.id, json.animations); restartPersonnageIdlePreview(json.id, json.animations);
@@ -87,7 +86,6 @@ function deleteSceneObject(id) {
var el = document.getElementById('el-' + id); var el = document.getElementById('el-' + id);
if (el) el.remove(); if (el) el.remove();
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id); if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
refreshCollisionRuleObjects();
// L'objet supprimé était forcément le sélectionné (seul cas où 🗑️ // L'objet supprimé était forcément le sélectionné (seul cas où 🗑️
// est affiché) — referme le panneau de propriétés et l'URL, sans // est affiché) — referme le panneau de propriétés et l'URL, sans
// requête supplémentaire au serveur. // requête supplémentaire au serveur.
@@ -355,25 +353,7 @@ function saveSceneObjectRole(role) {
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', { fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }), body: JSON.stringify({ role: role }),
}).then(refreshCollisionRuleObjects); });
}
// Réutilisé après ajout/suppression/changement de rôle ou de nom (voir
// routes/scenes/scene_collision_rule_objects.py) : un changement de rôle
// peut faire apparaître/disparaître une carte (passage à/depuis "joueur",
// jamais dans "🧩 Collision") et un changement de nom change son libellé
// affiché — recalculée entièrement côté serveur plutôt que rejouée en JS
// (même logique déjà nécessaire pour le rôle implicite d'une scène posée
// avant ce réglage, voir build_collision_rule_objects) SANS recharger la
// page.
function refreshCollisionRuleObjects() {
if (typeof COLLISION_RULE_OBJECTS === 'undefined') return;
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/collision-rule-objects')
.then(function (r) { return r.json(); })
.then(function (json) {
COLLISION_RULE_OBJECTS = json;
renderCollisionRuleCards();
});
} }
// Boîte de collision (voir screens/rendering/collision_settings.py, // Boîte de collision (voir screens/rendering/collision_settings.py,
@@ -579,26 +559,24 @@ function addPersonnageCommandExtraRow() {
} }
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un // "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue de // personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue d'un
// quête (voir static/js/quests/quest-editor.js). // déclencheur (voir static/js/triggers/trigger-editor.js).
function saveSceneObjectName(objectId, name) { function saveSceneObjectName(objectId, name) {
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', { fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name }), body: JSON.stringify({ name: name }),
}).then(function () { }).then(refreshSceneObjectNames);
refreshCollisionRuleObjects();
refreshSceneObjectNames();
});
} }
// SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) : // SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) :
// posée UNE SEULE FOIS au chargement complet de la page (<script> inline // posée UNE SEULE FOIS au chargement complet de la page (<script> inline
// en bas, HORS de #builder3, jamais rafraîchie par une simple sélection // en bas, HORS de #builder3, jamais rafraîchie par une simple sélection
// sans rechargement, voir goToSelected()) — sans ce rafraîchissement, // sans rechargement, voir goToSelected()) — sans ce rafraîchissement,
// renommer un personnage puis ouvrir la modale de dialogue dans la MÊME // renommer un personnage puis ouvrir un dialogue de déclencheur dans la
// session ne faisait jamais apparaître son nouveau nom dans "qui parle" // MÊME session ne faisait jamais apparaître son nouveau nom dans "qui
// (voir questSpeakerOptions(), static/js/quests/quest-editor.js) tant // parle" (voir triggerSpeakerOptions(), static/js/triggers/
// qu'aucun vrai rechargement de page n'avait eu lieu entre temps. // trigger-editor.js) tant qu'aucun vrai rechargement de page n'avait eu
// lieu entre temps.
function refreshSceneObjectNames() { function refreshSceneObjectNames() {
if (typeof SCENE_OBJECT_NAMES === 'undefined') return; if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
fetch('/game/' + GAME_SLUG + '/scene-object-names') fetch('/game/' + GAME_SLUG + '/scene-object-names')
@@ -721,21 +699,16 @@ initPersonnageCommandsPanel();
// viennent d'être recréés par le même remplacement, voir // viennent d'être recréés par le même remplacement, voir
// startAllPersonnagePreviews()/personnage-preview.js). // startAllPersonnagePreviews()/personnage-preview.js).
// //
// .builderCanvasArea (les onglets "🖥️ Scène"/"🧩 Collision"/"🗺️ Quêtes"/ // .builderCanvasArea (les onglets "🖥️ Scène"/"Déclencheurs"/"📣 Événements")
// "📣 Événements") est ENTIÈREMENT à l'intérieur de #builder3 (voir // est ENTIÈREMENT à l'intérieur de #builder3 (voir templates/scene_edit.html)
// templates/scene_edit.html) — le remplacer réinjecte donc aussi // — le remplacer réinjecte donc aussi le conteneur des déclencheurs tel
// #collisionRulesCards/le conteneur des quêtes tels que le SERVEUR les // que le SERVEUR le rend, c'est-à-dire VIDE (rendu entièrement en JS,
// rend, c'est-à-dire VIDES (ces listes sont rendues entièrement en JS, // jamais en Jinja, voir trigger-editor.js) : sans le redessiner ici,
// jamais en Jinja, voir collision-rules-editor.js/quest-editor.js) : sans // l'onglet "Déclencheurs" semblait se "vider" à chaque sélection d'un
// les redessiner ici, les onglets "🧩 Collision"/"🗺️ Quêtes" semblaient se // objet de scène.
// "vider" à chaque sélection d'un objet de scène. COLLISION_RULE_OBJECTS/
// QUESTS_JSON eux-mêmes ne sont PAS affectés par ce remplacement (déclarés
// dans le <script> inline en bas de page, HORS de #builder3) — il suffit
// donc de rejouer leur rendu, pas de les recharger.
function initSceneBuilderPanel() { function initSceneBuilderPanel() {
initScenePosFields(); initScenePosFields();
initPersonnageCommandsPanel(); initPersonnageCommandsPanel();
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews(); if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
if (typeof renderCollisionRuleCards === 'function') renderCollisionRuleCards(); if (typeof initTriggersTab === 'function') initTriggersTab();
if (typeof renderQuestListPage === 'function') renderQuestListPage();
} }
+648
View File
@@ -0,0 +1,648 @@
// ---------- Onglet "Déclencheurs" ----------
// templates/scene_edit.html : un GRAPHE DE LOGIQUE lu par déclencheur
// (demande explicite : "un RH doit le comprendre, un visuel global") —
// TOUS les déclencheurs du JEU ENTIER vivent côte à côte dans la même
// colonne (plus de "quête" pour les regrouper, voir screens/rendering/
// trigger_graph.py::list_all_triggers), chacun avec SON PROPRE dialogue
// embarqué ({id, lines}, voir screens/rendering/collision_rules.py::
// "dialogue".dialogue). Les boutons Réplique/Question sont posés
// DIRECTEMENT sur le rectangle de dialogue de chaque déclencheur — le
// seul bouton au bas de la colonne est "Ajouter un déclencheur". Le
// créateur décide lui-même, via ses variables globales et conditions
// (voir screens/rendering/collision_rules.py::"condition"), QUAND
// déclencher quel dialogue et QUAND marquer un dialogue comme terminé
// (action.mark_completed, voir static/js/play/dialogue-box-controller.js).
// 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
// en bleu.
function triggerBubbleKind(speaker) {
return speaker === 'Joueur' ? 'joueur' : 'pnj';
}
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
// valeur déjà enregistrée.
function triggerSpeakerOptions(currentSpeaker) {
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
return options;
}
function triggerEscAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
function triggerColumnHtml() {
return (
'<div class="questDialogueColumn">' +
'<div class="questDialogueColumnBody" id="triggerColumnBody">' +
'<div class="questTriggersSlot" id="triggersSlot"></div>' +
'</div>' +
'<div class="questDialogueColumnButtons">' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openAddTriggerModal()">Ajouter un déclencheur</button>' +
'</div>' +
'</div>'
);
}
// ---------- Dialogue INLINE (voir screens/rendering/collision_rules.py
// ::"dialogue".dialogue) ----------
// Un dialogue = {id, lines} porté PAR L'ACTION elle-même — rendu comme un
// rectangle à bords arrondis, MÊME largeur que le reste de la chaîne
// (pastilles/carte objet), avec un bouton "Réplique"/"Question" qui
// ajoute une bulle directement dedans.
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
// distinctes, pas un seul bloc.
function triggerDialogueBubbleHtml(dialogueId, line, index) {
var kind = triggerBubbleKind(line.speaker);
var speakerSelect = triggerSpeakerOptions(line.speaker).map(function (name) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
}).join('');
return (
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-' + kind + '">' +
'<div class="questBubbleHeader">' +
'<select class="questBubbleSpeaker" title="Qui parle" onchange="triggerInlineSetBubbleSpeaker(\'' + dialogueId + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
'onchange="triggerInlineUpdateBubbleText(\'' + dialogueId + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '&lt;') + '</textarea>' +
'</div>' +
'</div>' +
'</div>'
);
}
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
// dialogue (voir .questBubbleRow/.questBubble et static/style.css) mais
// un type de ligne différent (voir db/dialogue_lines.py) : header =
// nombre de choix + récompense (type + montant), body = la question et
// ses choix, avec la bonne réponse cochée.
function triggerQuestionChoiceRowHtml(dialogueId, choice, choiceIndex, correctIndex, index) {
var radioName = 'triggerCorrect-' + dialogueId + '-' + index;
return (
'<div class="questChoiceRow">' +
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
(choiceIndex === correctIndex ? 'checked' : '') +
' onchange="triggerInlineSetQuestionCorrectIndex(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ')">' +
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + triggerEscAttr(choice) + '" ' +
'onchange="triggerInlineSetQuestionChoiceText(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
'</div>'
);
}
function triggerQuestionBubbleHtml(dialogueId, line, index) {
var choicesHtml = line.choices.map(function (c, i) { return triggerQuestionChoiceRowHtml(dialogueId, c, i, line.correct_index, index); }).join('');
return (
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-question">' +
'<div class="questBubbleHeader">' +
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Nombre de choix</span>' +
'<input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
'onchange="triggerInlineSetQuestionChoiceCount(\'' + dialogueId + '\', ' + index + ', this.value)"></label>' +
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Points gagnés</span>' +
'<input type="number" min="0" value="' + line.reward_amount + '" ' +
'onchange="triggerInlineSetQuestionRewardAmount(\'' + dialogueId + '\', ' + index + ', this.value)"> pts</label>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + triggerEscAttr(line.text) + '" ' +
'onchange="triggerInlineUpdateQuestionText(\'' + dialogueId + '\', ' + index + ', this.value)">' +
'<div class="questChoicesList">' + choicesHtml + '</div>' +
'</div>' +
'</div>' +
'</div>'
);
}
// Pastille "Déclencher ce dialogue" — visuelle uniquement, la même pour
// TOUTES les occurrences : posée juste avant le rectangle de dialogue
// lui-même (voir triggerInlineDialogueHtml).
function triggerDeclencherDialoguePillHtml() {
return '<div class="questTriggerPill quete">Déclencher ce dialogue</div>';
}
// Libellé lisible d'une action "variable" (voir screens/rendering/
// collision_rules.py::"variable" et screens/labels/data_operations.py) —
// DATA_OPERATION_LABELS_MAP est déjà posé par templates/scene_edit.html.
function triggerVariableActionLabel(item) {
var opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
var valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ' ' + item.data_value : '';
return (item.target_variable || '').replace(/</g, '&lt;') + ' — ' + opLabel.replace(/</g, '&lt;') + valuePart;
}
// Feuille "Modifier une variable" — un encart compact (même largeur que
// le reste de la chaîne), pas de bulles dedans contrairement au
// rectangle de dialogue.
function triggerVariableActionHtml(item) {
return (
'<div class="dialogueBox questVariableAction">' +
'<div class="dialogueBoxHeader">' +
'<span class="dialogueBoxTitle">Variable</span>' +
'</div>' +
'<p class="questVariableActionLabel">' + triggerVariableActionLabel(item) + '</p>' +
'</div>'
);
}
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
// _describe_chain) : "Déclencher ce dialogue" + son rectangle, ou une
// feuille "variable", à la suite les unes des autres — PUIS, en bout de
// chaîne, "+ Ajouter une action" (demande explicite : "pouvoir modifier
// un déclencheur pour lui ajouter des actions", jamais bloqué à une
// seule action). `objectId` : nécessaire pour poster l'ajout sur le bon
// objet (voir triggerOpenAppendActionModal).
function triggerActionChainHtml(chain, objectId) {
if (!chain || !chain.length) return '';
var itemsHtml = chain.map(function (item) {
var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '<div class="questTriggerPill quete">Modifier une variable</div>';
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
}).join('');
var lastItem = chain[chain.length - 1];
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
var appendBtn = lastLeafId
? '<div class="questTriggerConnector"></div>' +
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
: '';
return itemsHtml + appendBtn;
}
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
// "dialogue".dialogue pour la forme {id, lines}. `dialogue` peut être
// absent (action posée sans passer par l'assistant) : rien n'est rendu
// plutôt que de planter, même tolérance que le reste de ce moteur.
function triggerInlineDialogueHtml(dialogue) {
if (!dialogue) return '';
var lines = dialogue.lines || [];
var bubblesHtml = lines.map(function (line, i) {
return line.type === 'question' ? triggerQuestionBubbleHtml(dialogue.id, line, i) : triggerDialogueBubbleHtml(dialogue.id, line, i);
}).join('');
return (
'<div class="dialogueBox">' +
'<div class="dialogueBoxHeader">' +
'<span class="dialogueBoxTitle">Dialogue</span>' +
'<div class="dialogueBoxActions">' +
'<button type="button" class="addReplyBtn" onclick="triggerInlineAddBubble(\'' + dialogue.id + '\')">Réplique</button>' +
'<button type="button" class="addReplyBtn addQuestionBtn" onclick="triggerInlineAddQuestion(\'' + dialogue.id + '\')">Question</button>' +
'</div>' +
'</div>' +
bubblesHtml +
'</div>'
);
}
// ---------- Déclencheurs (voir screens/rendering/trigger_graph.py::
// list_all_triggers) ----------
// Maquette validée : carte objet + pastilles empilées VERTICALEMENT
// reliées par des flèches. Le "Déclencher ce dialogue" se termine par le
// rectangle de dialogue lui-même — la flèche pointe DIRECTEMENT dessus.
// Quand l'action se termine par une "condition", la chaîne se sépare en
// un losange à embranchement Vrai/Faux (voir triggerConditionColumnHtml) —
// chaque branche affiche DIRECTEMENT son propre contenu (plus de "branche
// qui mène à telle quête" à choisir, chaque branche est indépendante).
// collisionRuleThumbHtml (static/js/scenes/collision-rules-editor.js,
// chargé sur la même page) reste réutilisé pour la vignette elle-même.
// Accordéon : object_id -> true si REPLIÉ — par défaut tout est déplié,
// absent de cette map = déplié.
var _triggerCollapsed = {};
// Le déclencheur actif est celui où Réplique/Question ajoutent leur bulle
// (surligné, voir .questTriggerCol.active en CSS) — par défaut le
// DERNIER dialogue trouvé, mais cliquer sur la carte d'un autre
// déclencheur en fait le nouvel actif.
var _triggerActiveDialogueId = null;
function _chainLeafIds(chain) {
return (chain || []).map(function (item) { return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id; }).filter(Boolean);
}
function _triggerLeafIds(trigger) {
if (trigger.condition) {
var vrai = trigger.condition.si_vrai, faux = trigger.condition.si_faux;
return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain));
}
return _chainLeafIds(trigger.chain);
}
function triggerChainHtml(trigger) {
var isCollapsed = !!_triggerCollapsed[trigger.object_id];
var leafIds = _triggerLeafIds(trigger);
var isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
var cardOnclick = 'triggerClickObjectCard(' + trigger.object_id + ', ' + (leafIds[0] ? '\'' + leafIds[0] + '\'' : 'null') + ')';
var card =
'<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="' + cardOnclick + '">' + collisionRuleThumbHtml(trigger) +
'<span class="questTriggerObjectName">' + (trigger.object_name || '').replace(/</g, '&lt;') + '</span>' +
'<span class="questTriggerCollapseChevron">▾</span>' +
'</div>';
if (isCollapsed) {
return (
'<div class="questTriggerCol collapsed">' +
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
card +
'</div>'
);
}
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }];
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
var pillsHtml = pills.map(function (pill, i) {
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
}).join('');
var tailHtml = trigger.condition
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
: triggerActionChainHtml(trigger.chain, trigger.object_id);
return (
'<div class="questTriggerCol' + (isActive ? ' active' : '') + '">' +
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
card +
'<div class="questTriggerConnector"></div>' +
pillsHtml +
tailHtml +
'</div>'
);
}
// Clic sur la carte objet : replie/déplie TOUTE la logique associée
// (accordéon, demande explicite) — et rend ce déclencheur actif au
// passage puisqu'on vient d'y porter son attention.
function triggerClickObjectCard(objectId, leafId) {
_triggerCollapsed[objectId] = !_triggerCollapsed[objectId];
if (leafId) _triggerActiveDialogueId = leafId;
triggerRenderSlot();
}
// Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
// "il faut tout mettre verticalement", sans toucher au losange lui-même) :
// la branche Vrai descend tout droit vers son "Déclencher ce dialogue"
// (ou "Ne rien faire"), PUIS la branche Faux descend à son tour — jamais
// deux branches côte à côte. Chaque branche affiche SON PROPRE dialogue
// (voir screens/rendering/trigger_graph.py::_resolve_action_trigger),
// indépendamment de l'autre.
function triggerCondArrowHtml(kind, label) {
var color = kind === 'yes' ? '#2ecc71' : '#e0524d';
var markerId = 'triggerCondArrow_' + kind + '_' + Math.random().toString(36).slice(2, 8);
return (
'<div class="questCondArrowWrap">' +
'<div class="questCondArrowLabel ' + kind + '">' + label + '</div>' +
'<svg viewBox="0 0 34 68" preserveAspectRatio="none" aria-hidden="true">' +
'<defs><marker id="' + markerId + '" markerWidth="10" markerHeight="10" refX="7" refY="5" orient="auto">' +
'<path d="M0,0 L10,5 L0,10 Z" fill="' + color + '"></path></marker></defs>' +
'<line x1="17" y1="0" x2="17" y2="56" stroke="' + color + '" stroke-width="4" marker-end="url(#' + markerId + ')"></line>' +
'</svg>' +
'</div>'
);
}
function triggerConditionColumnHtml(condition, objectId) {
var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
var label = (condition.variable || '').replace(/</g, '&lt;') + ' ' + (opLabel || '').replace(/</g, '&lt;') + ' ' + (condition.valeur || '').replace(/</g, '&lt;');
var branchesHtml = ['si_vrai', 'si_faux'].map(function (branchKey) {
var kind = branchKey === 'si_vrai' ? 'yes' : 'no';
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
var branch = condition[branchKey];
var content = branch
? triggerActionChainHtml(branch.chain, objectId)
: '<div class="questTriggerPill none">Ne rien faire</div>';
return triggerCondArrowHtml(kind, arrowLabel) + content;
}).join('');
return (
'<div class="questCondColumn">' +
'<div class="questCondDiamond"><span>' + label + '</span></div>' +
branchesHtml +
'</div>'
);
}
// Cache global (voir triggerLoadAll) — muté directement par les fonctions
// triggerInline* ci-dessous, puis ré-affiché + sauvegardé, sans jamais
// adopter la réponse du serveur comme nouvel état local (sanitize_dialogue_lines
// rejette toute réplique au texte encore vide — juste ajoutée, pas encore
// tapée — l'adopter effacerait silencieusement la bulle en cours
// d'écriture).
var _triggersCache = [];
// dialogue_id -> {objectId, dialogueRef} — `dialogueRef` est LA MÊME
// référence que dans _triggersCache, muter dialogueRef.lines ici mute
// donc aussi le cache (une seule source de vérité). Couvre aussi bien le
// dialogue direct d'un déclencheur que ceux embarqués dans ses branches
// de condition.
var _triggerDialogueRegistry = {};
function _registerChainDialogues(chain, objectId, dialogueIds) {
(chain || []).forEach(function (item) {
if (item.type === 'dialogue' && item.dialogue) {
_triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue };
dialogueIds.push(item.dialogue.id);
} else if (item.type === 'variable' && item.id) {
dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif"
}
});
}
function _registerTriggerDialogues(list) {
_triggerDialogueRegistry = {};
var dialogueIds = [];
list.forEach(function (t) {
if (t.condition) {
['si_vrai', 'si_faux'].forEach(function (branchKey) {
var branch = t.condition[branchKey];
if (branch) _registerChainDialogues(branch.chain, t.object_id, dialogueIds);
});
} else {
_registerChainDialogues(t.chain, t.object_id, dialogueIds);
}
});
return dialogueIds;
}
function triggerLoadAll() {
fetch('/game/' + GAME_SLUG + '/triggers')
.then(function (r) { return r.json(); })
.then(function (list) {
_triggersCache = list;
var dialogueIds = _registerTriggerDialogues(list);
// Le déclencheur actif par défaut est le DERNIER dialogue trouvé —
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
// garde (cliquer sur une autre carte reste possible, voir
// triggerClickObjectCard).
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
}
triggerRenderSlot();
});
}
function triggerRenderSlot() {
var slot = document.getElementById('triggersSlot');
if (!slot) return;
slot.innerHTML = _triggersCache.map(function (t) { return triggerChainHtml(t); }).join('');
}
function triggerDeleteTrigger(objectId) {
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId, { method: 'DELETE' })
.then(function () { triggerLoadAll(); });
}
// ---------- Mutateurs du dialogue INLINE ----------
// Chaque fonction retrouve son dialogue via le registre (dialogue_id,
// unique par occurrence de "Déclencher ce dialogue") — pas besoin de
// connaître l'objet, déjà mémorisé à triggerLoadAll.
function triggerInlineSaveDialogue(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dialogue_id: dialogueId, lines: entry.dialogueRef.lines }),
}).then(function (r) {
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 triggerNextDefaultSpeaker(lastSpeaker) {
if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ';
return 'Joueur';
}
function triggerInlineAddBubble(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
var lines = entry.dialogueRef.lines;
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
lines.push({ type: 'dialogue', speaker: triggerNextDefaultSpeaker(lastSpeaker), text: '' });
triggerRenderSlot();
triggerInlineSaveDialogue(dialogueId);
}
// "❓ Question" — voir triggerQuestionBubbleHtml 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 triggerInlineAddQuestion(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
triggerRenderSlot();
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].text = text;
triggerInlineSaveDialogue(dialogueId);
}
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.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 triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
var line = entry.dialogueRef.lines[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;
triggerRenderSlot();
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetQuestionChoiceText(dialogueId, index, choiceIndex, text) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].choices[choiceIndex] = text;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].correct_index = choiceIndex;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].speaker = speaker;
triggerRenderSlot();
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineUpdateBubbleText(dialogueId, index, text) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].text = text;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineDeleteBubble(dialogueId, index) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines.splice(index, 1);
triggerRenderSlot();
triggerInlineSaveDialogue(dialogueId);
}
// ---------- Modale d'ajout de déclencheur ----------
// Étape 1 : choisir l'objet de scène (toutes scènes du jeu confondues,
// voir GET .../triggers/objects-catalog — contrairement à l'éditeur de
// collision, limité à la scène actuellement ouverte). Étape 2 : le MÊME
// assistant "déclencheur -> action" que l'éditeur de collision (voir
// openTriggerWizard, static/js/scenes/collision-rules-editor.js).
function ensureTriggerModal() {
var modal = document.getElementById('questTriggerModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal questTriggerModal';
modal.id = 'questTriggerModal';
modal.innerHTML =
'<div class="modal-background" onclick="closeTriggerModal()"></div>' +
'<div class="modal-content questFieldsModalContent"><div class="box">' +
'<div class="fieldRow" style="align-items:center; justify-content:space-between;">' +
'<h2 class="title is-5" style="margin:0;" id="questTriggerModalTitle">Ajouter un déclencheur</h2>' +
'<button type="button" class="button is-small" onclick="closeTriggerModal()">✕</button>' +
'</div>' +
'<div id="questTriggerModalBody"></div>' +
'</div></div>';
document.body.appendChild(modal);
return modal;
}
function openAddTriggerModal() {
var modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur';
triggerRenderObjectStep();
}
function triggerRenderObjectStep() {
var body = document.getElementById('questTriggerModalBody');
body.innerHTML =
'<p class="hint mt-2 mb-2">Choisis l\'objet de scène qui déclenchera ce dialogue :</p>' +
'<div id="questTriggerObjectGrid" class="collisionWizardChoices">Chargement…</div>';
fetch('/game/' + GAME_SLUG + '/triggers/objects-catalog')
.then(function (r) { return r.json(); })
.then(function (objects) {
var grid = document.getElementById('questTriggerObjectGrid');
if (!grid) return;
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
grid.innerHTML = objects.map(function (o, i) {
return '<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="triggerSelectObject(' + o.id + ')">' +
'<div class="questTriggerObjectThumb">' + collisionRuleThumbHtml(o) + '</div>' +
'<span>' + (o.name || '').replace(/</g, '&lt;') + '</span>' +
'</button>';
}).join('');
});
}
// Objet choisi -> enchaîne directement sur l'assistant déclencheur/action
// de l'éditeur de collision (même écran, même modale, juste la suite du
// parcours) — voir openTriggerWizard.
function triggerSelectObject(objectId) {
var body = document.getElementById('questTriggerModalBody');
openTriggerWizard(objectId, body);
}
// ---------- "+ Ajouter une action" (bout de chaîne d'un déclencheur
// DÉJÀ posé) ----------
// Demande explicite : "pouvoir modifier un déclencheur pour lui ajouter
// des actions" — pas besoin de repasser par la modale de création, on
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
// choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner
// encore), avant d'envoyer le tout via /append-action.
function triggerOpenAppendActionModal(objectId, afterId) {
var modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
var body = document.getElementById('questTriggerModalBody');
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
// renderCollisionWizardVariableStep — elles ne lisent que .bodyEl).
_collisionWizard = { bodyEl: body };
body.innerHTML =
'<p class="hint mb-2">Ajouter...</p>' +
'<div class="collisionWizardChoices">' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
'<span>Déclencher un dialogue</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
'<span>Modifier une variable</span>' +
'</button>' +
'</div>';
}
function triggerAppendLeaf(objectId, afterId, type) {
if (type === 'dialogue') {
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
return;
}
renderCollisionWizardVariableStep(function (action) {
renderCollisionWizardChainStep(action, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
});
}
function triggerSaveAppendedAction(objectId, afterId, action) {
closeTriggerModal();
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId + '/append-action', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ after_id: afterId, action: action }),
}).then(function (r) {
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'ajouter cette action."); });
}).then(function () { triggerLoadAll(); });
}
function closeTriggerModal() {
var modal = document.getElementById('questTriggerModal');
if (modal) modal.classList.remove('is-active');
}
// ---------- Point d'entrée ----------
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
// un <script src> posé à la fin du contenu de la page — les éléments dont
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
// redéclenche jamais après le tout premier chargement réel) — sans ça,
// l'onglet "Déclencheurs" restait vide après un remplacement de <main>
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
// ne redéclenche jamais cet événement.
function initTriggersTab() {
var body = document.getElementById('triggersTabPanelBody');
if (!body) return;
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
triggerLoadAll();
}
initTriggersTab();
+165 -32
View File
@@ -725,7 +725,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
static/js/play/dialogue-box-controller.js) : header/body/footer, static/js/play/dialogue-box-controller.js) : header/body/footer,
toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/ toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/
styliser) mais masqué par défaut EN JEU tant qu'aucune règle de styliser) mais masqué par défaut EN JEU tant qu'aucune règle de
collision "quete" n'a de réplique à afficher. */ collision "dialogue" n'a de réplique à afficher. */
.dialogueBoxWidget{ .dialogueBoxWidget{
display:flex; flex-direction:column; border-radius:10px; overflow:hidden; display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto; box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
@@ -740,14 +740,6 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer; border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
} }
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); } .dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) :
Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la
boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur
de pied choisie par l'auteur (voir "🎨 Style"). */
.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; }
.dialogueBoxAcceptBtn:hover{ background:#3ddb80; }
.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; }
.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); }
/* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py, /* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py,
static/js/play/dialogue-box-controller.js) : même squelette que static/js/play/dialogue-box-controller.js) : même squelette que
@@ -942,6 +934,14 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.collisionWizardChoice:hover{ transform:translateY(-3px) scale(1.02); border-color:var(--accent); box-shadow:0 8px 18px rgba(0,0,0,.25); } .collisionWizardChoice:hover{ transform:translateY(-3px) scale(1.02); border-color:var(--accent); box-shadow:0 8px 18px rgba(0,0,0,.25); }
.collisionWizardChoice .emoji{ font-size:22px; } .collisionWizardChoice .emoji{ font-size:22px; }
/* Étape "condition" de l'assistant (voir renderCollisionWizardConditionStep,
collision-rules-editor.js) : variable / opérateur / valeur sur une
ligne — les <select>/<input> reprennent déjà le style générique
(voir plus haut, input[type=text]/select). */
.collisionWizardConditionForm{ display:flex; gap:8px; flex-wrap:wrap; }
.collisionWizardConditionForm select{ flex:1.3; min-width:120px; }
.collisionWizardConditionForm input{ flex:0.7; min-width:80px; }
/* Bulle "Appuie sur X" (voir forgeShowCollisionInteractBubble, /* Bulle "Appuie sur X" (voir forgeShowCollisionInteractBubble,
collision-rules-controller.js) : SŒUR de l'objet dans son parent collision-rules-controller.js) : SŒUR de l'objet dans son parent
(.sceneWorld/.sceneUI, déjà position:relative) plutôt qu'ENFANT (.sceneWorld/.sceneUI, déjà position:relative) plutôt qu'ENFANT
@@ -956,10 +956,9 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
white-space:nowrap; pointer-events:none; z-index:20; white-space:nowrap; pointer-events:none; z-index:20;
} }
/* ---- Éditeur de quêtes (onglet "🗺️ Quêtes" à côté de "🧩 Collision", /* ---- Reliquat de l'ancien onglet "🗺️ Quêtes" (retiré, voir onglet
templates/scene_edit.html + modale de dialogue partagée avec "Déclencheurs" et static/js/triggers/trigger-editor.js) — règles
l'assistant "+ Action" de l'éditeur de collision, voir orphelines gardées telles quelles, aucune ne s'applique plus. ---- */
static/js/quests/quest-editor.js) ---- */
.questListRow{ cursor:pointer; } .questListRow{ cursor:pointer; }
.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; } .questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; }
.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; } .questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; }
@@ -984,33 +983,166 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.questDialogueModal{ z-index:61; } .questDialogueModal{ z-index:61; }
.questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; } .questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; }
/* La modale entière est une colonne flex (jamais un scroll global, voir /* La modale entière est une colonne flex (jamais un scroll global, voir
overflow:hidden ici) : le header/la fiche restent fixes en haut, les 3 overflow:hidden ici) : le header reste fixe en haut, le panneau de
colonnes de répliques se partagent TOUT le reste de la hauteur déclencheurs prend TOUT le reste de la hauteur. */
(demandé explicitement) et défilent CHACUNE indépendamment (voir
.questDialogueColumnBubbles plus bas), jamais toute la page. */
.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; } .questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; }
.questDialogueModalContent{ width:100%; height:100%; max-width:100%; } .questDialogueModalContent{ width:100%; height:100%; max-width:100%; }
#questDialogueModalBody{ display:flex; flex-direction:column; flex:1; min-height:0; } #questDialogueModalBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
/* Onglet "Déclencheurs" (templates/scene_edit.html, #triggersTabPanel) —
MÊME comportement que #questDialogueModalBody ci-dessus (juste déplacé
hors de la modale plein écran dans un onglet, demande explicite :
"sans rien toucher au style en place") : colonne flex qui prend tout
l'espace du panneau d'onglet actif (voir .builderTabPanel.active). */
#triggersTabPanelBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
.questDialogueModalHeader{ flex:0 0 auto; margin-bottom:10px; } .questDialogueModalHeader{ flex:0 0 auto; margin-bottom:10px; }
.questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; } .questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; }
.questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; } .questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; }
.questField-wide{ grid-column:span 2; } .questField-wide{ grid-column:span 2; }
.questDialogueColumns{ flex:1; min-height:0; display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; } /* La modale de dialogue d'une quête est un GRAPHE DE LOGIQUE lu par
déclencheur (demande explicite : "un RH doit le comprendre, un visuel
global") : UN SEUL panneau (.questDialogueColumn, 90% de largeur,
centré, TOUTE LA HAUTEUR disponible) — les déclencheurs de la quête
côte à côte (voir .questTriggersSlot plus bas), chacun avec SON PROPRE
dialogue édité EN LIGNE (voir .dialogueBox plus bas), PUIS les 3
boutons Réplique/Question/Action TOUJOURS tout en bas — ils ajoutent
dans le dialogue du déclencheur ACTIF (voir .questTriggerCol.active),
jamais dans une liste séparée. */
.questDialogueColumns{ flex:1; min-height:0; display:flex; }
.questDialogueColumn{ .questDialogueColumn{
background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px; width:90%; margin:0 auto; height:100%; min-height:0; display:flex; flex-direction:column; gap:8px;
display:flex; flex-direction:column; gap:8px; height:100%; min-height:0; background:var(--panel2); border:1px solid var(--border); border-radius:12px; padding:12px;
} }
.questDialogueColumnTitle{ flex:0 0 auto; margin:0 0 2px; font-size:13px; text-align:center; } .questDialogueColumnBody{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; gap:14px; padding-top:4px; }
/* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un .questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; flex-wrap:wrap; }
trait entre chacune (voir ::before sur .questBubbleRow ci-dessous) —
demandé explicitement plutôt qu'un alignement gauche/droite. Seule
CETTE zone défile (flex:1 + overflow-y:auto) — le titre de colonne et
le bouton "+ Réplique" restent fixes, en haut/en bas. */
.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; padding-top:4px; }
.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; margin-top:4px; }
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } .questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; }
.questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); } .questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); }
/* TOUS les déclencheurs du jeu (voir screens/rendering/
trigger_graph.py::list_all_triggers) : réutilise TELLES QUELLES
.collisionRuleChain/.collisionRuleLink (collision-rules-editor.js) pour
la chaîne de pastilles, juste une petite fiche objet au-dessus. Vit dans le MÊME
flux que les répliques (.questDialogueColumnBody ci-dessus), aucun
scroll ni cadre séparé ici — plusieurs chaînes possibles côte à côte
(flex-wrap). */
.questTriggersSlot{ width:100%; display:flex; flex-wrap:wrap; gap:20px; justify-content:center; }
.questTriggersSlot:empty::before{ content:"Aucun déclencheur pour l'instant — ajoutes-en un avec le bouton ci-dessous."; color:var(--muted); font-size:13px; }
/* Carte objet + pastilles empilées VERTICALEMENT, reliées par des flèches
(maquette validée avec l'utilisateur) — PAS la chaîne horizontale en
chevrons de l'éditeur de collision : deux présentations volontairement
différentes pour le même modèle de données sous-jacent. */
.questTriggerCol{ position:relative; display:flex; flex-direction:column; align-items:center; width:70%; }
/* Modale d'ajout de déclencheur (voir static/js/triggers/trigger-editor.js::
openAddTriggerModal) — au-dessus de l'onglet "Déclencheurs", jamais
recouverte. */
.questTriggerModal{ z-index:62; }
.questTriggerDelete{
position:absolute; top:-8px; right:2px; width:20px; height:20px; border-radius:50%;
background:var(--panel2); border:1px solid var(--border); color:var(--muted, #8b95a7);
font-size:10px; display:flex; align-items:center; justify-content:center; cursor:pointer; z-index:2;
}
.questTriggerDelete:hover{ background:#e0524d; color:#fff; border-color:#e0524d; }
.questTriggerObjectCard{
width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:12px;
padding:10px; display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;
}
.questTriggerObjectCard img{ width:36px; height:36px; border-radius:8px; object-fit:cover; }
.questTriggerObjectCard .questTriggerObjectName{ font-weight:700; font-size:12.5px; text-align:center; }
/* Déclencheur ACTIF (voir questClickTriggerObjectCard) : c'est LUI qui
reçoit les prochaines répliques/questions ajoutées par les boutons du
bas de la colonne — surligné pour que ce soit visible sans avoir à
deviner. */
.questTriggerCol.active .questTriggerObjectCard{ border-color:#3bb3c9; box-shadow:0 0 0 2px rgba(59,179,201,.35); }
/* Accordéon (demande explicite : "ça retracte toute la logique associée") :
replié = seule la carte objet reste visible, chevron tourné. */
.questTriggerCol.collapsed{ width:auto; min-width:140px; }
.questTriggerObjectCard .questTriggerCollapseChevron{ font-size:10px; color:var(--muted); transition:transform .15s ease; }
.questTriggerCol.collapsed .questTriggerCollapseChevron{ transform:rotate(-90deg); }
.questTriggerConnector{ width:2px; height:16px; background:var(--border); position:relative; flex-shrink:0; }
.questTriggerConnector::after{
content:""; position:absolute; bottom:0; left:50%; transform:translate(-50%,45%);
border:5px solid transparent; border-top-color:var(--border);
}
/* Flèche menant à une condition — plus grande que les connecteurs
ordinaires (demande explicite : "la flèche qui part de interagir vers
le losange doit être plus grande"). */
.questTriggerConnector.tall{ height:32px; width:3px; }
.questTriggerConnector.tall::after{ border-width:7px; }
.questTriggerPill{ width:100%; border-radius:100px; padding:8px 14px; font-size:12px; font-weight:700; text-align:center; }
.questTriggerPill.trigger{ background:#f2994a; color:#1a1002; }
.questTriggerPill.interagir{ background:#d4a72c; color:#1a1002; }
.questTriggerPill.quete{ background:#9b59b6; color:#fff; }
.questTriggerPill.none{ background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7); }
/* Rectangle "dialogue" — même largeur que le reste de la chaîne (100% de
son conteneur, comme les pastilles, maquette validée : "le rectangle
doit avoir la même width que les autres éléments"). Un bouton "+" en
haut à droite ajoute une réplique DIRECTEMENT dedans (voir
static/js/triggers/trigger-editor.js::triggerInlineAddBubble) — chaque
occurrence de "Déclencher ce dialogue" porte le sien, édité ici même,
à l'endroit où la flèche pointe (plus de colonne séparée). */
.dialogueBox{
width:100%; background:rgba(0,0,0,.18); border:1.5px solid #3bb3c9; border-radius:14px;
padding:10px; display:flex; flex-direction:column; gap:8px;
}
.dialogueBoxHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; }
.dialogueBoxTitle{ font-size:11px; font-weight:800; color:#3bb3c9; text-transform:uppercase; letter-spacing:.04em; }
.dialogueBoxActions{ display:flex; gap:4px; }
.addReplyBtn{
background:#3bb3c9; color:#04222a; border:none; border-radius:999px; padding:5px 14px;
font-size:12px; font-weight:800; line-height:1.4; cursor:pointer; flex-shrink:0;
}
.addReplyBtn:hover{ filter:brightness(1.1); }
.addQuestionBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); color:#1a1002; }
/* Feuille "Modifier une variable" (voir screens/rendering/collision_rules.py
::"variable") — même famille visuelle que .dialogueBox (rectangle
pleine largeur), bordure jaune pour la distinguer d'un dialogue au
premier coup d'œil, pas de bulles dedans. */
.questVariableAction{ border-color:#e0b93d; }
.questVariableActionLabel{ margin:0; font-size:13px; color:var(--text); }
/* "+ Ajouter une action" en bout de chaîne (demande explicite : "pouvoir
modifier un déclencheur pour lui ajouter des actions") — même largeur
que le reste de la chaîne, discret (pointillés) tant qu'on ne survole
pas. */
.questTriggerAddChainedActionBtn{
width:100%; background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7);
}
.questTriggerAddChainedActionBtn:hover{ border-color:var(--accent); color:var(--accent); }
/* Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
"il faut tout mettre verticalement", le losange lui-même ne change
pas) : le losange reste petit et centré, la branche qui MÈNE à cette
quête descend tout droit en premier (flèche verte ou rouge selon
Vrai/Faux) vers "Déclencher ce dialogue" (pleine largeur, comme un
déclencheur sans condition) puis son rectangle de dialogue, PUIS
l'autre branche descend à son tour vers "Ne rien faire" — jamais deux
branches côte à côte. */
.questCondColumn{ width:100%; display:flex; flex-direction:column; align-items:center; }
/* Un VRAI losange (carré tourné à 45°, pas un rectangle aplati) — voir
demande explicite "je veut un vrai losange comme avant". */
.questCondDiamond{
width:150px; height:150px; margin:16px 0; transform:rotate(45deg);
background:#3bb3c9; color:#04222a; display:flex; align-items:center; justify-content:center; border-radius:16%;
box-shadow:0 4px 14px rgba(59,179,201,.35);
}
.questCondDiamond span{ transform:rotate(-45deg); font-size:11.5px; font-weight:800; text-align:center; line-height:1.3; padding:0 12px; }
/* Flèches Vrai/Faux plus grandes — demande explicite "c'est trop petit
trop tassé". */
.questCondArrowWrap{ position:relative; display:flex; flex-direction:column; align-items:center; width:100%; height:68px; }
.questCondArrowWrap svg{ width:34px; height:100%; overflow:visible; }
.questCondArrowLabel{
position:absolute; left:calc(50% + 26px); top:14px; z-index:2; border-radius:999px; padding:6px 16px;
font-size:12.5px; font-weight:800; white-space:nowrap;
}
.questCondArrowLabel.yes{ background:rgba(46,204,113,.18); color:#2ecc71; border:1px solid rgba(46,204,113,.4); }
.questCondArrowLabel.no{ background:rgba(224,82,77,.18); color:#e0524d; border:1px solid rgba(224,82,77,.4); }
.questTriggerObjectThumb{ width:40px; height:40px; border-radius:8px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; }
.questTriggerObjectThumb img{ width:100%; height:100%; object-fit:cover; }
.questTriggerObjectChoice{ align-items:center; }
.questTriggerObjectChoice.is-selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
.questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; } .questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; }
.questBubbleRow:not(:first-child)::before{ .questBubbleRow:not(:first-child)::before{
content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%); content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%);
@@ -1029,14 +1161,15 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); } .questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); }
.questBubble-joueur .questBubbleHeader{ background:#1e9e57; } .questBubble-joueur .questBubbleHeader{ background:#1e9e57; }
.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); } .questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); }
/* "❓ Question" (voir questQuestionBubbleHtml, static/js/quests/ /* "❓ Question" (voir triggerQuestionBubbleHtml, static/js/triggers/
quest-editor.js) — bulle JAUNE distincte des répliques de dialogue, trigger-editor.js) — bulle JAUNE distincte des répliques de dialogue,
même chaîne/alignement vertical (.questBubbleRow). */ même chaîne/alignement vertical (.questBubbleRow). */
.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; } .questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; }
.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); } .questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); }
.questQuestionHeaderField{ .questQuestionHeaderField{
display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap; display:inline-flex; align-items:center; gap:6px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap;
} }
.questQuestionHeaderFieldLabel{ font-weight:700; text-transform:uppercase; font-size:10px; letter-spacing:.02em; opacity:.85; }
.questQuestionHeaderField input{ .questQuestionHeaderField input{
width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px; width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px;
color:#fff; font:inherit; font-size:11px; padding:2px 4px; color:#fff; font:inherit; font-size:11px; padding:2px 4px;
@@ -1196,7 +1329,7 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; } .builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
/* overflow-y:auto : contrairement à "screen" (voir .canvasFrame plus haut, /* overflow-y:auto : contrairement à "screen" (voir .canvasFrame plus haut,
qui défile LUI-MÊME), "collision-rules"/"quests"/"events" n'ont pas de qui défile LUI-MÊME), "triggers"/"events" n'ont pas de
sous-conteneur dédié au défilement — sans ça, une liste plus haute que sous-conteneur dédié au défilement — sans ça, une liste plus haute que
l'espace disponible restait simplement coupée par .builderCanvasArea l'espace disponible restait simplement coupée par .builderCanvasArea
(overflow:hidden), au lieu de défiler. */ (overflow:hidden), au lieu de défiler. */
+1 -1
View File
@@ -145,7 +145,7 @@
// bonnes réponses au quiz requis pour un statut de fin "reussi" plutôt // bonnes réponses au quiz requis pour un statut de fin "reussi" plutôt
// que "echoue" (SCORM lesson_status ET xAPI, voir dialogue-box-controller.js). // que "echoue" (SCORM lesson_status ET xAPI, voir dialogue-box-controller.js).
window.FORGE_SUCCESS_THRESHOLD_PERCENT = {{ success_threshold_percent|tojson }}; window.FORGE_SUCCESS_THRESHOLD_PERCENT = {{ success_threshold_percent|tojson }};
// Score max possible du quiz (voir db/quests/reward_budget.py) : permet // Score max possible du quiz (voir db/dialogue_lines.py::sum_question_rewards) : permet
// au SCORM 1.2 de fournir cmi.core.score.min/max en plus du score brut // au SCORM 1.2 de fournir cmi.core.score.min/max en plus du score brut
// (voir static/js/play/offline/scorm-api.js), pour qu'un LMS calcule un // (voir static/js/play/offline/scorm-api.js), pour qu'un LMS calcule un
// vrai pourcentage au lieu d'afficher le score brut à tort étiqueté "%". // vrai pourcentage au lieu d'afficher le score brut à tort étiqueté "%".
+92 -67
View File
@@ -115,7 +115,7 @@
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('dialogue_box')">Boîte de dialogue</button> <button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('dialogue_box')">Boîte de dialogue</button>
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('quiz_box')">Boîte à quiz</button> <button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('quiz_box')">Boîte à quiz</button>
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('score_widget')">Score</button> <button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('score_widget')">Score</button>
<p class="help">Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "Quêtes") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.</p> <p class="help">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.</p>
</div> </div>
</details> </details>
</div> </div>
@@ -124,15 +124,16 @@
<div class="builderCanvasArea"> <div class="builderCanvasArea">
<div class="builderTabs" id="builderTabs"> <div class="builderTabs" id="builderTabs">
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">Scène</button> <button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">Scène</button>
<button type="button" class="builderTabBtn" data-tab="collision-rules" onclick="switchBuilderTab('collision-rules')">Collision</button> <button type="button" class="builderTabBtn" data-tab="triggers" onclick="switchBuilderTab('triggers')">Déclencheurs</button>
<button type="button" class="builderTabBtn" data-tab="quests" onclick="switchBuilderTab('quests')">Quêtes</button> <button type="button" class="builderTabBtn" data-tab="variables" onclick="switchBuilderTab('variables')">Variables</button>
{% if game.onboarding_type != 'rpg' %} {% if game.onboarding_type != 'rpg' %}
<!-- Événements personnalisés (voir db.list_custom_events) : aucun <!-- Événements personnalisés (voir db.list_custom_events) : aucun
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le
l'assistant "+ Action" de l'onglet Collision exclut seul point d'entrée qui le permettait (l'action "evenement" de
délibérément le type "evenement" (voir COLLISION_WIZARD_ l'ancien éditeur de collision) a été retiré (demande explicite,
ACTION_TYPES, static/js/scenes/collision-rules-editor.js) — ne servait à rien). game.onboarding_type est de toute façon
demande explicite : cet onglet n'a donc aucune utilité ici. --> TOUJOURS "rpg" désormais (voir la suppression des types Quiz/
Embranchement) — cet onglet ne s'affiche donc plus jamais. -->
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">Événements</button> <button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">Événements</button>
{% endif %} {% endif %}
</div> </div>
@@ -179,55 +180,82 @@
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p> <p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p>
</div> </div>
<!-- Éditeur de collision (voir screens/rendering/collision_rules.py) : <!-- Onglet "Déclencheurs" (voir static/js/triggers/trigger-editor.js) :
une carte par objet ÉLIGIBLE (jamais un "fond", jamais le un graphe de logique lu par déclencheur — TOUS les déclencheurs du
personnage "joueur" — ce sont toujours les AUTRES objets qui jeu, plus aucune notion de quête pour les regrouper (un créateur
réagissent à SA présence), listant ses règles "déclencheur -> peut faire un simple quiz sans jamais poser de personnage). Chaque
action" déjà posées + un bouton "+ Action" qui ouvre l'assistant déclencheur (objet -> collision -> action) porte SON PROPRE
(modale plus bas). Rendu ENTIÈREMENT en JS (voir dialogue, édité en ligne (voir openTriggerWizard(), static/js/
static/js/scenes/collision-rules-editor.js) à partir de scenes/collision-rules-editor.js). -->
COLLISION_RULE_OBJECTS/CUSTOM_EVENTS_MAP — jamais en Jinja, pour <div class="builderTabPanel" id="triggersTabPanel" data-tab-panel="triggers">
pouvoir se redessiner à chaud après chaque ajout/suppression de <div id="triggersTabPanelBody"></div>
règle sans recharger la page. -->
<div class="builderTabPanel" id="collisionRulesTabPanel" data-tab-panel="collision-rules">
<div class="dashPanelHeader">
<h2>Collision</h2>
</div>
<p class="hint">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.</p>
<div id="collisionRulesCards" class="collisionRulesCards"></div>
</div> </div>
<!-- Assistant "+ Action" — un seul écran affiché à la fois <!-- Variables globales (voir db/global_vars/) : accessibles à TOUT le
(#collisionRuleModalBody, reconstruit à chaque étape), jamais un jeu par leur NOM (immuable après création — condition de
vrai formulaire à plusieurs pages : chaque clic choisit une visibilité, action "Modifier une variable", voir
option et avance directement à la suivante. --> static/js/play/conditions.js::readVariableValue), pas seulement à
<div class="modal" id="collisionRuleModal"> un écran. Même patron que l'onglet Événements ci-dessous (table +
<div class="modal-background" onclick="closeCollisionRuleModal()"></div> formulaires classiques, POST/redirect — pas de rendu JS). -->
<div class="modal-content"> <div class="builderTabPanel" id="variablesTabPanel" data-tab-panel="variables">
<div class="box">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<h2 class="title is-5" style="margin:0;">Nouvelle règle</h2>
<button type="button" class="button is-small" onclick="closeCollisionRuleModal()">✕</button>
</div>
<div id="collisionRuleModalBody"></div>
</div>
</div>
</div>
<!-- Éditeur de quêtes (voir db/quests/) : une LISTE (comme l'onglet
Événements juste après), pas une grille de cartes — chaque ligne
résume titre/objectif/récompense/statut/résultat, cliquer dessus
ouvre la modale d'arbre de dialogue (static/js/quests/
quest-editor.js, partagée avec l'assistant "+ Action" de l'onglet
Collision). Rendu ENTIÈREMENT en JS depuis QUESTS_JSON, même
convention que #collisionRulesCards ci-dessus. -->
<div class="builderTabPanel" id="questsTabPanel" data-tab-panel="quests">
<div class="dashPanelHeader"> <div class="dashPanelHeader">
<h2>Quêtes</h2> <h2>Variables</h2>
<button type="button" class="button is-small primary" onclick="createQuestFromListPage()">+ Créer une quête</button> <button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</button>
</div> </div>
<p class="hint">Titre, objectif, récompense, statut, résultat — clique sur une ligne pour écrire son enchaînement de dialogue.</p> <p class="hint">Accessibles depuis n'importe quelle scène (conditions, actions "Modifier une variable"). Le nom ne peut plus changer une fois créé.</p>
<div id="questsListWrap"></div> <form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-variables" style="display:none;">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<input type="text" name="name" placeholder="NOM (ex. score_total)" required autocomplete="off">
<select name="type">
{% for key, t in global_variable_types.items() %}
<option value="{{ key }}">{{ t.label }}</option>
{% endfor %}
</select>
<input type="text" name="default_value" placeholder="Valeur par défaut">
<label class="checkbox" style="display:flex; align-items:center; gap:4px; white-space:nowrap;" title="Chaque joueur a sa propre valeur (décoché = valeur unique partagée par tous)">
<input type="checkbox" name="per_player" value="1" checked> Par joueur
</label>
<button type="submit" class="primary">Créer</button>
</form>
{% if global_variables %}
{% for v in global_variables %}
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.id }}">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
</form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th>Par joueur</th><th></th></tr></thead>
<tbody>
{% for v in global_variables %}
<tr>
<td><code>{{ v.name }}</code></td>
<td>
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
{% for key, t in global_variable_types.items() %}
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ t.label }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" placeholder="Valeur" aria-label="Valeur de « {{ v.name }} »"></td>
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non (partagée)' }}</span></td>
<td class="fieldsTableActions">
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small" title="Enregistrer">💾</button>
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
onsubmit="return confirm('Supprimer la variable « {{ v.name }} » ? Toute condition/action qui la référence encore cessera de fonctionner.');">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucune variable pour l'instant — crée la première ci-dessus.</p>
{% endif %}
</div> </div>
{% if game.onboarding_type != 'rpg' %} {% if game.onboarding_type != 'rpg' %}
@@ -311,9 +339,9 @@
screens/scenes/set_scene_object_name.py) — n'importe quel kind screens/scenes/set_scene_object_name.py) — n'importe quel kind
(personnage, décor, fond), pas seulement un personnage — c'est (personnage, décor, fond), pas seulement un personnage — c'est
CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur
de dialogue de quête (voir "🗺️ Quêtes", static/js/quests/ de dialogue d'un déclencheur (voir "Déclencheurs", static/js/
quest-editor.js) : un dialogue peut mettre en scène n'importe triggers/trigger-editor.js) : un dialogue peut mettre en scène
quel objet, pas seulement un personnage. --> n'importe quel objet, pas seulement un personnage. -->
<details class="propGroup" open> <details class="propGroup" open>
<summary>Informations</summary> <summary>Informations</summary>
<div class="propGroupBody"> <div class="propGroupBody">
@@ -602,18 +630,15 @@
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }}; var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
var SPRITE_LIBRARY = {{ sprite_library_json|safe }}; var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_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" + // "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" +
// static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe // static/js/triggers/trigger-editor.js::triggerSpeakerOptions) —
// quel objet de scène nommé, pas seulement un personnage. // n'importe quel objet de scène nommé, pas seulement un personnage.
var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }}; 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 }};
</script> </script>
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script> <script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script> <script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
@@ -621,6 +646,6 @@
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script> <script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script> <script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script> <script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/quests/quest-editor.js') }}"></script> <script src="{{ url_for('static', filename='js/triggers/trigger-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script> <script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
{% endblock %} {% endblock %}
+294 -99
View File
@@ -1,12 +1,11 @@
"""Éditeur de collision (onglet "🧩 Collision", voir """Règles de collision (voir screens/rendering/collision_rules.py) : une
screens/rendering/collision_rules.py) : une règle "déclencheur (collision/ règle "déclencheur (collision) -> action (dialogue/interagir/condition)"
périmètre) -> action (quête/attaque/événement/interagir)" par carte par objet de scène — toujours les AUTRES objets qui réagissent à la
objet — toujours les AUTRES objets qui réagissent à la présence du présence du joueur, jamais l'inverse. Posées depuis l'onglet
joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js "Déclencheurs" (voir tests/test_triggers.py pour le parcours complet côté
construit l'assistant "+ Action" ; ce fichier teste la couche serveur route) — ce fichier teste la couche de validation/lecture elle-même
(défauts, validation, exposition dans full_game_payload).""" (sanitize_collision_rules/resolve_collision_rules), plus l'exposition
import re dans full_game_payload."""
import db import db
import screens 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): 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) 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 assert resp.status_code == 302, resp.data
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) 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 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): 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) slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") 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) == [] assert screens.resolve_collision_rules(obj) == []
def test_rules_route_persists_a_collision_trigger_attack_action(client, tmp_game_slug_cleanup): def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) slug = tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") rules = screens.sanitize_collision_rules([
{"trigger": "collision", "action": {"type": "dialogue"}},
resp = client.post( ])
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}]
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_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp_game_slug_cleanup): def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting():
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):
""""interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action """"interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
à l'appui — un seul niveau d'imbrication autorisé (voir à l'appui — un seul niveau d'imbrication autorisé (voir
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH).""" screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) rules = screens.sanitize_collision_rules([
pnj_id = _add_personnage(client, slug, screen_id, role="pnj") {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
])
resp = client.post( assert rules == [
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
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}}},
] ]
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 """Une imbrication trop profonde (interagir -> interagir -> ...) est
ignorée plutôt que d'accepter une structure sans fin postée à la main.""" 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) rules = screens.sanitize_collision_rules([
pnj_id = _add_personnage(client, slug, screen_id, role="pnj") {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}},
])
resp = client.post( assert rules == [] # la règle entière est rejetée
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
def test_rules_route_drops_malformed_rules(client, tmp_game_slug_cleanup): def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches():
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) """"condition" (losange à embranchement, maquette validée) : Vrai/Faux
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
rules = screens.sanitize_collision_rules([{
resp = client.post( "trigger": "collision",
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", "action": {
data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}', "type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
content_type="application/json", "si_vrai": {"type": "dialogue"}, "si_faux": None,
) },
assert resp.get_json()["rules"] == [] }])
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): def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator():
resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json") rules = screens.sanitize_collision_rules([
assert resp.status_code == 404 {"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): def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(client, tmp_game_slug_cleanup):
@@ -148,17 +341,19 @@ def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_obje
assert enemy_payload["collision_rules"] == [] 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) 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") player_id = _add_personnage(client, slug, screen_id, role="joueur")
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") 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) objects = screens.list_scene_objects(slug, screen_id)
idx = html.find("var COLLISION_RULE_OBJECTS") for o in objects:
end = html.find(";", idx) o["rendered_html"] = screens.render_scene_object(o)
snippet = html[idx:end] rule_objects = screens.build_collision_rule_objects(objects)
assert f'"id": {enemy_id}' in snippet ids = [o["id"] for o in rule_objects]
assert f'"id": {player_id}' not in snippet assert enemy_id in ids
assert player_id not in ids
+13 -10
View File
@@ -1,11 +1,11 @@
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir """Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en 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 static/js/play/dialogue-box-controller.js) — header/body/footer
stylisables. Et le nom d'un personnage ("ℹ️ Informations", stylisables. Et le nom d'un personnage ("ℹ️ Informations",
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans 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 db
import screens 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 assert f'data-object-id="{obj_id}"' in html
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul # Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS # son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
# (forgeShowQuestOffer, static/js/play/dialogue-box-controller.js) le # (forgeResetDialogueBoxFooter, static/js/play/dialogue-box-controller.js)
# cherche par ce sélecteur pour y injecter Accepter/Refuser en fin de # le cherche par ce sélecteur pour y (re)poser le bouton "Suivant".
# dialogue d'une quête "nouvelle" ; sans lui, l'écran d'offre gardait
# silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons.
assert 'data-dialogue-role="header"' in html assert 'data-dialogue-role="header"' in html
assert 'data-dialogue-role="body"' in html assert 'data-dialogue-role="body"' in html
assert 'data-dialogue-role="footer"' 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 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) 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) 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") ---------- # ---------- 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_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id) obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj) html = screens.render_scene_object(obj)
assert 'data-quiz-role="header"' in html # "Quête : <titre>", 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="question"' in html # le texte de la question, dans le corps
assert 'data-quiz-role="choices"' in html assert 'data-quiz-role="choices"' in html
assert f'data-object-id="{obj_id}"' in html assert f'data-object-id="{obj_id}"' in html
+53 -9
View File
@@ -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 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 plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
automatisée).""" automatisée)."""
import re
import zipfile import zipfile
import db import db
import screens
from tests.test_auth import anon_client # noqa: F401 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): 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).""" barémer, voir static/js/play/offline/scorm-api.js)."""
resp = client.post(f"/game/{game}/export-scorm") resp = client.post(f"/game/{game}/export-scorm")
import io 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") html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
quest = db.create_quest(game, "Une quête") # Les dialogues vivent dans les règles de collision d'un objet de
db.set_quest_dialogues(game, quest["id"], { # scène (voir screens/rendering/collision_rules.py::"dialogue".dialogue).
"nouvelle": [], "terminee": [], screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
"en_cours": [ resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5}, obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7}, 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") resp = client.post(f"/game/{game}/export-scorm")
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8") html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html 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): def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client):
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) — """Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route.""" aucune vérification supplémentaire écrite pour cette route."""
+74
View File
@@ -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
-264
View File
@@ -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
+20 -16
View File
@@ -1,9 +1,12 @@
"""Liste "🧩 Collision" à jour en AJAX (voir routes/scenes/ """screens.build_collision_rule_objects (voir screens/scenes/
scene_collision_rule_objects.py, refreshCollisionRuleObjects() dans build_collision_rule_objects.py) : la liste d'objets éligibles à un
static/js/scenes/scene-editor.js) : appelée après ajout/suppression d'un déclencheur de collision, réutilisée par le listage de TOUS les
objet de scène ou changement de rôle/nom, pour ne jamais recharger la page déclencheurs du jeu (screens/rendering/trigger_graph.py, voir
alors que cette liste doit refléter le changement (un objet apparaît/ tests/test_triggers.py) — un objet apparaît/disparaît selon son
disparaît selon son rôle, ou change de libellé affiché).""" 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 import screens
@@ -21,16 +24,17 @@ def _add_ajax(client, slug, screen_id, **data):
).get_json() ).get_json()
def _list(client, slug, screen_id): def _list(slug, screen_id):
resp = client.get(f"/game/{slug}/screens/{screen_id}/collision-rule-objects") objects = screens.list_scene_objects(slug, screen_id)
assert resp.status_code == 200 for o in objects:
return resp.get_json() 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): 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) slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] 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 len(entries) == 1
assert entries[0]["id"] == obj_id assert entries[0]["id"] == obj_id
assert entries[0]["role"] == "pnj" 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) slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] 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": "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): 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"] 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": "joueur"}', content_type="application/json")
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', 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 len(entries) == 1
assert entries[0]["role"] == "ennemie" 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) slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] 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") 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" 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) slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"] 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"}) 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): def test_decor_has_no_role(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_ajax(client, slug, screen_id, kind="decor") _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 assert entries[0]["role"] is None
+356
View File
@@ -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/<object_id>/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/<slug>/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")