diff --git a/document_engine/__init__.py b/document_engine/__init__.py
index a91ec5f9..4517a76b 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -66,7 +66,6 @@ from .labels.quiz_config import (
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
@@ -90,7 +89,6 @@ __all__ = [
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
- "MIN_SCENARIO_CHOICES",
"MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS",
diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index 13ca82e0..244368a6 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -183,21 +183,41 @@ liste finale est tronquée à `MAX_WORDS`.
## `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`.
+choisit une option, puis 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 jusqu'à une fin. 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
+(jamais mélangés, contrairement à Association/Memory/Mots mêlés). Même
+convention resolve_X/sanitize_X 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
+**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 — voir
+`forgeDocRenderScenarioNodeHtml`, static/document/js/document-editor.js).
+Chaque nœud est `{"id": str, "text": str, "choices": [...]}` — `id` est
+une référence STABLE (générée côté client, jamais recalculée côté
+serveur) 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 (état normal,
+pas une erreur — contrairement à l'ancien modèle qui exigeait 2 à 4
+choix par scénario). Un choix est `{"text": str, "target_id": str |
+None}` — `target_id` à `None` signifie "fin de branche à ce choix".
+
+### `MAX_SCENARIO_CHOICES: int`
+Borne technique du nombre de choix par nœud (`4`) — évite une UI
+disproportionnée, aucun lien avec une notion de bonne/mauvaise réponse.
+Nommée explicitement "SCENARIO" (pas juste `MAX_CHOICES`) pour éviter
+toute collision avec la constante de même nom de `quiz_config.py`, qui
+borne 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.
+Contrairement à l'ancien modèle, il n'existe PAS de minimum : un nœud
+peut avoir 0 choix (fin de branche), c'est un état valide, pas une
+erreur à filtrer.
### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "scenarios": []}`.
@@ -207,13 +227,16 @@ 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.
+`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
+`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
+vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier
+est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Le
+scénario entier est supprimé s'il ne reste plus aucun nœud valide après
+nettoyage (il faut au moins la situation initiale). Une fois l'ensemble
+des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
+existant (nœud invalide/supprimé) est silencieusement remis à `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 faire échouer une
+structure entière pour une seule référence cassée.
- **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
index f6879cba..ea4b079f 100644
--- a/document_engine/labels/scenario_config.py
+++ b/document_engine/labels/scenario_config.py
@@ -1,22 +1,30 @@
"""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."""
+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, "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. 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
-# 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] = {
@@ -25,41 +33,76 @@ DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
}
-def _sanitize_scenario_choice(raw: Any) -> dict[str, str] | None:
+def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | 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)."""
+ _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
- consequence = str(raw.get("consequence", "")).strip()
- return {"text": text, "consequence": consequence}
+ target_id = raw.get("target_id")
+ return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
+
+
+def _sanitize_scenario_node(raw: Any) -> 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]
+ return {"id": node_id, "text": text, "choices": choices}
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."""
+ """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
- situation = str(raw.get("situation", "")).strip()
- if not situation:
+ title = str(raw.get("title", "")).strip()
+ if not title:
return None
- raw_choices = raw.get("choices")
- if not isinstance(raw_choices, list):
+ raw_nodes = raw.get("nodes")
+ if not isinstance(raw_nodes, 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:
+ nodes = []
+ seen_ids: set[str] = set()
+ for item in raw_nodes:
+ node = _sanitize_scenario_node(item)
+ if node is None or node["id"] in seen_ids:
+ continue
+ seen_ids.add(node["id"])
+ nodes.append(node)
+ if not nodes:
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}
+ 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]:
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index 4b87eb44..22512c9b 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -461,23 +461,27 @@ 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 à
+ aller-retour serveur, tout le déroulé (navigation dans l'arbre de
+ décision, scénario suivant) est géré par static/document/js/
+ document-editor.js à partir du JSON embarqué (voir scenario_config.py
+ pour la forme exacte d'un scénario : {"title", "nodes"}, nodes[0]
+ étant la situation initiale). Les scénarios (plusieurs arbres
+ indépendants) 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. Un seul
bloc de texte (.docScenarioSituation) sert successivement à afficher
- la situation PUIS, une fois un choix fait, la conséquence à sa place
- (les boutons de choix disparaissent aussi) — volontairement PAS le
- comportement du Quiz, où la question reste affichée à côté d'un
- encart de feedback séparé (retour utilisateur du 20/09/2026 : "ce
- n'est pas un quiz, la conséquence s'affiche à la place de la
- situation précédente"). 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."""
+ le texte de chaque nœud visité : une fois un choix fait, le texte du
+ nœud suivant REMPLACE le précédent (les boutons de choix disparaissent
+ avec lui) — volontairement PAS le comportement du Quiz, où la
+ question resterait affichée à côté d'un encart de feedback séparé.
+ Aucune notion de bonne/mauvaise réponse ici (retour utilisateur du
+ 20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe
+ les conséquences") — un nœud sans choix est simplement une fin de
+ branche. Réutilise les classes visuelles du Quiz
+ (.docQuizOptions/.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'
'
diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md
index 4e635d8d..527b8c37 100644
--- a/document_engine/rendering/rendering.md
+++ b/document_engine/rendering/rendering.md
@@ -102,25 +102,35 @@ regroupement à chaque appel.
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
au moins un scénario existe, s'y ajoute (fonction privée
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
- l'apprenant lit la situation, choisit une option, puis la conséquence
- de SON choix REMPLACE l'affichage de la situation (les boutons de
- choix disparaissent avec elle) avant de passer au scénario suivant —
- volontairement PAS le comportement du Quiz, où la question resterait
- affichée à côté d'un encart de feedback séparé (retour utilisateur du
- 20/09/2026 : "ce n'est pas un quiz, la conséquence s'affiche à la
- place de la situation précédente"). Les scénarios gardent l'ORDRE
- d'écriture du créateur (jamais mélangés, contrairement à
- Association/Memory/Mots mêlés — ce sont des mises en situation
+ un scénario est un ARBRE DE DÉCISION (voir `scenario_config.py` pour la
+ forme exacte — `nodes[0]` = situation initiale, chaque choix pointe
+ vers un autre nœud via `target_id`, un nœud sans choix est une fin de
+ branche), pas une simple question à une seule conséquence. 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 choisit une option, le texte du nœud visé
+ REMPLACE l'affichage du nœud précédent (les boutons de choix
+ disparaissent avec lui), et ainsi de suite jusqu'à une fin de branche
+ — volontairement PAS le comportement du Quiz, où la question resterait
+ affichée à côté d'un encart de feedback séparé. Une fois une fin de
+ branche atteinte, passage au scénario (arbre) suivant. Les scénarios
+ gardent l'ORDRE d'écriture du créateur (jamais mélangés, contrairement
+ à Association/Memory/Mots mêlés — ce sont des mises en situation
séquentielles, pas des éléments à faire correspondre/retrouver).
Réutilise les classes visuelles du Quiz (`.docQuizOptions`/
- `.docQuizFeedback`/`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que
- de dupliquer ces règles. Embarqué en
- JSON dans un attribut `data-scenario-config`, échappé pour l'HTML —
- même principe que les autres mini-jeux, aucun aller-retour serveur
- pendant qu'on joue. Comme l'Association/Memory/Mots mêlés (mais
- contrairement au Quiz), le dernier scénario reste affiché une fois
- répondu : seul le bouton "Recommencer" (`.docMinigameRestartBar`)
- apparaît.
+ `.docQuizNextBar`/`.docQuizQuestionText`) plutôt que de dupliquer ces
+ règles. L'arbre complet (tous les nœuds/choix de tous les scénarios)
+ est embarqué en JSON dans un attribut `data-scenario-config`, échappé
+ pour l'HTML — même principe que les autres mini-jeux, aucun
+ aller-retour serveur pendant qu'on joue : toute la navigation dans
+ l'arbre se fait côté client. Comme l'Association/Memory/Mots mêlés
+ (mais contrairement au Quiz), le dernier scénario reste affiché une
+ fois une fin de branche atteinte : seul le bouton "Recommencer"
+ (`.docMinigameRestartBar`) apparaît. L'arbre lui-même se construit
+ dans une MODALE dédiée depuis le panneau Propriétés (voir
+ `forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
+ — trop de structure (nœuds + choix + destinations) pour la colonne
+ étroite du panneau Propriétés, contrairement aux autres mini-jeux.
- **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé
du type (voir `document_engine/labels/element_kind_labels.py`) —
emplacement réservé, formulaire de contenu dédié hors périmètre de
diff --git a/static/document/document-editor.css b/static/document/document-editor.css
index 3466b22d..b5f670ed 100644
--- a/static/document/document-editor.css
+++ b/static/document/document-editor.css
@@ -1398,6 +1398,77 @@ img.docImage {
padding: 40px;
}
+/* ---- Modale générique (utilisée aujourd'hui par l'éditeur d'arbre du
+ mini-jeu Scénario, voir forgeDocOpenScenarioTreeModal) — overlay +
+ boîte de dialogue centrée, indépendante de tout framework externe
+ (aucune dépendance partagée avec game_engine, voir CLAUDE.md). ---- */
+.docModalBackdrop {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgb(11 14 20 / 65%);
+ z-index: 200;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+}
+
+.docModalBackdrop.is-open {
+ display: flex;
+}
+
+.docModalDialog {
+ background: var(--doc-bg-2);
+ border: 1px solid var(--doc-border);
+ border-radius: 16px;
+ width: min(640px, 100%);
+ max-height: min(80vh, 720px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.docModalHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--doc-border);
+}
+
+.docModalTitle {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--doc-text);
+}
+
+.docModalCloseBtn {
+ border: none;
+ background: transparent;
+ color: var(--doc-muted);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 4px;
+}
+
+.docModalCloseBtn:hover {
+ color: var(--doc-text);
+}
+
+.docModalBody {
+ padding: 20px;
+ overflow-y: auto;
+}
+
+/* ---- Scénario : panneau Propriétés — une carte par scénario (titre +
+ nombre de nœuds + bouton d'ouverture de la modale), l'édition de
+ l'arbre lui-même se fait entièrement dans la modale (voir
+ forgeDocRenderScenarioTreeModal). ---- */
+.docScenarioCard .docBtnSecondary {
+ width: 100%;
+ justify-content: center;
+}
+
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
@media (width <= 900px) {
.docCrumbDim {
diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js
index 6b8d87b7..2c6c90f3 100644
--- a/static/document/js/document-editor.js
+++ b/static/document/js/document-editor.js
@@ -1138,15 +1138,22 @@ function forgeDocInitMotsPlayers() {
/* ---------------------------------------------------------------------
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
- * l'apprenant lit une situation, choisit une option, découvre la
- * conséquence de SON choix ainsi que si c'était le bon, puis passe au
- * scénario suivant. Les scénarios se jouent dans l'ORDRE d'écriture
- * (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés) :
- * ce sont des mises en situation séquentielles. Comme ces trois
- * mini-jeux, le dernier scénario reste affiché une fois répondu : seul
- * le bouton Recommencer (.docMinigameRestartBar) apparaît, jamais un
- * écran de résultat séparé (ça reste le comportement du Quiz). État
- * tenu en mémoire, jamais persisté.
+ * un scénario est un ARBRE DE DÉCISION (voir document_engine/labels/
+ * scenario_config.py pour la forme exacte), pas une simple question à
+ * une seule conséquence. L'apprenant lit le texte du nœud courant
+ * (situation initiale, puis conséquences), choisit une option, et le
+ * texte du nœud visé REMPLACE l'affichage du nœud précédent (les
+ * boutons de choix disparaissent avec lui) — et ainsi de suite jusqu'à
+ * une fin de branche (nœud sans choix). 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"). Les scénarios
+ * (plusieurs arbres indépendants) se jouent dans l'ORDRE d'écriture
+ * (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés).
+ * Comme ces trois mini-jeux, le dernier arbre reste affiché une fois une
+ * fin de branche atteinte : seul le bouton Recommencer
+ * (.docMinigameRestartBar) apparaît, jamais un écran de résultat séparé
+ * (ça reste le comportement du Quiz). État tenu en mémoire, jamais
+ * persisté.
* ------------------------------------------------------------------- */
function forgeDocScenarioPlayerData(playerEl) {
@@ -1158,54 +1165,12 @@ function forgeDocScenarioPlayerData(playerEl) {
}
}
-function forgeDocRenderScenarioPlayerScenario(playerEl, state) {
- state.answered = false;
- const item = state.scenarios[state.current];
- const situationEl = playerEl.querySelector('.docScenarioSituation');
- const choicesEl = playerEl.querySelector('.docScenarioChoices');
- const progEl = playerEl.querySelector('.docScenarioProg');
- const nextBtn = playerEl.querySelector('.docScenarioNextBtn');
-
- // Repart d'un bloc "situation" neutre (jamais un résidu des classes
- // is-visible/is-ok/is-ko posées par une réponse précédente, voir
- // forgeDocScenarioPlayerAnswer ci-dessous).
- situationEl.className = 'docScenarioSituation docQuizQuestionText';
- situationEl.textContent = item.situation;
- choicesEl.innerHTML = '';
- nextBtn.classList.remove('is-visible');
- playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
- item.choices.forEach((choice, idx) => {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'docQuizOption docScenarioChoice';
- btn.textContent = choice.text;
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- forgeDocScenarioPlayerAnswer(playerEl, state, idx);
- });
- choicesEl.appendChild(btn);
- });
- progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length}`;
+function forgeDocScenarioFindNode(tree, nodeId) {
+ return tree.nodes.find((n) => n.id === nodeId) || tree.nodes[0];
}
-function forgeDocScenarioPlayerAnswer(playerEl, state, idx) {
- if (state.answered) return;
- state.answered = true;
- const item = state.scenarios[state.current];
- const situationEl = playerEl.querySelector('.docScenarioSituation');
- const choicesEl = playerEl.querySelector('.docScenarioChoices');
+function forgeDocScenarioReachEnd(playerEl, state) {
const isLast = state.current === state.scenarios.length - 1;
- const isCorrect = idx === item.correct_index;
- const consequenceText = item.choices[idx].consequence || 'Aucune conséquence renseignée pour ce choix.';
-
- // Ce n'est pas un Quiz : la conséquence REMPLACE la situation (le récit
- // avance), et les boutons de choix disparaissent avec elle — jamais un
- // encart de feedback séparé pendant que la situation resterait affichée
- // (retour utilisateur du 20/09/2026).
- situationEl.className = `docScenarioSituation docQuizFeedback is-visible ${isCorrect ? 'is-ok' : 'is-ko'}`;
- situationEl.textContent = consequenceText;
- choicesEl.innerHTML = '';
-
if (isLast) {
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
} else {
@@ -1213,24 +1178,71 @@ function forgeDocScenarioPlayerAnswer(playerEl, state, idx) {
}
}
+function forgeDocScenarioPlayerChoose(playerEl, state, choice) {
+ const tree = state.scenarios[state.current];
+ if (choice.target_id && tree.nodes.some((n) => n.id === choice.target_id)) {
+ state.currentNodeId = choice.target_id;
+ forgeDocRenderScenarioNode(playerEl, state);
+ return;
+ }
+ // Choix sans destination valide (fin de branche déclarée sur CE choix,
+ // ou référence cassée dégradée côté serveur — voir
+ // sanitize_scenario_config) : le texte du nœud courant reste affiché
+ // tel quel, seuls les boutons de choix disparaissent.
+ playerEl.querySelector('.docScenarioChoices').innerHTML = '';
+ forgeDocScenarioReachEnd(playerEl, state);
+}
+
+function forgeDocRenderScenarioNode(playerEl, state) {
+ const tree = state.scenarios[state.current];
+ const node = forgeDocScenarioFindNode(tree, state.currentNodeId);
+ const situationEl = playerEl.querySelector('.docScenarioSituation');
+ const choicesEl = playerEl.querySelector('.docScenarioChoices');
+ const progEl = playerEl.querySelector('.docScenarioProg');
+
+ situationEl.textContent = node.text;
+ choicesEl.innerHTML = '';
+ playerEl.querySelector('.docScenarioNextBtn').classList.remove('is-visible');
+ playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
+ progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length} — ${tree.title}`;
+
+ if (!node.choices.length) {
+ forgeDocScenarioReachEnd(playerEl, state);
+ return;
+ }
+ node.choices.forEach((choice) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'docQuizOption docScenarioChoice';
+ btn.textContent = choice.text;
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ forgeDocScenarioPlayerChoose(playerEl, state, choice);
+ });
+ choicesEl.appendChild(btn);
+ });
+}
+
function forgeDocScenarioPlayerNext(playerEl, state) {
state.current += 1;
if (state.current < state.scenarios.length) {
- forgeDocRenderScenarioPlayerScenario(playerEl, state);
+ state.currentNodeId = state.scenarios[state.current].nodes[0].id;
+ forgeDocRenderScenarioNode(playerEl, state);
}
}
function forgeDocScenarioRestart(playerEl, state) {
state.current = 0;
- forgeDocRenderScenarioPlayerScenario(playerEl, state);
+ state.currentNodeId = state.scenarios[0].nodes[0].id;
+ forgeDocRenderScenarioNode(playerEl, state);
}
function forgeDocInitScenarioPlayers() {
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
const scenarios = forgeDocScenarioPlayerData(playerEl);
- if (!scenarios.length) return;
- const state = { current: 0, answered: false, scenarios };
- forgeDocRenderScenarioPlayerScenario(playerEl, state);
+ if (!scenarios.length || !scenarios[0].nodes || !scenarios[0].nodes.length) return;
+ const state = { current: 0, scenarios, currentNodeId: scenarios[0].nodes[0].id };
+ forgeDocRenderScenarioNode(playerEl, state);
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocScenarioPlayerNext(playerEl, state);
@@ -1844,75 +1856,51 @@ function forgeDocRenderMotsProps(panel, el) {
});
}
+/* ---------------------------------------------------------------------
+ * Scénario — panneau Propriétés : une carte par scénario (titre + nombre
+ * de nœuds), l'arbre lui-même se construit dans une MODALE dédiée (trop
+ * de structure — nœuds + choix + destinations — pour la colonne étroite
+ * du panneau Propriétés, contrairement aux autres mini-jeux). Voir
+ * forgeDocOpenScenarioTreeModal ci-dessous.
+ * ------------------------------------------------------------------- */
+
function forgeDocScenarioNewScenario() {
- // Champs pré-remplis (jamais vides) : sanitize_scenario_config
- // (routes/document/document_element_update.py) rejette silencieusement
- // tout scénario dont la situation est vide, ou tout choix dont le
- // texte est vide — même leçon que forgeDocAssociationNewPair/
- // forgeDocMemoryNewCard/l'ajout de mot des Mots mêlés.
+ // Titre et situation initiale pré-remplis (jamais vides) :
+ // sanitize_scenario_config (routes/document/document_element_update.py)
+ // rejette silencieusement tout scénario dont le titre est vide, ou
+ // tout nœud dont le texte est vide — même leçon que
+ // forgeDocAssociationNewPair/forgeDocMemoryNewCard/l'ajout de mot des
+ // Mots mêlés.
return {
- situation: 'Nouvelle situation',
- choices: [
- { text: 'Choix 1', consequence: 'Conséquence du choix 1' },
- { text: 'Choix 2', consequence: 'Conséquence du choix 2' },
- ],
- correct_index: 0,
+ title: 'Nouveau scénario',
+ nodes: [{ id: 'n1', text: 'Nouvelle situation', choices: [] }],
};
}
-function forgeDocRenderScenarioChoiceHtml(choice, sIndex, cIndex, correctIndex) {
- return `
-
-
-
-
-
-
-
- `;
+function forgeDocScenarioNewNode(id) {
+ return { id, text: 'Nouveau nœud', choices: [] };
}
-function forgeDocRenderScenarioHtml(scenario, sIndex) {
- const choicesHtml = scenario.choices
- .map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index))
- .join('');
+function forgeDocScenarioGenerateNodeId(nodes) {
+ // Un id JAMAIS réutilisé, même après suppression d'un nœud (sinon un
+ // choix existant pointant vers un ancien id supprimé pourrait se
+ // retrouver à pointer vers le NOUVEAU nœud qui hérite du même id, un
+ // lien créé par erreur plutôt qu'une vraie fin de branche).
+ const used = nodes.map((n) => Number((n.id.match(/^n(\d+)$/) || [])[1])).filter((n) => !Number.isNaN(n));
+ const next = (used.length ? Math.max(...used) : 0) + 1;
+ return `n${next}`;
+}
+
+function forgeDocRenderScenarioCardHtml(scenario, sIndex) {
+ const nodeCount = scenario.nodes.length;
return `
${forgeDocDeleteButtonHtml()}
`;
@@ -1956,65 +1944,195 @@ function forgeDocRenderScenarioProps(panel, el) {
});
});
- panel.querySelectorAll('.docScenarioSituationInput').forEach((input) => {
- input.addEventListener('change', (e) => {
- const sIndex = Number(input.dataset.scenarioIndex);
- patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, situation: e.target.value } : s)) });
+ panel.querySelectorAll('.docScenarioEditTreeBtn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const sIndex = Number(btn.dataset.scenarioIndex);
+ forgeDocOpenScenarioTreeModal(el.id, sIndex);
+ });
+ });
+}
+
+/* ---------------------------------------------------------------------
+ * Scénario — modale de construction de l'arbre : une carte par nœud
+ * (texte + liste de choix), chaque choix a un menu déroulant "mène à"
+ * listant les autres nœuds du même scénario (ou "— Fin de branche —").
+ * La situation initiale (nodes[0]) n'est jamais supprimable depuis ici.
+ * Persistance identique aux autres panneaux : chaque modification
+ * envoie l'état COMPLET du scénario au serveur (voir patch ci-dessous),
+ * jamais un état simulé côté client qui pourrait diverger de ce que
+ * sanitize_scenario_config a réellement accepté.
+ * ------------------------------------------------------------------- */
+
+function forgeDocRenderScenarioNodeHtml(node, nIndex, allNodes, isRoot) {
+ const choicesHtml = node.choices.map((choice, cIndex) => `
+