Files
Forge-Engine/screens/rendering/collision_rules.py
T
williamandClaude Sonnet 5 d664ed5637
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
Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
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>
2026-09-07 22:05:06 +02:00

315 lines
14 KiB
Python

import json
from db.dialogue_lines import sanitize_dialogue_lines
from screens.flow.constants import CONDITION_OPERATORS
from screens.labels.data_operations import DATA_OPERATION_LABELS
# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js::
# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque
# objet de scène (hors le personnage "joueur" — voir screens/rendering/
# personnage_role.py — et un "fond", jamais une cible/source de collision)
# porte une LISTE de règles "déclencheur -> action".
#
# Déclencheur : "collision" (chevauchement direct, voir
# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
# demande explicite).
#
# Action :
# - "dialogue" : la logique n'est plus portée par une "quête" mais
# DIRECTEMENT par le déclencheur — cette action EST un
# dialogue, pas une référence à autre chose. `dialogue`
# (optionnel) : {id, lines} — `id` généré côté client
# (voir static/js/scenes/collision-rules-editor.js),
# `lines` une liste de répliques/questions (voir
# db/dialogue_lines.py) — chaque occurrence de
# "Déclencher ce dialogue" (directe, sous "interagir",
# ou dans une branche de "condition") porte SON PROPRE
# dialogue, édité en ligne dans l'onglet "Déclencheurs".
# `mark_completed` (optionnel, bool) : le créateur
# marque explicitement ce déclencheur comme "terminé"
# une fois ce dialogue joué (voir static/js/play/
# dialogue-box-controller.js::
# forgeSyncAllDialoguesCompletionToScorm).
# - "variable" : modifie une variable globale — MÊME vocabulaire que
# l'action "Modifier une variable" du graphe de logique
# document (voir screens/data_actions/apply_variable_action.py,
# screens/labels/data_operations.py::DATA_OPERATION_LABELS,
# réutilisé tel quel plutôt que réinventé) :
# `target_variable` (nom), `data_operation` (une clé de
# DATA_OPERATION_LABELS), `data_value` (optionnel, absent
# pour definir_bool_vrai/definir_bool_faux/basculer_bool
# qui n'ont besoin d'aucune valeur). `id` généré côté
# client, comme `dialogue.id` — sert à adresser CETTE
# feuille précise pour y attacher un "then" plus tard
# (voir set_then_action_in_rules).
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir"
# du joueur, voir personnage_commands.py) tant que la
# collision est active, puis exécute `sub_action` (même
# forme qu'une action, SAUF "interagir" à nouveau — UN
# seul niveau d'imbrication, pas une récursion illimitée)
# à l'appui de la touche.
# - "condition" : Si <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
# structure sans fin construite par un JSON posté à la main.
_MAX_ACTION_DEPTH = 2
# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH.
_MAX_THEN_CHAIN = 4
_MAX_ACTION_ID_LENGTH = 40
# Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
# data_operations.py::DATA_OPERATIONS) — data_value est toujours omis
# pour celles-ci, quoi que le client envoie (même logique que
# static/js/scenes/collision-rules-editor.js::COLLISION_NO_VALUE_OPERATIONS,
# qui masque le champ correspondant côté formulaire).
_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool")
def _sanitize_dialogue(raw_dialogue):
if not isinstance(raw_dialogue, dict):
return None
dialogue_id = raw_dialogue.get("id")
if not isinstance(dialogue_id, str) or not dialogue_id.strip():
return None
return {
"id": dialogue_id.strip()[:_MAX_ACTION_ID_LENGTH],
"lines": sanitize_dialogue_lines(raw_dialogue.get("lines")),
}
def _sanitize_dialogue_action(raw_action):
action = {"type": "dialogue"}
dialogue = _sanitize_dialogue(raw_action.get("dialogue"))
if dialogue:
action["dialogue"] = dialogue
if raw_action.get("mark_completed") is True:
action["mark_completed"] = True
return action
def _sanitize_variable_action(raw_action):
target_variable = raw_action.get("target_variable")
if not isinstance(target_variable, str) or not target_variable.strip():
return None
data_operation = raw_action.get("data_operation")
if data_operation not in DATA_OPERATION_LABELS:
return None
action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation}
action_id = raw_action.get("id")
if isinstance(action_id, str) and action_id.strip():
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
if data_operation not in _NO_VALUE_OPERATIONS:
raw_value = raw_action.get("data_value")
if isinstance(raw_value, str) and raw_value.strip():
action["data_value"] = raw_value.strip()
elif isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool):
action["data_value"] = str(raw_value)
return action
def _sanitize_leaf_action(raw_action, depth=1):
"""Sanitize une FEUILLE ("dialogue" ou "variable") et son éventuelle
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
feuille est attendue : action de premier niveau, sous "interagir", ou
branche Vrai/Faux de "condition"."""
if not isinstance(raw_action, dict):
return None
action_type = raw_action.get("type")
if action_type == "dialogue":
action = _sanitize_dialogue_action(raw_action)
elif action_type == "variable":
action = _sanitize_variable_action(raw_action)
else:
return None
if not action:
return None
if depth < _MAX_THEN_CHAIN:
then_action = _sanitize_leaf_action(raw_action.get("then"), depth=depth + 1)
if then_action:
action["then"] = then_action
return action
def _sanitize_condition_branch(raw_branch):
"""Une branche Vrai/Faux de "condition" : soit None ("ne rien
faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou
"variable", éventuellement chaînée via "then") — jamais "interagir"/
"condition" à nouveau, pour garder la structure bornée (voir maquette
validée : chaque branche n'a que ces choix)."""
if raw_branch is None:
return None
return _sanitize_leaf_action(raw_branch)
def _sanitize_action(raw_action, depth=1):
if not isinstance(raw_action, dict):
return None
action_type = raw_action.get("type")
if action_type not in ACTION_TYPES:
return None
if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH:
return None # un seul niveau d'imbrication autorisé
if action_type in LEAF_ACTION_TYPES:
return _sanitize_leaf_action(raw_action)
if action_type == "interagir":
sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1)
if not sub_action:
return None
return {"type": "interagir", "sub_action": sub_action}
# "condition"
variable = (raw_action.get("variable") or "").strip()
operateur = raw_action.get("operateur")
if not variable or operateur not in CONDITION_OPERATOR_KEYS:
return None
return {
"type": "condition",
"variable": variable,
"operateur": operateur,
"valeur": str(raw_action.get("valeur") if raw_action.get("valeur") is not None else ""),
"si_vrai": _sanitize_condition_branch(raw_action.get("si_vrai")),
"si_faux": _sanitize_condition_branch(raw_action.get("si_faux")),
}
def sanitize_collision_rules(raw_rules):
"""Valide/nettoie une liste de règles postée par le client (voir
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
la sauvegarde entière."""
if not isinstance(raw_rules, list):
return []
cleaned = []
for raw_rule in raw_rules[:50]: # marge large, jamais illimité
if not isinstance(raw_rule, dict):
continue
trigger = raw_rule.get("trigger")
if trigger not in TRIGGER_TYPES:
continue
action = _sanitize_action(raw_rule.get("action"))
if not action:
continue
cleaned.append({"trigger": trigger, "action": action})
return cleaned
def _apply_dialogue_lines(action, dialogue_id, lines):
if not isinstance(action, dict):
return False
action_type = action.get("type")
if action_type == "dialogue":
dialogue = action.get("dialogue")
if isinstance(dialogue, dict) and dialogue.get("id") == dialogue_id:
action["dialogue"] = {"id": dialogue_id, "lines": lines}
return True
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
if action_type == "variable":
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
if action_type == "interagir":
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
if action_type == "condition":
return (
_apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines)
or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines)
)
return False
def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
"""Met à jour EN PLACE les répliques du dialogue `dialogue_id`,
où qu'il se trouve dans l'arbre d'action d'une des règles (direct,
chaîné via "then", sous "interagir", ou dans une branche de
"condition") — utilisé par routes/triggers/ pour l'édition en ligne
d'un dialogue depuis l'onglet "Déclencheurs". Retourne False sans
rien modifier si `dialogue_id` n'existe dans aucune règle (référence
orpheline, ex. règle supprimée entre-temps)."""
lines = sanitize_dialogue_lines(raw_lines)
for rule in rules:
if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines):
return True
return False
def _leaf_id(action):
if action.get("type") == "dialogue":
dialogue = action.get("dialogue")
return dialogue.get("id") if isinstance(dialogue, dict) else None
if action.get("type") == "variable":
return action.get("id")
return None
def _apply_then_action(action, leaf_id, then_action):
if not isinstance(action, dict):
return False
action_type = action.get("type")
if action_type in LEAF_ACTION_TYPES:
if _leaf_id(action) == leaf_id:
if "then" in action:
return False # jamais écraser un chaînage déjà posé
action["then"] = then_action
return True
return _apply_then_action(action.get("then"), leaf_id, then_action)
if action_type == "interagir":
return _apply_then_action(action.get("sub_action"), leaf_id, then_action)
if action_type == "condition":
return (
_apply_then_action(action.get("si_vrai"), leaf_id, then_action)
or _apply_then_action(action.get("si_faux"), leaf_id, then_action)
)
return False
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
"""Attache EN PLACE une NOUVELLE action `then` sur la feuille
("dialogue" ou "variable") identifiée par `leaf_id` (dialogue.id pour
une feuille dialogue, id pour une feuille variable) — n'importe où
dans l'arbre d'action d'une des règles. Utilisé par routes/triggers/
pour "+ Ajouter une action" en bout de chaîne (voir static/js/triggers/
trigger-editor.js). Retourne False sans rien modifier si `leaf_id`
est introuvable, OU si la feuille trouvée a DÉJÀ un "then" (on
n'écrase jamais un chaînage existant par cette route — seulement
ajouter à la fin réelle de la chaîne)."""
then_action = _sanitize_leaf_action(raw_then_action)
if not then_action:
return False
for rule in rules:
if _apply_then_action(rule.get("action"), leaf_id, then_action):
return True
return False
def resolve_collision_rules(obj):
"""Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement
inchangé pour tout objet déjà posé avant l'ajout de cet éditeur)."""
raw = (obj.get("attributes") or {}).get("_collision_rules")
if not raw:
return []
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return []
return sanitize_collision_rules(parsed)