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
+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"])
def create_global_var(slug):
"""Depuis la page de gestion des variables (game_variables.html) —
idempotent par nom (voir db.create_global_variable), donc sans risque en
cas de double soumission."""
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
— idempotent par nom (voir db.create_global_variable), donc sans
risque en cas de double soumission."""
name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or ""
screen_id = request.form.get("screen_id", type=int)
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
# explicitement rend la variable partagée par tous les joueurs, voir
# db/global_vars/create_global_variable.py) — absent du formulaire
@@ -20,4 +21,6 @@ def create_global_var(slug):
per_player = request.form.get("per_player", "1") == "1"
if name:
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
if screen_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
return redirect(url_for("game_dashboard", slug=slug))
+5 -2
View File
@@ -1,4 +1,4 @@
from flask import redirect, url_for
from flask import request, redirect, url_for
import db
@@ -7,5 +7,8 @@ from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
def global_var_delete(slug, var_id):
screen_id = request.form.get("screen_id", type=int)
db.delete_global_variable_by_id(slug, var_id)
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
if screen_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
return redirect(url_for("game_dashboard", slug=slug))
+4 -1
View File
@@ -9,5 +9,8 @@ from core.flask_app import app
def global_var_edit(slug, var_id):
var_type = request.form.get("type") or "texte"
value = request.form.get("value") or ""
screen_id = request.form.get("screen_id", type=int)
db.update_global_variable(slug, var_id, var_type, value)
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
if screen_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
return redirect(url_for("game_dashboard", slug=slug))
-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_role,
scene_object_collision,
scene_object_collision_rules,
scene_object_name,
scene_object_dialogue_box_style,
scene_collision_rule_objects,
scene_object_names_list,
)
-61
View File
@@ -1,61 +0,0 @@
import screens
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
def build_collision_rule_objects(objects):
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
screens/rendering/collision_rules.py) — factorisé ici plutôt que
dupliqué : utilisé au premier rendu de la page (routes/scenes/
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
scene-editor.js) pour reconstruire la liste SANS recharger la page.
`objects` doit déjà porter "rendered_html" (voir
screens.render_scene_object) sur chaque élément.
resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans
rôle explicitement enregistré (voir screens/rendering/
personnage_role.py — comportement voulu pour une scène à un seul
personnage, posé avant l'ajout de ce réglage). Appliqué tel quel,
ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite
(le cas de toute scène créée avant ce réglage — un personnage ajouté
depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite,
voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et
disparaîtraient donc TOUS de cette liste. On ne considère donc comme
"joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le
plus ancien, z_index croissant comme `objects`) — les suivants sans
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
base."""
implicit_joueur_claimed = any(
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
for o in objects if o["kind"] == "personnage"
)
result = []
for o in objects:
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
continue
role = None
if o["kind"] == "personnage":
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
if explicit_role in screens.PERSONNAGE_ROLES:
role = explicit_role
elif not implicit_joueur_claimed:
role = "joueur"
implicit_joueur_claimed = True
else:
role = "pnj"
if role == "joueur":
continue
result.append({
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
})
return result
@@ -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 screens
from .collision_rule_objects import build_collision_rule_objects
def render_scene_edit(slug, screen_id, game):
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
@@ -63,12 +61,6 @@ def render_scene_edit(slug, screen_id, game):
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
}
# "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/
# collision_rule_objects.py, factorisé pour être réutilisé par
# l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/
# suppression/changement de rôle ou de nom sans recharger la page).
collision_rule_objects = build_collision_rule_objects(objects)
flow_blocks = screens.list_flow_blocks(slug, screen_id)
flow_nodes = screens.list_flow_nodes(slug, screen_id)
flow_edges = screens.list_flow_edges(slug, screen_id)
@@ -125,24 +117,15 @@ def render_scene_edit(slug, screen_id, game):
flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view,
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
global_variables=global_variables,
global_variables=global_variables, global_variable_types=db.GLOBAL_VARIABLE_TYPES,
custom_events=custom_events, custom_event_usages=custom_event_usages,
custom_events_map_json=json.dumps(custom_events_map),
definitions=db.list_definitions(slug),
definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}),
definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}),
collision_rule_objects_json=json.dumps(collision_rule_objects),
collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES,
collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX,
# Éditeur de quêtes (voir db/quests/) : préchargées ici pour que
# collisionRuleActionSummary (collision-rules-editor.js) affiche
# tout de suite le TITRE de la quête référencée par une règle
# existante, sans attendre l'ouverture de l'assistant "+ Action"
# (seul moment où questPickerHtml irait la chercher sinon).
quests_json=json.dumps(db.list_quests(slug)),
# "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations",
# static/js/quests/quest-editor.js) : les noms donnés à N'IMPORTE
# QUEL objet de scène (personnage, décor, fond), toutes scènes du
# jeu confondues.
# "qui parle" dans un dialogue de déclencheur (voir "ℹ️
# Informations", static/js/triggers/trigger-editor.js) : les noms
# donnés à N'IMPORTE QUEL objet de scène (personnage, décor,
# fond), toutes scènes du jeu confondues.
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
)
@@ -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):
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
templates/scene_edit.html, questSpeakerOptions() dans static/js/
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
chargement complet de la page (HORS de #builder3, jamais rafraîchie
par une sélection d'objet sans rechargement, voir goToSelected()) :
sans cet endpoint, renommer un personnage puis rouvrir la modale de
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
temps — appelée en AJAX depuis saveSceneObjectName()."""
templates/scene_edit.html, triggerSpeakerOptions() dans static/js/
triggers/trigger-editor.js) — cette variable n'est posée qu'une fois
au chargement complet de la page (HORS de #builder3, jamais
rafraîchie par une sélection d'objet sans rechargement, voir
goToSelected()) : sans cet endpoint, renommer un personnage puis
rouvrir un dialogue de déclencheur dans la MÊME session ne faisait
jamais apparaître son nouveau nom, tant qu'aucun vrai rechargement de
page n'avait eu lieu entre temps — appelée en AJAX depuis
saveSceneObjectName()."""
return jsonify(screens.list_named_scene_object_names(slug))
+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})