Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
688 lines
32 KiB
Python
688 lines
32 KiB
Python
import json
|
|
import uuid
|
|
from typing import Any, Callable
|
|
|
|
from db.dialogue_lines import sanitize_dialogue_lines
|
|
from game_engine.flow.constants import CONDITION_OPERATORS
|
|
from game_engine.labels.data_operations import DATA_OPERATION_LABELS
|
|
from game_engine.labels.element_visibility import ELEMENT_VISIBILITY_LABELS
|
|
from game_engine.labels.surbrillance_values import SURBRILLANCE_LABELS
|
|
from game_engine.labels.video_modes import VIDEO_MODE_LABELS
|
|
|
|
# Éditeur de déclencheurs (voir static/game/js/triggers/trigger-editor.js::
|
|
# openTriggerWizard et static/game/js/scenes/collision-rule-wizard.js) : chaque
|
|
# objet de scène (hors le personnage "joueur" — voir game_engine/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 avec le joueur (voir
|
|
# static/game/js/play/conditions.js::forgeShapesOverlap) —
|
|
# nécessite un personnage "joueur" sur l'écran
|
|
# ("perimetre" retiré, ne servait à rien pour l'instant,
|
|
# demande explicite).
|
|
# - "clic" : l'objet est cliqué/touché — pas besoin de joueur, écouteur
|
|
# posé directement sur l'objet (voir static/game/js/play/
|
|
# collision-rules-controller.js::forgeBindClicSurvolRules).
|
|
# - "survol" : le pointeur survole l'objet (souris) — même remarque
|
|
# que "clic".
|
|
#
|
|
# 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/game/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/game/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 game_engine/data_actions/apply_variable_action.py,
|
|
# game_engine/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
|
|
# game_engine/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 (voir
|
|
# LEAF_ACTION_TYPES ci-dessous), voir maquette validée,
|
|
# JAMAIS une nouvelle "interagir"/"condition" imbriquée :
|
|
# une branche reste une feuille).
|
|
# - "surbrillance" : met un objet en surbrillance (n'importe lequel de
|
|
# l'écran, pas forcément celui qui porte la règle — voir
|
|
# `object_id`). MÊME vocabulaire ("on"/"off"/"toggle",
|
|
# classe CSS .forgeHighlight) que la propriété
|
|
# "surbrillance" de "Modifier un élément" (voir
|
|
# static/game/js/play/actions.js::applyElementProperty,
|
|
# game_engine/labels/surbrillance_values.py), réutilisée telle
|
|
# quelle plutôt que réinventée.
|
|
# - "visibilite" : rend un objet visible/masqué/invisible (voir
|
|
# `object_id`, game_engine/labels/element_visibility.py::
|
|
# ELEMENT_VISIBILITY_VALUES, même vocabulaire que
|
|
# "Modifier un élément" côté Flow).
|
|
# - "son" : joue un effet sonore ponctuel, non bouclé (voir
|
|
# `asset_url`) — même comportement que l'action "Jouer un
|
|
# son" du Flow (static/game/js/play/actions.js, `new
|
|
# Audio(url).play()`), indépendant de la musique de fond.
|
|
# - "video" : joue une vidéo uploadée dans "Mes assets" (voir
|
|
# `asset_url`, `mode` ∈ VIDEO_MODES) — bloque la suite de
|
|
# la chaîne "then" jusqu'à la fin de la vidéo (ou "passer"),
|
|
# comme un dialogue.
|
|
# - "indication": affiche une bulle de texte courte près d'un objet (voir
|
|
# `object_id`, `texte`, `duree_ms` optionnel — disparaît
|
|
# automatiquement après ce délai, par défaut voir
|
|
# static/game/js/play/collision-rules-controller.js) — même
|
|
# esprit que la bulle "Appuie sur [touche]" déjà utilisée
|
|
# par "interagir".
|
|
# - "attendre" : suspend la chaîne "then" pendant `data_value` SECONDES
|
|
# (même vocabulaire que l'action "attendre" du Flow, voir
|
|
# static/game/js/play/actions.js) avant de continuer — utile
|
|
# par ex. pour laisser un temps de lecture après une
|
|
# narration à l'affichage de l'écran.
|
|
#
|
|
# CHAÎNAGE : une FEUILLE (voir LEAF_ACTION_TYPES) 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",
|
|
"surbrillance",
|
|
"visibilite",
|
|
"son",
|
|
"video",
|
|
"indication",
|
|
"attendre",
|
|
"interagir",
|
|
"condition",
|
|
]
|
|
TRIGGER_TYPES = ["collision", "clic", "survol"]
|
|
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
|
|
# (jamais illimité, mais large : voir "+ Ajouter une action" posé après
|
|
# CHAQUE bloc, demande explicite "n'importe quel niveau du schéma"). Bug
|
|
# corrigé : à 4, une chaîne suffisait à peine à enchaîner attendre ->
|
|
# dialogue -> surbrillance -> attendre — le 5e maillon ajouté par
|
|
# l'utilisateur (ex. une seconde surbrillance pour désactiver) était
|
|
# TRONQUÉ EN SILENCE par la resanitisation qui suit chaque écriture
|
|
# (set_then_action_in_rules), donnant l'impression que l'ajout n'avait
|
|
# aucun effet alors qu'il avait bien été inséré en mémoire.
|
|
_MAX_THEN_CHAIN = 30
|
|
|
|
|
|
_MAX_ACTION_ID_LENGTH = 40
|
|
# Opérations qui n'ont besoin d'AUCUNE valeur (voir game_engine/labels/
|
|
# data_operations.py::DATA_OPERATIONS) — data_value est toujours omis
|
|
# pour celles-ci, quoi que le client envoie (même logique que
|
|
# static/game/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: Any) -> dict[str, Any] | None:
|
|
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: dict[str, Any]) -> dict[str, Any]:
|
|
action: dict[str, Any] = {"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: dict[str, Any]) -> dict[str, Any] | None:
|
|
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: dict[str, Any] = {
|
|
"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_object_id(raw_action: dict[str, Any]) -> int | None:
|
|
object_id = raw_action.get("object_id")
|
|
if object_id is None:
|
|
return None
|
|
try:
|
|
return int(object_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _with_optional_id(action: dict[str, Any], raw_action: dict[str, Any]) -> dict[str, Any]:
|
|
"""Ajoute le champ `id` (optionnel, généré côté client) commun à
|
|
toutes les feuilles autres que "dialogue" — sert à adresser CETTE
|
|
feuille précise pour y attacher un "then" plus tard (voir
|
|
set_then_action_in_rules), même rôle que `variable.id`."""
|
|
action_id = raw_action.get("id")
|
|
if isinstance(action_id, str) and action_id.strip():
|
|
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
|
|
return action
|
|
|
|
|
|
def _sanitize_surbrillance_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
|
|
object_id = _sanitize_object_id(raw_action)
|
|
valeur = raw_action.get("valeur")
|
|
if object_id is None or valeur not in SURBRILLANCE_LABELS:
|
|
return None
|
|
return _with_optional_id({"type": "surbrillance", "object_id": object_id, "valeur": valeur}, raw_action)
|
|
|
|
|
|
def _sanitize_visibilite_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
|
|
object_id = _sanitize_object_id(raw_action)
|
|
valeur = raw_action.get("valeur")
|
|
if object_id is None or valeur not in ELEMENT_VISIBILITY_LABELS:
|
|
return None
|
|
return _with_optional_id({"type": "visibilite", "object_id": object_id, "valeur": valeur}, raw_action)
|
|
|
|
|
|
def _sanitize_son_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
|
|
asset_url = raw_action.get("asset_url")
|
|
if not isinstance(asset_url, str) or not asset_url.strip():
|
|
return None
|
|
return _with_optional_id({"type": "son", "asset_url": asset_url.strip()}, raw_action)
|
|
|
|
|
|
def _sanitize_video_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
|
|
asset_url = raw_action.get("asset_url")
|
|
mode = raw_action.get("mode")
|
|
if not isinstance(asset_url, str) or not asset_url.strip() or mode not in VIDEO_MODE_LABELS:
|
|
return None
|
|
return _with_optional_id({"type": "video", "asset_url": asset_url.strip(), "mode": mode}, raw_action)
|
|
|
|
|
|
def _sanitize_indication_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
|
|
object_id = _sanitize_object_id(raw_action)
|
|
texte = raw_action.get("texte")
|
|
if object_id is None or not isinstance(texte, str) or not texte.strip():
|
|
return None
|
|
action: dict[str, Any] = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
|
|
duree_ms = raw_action.get("duree_ms")
|
|
if isinstance(duree_ms, (int, float)) and not isinstance(duree_ms, bool) and duree_ms > 0:
|
|
action["duree_ms"] = int(duree_ms)
|
|
return _with_optional_id(action, raw_action)
|
|
|
|
|
|
def _sanitize_attendre_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
|
|
""" "Attendre N secondes" avant de continuer la chaîne — MÊME
|
|
vocabulaire que l'action "attendre" du Flow (voir static/game/js/play/
|
|
actions.js, `data_value` en SECONDES, pas en ms) : suspend le "then"
|
|
plutôt que de l'exécuter immédiatement (comme "video"), utile par ex.
|
|
pour une narration à l'affichage de l'écran qui doit laisser un temps
|
|
de lecture avant d'enchaîner."""
|
|
raw_value = raw_action.get("data_value")
|
|
if raw_value is None:
|
|
return None
|
|
try:
|
|
seconds = float(raw_value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if seconds < 0:
|
|
return None
|
|
return _with_optional_id({"type": "attendre", "data_value": str(seconds)}, raw_action)
|
|
|
|
|
|
# Une seule table de vérité pour les feuilles : ajouter un type ici suffit
|
|
# (voir LEAF_ACTION_TYPES dérivé de ses clés) — évite le if/elif qui aurait
|
|
# dû être répété à chaque nouvel ajout.
|
|
_LEAF_ACTION_SANITIZERS: dict[str, Callable[[dict[str, Any]], dict[str, Any] | None]] = {
|
|
"dialogue": _sanitize_dialogue_action,
|
|
"variable": _sanitize_variable_action,
|
|
"surbrillance": _sanitize_surbrillance_action,
|
|
"visibilite": _sanitize_visibilite_action,
|
|
"son": _sanitize_son_action,
|
|
"video": _sanitize_video_action,
|
|
"indication": _sanitize_indication_action,
|
|
"attendre": _sanitize_attendre_action,
|
|
}
|
|
LEAF_ACTION_TYPES = tuple(_LEAF_ACTION_SANITIZERS)
|
|
|
|
|
|
def _sanitize_leaf_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
|
|
"""Sanitize une FEUILLE (voir LEAF_ACTION_TYPES) 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
|
|
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type") or "")
|
|
if not sanitizer:
|
|
return None
|
|
action = sanitizer(raw_action)
|
|
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: Any) -> dict[str, Any] | None:
|
|
"""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: Any, depth: int = 1) -> dict[str, Any] | None:
|
|
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: Any, allowed_triggers: list[str] | None = None) -> list[dict[str, Any]]:
|
|
"""Valide/nettoie une liste de règles postée par le client (voir
|
|
routes/game/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. `allowed_triggers` (par défaut TRIGGER_TYPES) :
|
|
réutilisé tel quel par game_engine/rendering/screen_triggers.py pour les
|
|
déclencheurs D'ÉCRAN (sans objet), qui partagent la même logique de
|
|
sanitisation d'action mais un vocabulaire de déclencheur différent
|
|
(seulement "affichage")."""
|
|
if allowed_triggers is None:
|
|
allowed_triggers = TRIGGER_TYPES
|
|
if not isinstance(raw_rules, list):
|
|
return []
|
|
cleaned: list[dict[str, Any]] = []
|
|
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 allowed_triggers:
|
|
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: Any, dialogue_id: str, lines: list[dict[str, Any]]) -> bool:
|
|
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 in LEAF_ACTION_TYPES:
|
|
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: list[dict[str, Any]], dialogue_id: str, raw_lines: Any) -> bool:
|
|
"""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/game/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)
|
|
return any(_apply_dialogue_lines(rule.get("action"), dialogue_id, lines) for rule in rules)
|
|
|
|
|
|
def _leaf_id(action: dict[str, Any]) -> str | None:
|
|
if action.get("type") == "dialogue":
|
|
dialogue = action.get("dialogue")
|
|
return dialogue.get("id") if isinstance(dialogue, dict) else None
|
|
if action.get("type") in LEAF_ACTION_TYPES:
|
|
return action.get("id")
|
|
return None
|
|
|
|
|
|
def _apply_then_action(action: Any, leaf_id: str, then_action: dict[str, Any]) -> bool:
|
|
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:
|
|
# INSÈRE `then_action` juste après cette feuille — l'éventuelle
|
|
# suite déjà présente passe DERRIÈRE elle plutôt que d'être
|
|
# écrasée (demande explicite : "pouvoir ajouter n'importe
|
|
# quelle action à n'importe quel niveau du schéma", pas
|
|
# seulement en bout de chaîne — comportement précédent, qui
|
|
# refusait si "then" existait déjà).
|
|
existing_then = action.get("then")
|
|
if existing_then:
|
|
then_action["then"] = existing_then
|
|
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: list[dict[str, Any]], leaf_id: str, raw_then_action: Any) -> bool:
|
|
"""INSÈRE EN PLACE une NOUVELLE action juste après la feuille
|
|
identifiée par `leaf_id` (dialogue.id pour une feuille dialogue, id
|
|
pour toute autre feuille) — n'importe où dans l'arbre d'action d'une
|
|
des règles. Utilisé par "+ Ajouter une action", posé après CHAQUE
|
|
bloc de la chaîne (voir static/game/js/triggers/trigger-editor.js) : si la
|
|
feuille visée a déjà une suite ("then"), celle-ci passe DERRIÈRE la
|
|
nouvelle action plutôt que d'être remplacée — permet d'insérer une
|
|
action à N'IMPORTE QUEL niveau, pas seulement en bout de chaîne
|
|
réelle. Retourne False sans rien modifier si `leaf_id` est
|
|
introuvable."""
|
|
then_action = _sanitize_leaf_action(raw_then_action)
|
|
if not then_action:
|
|
return False
|
|
return any(_apply_then_action(rule.get("action"), leaf_id, then_action) for rule in rules)
|
|
|
|
|
|
# ---------- Suppression/réordonnancement d'un maillon précis (demande
|
|
# explicite : "que chaque bloc étiquette + action soit déplaçable ou
|
|
# supprimable par l'utilisateur ET par l'IA") ----------
|
|
# Les deux opérations ci-dessous reconstruisent l'arbre par le HAUT
|
|
# (renvoient un NOUVEL arbre plutôt que de muter les dicts en place, voir
|
|
# _remove_leaf_id/_move_in_action_tree) — plus sûr qu'un patch en place
|
|
# pour un maillon qui peut être la TÊTE d'une règle (auquel cas la
|
|
# RÈGLE elle-même doit être remplacée ou retirée, pas juste un champ
|
|
# interne).
|
|
|
|
|
|
def _remove_leaf_id(action: Any, leaf_id: str) -> tuple[Any, bool]:
|
|
"""Retire la feuille `leaf_id` de l'arbre `action` — renvoie
|
|
(nouvel_arbre_ou_None, trouvé). `nouvel_arbre` vaut None si `action`
|
|
lui-même était la cible et n'avait rien pour le remplacer (ni "then",
|
|
ni sous-action restante pour "interagir")."""
|
|
if not isinstance(action, dict):
|
|
return action, False
|
|
action_type = action.get("type")
|
|
if action_type in LEAF_ACTION_TYPES:
|
|
if _leaf_id(action) == leaf_id:
|
|
return action.get("then"), True
|
|
new_then, removed = _remove_leaf_id(action.get("then"), leaf_id)
|
|
if not removed:
|
|
return action, False
|
|
new_action = dict(action)
|
|
if new_then:
|
|
new_action["then"] = new_then
|
|
else:
|
|
new_action.pop("then", None)
|
|
return new_action, True
|
|
if action_type == "interagir":
|
|
new_sub, removed = _remove_leaf_id(action.get("sub_action"), leaf_id)
|
|
if not removed:
|
|
return action, False
|
|
if new_sub is None:
|
|
# Plus de sous-action : "interagir" perd son sens (une touche
|
|
# qui ne déclenche plus rien), retiré à son tour.
|
|
return None, True
|
|
new_action = dict(action)
|
|
new_action["sub_action"] = new_sub
|
|
return new_action, True
|
|
if action_type == "condition":
|
|
new_action = dict(action)
|
|
changed = False
|
|
for branch_key in ("si_vrai", "si_faux"):
|
|
new_branch, removed = _remove_leaf_id(action.get(branch_key), leaf_id)
|
|
if removed:
|
|
new_action[branch_key] = new_branch
|
|
changed = True
|
|
return (new_action, True) if changed else (action, False)
|
|
return action, False
|
|
|
|
|
|
def remove_action_from_rules(rules: list[dict[str, Any]], leaf_id: str) -> bool:
|
|
"""Retire EN PLACE l'action `leaf_id` n'importe où dans l'arbre d'une
|
|
des règles (feuille directe d'une règle, maillon d'une chaîne "then",
|
|
sous-action d'un "interagir", ou dans une branche de "condition"). Si
|
|
l'action retirée était la TÊTE d'une règle : la règle est remplacée
|
|
par sa suite ("then") si elle en avait une, sinon la RÈGLE ENTIÈRE
|
|
disparaît (plus rien à garder). Renvoie True si quelque chose a été
|
|
retiré."""
|
|
for i, rule in enumerate(rules):
|
|
new_action, removed = _remove_leaf_id(rule.get("action"), leaf_id)
|
|
if not removed:
|
|
continue
|
|
if new_action is None:
|
|
del rules[i]
|
|
else:
|
|
rules[i] = {"trigger": rule["trigger"], "action": new_action}
|
|
return True
|
|
return False
|
|
|
|
|
|
def _chain_as_list(head_action: Any) -> list[dict[str, Any]]:
|
|
"""Aplatit une chaîne de FEUILLES pures (voir LEAF_ACTION_TYPES) en
|
|
liste — le réordonnancement ne s'applique qu'à l'intérieur d'une
|
|
chaîne "then" (jamais entre deux branches différentes d'une
|
|
condition, ni entre une "interagir" et ce qui l'entoure)."""
|
|
items: list[dict[str, Any]] = []
|
|
node = head_action
|
|
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
|
|
items.append(node)
|
|
node = node.get("then")
|
|
return items
|
|
|
|
|
|
def _relink_chain(items: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
items = [dict(n) for n in items]
|
|
for i in range(len(items) - 1):
|
|
items[i]["then"] = items[i + 1]
|
|
if items:
|
|
items[-1].pop("then", None)
|
|
return items[0] if items else None
|
|
|
|
|
|
def _move_in_action_tree(action: Any, leaf_id: str, direction: str) -> dict[str, Any] | None:
|
|
"""Cherche, dans `action`, LA chaîne "then" qui contient `leaf_id`
|
|
(au premier niveau, sous une "interagir", ou dans une branche de
|
|
"condition"), et échange `leaf_id` avec son voisin immédiat
|
|
("up"/"down") DANS CETTE CHAÎNE. Renvoie le nouvel arbre, ou None si
|
|
`leaf_id` n'a été trouvé nulle part dans `action` (jamais rien à
|
|
changer dans ce cas — distinct d'un déplacement sans effet parce que
|
|
déjà en bout de chaîne, qui renvoie quand même un arbre, identique à
|
|
l'original)."""
|
|
if not isinstance(action, dict):
|
|
return None
|
|
action_type = action.get("type")
|
|
if action_type in LEAF_ACTION_TYPES:
|
|
chain = _chain_as_list(action)
|
|
ids = [_leaf_id(n) for n in chain]
|
|
if leaf_id not in ids:
|
|
return None
|
|
idx = ids.index(leaf_id)
|
|
swap_idx = idx - 1 if direction == "up" else idx + 1
|
|
if 0 <= swap_idx < len(chain):
|
|
chain[idx], chain[swap_idx] = chain[swap_idx], chain[idx]
|
|
return _relink_chain(chain)
|
|
if action_type == "interagir":
|
|
new_sub = _move_in_action_tree(action.get("sub_action"), leaf_id, direction)
|
|
if new_sub is None:
|
|
return None
|
|
new_action = dict(action)
|
|
new_action["sub_action"] = new_sub
|
|
return new_action
|
|
if action_type == "condition":
|
|
for branch_key in ("si_vrai", "si_faux"):
|
|
new_branch = _move_in_action_tree(action.get(branch_key), leaf_id, direction)
|
|
if new_branch is not None:
|
|
new_action = dict(action)
|
|
new_action[branch_key] = new_branch
|
|
return new_action
|
|
return None
|
|
return None
|
|
|
|
|
|
def move_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, direction: str) -> bool:
|
|
"""Déplace EN PLACE la feuille `leaf_id` d'un cran vers le haut
|
|
("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then" (jamais
|
|
au-delà de sa branche de condition/sous-action interagir — voir
|
|
_move_in_action_tree). `direction` autre que "up"/"down" : aucun
|
|
effet. Renvoie True si `leaf_id` a été trouvé (même si déjà en bout
|
|
de chaîne et donc sans déplacement réel — la règle est alors
|
|
réécrite à l'identique)."""
|
|
if direction not in ("up", "down"):
|
|
return False
|
|
for i, rule in enumerate(rules):
|
|
new_action = _move_in_action_tree(rule.get("action"), leaf_id, direction)
|
|
if new_action is not None:
|
|
rules[i] = {"trigger": rule["trigger"], "action": new_action}
|
|
return True
|
|
return False
|
|
|
|
|
|
def _generate_leaf_id() -> str:
|
|
return "d_" + uuid.uuid4().hex[:12]
|
|
|
|
|
|
def _backfill_leaf_ids(action: Any) -> bool:
|
|
"""Assure EN PLACE qu'un id STABLE existe sur toute feuille non-
|
|
"dialogue" (qui a déjà le sien via dialogue.id) qui n'en avait pas —
|
|
ex. une action "variable" posée AVANT que ce champ ne devienne
|
|
systématique, ou par l'IA (le champ reste optionnel dans son schéma,
|
|
voir ai/tools.py). Sans id, une feuille reste à jamais impossible à
|
|
adresser (chaînage/ajout/retrait/déplacement ciblé, voir
|
|
"+ Ajouter une action" posé après CHAQUE bloc). Renvoie True si
|
|
quelque chose a été ajouté — l'appelant doit alors PERSISTER `action`
|
|
(voir backfill_missing_leaf_ids), sans quoi l'id resterait instable
|
|
d'une lecture à l'autre (régénéré à chaque appel)."""
|
|
if not isinstance(action, dict):
|
|
return False
|
|
changed = False
|
|
action_type = action.get("type")
|
|
if action_type in LEAF_ACTION_TYPES:
|
|
if action_type != "dialogue" and not action.get("id"):
|
|
action["id"] = _generate_leaf_id()
|
|
changed = True
|
|
if _backfill_leaf_ids(action.get("then")):
|
|
changed = True
|
|
elif action_type == "interagir":
|
|
if _backfill_leaf_ids(action.get("sub_action")):
|
|
changed = True
|
|
elif action_type == "condition":
|
|
if _backfill_leaf_ids(action.get("si_vrai")):
|
|
changed = True
|
|
if _backfill_leaf_ids(action.get("si_faux")):
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def backfill_missing_leaf_ids(rules: list[dict[str, Any]]) -> bool:
|
|
"""Applique _backfill_leaf_ids à CHAQUE règle de `rules` (mutées EN
|
|
PLACE) — renvoie True si au moins une règle a été modifiée, auquel
|
|
cas l'appelant DOIT persister `rules` (voir routes/game/triggers/
|
|
triggers.py::triggers_get, routes/game/screens/screen_triggers.py::
|
|
screen_triggers_get, game_engine/rendering/trigger_graph.py::
|
|
list_all_triggers — appelé à la LECTURE, pour que le premier
|
|
affichage d'une règle ancienne rende ses feuilles adressables une
|
|
bonne fois pour toutes)."""
|
|
changed = False
|
|
for rule in rules:
|
|
if _backfill_leaf_ids(rule.get("action")):
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def resolve_collision_rules(obj: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""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)
|