"""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 parmi 2 à 4, puis découvre la conséquence de SON choix ainsi que s'il s'agissait du bon choix (voir document_engine/rendering/render_document_element.py:: _render_scenario_player). Le créateur peut définir plusieurs scénarios, 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.""" from typing import Any # Nommés explicitement "SCENARIO" (pas juste MIN/MAX_CHOICES) : document_ # engine/__init__.py ré-exporte l'intégralité de l'API publique du paquet # à plat (voir CLAUDE.md, "1 fichier = 1 fonction publique") — un nom # générique entrerait en collision avec MIN_CHOICES/MAX_CHOICES de # quiz_config.py, qui borne un concept différent (le nombre de réponses # d'une question de quiz, pas le nombre d'options d'une décision). MIN_SCENARIO_CHOICES = 2 MAX_SCENARIO_CHOICES = 4 DEFAULT_SCENARIO_CONFIG: dict[str, Any] = { "theme_color": "#ff5f2e", "scenarios": [], } def _sanitize_scenario_choice(raw: Any) -> dict[str, str] | None: """None si le choix est invalide (texte vide) — filtrée par _sanitize_scenario plutôt que de faire échouer tout le scénario, même convention que quiz_config.py::_sanitize_question. La conséquence, elle, peut rester vide (un créateur peut vouloir la renseigner plus tard sans que ça invalide le choix).""" if not isinstance(raw, dict): return None text = str(raw.get("text", "")).strip() if not text: return None consequence = str(raw.get("consequence", "")).strip() return {"text": text, "consequence": consequence} def _sanitize_scenario(raw: Any) -> dict[str, Any] | None: """None si le scénario est invalide (situation vide, ou moins de MIN_SCENARIO_CHOICES choix non vides) — même convention que quiz_config.py::_sanitize_question.""" if not isinstance(raw, dict): return None situation = str(raw.get("situation", "")).strip() if not situation: return None raw_choices = raw.get("choices") if not isinstance(raw_choices, list): return None choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None] choices = choices[:MAX_SCENARIO_CHOICES] if len(choices) < MIN_SCENARIO_CHOICES: return None correct_index = raw.get("correct_index") if not isinstance(correct_index, int) or isinstance(correct_index, bool) or not (0 <= correct_index < len(choices)): correct_index = 0 return {"situation": situation, "choices": choices, "correct_index": correct_index} 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