Implemente le mini-jeu Mots meles (grille reelle, placement 4 directions)
Build and deploy / test-python (push) Failing after 1m0s
Build and deploy / test-js (push) Failing after 50s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s
Build and deploy / test-python (push) Failing after 1m0s
Build and deploy / test-js (push) Failing after 50s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s
Quatrieme mini-jeu du support de formation : le createur ecrit 5 a 10 mots (recommandation souple, comme MIN_PAIRS/MIN_CARDS), places par le serveur dans une grille carree horizontalement, verticalement, ou en diagonale (deux sens seulement, jamais a l'envers) via un vrai algorithme de placement avec retry/agrandissement de grille en cas de conflit. La grille ET la position exacte de chaque mot sont calculees cote serveur puis embarquees en JSON ; le client valide chaque selection (glisser ou cliquer-cliquer) par comparaison de coordonnees exactes, jamais une simple comparaison de texte (qui se tromperait sur des lettres partagees entre deux mots qui se croisent). Comme Association/Memory, la grille reste affichee une fois tous les mots trouves : seul le bouton Recommencer (.docMinigameRestartBar, partage) apparait. Verifie via simulation DOM reelle (jsdom) : selection au glisser ET au clic-clic, mot invalide sans crash, barre de fin qui bascule, panneau Proprietes (ajout/suppression de mot avec revalidation serveur). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1b1307bb85
commit
d8be80ebd1
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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") —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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'<div class="docMotsPlayer" data-mots-config="{config_json}">'
|
||||
f'<div class="docAssocCard">'
|
||||
f'<div class="docQuizKicker">Mots mêlés</div>'
|
||||
f'<div class="docAssocTitle">Retrouvez chaque mot caché dans la grille</div>'
|
||||
f'<div class="docAssocMeta"><span class="docMotsProg"></span></div>'
|
||||
f'<div class="docMotsBoard">'
|
||||
f'<div class="docMotsGrid"></div>'
|
||||
f'<div class="docMotsWordList"></div>'
|
||||
f"</div>"
|
||||
f'<div class="docMinigameRestartBar">'
|
||||
f'<button type="button" class="docMotsRestartBtn">Recommencer</button>'
|
||||
f"</div>"
|
||||
f"</div></div>"
|
||||
)
|
||||
|
||||
|
||||
def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
from ..labels.mots_config import sanitize_mots_config
|
||||
|
||||
config = sanitize_mots_config(el["attributes"])
|
||||
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||
word_count = len(config["words"])
|
||||
word_label = "mot" if word_count <= 1 else "mots"
|
||||
player_html = _render_mots_player(config) if config["words"] else ""
|
||||
return (
|
||||
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="mots" '
|
||||
f'style="border-color:{theme_color};">'
|
||||
f'<div class="docMinigameBadge">'
|
||||
f'<span class="docMinigameLabel">Mots mêlés</span>'
|
||||
f'<span class="docMinigamePlaceholder">{word_count} {word_label}</span>'
|
||||
f"</div>"
|
||||
f"{player_html}"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
||||
|
||||
@@ -329,7 +475,7 @@ _RENDERERS = {
|
||||
"quiz": _render_quiz,
|
||||
"association": _render_association,
|
||||
"memory": _render_memory,
|
||||
"mots": _render_minigame_placeholder,
|
||||
"mots": _render_mots,
|
||||
"scenario": _render_minigame_placeholder,
|
||||
"zones": _render_minigame_placeholder,
|
||||
}
|
||||
|
||||
@@ -77,7 +77,28 @@ regroupement à chaque appel.
|
||||
Comme l'Association, le plateau reste affiché une fois toutes les
|
||||
paires trouvées (ou toutes les cartes révélées en mode `"single"`) :
|
||||
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
|
||||
- **Autres mini-jeux** (`mots`/`scenario`/`zones`) : carte placeholder
|
||||
portant le libellé du type (voir
|
||||
- **Mots mêlés** : toujours une carte résumant la config réelle (nombre
|
||||
de mots) — sanitizée (`sanitize_mots_config`) avant lecture. Si au
|
||||
moins un mot existe, s'y ajoute (fonction privée `_render_mots_player`)
|
||||
la grille RÉELLE et interactive. 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) : chaque mot est placé horizontalement,
|
||||
verticalement, ou en diagonale (haut-gauche→bas-droite ou
|
||||
haut-droite→bas-gauche — jamais à l'envers), les lettres restantes
|
||||
tirées au hasard (`random.choice`/`random.randint`, tirage de jeu —
|
||||
voir `CODE_QUALITY.md`) ; si un mot ne trouve pas sa place, la grille
|
||||
entière est agrandie et le placement retenté depuis zéro, plutôt que
|
||||
d'abandonner silencieusement ce mot. Le tout (grille + coordonnées de
|
||||
chaque mot) est embarqué en JSON dans un attribut `data-mots-config`,
|
||||
échappé pour l'HTML — même principe que les autres mini-jeux, aucun
|
||||
aller-retour serveur pendant qu'on joue : `static/document/js/
|
||||
document-editor.js` compare les coordonnées EXACTES sélectionnées par
|
||||
l'apprenant à celles de chaque mot (jamais une simple comparaison de
|
||||
texte, qui se tromperait sur des lettres partagées entre deux mots qui
|
||||
se croisent). Comme l'Association/Memory, la grille reste affichée une
|
||||
fois tous les mots trouvés : seul le bouton "Recommencer"
|
||||
(`.docMinigameRestartBar`) apparaît.
|
||||
- **Autres mini-jeux** (`scenario`/`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 cette passe.
|
||||
|
||||
Reference in New Issue
Block a user