Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance,
  video, son, visibilite, indication, attendre comme actions, utilisables
  aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par
  Ruby (IA), avec blocs deplacables/supprimables dans une chaine.
- Corrige plusieurs variantes du bug "impossible de poser un objet hors
  du champ de la camera" (troncature du chainage d'actions a 4 maillons,
  fond importe pose a 128x128 au lieu de sa taille reelle, decalage du
  fond au vrai glisser-depose, redimensionnement manuel jamais propage
  au monde).
- Ajoute un vrai glisser-depose depuis la galerie vers la scene, la
  gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/
  Videos, suppression, reclassement fond<->decor sans re-upload).
- Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement,
  limite 5 min), avec validation de la duree reelle du fichier, et une
  replique audio optionnelle dans une bulle de dialogue.
- Fixe la taille de pose d'un objet/decor importe a 200x200 avec une
  boite de collision de 150x150.
- Filtre le selecteur de fichier des actions son/video par type reel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-12 00:28:46 +02:00
co-authored by Claude Sonnet 5
parent b07b231a61
commit 559331f9cf
67 changed files with 4739 additions and 306 deletions
+400 -37
View File
@@ -1,8 +1,12 @@
import json
import uuid
from db.dialogue_lines import sanitize_dialogue_lines
from screens.flow.constants import CONDITION_OPERATORS
from screens.labels.data_operations import DATA_OPERATION_LABELS
from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS
from screens.labels.surbrillance_values import SURBRILLANCE_LABELS
from screens.labels.video_modes import VIDEO_MODE_LABELS
# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js::
# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque
@@ -10,10 +14,17 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
# 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).
# Déclencheur :
# - "collision" : chevauchement direct avec le joueur (voir
# static/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/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
@@ -53,12 +64,43 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
# 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).
# 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/js/play/actions.js::applyElementProperty,
# screens/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`, screens/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/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/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/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 ("dialogue" ou "variable") peut porter un champ
# 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
@@ -67,15 +109,25 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
#
# ("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"]
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.
_MAX_THEN_CHAIN = 4
# 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
@@ -129,20 +181,112 @@ def _sanitize_variable_action(raw_action):
return action
def _sanitize_object_id(raw_action):
object_id = raw_action.get("object_id")
try:
return int(object_id)
except (TypeError, ValueError):
return None
def _with_optional_id(action, raw_action):
"""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):
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):
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):
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):
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):
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 = {"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):
""""Attendre N secondes" avant de continuer la chaîne — MÊME
vocabulaire que l'action "attendre" du Flow (voir static/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")
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 = {
"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, depth=1):
"""Sanitize une FEUILLE ("dialogue" ou "variable") et son éventuelle
"""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
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:
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type"))
if not sanitizer:
return None
action = sanitizer(raw_action)
if not action:
return None
if depth < _MAX_THEN_CHAIN:
@@ -193,11 +337,17 @@ def _sanitize_action(raw_action, depth=1):
}
def sanitize_collision_rules(raw_rules):
def sanitize_collision_rules(raw_rules, allowed_triggers=None):
"""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."""
la sauvegarde entière. `allowed_triggers` (par défaut TRIGGER_TYPES) :
réutilisé tel quel par screens/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 = []
@@ -205,7 +355,7 @@ def sanitize_collision_rules(raw_rules):
if not isinstance(raw_rule, dict):
continue
trigger = raw_rule.get("trigger")
if trigger not in TRIGGER_TYPES:
if trigger not in allowed_triggers:
continue
action = _sanitize_action(raw_rule.get("action"))
if not action:
@@ -224,7 +374,7 @@ def _apply_dialogue_lines(action, dialogue_id, lines):
action["dialogue"] = {"id": dialogue_id, "lines": lines}
return True
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
if action_type == "variable":
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)
@@ -255,7 +405,7 @@ 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":
if action.get("type") in LEAF_ACTION_TYPES:
return action.get("id")
return None
@@ -266,8 +416,15 @@ def _apply_then_action(action, leaf_id, then_action):
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é
# 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)
@@ -282,15 +439,16 @@ def _apply_then_action(action, leaf_id, then_action):
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)."""
"""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/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
@@ -300,6 +458,211 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
return False
# ---------- 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, leaf_id):
"""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, leaf_id):
"""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):
"""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 = []
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):
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, leaf_id, direction):
"""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, leaf_id, direction):
"""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():
return "d_" + uuid.uuid4().hex[:12]
def _backfill_leaf_ids(action):
"""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):
"""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/triggers/
triggers.py::triggers_get, routes/screens/screen_triggers.py::
screen_triggers_get, screens/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):
"""Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement