diff --git a/document_engine/__init__.py b/document_engine/__init__.py
index f42eeca1..2495573c 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -35,12 +35,26 @@ from .labels.element_kind_labels import (
SHAPE_KINDS,
element_default_attributes,
)
+from .labels.quiz_config import (
+ DEFAULT_QUIZ_CONFIG,
+ MAX_CHOICES,
+ MAX_TIMER_SECONDS,
+ MIN_CHOICES,
+ MIN_TIMER_SECONDS,
+ quiz_total_points,
+ sanitize_quiz_config,
+)
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
"CONTENT_KINDS",
+ "DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
+ "MAX_CHOICES",
+ "MAX_TIMER_SECONDS",
+ "MIN_CHOICES",
+ "MIN_TIMER_SECONDS",
"MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element",
@@ -49,7 +63,9 @@ __all__ = [
"get_document_element",
"list_document_elements",
"move_document_element",
+ "quiz_total_points",
"render_document",
"render_document_element",
+ "sanitize_quiz_config",
"update_document_element_attributes",
]
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index 2b599a27..2bf0e4aa 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -5,6 +5,8 @@ défaut posés à la création de chaque type."""
from typing import Any
+from .quiz_config import DEFAULT_QUIZ_CONFIG
+
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
@@ -74,6 +76,14 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
return {"label": "Bouton", "target": ""}
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
+ if kind == "quiz":
+ # Seul mini-jeu implémenté pour l'instant (voir quiz_config.py) —
+ # les autres restent un simple emplacement réservé ci-dessous.
+ # Copie de "questions" (liste, jamais un simple dict(...) qui la
+ # partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant
+ # ne la mute en place aujourd'hui, mais la copier ici coûte rien et
+ # évite d'ancrer cette hypothèse fragile pour la suite.
+ return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])}
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 3d483b7c..cc49c519 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -13,8 +13,9 @@ toujours en position absolue (`parent_id = NULL`).
être top-niveau ou enfants d'une rangée.
## `MINIGAME_KINDS: tuple[str, ...]`
-`("quiz", "association", "memory", "mots", "scenario", "zones")` —
-panneau Propriétés minimal aujourd'hui (emplacement réservé), formulaires
+`("quiz", "association", "memory", "mots", "scenario", "zones")` — seul
+`"quiz"` est implémenté (voir `quiz_config.py` ci-dessous) ; les 5 autres
+gardent un panneau Propriétés minimal (emplacement réservé), formulaires
de contenu dédiés hors périmètre de cette passe.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
@@ -35,5 +36,39 @@ Attributs posés à la création d'un élément de ce type (voir
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
- mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
+ quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), autre mini-jeu
+ (`theme_color`), ou `{}` pour un `kind` inconnu.
+- **Exceptions** : aucune.
+
+## `quiz_config.py` — modèle de données du mini-jeu Quiz
+
+Seul mini-jeu réellement implémenté (les autres `MINIGAME_KINDS` restent
+un emplacement réservé). Même convention `resolve_X`/`sanitize_X` que
+`game_engine/rendering/quiz_box_config.py` côté jeu, mais sans aucun
+import croisé (voir `docs/plan/PLAN.md`).
+
+### `MIN_CHOICES`, `MAX_CHOICES`, `MIN_TIMER_SECONDS`, `MAX_TIMER_SECONDS`, `DEFAULT_TIMER_SECONDS: int`
+Bornes de validation (`2`/`4` choix, `5`/`300` secondes, `30` par défaut).
+
+### `DEFAULT_QUIZ_CONFIG: dict[str, Any]`
+`{"theme_color": "#ff5f2e", "timer_enabled": False, "timer_seconds": 30, "questions": []}`.
+
+### `sanitize_quiz_config(raw_config: Any) -> dict[str, Any]`
+Valide/nettoie une config de quiz arbitraire (JSON venu du client) —
+jamais lève, renvoie toujours un dict COMPLET fusionné sur
+`DEFAULT_QUIZ_CONFIG`. Chaque question de `raw_config["questions"]` est
+validée indépendamment (voir `_sanitize_question`, privée) : texte non
+vide, au moins 2 choix non vides (tronqués à 4), `correct_index` remis à
+0 s'il est absent/hors bornes/non-entier (un `bool` — qui est un `int` en
+Python — est explicitement rejeté), `points` codé en entier ≥ 0. Une
+question invalide est silencieusement supprimée de la liste (jamais une
+levée qui ferait échouer tout le reste du quiz).
+- **Retour** : dict complet (mêmes clés que `DEFAULT_QUIZ_CONFIG`).
+- **Exceptions** : aucune.
+
+### `quiz_total_points(config: dict[str, Any]) -> int`
+Somme du `points` de toutes les questions d'une config déjà sanitizée —
+mirroir de `db/dialogue_lines.py::sum_question_rewards` côté jeu, utile
+le jour où un export calculera un score maximum.
+- **Retour** : entier ≥ 0.
- **Exceptions** : aucune.
diff --git a/document_engine/labels/quiz_config.py b/document_engine/labels/quiz_config.py
new file mode 100644
index 00000000..5adadeb3
--- /dev/null
+++ b/document_engine/labels/quiz_config.py
@@ -0,0 +1,75 @@
+"""Modèle de données du mini-jeu Quiz (voir docs/plan/PLAN.md §3.2) — seul
+mini-jeu implémenté pour l'instant (les 5 autres kinds de MINIGAME_KINDS
+restent un simple emplacement réservé, voir element_kind_labels.py). Même
+convention resolve_X/sanitize_X que game_engine/rendering/quiz_box_config.py
+(distinct, aucun import croisé — voir docs/plan/PLAN.md, deux moteurs
+isolés) : sanitize_quiz_config est pure, ne lève jamais, et renvoie
+toujours un dict complet (jamais partiel) pour que le rendu puisse
+supposer chaque clé présente."""
+
+from typing import Any
+
+MIN_CHOICES = 2
+MAX_CHOICES = 4
+MIN_TIMER_SECONDS = 5
+MAX_TIMER_SECONDS = 300
+DEFAULT_TIMER_SECONDS = 30
+
+DEFAULT_QUIZ_CONFIG: dict[str, Any] = {
+ "theme_color": "#ff5f2e",
+ "timer_enabled": False,
+ "timer_seconds": DEFAULT_TIMER_SECONDS,
+ "questions": [],
+}
+
+
+def _sanitize_question(raw: Any) -> dict[str, Any] | None:
+ """None si la question est invalide (texte vide, moins de 2 choix non
+ vides) — filtrée par sanitize_quiz_config plutôt que de faire échouer
+ tout le quiz, même convention que db/dialogue_lines.py::
+ _sanitize_question_line côté jeu."""
+ if not isinstance(raw, dict):
+ return None
+ text = str(raw.get("text", "")).strip()
+ if not text:
+ return None
+ raw_choices = raw.get("choices")
+ if not isinstance(raw_choices, list):
+ return None
+ choices = [str(c).strip() for c in raw_choices if str(c).strip()][:MAX_CHOICES]
+ if len(choices) < MIN_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
+ try:
+ points = max(0, int(raw.get("points", 0)))
+ except (TypeError, ValueError):
+ points = 0
+ return {"text": text, "choices": choices, "correct_index": correct_index, "points": points}
+
+
+def sanitize_quiz_config(raw_config: Any) -> dict[str, Any]:
+ config = dict(DEFAULT_QUIZ_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
+ config["timer_enabled"] = bool(raw_config.get("timer_enabled"))
+ try:
+ timer_seconds = int(raw_config.get("timer_seconds", DEFAULT_TIMER_SECONDS))
+ except (TypeError, ValueError):
+ timer_seconds = DEFAULT_TIMER_SECONDS
+ config["timer_seconds"] = max(MIN_TIMER_SECONDS, min(MAX_TIMER_SECONDS, timer_seconds))
+ raw_questions = raw_config.get("questions")
+ if isinstance(raw_questions, list):
+ config["questions"] = [q for q in (_sanitize_question(item) for item in raw_questions) if q is not None]
+ return config
+
+
+def quiz_total_points(config: dict[str, Any]) -> int:
+ """Somme des points de toutes les questions — mirroir de
+ db/dialogue_lines.py::sum_question_rewards côté jeu, utile le jour où
+ un export calculera un score maximum."""
+ return sum(int(q.get("points", 0)) for q in config.get("questions", []))
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index 7d412315..89fe2195 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -136,6 +136,25 @@ def _render_minigame_placeholder(
)
+def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
+ from ..labels.quiz_config import quiz_total_points, sanitize_quiz_config
+
+ config = sanitize_quiz_config(el["attributes"])
+ theme_color = html_lib.escape(str(config["theme_color"]))
+ question_count = len(config["questions"])
+ question_label = "question" if question_count <= 1 else "questions"
+ total_points = quiz_total_points(config)
+ timer_note = f" · ⏱ {config['timer_seconds']}s/question" if config["timer_enabled"] else ""
+ subtitle = f"{question_count} {question_label} · {total_points} points{timer_note}"
+ return (
+ f'
'
+ f'Quiz'
+ f'{html_lib.escape(subtitle)}'
+ f"
"
+ )
+
+
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
return f'Type inconnu : {html_lib.escape(el["kind"])}
'
@@ -150,7 +169,7 @@ _RENDERERS = {
"paragraphe": _render_text,
"image": _render_image,
"bouton": _render_button,
- "quiz": _render_minigame_placeholder,
+ "quiz": _render_quiz,
"association": _render_minigame_placeholder,
"memory": _render_minigame_placeholder,
"mots": _render_minigame_placeholder,
diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md
index 8e5f308d..e0e62e4e 100644
--- a/document_engine/rendering/rendering.md
+++ b/document_engine/rendering/rendering.md
@@ -35,7 +35,11 @@ regroupement à chaque appel.
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `
`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `