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.update_custom_event import update_custom_event
from .quests.constants import (
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS,
)
from .quests.list_quests import list_quests
from .quests.get_quest import get_quest
from .quests.create_quest import create_quest
from .quests.update_quest import update_quest
from .quests.delete_quest import delete_quest
from .quests.set_quest_dialogues import set_quest_dialogues
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES
from .quests.reward_budget import quest_dialogues_total_reward
from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
@@ -113,7 +103,5 @@ __all__ = [
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
]
@@ -1,9 +1,7 @@
from .constants import QUEST_STATUS_CHOICES
_MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60
# Question à choix (voir "❓ Question", static/js/quests/quest-editor.js) :
# Question à choix (voir "❓ Question", static/js/triggers/trigger-editor.js) :
# une bulle jaune posée dans le même enchaînement qu'une réplique de
# dialogue — plusieurs choix, une seule bonne réponse, une récompense.
# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/
@@ -61,22 +59,31 @@ def _sanitize_line(raw_line):
return _sanitize_dialogue_line(raw_line)
def sanitize_quest_dialogues(raw_dialogues):
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de
répliques de dialogue ({type: "dialogue", speaker, text} — le nom
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur",
voir screens/rendering/scene_object_names.py) ET/OU de questions à
choix ({type: "question", text, choices, correct_index, reward_type,
reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit
que resolve_collision_rules.sanitize_collision_rules : rejette tout
élément invalide plutôt que de lever."""
result = {status: [] for status in QUEST_STATUS_CHOICES}
if not isinstance(raw_dialogues, dict):
return result
for status in QUEST_STATUS_CHOICES:
raw_lines = raw_dialogues.get(status)
if not isinstance(raw_lines, list):
continue
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
result[status] = [line for line in cleaned if line]
return result
def sanitize_dialogue_lines(raw_lines):
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
ET/OU de questions à choix ({type: "question", text, choices,
correct_index, reward_type, reward_amount}, voir QUESTION_REWARD_TYPES
ci-dessus) — même esprit que resolve_collision_rules.sanitize_collision_rules :
rejette tout élément invalide plutôt que de lever. Les lignes d'UN
dialogue embarqué dans une action "dialogue" (voir
screens/rendering/collision_rules.py) : chaque occurrence de
"Déclencher ce dialogue" porte le sien."""
if not isinstance(raw_lines, list):
return []
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
return [line for line in cleaned if line]
def sum_question_rewards(lines):
"""Somme des récompenses de toutes les questions ("❓ Question" ci-
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le
score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines,
publish/build_scorm_package.py)."""
total = 0
for line in (lines or []):
if isinstance(line, dict) and line.get("type") == "question":
total += line.get("reward_amount", 0) or 0
return total
-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)