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 `
- Scénario ${sIndex + 1} + ${forgeDocEscapeHtml(scenario.title)}
-
- -
-
- - -
-
${choicesHtml}
+
${nodeCount} nœud${nodeCount > 1 ? 's' : ''}
+
`; } @@ -1936,7 +1924,7 @@ function forgeDocRenderScenarioProps(panel, el) {
Scénarios
-
${scenarios.length ? scenarios.map(forgeDocRenderScenarioHtml).join('') : '
Aucun scénario — ajoute le premier ci-dessous.
'}
+
${scenarios.length ? scenarios.map(forgeDocRenderScenarioCardHtml).join('') : '
Aucun scénario — ajoute le premier ci-dessous.
'}
${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) => ` +
+
+ + +
+
+ + +
+
+ `).join(''); + + return ` +
+
+ ${isRoot ? 'Situation initiale' : `Nœud ${nIndex + 1}`} + ${isRoot ? '' : ``} +
+
+ +
+
Choix
+
${choicesHtml || '
Aucun choix — fin de cette branche.
'}
+ +
+ `; +} + +function forgeDocRenderScenarioTreeModal(elementId, sIndex) { + const el = window.forgeDocState.elementsById[elementId]; + const scenario = el && (el.attributes.scenarios || [])[sIndex]; + if (!scenario) { + forgeDocCloseScenarioTreeModal(); + return; + } + const body = document.getElementById('docScenarioTreeModalBody'); + document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`; + + function patch(partial) { + const scenarios = el.attributes.scenarios.map((s, i) => (i === sIndex ? { ...s, ...partial } : s)); + forgeDocUpdateAttributes(elementId, { ...el.attributes, scenarios }).then(() => { + forgeDocRenderScenarioTreeModal(elementId, sIndex); + // Le panneau Propriétés affiche le nombre de nœuds par carte : + // le tenir synchronisé pendant que la modale reste ouverte. + if (window.forgeDocState.selectedId === elementId) { + forgeDocRenderProps(window.forgeDocState.elementsById[elementId]); + } + }); + } + + body.innerHTML = ` +
+ + +
+
${scenario.nodes.map((n, i) => forgeDocRenderScenarioNodeHtml(n, i, scenario.nodes, i === 0)).join('')}
+ + `; + + document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => patch({ title: e.target.value })); + + body.querySelector('.docScenarioTreeAddNodeBtn').addEventListener('click', () => { + const newId = forgeDocScenarioGenerateNodeId(scenario.nodes); + patch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId)] }); + }); + + body.querySelectorAll('.docScenarioTreeNodeRemoveBtn').forEach((btn) => { + btn.addEventListener('click', () => { + const nIndex = Number(btn.dataset.nodeIndex); + const removedId = scenario.nodes[nIndex].id; + // Un choix qui pointait vers ce nœud devient "fin de branche" + // (jamais une référence morte) — même filet de sécurité que + // sanitize_scenario_config côté serveur. + const nodes = scenario.nodes + .filter((_, i) => i !== nIndex) + .map((n) => ({ + ...n, + choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)), + })); + patch({ nodes }); }); }); - panel.querySelectorAll('.docScenarioChoiceCountInput').forEach((input) => { + body.querySelectorAll('.docScenarioTreeTextInput').forEach((input) => { input.addEventListener('change', (e) => { - const sIndex = Number(input.dataset.scenarioIndex); - const count = Math.max(2, Math.min(4, Number(e.target.value) || 2)); - patch({ - scenarios: scenarios.map((s, i) => { - if (i !== sIndex) return s; - const choices = s.choices.slice(0, count); - while (choices.length < count) { - choices.push({ text: `Choix ${choices.length + 1}`, consequence: '' }); - } - return { ...s, choices, correct_index: s.correct_index < count ? s.correct_index : 0 }; - }), - }); + const nIndex = Number(input.dataset.nodeIndex); + patch({ nodes: scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n)) }); }); }); - panel.querySelectorAll('.docScenarioChoiceInput').forEach((input) => { - input.addEventListener('change', (e) => { - const sIndex = Number(input.dataset.scenarioIndex); - const cIndex = Number(input.dataset.choiceIndex); + body.querySelectorAll('.docScenarioTreeAddChoiceBtn').forEach((btn) => { + btn.addEventListener('click', () => { + const nIndex = Number(btn.dataset.nodeIndex); patch({ - scenarios: scenarios.map((s, i) => ( - i === sIndex - ? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) } - : s + nodes: scenario.nodes.map((n, i) => ( + i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n )), }); }); }); - panel.querySelectorAll('.docScenarioConsequenceInput').forEach((input) => { - input.addEventListener('change', (e) => { - const sIndex = Number(input.dataset.scenarioIndex); - const cIndex = Number(input.dataset.choiceIndex); + body.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => { + btn.addEventListener('click', () => { + const nIndex = Number(btn.dataset.nodeIndex); + const cIndex = Number(btn.dataset.choiceIndex); patch({ - scenarios: scenarios.map((s, i) => ( - i === sIndex - ? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, consequence: e.target.value } : c)) } - : s + nodes: scenario.nodes.map((n, i) => ( + i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n )), }); }); }); - panel.querySelectorAll('.docScenarioCorrectRadio').forEach((radio) => { - radio.addEventListener('change', () => { - const sIndex = Number(radio.dataset.scenarioIndex); - const cIndex = Number(radio.dataset.choiceIndex); - patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, correct_index: cIndex } : s)) }); + body.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => { + input.addEventListener('change', (e) => { + const nIndex = Number(input.dataset.nodeIndex); + const cIndex = Number(input.dataset.choiceIndex); + patch({ + nodes: scenario.nodes.map((n, i) => ( + i === nIndex + ? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) } + : n + )), + }); }); }); + + body.querySelectorAll('.docScenarioTreeTargetSelect').forEach((select) => { + select.addEventListener('change', (e) => { + const nIndex = Number(select.dataset.nodeIndex); + const cIndex = Number(select.dataset.choiceIndex); + const targetId = e.target.value || null; + patch({ + nodes: scenario.nodes.map((n, i) => ( + i === nIndex + ? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: targetId } : c)) } + : n + )), + }); + }); + }); +} + +function forgeDocOpenScenarioTreeModal(elementId, sIndex) { + document.getElementById('docScenarioTreeModal').classList.add('is-open'); + forgeDocRenderScenarioTreeModal(elementId, sIndex); +} + +function forgeDocCloseScenarioTreeModal() { + document.getElementById('docScenarioTreeModal').classList.remove('is-open'); +} + +function forgeDocBindScenarioTreeModal() { + document.getElementById('docScenarioTreeModalClose').addEventListener('click', forgeDocCloseScenarioTreeModal); + document.getElementById('docScenarioTreeModal').addEventListener('click', (e) => { + if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') forgeDocCloseScenarioTreeModal(); + }); } function forgeDocRenderProps(el) { @@ -2173,6 +2291,7 @@ function forgeDocInit() { forgeDocBindThemeToggle(); forgeDocBindMobileNav(); forgeDocBindKeyboardShortcuts(); + forgeDocBindScenarioTreeModal(); forgeDocUpdateHistoryButtons(); document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo); diff --git a/templates/document/document_edit.html b/templates/document/document_edit.html index ba5b1058..56d8cb4f 100644 --- a/templates/document/document_edit.html +++ b/templates/document/document_edit.html @@ -150,6 +150,18 @@ +
+
+
+ Arbre du scénario + +
+
+
+
', - "choices": [{"text": "A"}, {"text": "B"}], + "title": "Titre", + "nodes": [{"id": "n1", "text": '">', "choices": []}], } ] }