"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) — l'apprenant lit une situation initiale, choisit une option, et découvre la conséquence de SON choix ; cette conséquence peut elle-même mener à de nouveaux choix, et ainsi de suite (arbre de décision, voir document_engine/rendering/render_document_element.py:: _render_scenario_player). Aucune notion de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe les conséquences") — l'apprenant explore simplement les branches. Le créateur peut définir plusieurs scénarios (plusieurs arbres indépendants), joués les uns après les autres dans l'ordre d'écriture. Même convention resolve_X/sanitize_X que quiz_config.py/association_config.py/memory_config.py/mots_config.py (aucun import croisé) : sanitize_scenario_config est pure, ne lève jamais, et renvoie toujours un dict complet. Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable depuis le panneau Propriétés). Chaque nœud est {"id": str, "text": str, "x": float, "y": float, "choices": [...]} — `id` est une référence STABLE (générée côté client, jamais recalculée ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son `target_id` ; un nœud sans choix est une fin de branche. `x`/`y` positionnent le nœud sur le canevas du graphe visuel (voir document_engine/rendering/render_document_element.py:: _render_scenario_player et forgeDocRenderScenarioGraph, static/document/ js/document-editor.js) — une position manquante/invalide retombe sur un quadrillage en cascade calculé depuis l'INDEX du nœud dans la liste (_scenario_node_fallback_position), jamais (0, 0) pour tous les nœuds, ce qui les empilerait exactement au même endroit. Un choix est {"text": str, "target_id": str | None} — `target_id` à None signifie "fin de branche à ce choix" (aucun nœud suivant).""" from typing import Any MAX_SCENARIO_CHOICES = 4 _NODE_GRID_COLUMNS = 4 _NODE_GRID_STEP_X = 220 _NODE_GRID_STEP_Y = 160 _NODE_GRID_MARGIN = 40 DEFAULT_SCENARIO_CONFIG: dict[str, Any] = { "theme_color": "#ff5f2e", "scenarios": [], } def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None: """None si le choix est invalide (texte vide) — filtrée par _sanitize_scenario_node plutôt que de faire échouer tout le nœud, même convention que quiz_config.py::_sanitize_question. `target_id` est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois l'ensemble des ids valides du scénario connu), jamais ici.""" if not isinstance(raw, dict): return None text = str(raw.get("text", "")).strip() if not text: return None target_id = raw.get("target_id") return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None} def _scenario_node_fallback_position(index: int) -> tuple[float, float]: """Position par défaut d'un nœud dont x/y est manquant/invalide — un quadrillage en cascade dérivé de son INDEX dans la liste, jamais (0, 0) pour tous les nœuds (qui les empilerait exactement au même endroit, rendant le graphe visuel illisible à la première ouverture d'un scénario créé avant l'ajout de x/y au modèle, ou d'un nœud ajouté par un client qui n'enverrait pas encore de position).""" col = index % _NODE_GRID_COLUMNS row = index // _NODE_GRID_COLUMNS return ( _NODE_GRID_MARGIN + col * _NODE_GRID_STEP_X, _NODE_GRID_MARGIN + row * _NODE_GRID_STEP_Y, ) def _sanitize_scenario_node(raw: Any, index: int) -> dict[str, Any] | None: """None si le nœud est invalide (id absent, ou texte vide) — même convention que les autres sanitize_X de ce module. Un nœud SANS choix est parfaitement valide (fin de branche), contrairement à l'ancien modèle où un scénario exigeait 2 à 4 choix : ici, 0 choix est un état normal, pas une erreur.""" if not isinstance(raw, dict): return None node_id = raw.get("id") if not isinstance(node_id, str) or not node_id: return None text = str(raw.get("text", "")).strip() if not text: return None raw_choices = raw.get("choices") choices = [] if isinstance(raw_choices, list): choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None] choices = choices[:MAX_SCENARIO_CHOICES] fallback_x, fallback_y = _scenario_node_fallback_position(index) raw_x, raw_y = raw.get("x"), raw.get("y") x = raw_x if isinstance(raw_x, (int, float)) and not isinstance(raw_x, bool) else fallback_x y = raw_y if isinstance(raw_y, (int, float)) and not isinstance(raw_y, bool) else fallback_y return {"id": node_id, "text": text, "x": x, "y": y, "choices": choices} def _sanitize_scenario(raw: Any) -> dict[str, Any] | None: """None si le scénario est invalide (titre vide, ou aucun nœud valide restant après nettoyage — il faut au moins la situation initiale). Un choix qui pointait vers un nœud devenu invalide/ supprimé dégrade silencieusement vers `target_id: None` ("fin de branche") plutôt que de faire échouer tout le scénario — même philosophie que le reste de ce module : ne jamais lever, ne jamais faire échouer une structure entière pour une seule référence cassée.""" if not isinstance(raw, dict): return None title = str(raw.get("title", "")).strip() if not title: return None raw_nodes = raw.get("nodes") if not isinstance(raw_nodes, list): return None nodes: list[dict[str, Any]] = [] seen_ids: set[str] = set() for item in raw_nodes: node = _sanitize_scenario_node(item, len(nodes)) if node is None or node["id"] in seen_ids: continue seen_ids.add(node["id"]) nodes.append(node) if not nodes: return None valid_ids = {n["id"] for n in nodes} for node in nodes: node["choices"] = [ {**c, "target_id": c["target_id"] if c["target_id"] in valid_ids else None} for c in node["choices"] ] return {"title": title, "nodes": nodes} def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]: config = dict(DEFAULT_SCENARIO_CONFIG) if not isinstance(raw_config, dict): return config theme_color = raw_config.get("theme_color") if isinstance(theme_color, str) and theme_color: config["theme_color"] = theme_color raw_scenarios = raw_config.get("scenarios") if isinstance(raw_scenarios, list): config["scenarios"] = [s for s in (_sanitize_scenario(item) for item in raw_scenarios) if s is not None] return config