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 (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)