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 # 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 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 # 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 (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 (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 (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 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_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 (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")) 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): """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, 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. `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 = [] 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, 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 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, 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") in LEAF_ACTION_TYPES: 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: # 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, leaf_id, raw_then_action): """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 for rule in rules: if _apply_then_action(rule.get("action"), leaf_id, then_action): return True 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 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)