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

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:
william
2026-09-20 15:31:46 +02:00
co-authored by Claude Sonnet 5
parent 1b1307bb85
commit d8be80ebd1
11 changed files with 789 additions and 20 deletions
@@ -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,
}