diff --git a/document_engine/__init__.py b/document_engine/__init__.py
index be75403d..a91ec5f9 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -63,6 +63,12 @@ from .labels.quiz_config import (
quiz_total_points,
sanitize_quiz_config,
)
+from .labels.scenario_config import (
+ DEFAULT_SCENARIO_CONFIG,
+ MAX_SCENARIO_CHOICES,
+ MIN_SCENARIO_CHOICES,
+ sanitize_scenario_config,
+)
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
@@ -72,16 +78,19 @@ __all__ = [
"DEFAULT_MEMORY_CONFIG",
"DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG",
+ "DEFAULT_SCENARIO_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MAX_CARDS",
"MAX_CHOICES",
"MAX_PAIRS",
+ "MAX_SCENARIO_CHOICES",
"MAX_TIMER_SECONDS",
"MAX_WORDS",
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
+ "MIN_SCENARIO_CHOICES",
"MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS",
@@ -99,5 +108,6 @@ __all__ = [
"sanitize_memory_config",
"sanitize_mots_config",
"sanitize_quiz_config",
+ "sanitize_scenario_config",
"update_document_element_attributes",
]
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index fb6d4768..ed2cdd59 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -9,6 +9,7 @@ from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG
from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
+from .scenario_config import DEFAULT_SCENARIO_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
@@ -94,6 +95,9 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "mots":
# Même raison de copie que "quiz"/"association"/"memory" ci-dessus (voir mots_config.py).
return {**DEFAULT_MOTS_CONFIG, "words": list(DEFAULT_MOTS_CONFIG["words"])}
+ if kind == "scenario":
+ # Même raison de copie que ci-dessus (voir scenario_config.py).
+ return {**DEFAULT_SCENARIO_CONFIG, "scenarios": list(DEFAULT_SCENARIO_CONFIG["scenarios"])}
if kind in MINIGAME_KINDS:
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
# paragraphe : "état par défaut en attendant sa spécification") —
diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index 3876f5bc..13ca82e0 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -179,3 +179,41 @@ supprimé de la liste, de même qu'un doublon exact d'un mot déjà retenu
liste finale est tronquée à `MAX_WORDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MOTS_CONFIG`).
- **Exceptions** : aucune.
+
+## `scenario_config.py` — modèle de données du mini-jeu Scénario
+
+Cinquième mini-jeu implémenté : 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 (jamais mélangés,
+contrairement à Association/Memory/Mots mêlés). Même convention que
+`quiz_config.py`.
+
+### `MIN_SCENARIO_CHOICES`, `MAX_SCENARIO_CHOICES: int`
+Bornes de validation (`2`/`4` choix par scénario). Nommées explicitement
+"SCENARIO" (pas juste `MIN_CHOICES`/`MAX_CHOICES`) pour éviter toute
+collision avec les constantes de même nom de `quiz_config.py`, qui
+bornent un concept différent (le nombre de réponses d'une question de
+quiz) — `document_engine/__init__.py` ré-exporte l'intégralité de l'API
+publique du paquet à plat, un nom générique entrerait en collision.
+
+### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
+`{"theme_color": "#ff5f2e", "scenarios": []}`.
+
+### `sanitize_scenario_config(raw_config: Any) -> dict[str, Any]`
+Valide/nettoie une config de scénario arbitraire (JSON venu du client) —
+jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
+`DEFAULT_SCENARIO_CONFIG`. Chaque scénario de `raw_config["scenarios"]`
+est validé indépendamment (voir `_sanitize_scenario`/
+`_sanitize_scenario_choice`, privées) : la `situation` doit être non
+vide une fois `.strip()`-ée, et il doit rester au moins
+`MIN_SCENARIO_CHOICES` choix valides (texte non vide) une fois la liste
+tronquée à `MAX_SCENARIO_CHOICES` — sinon le scénario entier est
+silencieusement supprimé de la liste. La `consequence` d'un choix, elle,
+peut rester vide (un créateur peut vouloir la renseigner plus tard sans
+que ça invalide le choix). `correct_index` retombe sur `0` s'il est hors
+bornes ou absent.
+- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
+- **Exceptions** : aucune.
diff --git a/document_engine/labels/scenario_config.py b/document_engine/labels/scenario_config.py
new file mode 100644
index 00000000..f6879cba
--- /dev/null
+++ b/document_engine/labels/scenario_config.py
@@ -0,0 +1,75 @@
+"""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
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index 507d4010..1904c53b 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -458,6 +458,62 @@ def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[
)
+def _render_scenario_player(config: dict[str, Any]) -> str:
+ """Mise en situation RÉELLEMENT interactive — affichée uniquement en
+ Mode Aperçu, même principe que les autres mini-jeux : aucun
+ aller-retour serveur, tout le déroulé (lecture de la situation, choix,
+ conséquence révélée, scénario suivant) est géré par
+ static/document/js/document-editor.js à partir du JSON embarqué. Les
+ scénarios gardent l'ORDRE d'écriture du créateur (contrairement à
+ l'Association/Memory/Mots mêlés, jamais mélangés) : ce sont des mises
+ en situation séquentielles, pas des éléments à faire correspondre ou
+ retrouver — un mélange n'aurait ici aucun sens pédagogique. Réutilise
+ les classes visuelles du Quiz (.docQuizOptions/.docQuizFeedback/
+ .docQuizNextBar/.docQuizQuestionText) plutôt que de dupliquer ces
+ règles, même esprit que .docAssocCard/.docMinigameRestartBar."""
+ config_json = html_lib.escape(json.dumps({"scenarios": config["scenarios"]}), quote=True)
+ return (
+ f'
'
+ f'
'
+ f'
Scénario
'
+ f'
'
+ f''
+ f''
+ f''
+ f'
'
+ f''
+ f"
"
+ # Même choix que l'Association/Memory/Mots mêlés : le dernier
+ # scénario reste affiché une fois répondu, seul ce bouton
+ # apparaît (jamais un écran de résultat séparé — ça reste le
+ # comportement du Quiz).
+ f'