diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md
index e85b0e9a..07ebc6df 100644
--- a/CODE_QUALITY.md
+++ b/CODE_QUALITY.md
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| Site(s) | Outil / règle | Raison | Contexte |
|---|---|---|---|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
-| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×3 : `_render_association_player` ×2, `_render_memory_player` ×1 — ajoutés le 20/09/2026) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association, mélange des cartes du mini-jeu Memory) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 |
+| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×7 : `_render_association_player` ×2, `_render_memory_player` ×1, `_mots_place_word` ×3, `_mots_attempt_placement` ×1 — les 3 derniers ajoutés le 20/09/2026 pour le mini-jeu Mots mêlés) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association, mélange des cartes du mini-jeu Memory, direction/position de placement + lettre de remplissage de la grille du mini-jeu Mots mêlés) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 |
| `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
diff --git a/document_engine/__init__.py b/document_engine/__init__.py
index 3a866686..be75403d 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -48,6 +48,12 @@ from .labels.memory_config import (
MIN_CARDS,
sanitize_memory_config,
)
+from .labels.mots_config import (
+ DEFAULT_MOTS_CONFIG,
+ MAX_WORDS,
+ MIN_WORDS,
+ sanitize_mots_config,
+)
from .labels.quiz_config import (
DEFAULT_QUIZ_CONFIG,
MAX_CHOICES,
@@ -64,6 +70,7 @@ __all__ = [
"CONTENT_KINDS",
"DEFAULT_ASSOCIATION_CONFIG",
"DEFAULT_MEMORY_CONFIG",
+ "DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
@@ -71,10 +78,12 @@ __all__ = [
"MAX_CHOICES",
"MAX_PAIRS",
"MAX_TIMER_SECONDS",
+ "MAX_WORDS",
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
"MIN_TIMER_SECONDS",
+ "MIN_WORDS",
"MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element",
@@ -88,6 +97,7 @@ __all__ = [
"render_document_element",
"sanitize_association_config",
"sanitize_memory_config",
+ "sanitize_mots_config",
"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 8a6271c5..fb6d4768 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -7,6 +7,7 @@ from typing import Any
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
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
@@ -90,6 +91,9 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "memory":
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
+ 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 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 6fd9efd7..3876f5bc 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -139,3 +139,43 @@ carte invalide est silencieusement supprimée de la liste. La liste finale
est tronquée à `MAX_CARDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
- **Exceptions** : aucune.
+
+## `mots_config.py` — modèle de données du mini-jeu Mots mêlés
+
+Quatrième mini-jeu implémenté : l'apprenant retrouve chaque mot caché
+dans une grille de lettres (horizontalement, verticalement, ou en
+diagonale — voir `document_engine/rendering/render_document_element.py`::
+`_render_mots_player`, qui construit la grille elle-même). Même
+convention que `quiz_config.py`.
+
+### `MIN_WORDS`, `MAX_WORDS: int`
+Bornes de validation (`5`/`10` mots). `MIN_WORDS` n'est pas imposé par
+`sanitize_mots_config` (même logique que `MIN_PAIRS`/`MIN_CARDS`
+côté Association/Memory) — recommandation pour le panneau Propriétés,
+pas une contrainte technique du rendu : un seul mot valide reste
+accepté, la grille se construit quand même autour de lui.
+
+### `MIN_WORD_LENGTH`, `MAX_WORD_LENGTH: int`
+Bornes de longueur d'un mot individuel une fois nettoyé (`2`/`20`
+lettres) — un mot trop court n'a pas de sens à chercher, un mot trop
+long compliquerait inutilement le calcul de la taille de la grille
+(voir `_mots_grid_size_for_words` dans `render_document_element.py`).
+
+### `DEFAULT_MOTS_CONFIG: dict[str, Any]`
+`{"theme_color": "#ff5f2e", "words": []}`.
+
+### `sanitize_mots_config(raw_config: Any) -> dict[str, Any]`
+Valide/nettoie une config de mots mêlés arbitraire (JSON venu du
+client) — jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
+`DEFAULT_MOTS_CONFIG`. Chaque mot de `raw_config["words"]` est nettoyé
+indépendamment (voir `_sanitize_word`, privée) : mis en MAJUSCULES,
+les accents sont retirés (décomposition NFKD + filtrage ASCII, pour que
+deux mots qui se croisent sur une même case de la grille puissent
+partager exactement la même lettre), seules les lettres sont
+conservées. Un mot qui ne contient plus rien d'exploitable (ou moins de
+`MIN_WORD_LENGTH` lettres) une fois nettoyé est silencieusement
+supprimé de la liste, de même qu'un doublon exact d'un mot déjà retenu
+(un mot répété deux fois n'aurait rien de plus à faire trouver). La
+liste finale est tronquée à `MAX_WORDS`.
+- **Retour** : dict complet (mêmes clés que `DEFAULT_MOTS_CONFIG`).
+- **Exceptions** : aucune.
diff --git a/document_engine/labels/mots_config.py b/document_engine/labels/mots_config.py
new file mode 100644
index 00000000..1dc0d570
--- /dev/null
+++ b/document_engine/labels/mots_config.py
@@ -0,0 +1,59 @@
+"""Modèle de données du mini-jeu Mots mêlés (voir docs/plan/PLAN.md §3.2)
+— l'apprenant retrouve chaque mot caché dans une grille de lettres, placé
+horizontalement, verticalement, ou en diagonale (haut-gauche vers
+bas-droite, ou haut-droite vers bas-gauche), jamais à l'envers ni en
+diagonale inversée (voir document_engine/rendering/render_document_element.py
+::_render_mots_player, qui construit la grille elle-même). Même
+convention resolve_X/sanitize_X que quiz_config.py/association_config.py/
+memory_config.py (aucun import croisé)."""
+
+import unicodedata
+from typing import Any
+
+MIN_WORDS = 5
+MAX_WORDS = 10
+MIN_WORD_LENGTH = 2
+MAX_WORD_LENGTH = 20
+
+DEFAULT_MOTS_CONFIG: dict[str, Any] = {
+ "theme_color": "#ff5f2e",
+ "words": [],
+}
+
+
+def _sanitize_word(raw: Any) -> str | None:
+ """None si, une fois nettoyé, il ne reste aucune lettre exploitable —
+ filtré par sanitize_mots_config plutôt que de faire échouer toute la
+ grille, même convention que quiz_config.py::_sanitize_question. Les
+ accents sont retirés (décomposition NFKD puis filtrage ASCII) : deux
+ mots qui se croisent sur une même case doivent pouvoir partager
+ exactement la même lettre, ce qu'un "É" et un "E" ne permettraient
+ pas."""
+ if not isinstance(raw, str):
+ return None
+ decomposed = unicodedata.normalize("NFKD", raw.upper())
+ letters = "".join(ch for ch in decomposed if ch.isalpha() and ch.isascii())
+ if len(letters) < MIN_WORD_LENGTH:
+ return None
+ return letters[:MAX_WORD_LENGTH]
+
+
+def sanitize_mots_config(raw_config: Any) -> dict[str, Any]:
+ config = dict(DEFAULT_MOTS_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_words = raw_config.get("words")
+ if isinstance(raw_words, list):
+ words: list[str] = []
+ seen: set[str] = set()
+ for item in raw_words:
+ word = _sanitize_word(item)
+ if word is None or word in seen:
+ continue
+ seen.add(word)
+ words.append(word)
+ config["words"] = words[:MAX_WORDS]
+ return config
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index a25f1ca9..507d4010 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -312,6 +312,152 @@ def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, lis
)
+_MOTS_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+_MOTS_DIRECTIONS = (
+ (0, 1), # horizontale, gauche -> droite
+ (1, 0), # verticale, haut -> bas
+ (1, 1), # diagonale droite, haut-gauche -> bas-droite
+ (1, -1), # diagonale gauche, haut-droite -> bas-gauche
+)
+_MOTS_MAX_PLACEMENT_ATTEMPTS = 200
+_MOTS_MAX_GRID_GROWTH_ATTEMPTS = 20
+
+
+def _mots_grid_size_for_words(words: list[str]) -> int:
+ """Taille de départ de la grille (carrée) — assez grande pour loger le
+ plus long mot ET laisser assez de cases libres pour le remplissage
+ aléatoire, sans grille disproportionnée pour une courte liste de mots.
+ _build_mots_grid grandit cette taille si le placement échoue malgré
+ tout (mots qui se contraignent mutuellement), donc une estimation
+ approximative suffit ici."""
+ from ..labels.mots_config import MIN_WORD_LENGTH
+
+ longest = max((len(w) for w in words), default=MIN_WORD_LENGTH)
+ total_letters = sum(len(w) for w in words)
+ return max(longest, 8, int(total_letters**0.5) + 2)
+
+
+def _mots_can_place_word(
+ grid: list[list[str]], word: str, row: int, col: int, delta_row: int, delta_col: int, size: int
+) -> bool:
+ for i, letter in enumerate(word):
+ r, c = row + i * delta_row, col + i * delta_col
+ if not (0 <= r < size and 0 <= c < size):
+ return False
+ if grid[r][c] not in ("", letter):
+ return False
+ return True
+
+
+def _mots_place_word(grid: list[list[str]], word: str, size: int) -> list[list[int]] | None:
+ """None si aucun emplacement libre n'a été trouvé après le nombre
+ d'essais autorisé — laisse l'appelant décider (agrandir la grille et
+ tout retenter, voir _build_mots_grid) plutôt que de placer le mot de
+ force en écrasant des lettres déjà posées."""
+ for _ in range(_MOTS_MAX_PLACEMENT_ATTEMPTS):
+ delta_row, delta_col = random.choice(_MOTS_DIRECTIONS) # nosec B311 # noqa: S311 - placement de mot, jamais crypto # NOSONAR python:S2245
+ row = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
+ col = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
+ if not _mots_can_place_word(grid, word, row, col, delta_row, delta_col, size):
+ continue
+ cells = []
+ for i, letter in enumerate(word):
+ r, c = row + i * delta_row, col + i * delta_col
+ grid[r][c] = letter
+ cells.append([r, c])
+ return cells
+ return None
+
+
+def _mots_attempt_placement(words: list[str], size: int) -> dict[str, Any] | None:
+ grid: list[list[str]] = [["" for _ in range(size)] for _ in range(size)]
+ placed_words = []
+ # Les mots les plus longs sont placés en premier : ce sont les plus
+ # difficiles à caser, autant le faire tant que la grille est encore
+ # majoritairement libre.
+ for word in sorted(words, key=len, reverse=True):
+ cells = _mots_place_word(grid, word, size)
+ if cells is None:
+ return None
+ placed_words.append({"text": word, "cells": cells})
+ for r in range(size):
+ for c in range(size):
+ if not grid[r][c]:
+ grid[r][c] = random.choice(_MOTS_ALPHABET) # nosec B311 # noqa: S311 - lettre de remplissage, jamais crypto # NOSONAR python:S2245
+ return {"size": size, "grid": grid, "words": placed_words}
+
+
+def _build_mots_grid(words: list[str]) -> dict[str, Any]:
+ """Construit la grille ET la position exacte de chaque mot (jamais
+ recalculée côté client, voir _render_mots_player) : une grille carrée,
+ chaque mot placé horizontalement/verticalement/en diagonale (deux sens
+ de diagonale seulement, jamais à l'envers — voir _MOTS_DIRECTIONS),
+ les cases restantes remplies de lettres aléatoires. Si un mot ne
+ trouve pas sa place (mots qui se contraignent mutuellement), la
+ grille entière est agrandie et le placement retenté depuis zéro,
+ plutôt que d'abandonner silencieusement ce mot."""
+ if not words:
+ return {"size": 0, "grid": [], "words": []}
+ size = _mots_grid_size_for_words(words)
+ for _ in range(_MOTS_MAX_GRID_GROWTH_ATTEMPTS):
+ result = _mots_attempt_placement(words, size)
+ if result is not None:
+ return result
+ size += 2
+ # Filet de sécurité théorique : avec MAX_WORDS=10 mots de
+ # MAX_WORD_LENGTH=20 lettres au plus, la grille finit toujours par
+ # être assez grande pour tous les loger bien avant cette limite.
+ return _mots_attempt_placement(words, size) or {"size": size, "grid": [], "words": []}
+
+
+def _render_mots_player(config: dict[str, Any]) -> str:
+ """Grille de mots mêlés RÉELLEMENT interactive — affichée uniquement
+ en Mode Aperçu, même principe que les autres mini-jeux : la grille ET
+ la position exacte de chaque mot sont calculées ICI côté serveur
+ (_build_mots_grid, jamais recalculées côté client) puis embarquées en
+ JSON ; static/document/js/document-editor.js compare les coordonnées
+ de la sélection de l'apprenant aux coordonnées exactes de chaque mot
+ — jamais une simple comparaison de texte, qui se tromperait sur des
+ lettres partagées entre deux mots qui se croisent."""
+ built = _build_mots_grid(config["words"])
+ config_json = html_lib.escape(json.dumps(built), quote=True)
+ return (
+ f'