Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+64 -54
View File
@@ -1,5 +1,6 @@
import json
import uuid
from typing import Any, Callable
from db.dialogue_lines import sanitize_dialogue_lines
from screens.flow.constants import CONDITION_OPERATORS
@@ -110,8 +111,16 @@ from screens.labels.video_modes import VIDEO_MODE_LABELS
# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant,
# demande explicite.)
ACTION_TYPES = [
"dialogue", "variable", "surbrillance", "visibilite", "son", "video", "indication", "attendre",
"interagir", "condition",
"dialogue",
"variable",
"surbrillance",
"visibilite",
"son",
"video",
"indication",
"attendre",
"interagir",
"condition",
]
TRIGGER_TYPES = ["collision", "clic", "survol"]
CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS}
@@ -139,7 +148,7 @@ _MAX_ACTION_ID_LENGTH = 40
_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool")
def _sanitize_dialogue(raw_dialogue):
def _sanitize_dialogue(raw_dialogue: Any) -> dict[str, Any] | None:
if not isinstance(raw_dialogue, dict):
return None
dialogue_id = raw_dialogue.get("id")
@@ -151,8 +160,8 @@ def _sanitize_dialogue(raw_dialogue):
}
def _sanitize_dialogue_action(raw_action):
action = {"type": "dialogue"}
def _sanitize_dialogue_action(raw_action: dict[str, Any]) -> dict[str, Any]:
action: dict[str, Any] = {"type": "dialogue"}
dialogue = _sanitize_dialogue(raw_action.get("dialogue"))
if dialogue:
action["dialogue"] = dialogue
@@ -161,14 +170,18 @@ def _sanitize_dialogue_action(raw_action):
return action
def _sanitize_variable_action(raw_action):
def _sanitize_variable_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
target_variable = raw_action.get("target_variable")
if not isinstance(target_variable, str) or not target_variable.strip():
return None
data_operation = raw_action.get("data_operation")
if data_operation not in DATA_OPERATION_LABELS:
return None
action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation}
action: dict[str, Any] = {
"type": "variable",
"target_variable": target_variable.strip(),
"data_operation": data_operation,
}
action_id = raw_action.get("id")
if isinstance(action_id, str) and action_id.strip():
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
@@ -181,15 +194,17 @@ def _sanitize_variable_action(raw_action):
return action
def _sanitize_object_id(raw_action):
def _sanitize_object_id(raw_action: dict[str, Any]) -> int | None:
object_id = raw_action.get("object_id")
if object_id is None:
return None
try:
return int(object_id)
except (TypeError, ValueError):
return None
def _with_optional_id(action, raw_action):
def _with_optional_id(action: dict[str, Any], raw_action: dict[str, Any]) -> dict[str, Any]:
"""Ajoute le champ `id` (optionnel, généré côté client) commun à
toutes les feuilles autres que "dialogue" — sert à adresser CETTE
feuille précise pour y attacher un "then" plus tard (voir
@@ -200,7 +215,7 @@ def _with_optional_id(action, raw_action):
return action
def _sanitize_surbrillance_action(raw_action):
def _sanitize_surbrillance_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in SURBRILLANCE_LABELS:
@@ -208,7 +223,7 @@ def _sanitize_surbrillance_action(raw_action):
return _with_optional_id({"type": "surbrillance", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_visibilite_action(raw_action):
def _sanitize_visibilite_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in ELEMENT_VISIBILITY_LABELS:
@@ -216,14 +231,14 @@ def _sanitize_visibilite_action(raw_action):
return _with_optional_id({"type": "visibilite", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_son_action(raw_action):
def _sanitize_son_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
asset_url = raw_action.get("asset_url")
if not isinstance(asset_url, str) or not asset_url.strip():
return None
return _with_optional_id({"type": "son", "asset_url": asset_url.strip()}, raw_action)
def _sanitize_video_action(raw_action):
def _sanitize_video_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
asset_url = raw_action.get("asset_url")
mode = raw_action.get("mode")
if not isinstance(asset_url, str) or not asset_url.strip() or mode not in VIDEO_MODE_LABELS:
@@ -231,26 +246,28 @@ def _sanitize_video_action(raw_action):
return _with_optional_id({"type": "video", "asset_url": asset_url.strip(), "mode": mode}, raw_action)
def _sanitize_indication_action(raw_action):
def _sanitize_indication_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
object_id = _sanitize_object_id(raw_action)
texte = raw_action.get("texte")
if object_id is None or not isinstance(texte, str) or not texte.strip():
return None
action = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
action: dict[str, Any] = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
duree_ms = raw_action.get("duree_ms")
if isinstance(duree_ms, (int, float)) and not isinstance(duree_ms, bool) and duree_ms > 0:
action["duree_ms"] = int(duree_ms)
return _with_optional_id(action, raw_action)
def _sanitize_attendre_action(raw_action):
""""Attendre N secondes" avant de continuer la chaîne — MÊME
def _sanitize_attendre_action(raw_action: dict[str, Any]) -> dict[str, Any] | None:
""" "Attendre N secondes" avant de continuer la chaîne — MÊME
vocabulaire que l'action "attendre" du Flow (voir static/js/play/
actions.js, `data_value` en SECONDES, pas en ms) : suspend le "then"
plutôt que de l'exécuter immédiatement (comme "video"), utile par ex.
pour une narration à l'affichage de l'écran qui doit laisser un temps
de lecture avant d'enchaîner."""
raw_value = raw_action.get("data_value")
if raw_value is None:
return None
try:
seconds = float(raw_value)
except (TypeError, ValueError):
@@ -263,7 +280,7 @@ def _sanitize_attendre_action(raw_action):
# Une seule table de vérité pour les feuilles : ajouter un type ici suffit
# (voir LEAF_ACTION_TYPES dérivé de ses clés) — évite le if/elif qui aurait
# dû être répété à chaque nouvel ajout.
_LEAF_ACTION_SANITIZERS = {
_LEAF_ACTION_SANITIZERS: dict[str, Callable[[dict[str, Any]], dict[str, Any] | None]] = {
"dialogue": _sanitize_dialogue_action,
"variable": _sanitize_variable_action,
"surbrillance": _sanitize_surbrillance_action,
@@ -276,14 +293,14 @@ _LEAF_ACTION_SANITIZERS = {
LEAF_ACTION_TYPES = tuple(_LEAF_ACTION_SANITIZERS)
def _sanitize_leaf_action(raw_action, depth=1):
def _sanitize_leaf_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
"""Sanitize une FEUILLE (voir LEAF_ACTION_TYPES) et son éventuelle
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
feuille est attendue : action de premier niveau, sous "interagir", ou
branche Vrai/Faux de "condition"."""
if not isinstance(raw_action, dict):
return None
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type"))
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type") or "")
if not sanitizer:
return None
action = sanitizer(raw_action)
@@ -296,7 +313,7 @@ def _sanitize_leaf_action(raw_action, depth=1):
return action
def _sanitize_condition_branch(raw_branch):
def _sanitize_condition_branch(raw_branch: Any) -> dict[str, Any] | None:
"""Une branche Vrai/Faux de "condition" : soit None ("ne rien
faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou
"variable", éventuellement chaînée via "then") — jamais "interagir"/
@@ -307,7 +324,7 @@ def _sanitize_condition_branch(raw_branch):
return _sanitize_leaf_action(raw_branch)
def _sanitize_action(raw_action, depth=1):
def _sanitize_action(raw_action: Any, depth: int = 1) -> dict[str, Any] | None:
if not isinstance(raw_action, dict):
return None
action_type = raw_action.get("type")
@@ -337,7 +354,7 @@ def _sanitize_action(raw_action, depth=1):
}
def sanitize_collision_rules(raw_rules, allowed_triggers=None):
def sanitize_collision_rules(raw_rules: Any, allowed_triggers: list[str] | None = None) -> list[dict[str, Any]]:
"""Valide/nettoie une liste de règles postée par le client (voir
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
@@ -350,7 +367,7 @@ def sanitize_collision_rules(raw_rules, allowed_triggers=None):
allowed_triggers = TRIGGER_TYPES
if not isinstance(raw_rules, list):
return []
cleaned = []
cleaned: list[dict[str, Any]] = []
for raw_rule in raw_rules[:50]: # marge large, jamais illimité
if not isinstance(raw_rule, dict):
continue
@@ -364,7 +381,7 @@ def sanitize_collision_rules(raw_rules, allowed_triggers=None):
return cleaned
def _apply_dialogue_lines(action, dialogue_id, lines):
def _apply_dialogue_lines(action: Any, dialogue_id: str, lines: list[dict[str, Any]]) -> bool:
if not isinstance(action, dict):
return False
action_type = action.get("type")
@@ -379,14 +396,13 @@ def _apply_dialogue_lines(action, dialogue_id, lines):
if action_type == "interagir":
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
if action_type == "condition":
return (
_apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines)
or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines)
return _apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines) or _apply_dialogue_lines(
action.get("si_faux"), dialogue_id, lines
)
return False
def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
def set_dialogue_lines_in_rules(rules: list[dict[str, Any]], dialogue_id: str, raw_lines: Any) -> bool:
"""Met à jour EN PLACE les répliques du dialogue `dialogue_id`,
où qu'il se trouve dans l'arbre d'action d'une des règles (direct,
chaîné via "then", sous "interagir", ou dans une branche de
@@ -395,13 +411,10 @@ def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
rien modifier si `dialogue_id` n'existe dans aucune règle (référence
orpheline, ex. règle supprimée entre-temps)."""
lines = sanitize_dialogue_lines(raw_lines)
for rule in rules:
if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines):
return True
return False
return any(_apply_dialogue_lines(rule.get("action"), dialogue_id, lines) for rule in rules)
def _leaf_id(action):
def _leaf_id(action: dict[str, Any]) -> str | None:
if action.get("type") == "dialogue":
dialogue = action.get("dialogue")
return dialogue.get("id") if isinstance(dialogue, dict) else None
@@ -410,7 +423,7 @@ def _leaf_id(action):
return None
def _apply_then_action(action, leaf_id, then_action):
def _apply_then_action(action: Any, leaf_id: str, then_action: dict[str, Any]) -> bool:
if not isinstance(action, dict):
return False
action_type = action.get("type")
@@ -431,14 +444,13 @@ def _apply_then_action(action, leaf_id, then_action):
if action_type == "interagir":
return _apply_then_action(action.get("sub_action"), leaf_id, then_action)
if action_type == "condition":
return (
_apply_then_action(action.get("si_vrai"), leaf_id, then_action)
or _apply_then_action(action.get("si_faux"), leaf_id, then_action)
return _apply_then_action(action.get("si_vrai"), leaf_id, then_action) or _apply_then_action(
action.get("si_faux"), leaf_id, then_action
)
return False
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
def set_then_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, raw_then_action: Any) -> bool:
"""INSÈRE EN PLACE une NOUVELLE action juste après la feuille
identifiée par `leaf_id` (dialogue.id pour une feuille dialogue, id
pour toute autre feuille) — n'importe où dans l'arbre d'action d'une
@@ -452,10 +464,7 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
then_action = _sanitize_leaf_action(raw_then_action)
if not then_action:
return False
for rule in rules:
if _apply_then_action(rule.get("action"), leaf_id, then_action):
return True
return False
return any(_apply_then_action(rule.get("action"), leaf_id, then_action) for rule in rules)
# ---------- Suppression/réordonnancement d'un maillon précis (demande
@@ -468,7 +477,8 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
# RÈGLE elle-même doit être remplacée ou retirée, pas juste un champ
# interne).
def _remove_leaf_id(action, leaf_id):
def _remove_leaf_id(action: Any, leaf_id: str) -> tuple[Any, bool]:
"""Retire la feuille `leaf_id` de l'arbre `action` — renvoie
(nouvel_arbre_ou_None, trouvé). `nouvel_arbre` vaut None si `action`
lui-même était la cible et n'avait rien pour le remplacer (ni "then",
@@ -511,7 +521,7 @@ def _remove_leaf_id(action, leaf_id):
return action, False
def remove_action_from_rules(rules, leaf_id):
def remove_action_from_rules(rules: list[dict[str, Any]], leaf_id: str) -> bool:
"""Retire EN PLACE l'action `leaf_id` n'importe où dans l'arbre d'une
des règles (feuille directe d'une règle, maillon d'une chaîne "then",
sous-action d'un "interagir", ou dans une branche de "condition"). Si
@@ -531,12 +541,12 @@ def remove_action_from_rules(rules, leaf_id):
return False
def _chain_as_list(head_action):
def _chain_as_list(head_action: Any) -> list[dict[str, Any]]:
"""Aplatit une chaîne de FEUILLES pures (voir LEAF_ACTION_TYPES) en
liste — le réordonnancement ne s'applique qu'à l'intérieur d'une
chaîne "then" (jamais entre deux branches différentes d'une
condition, ni entre une "interagir" et ce qui l'entoure)."""
items = []
items: list[dict[str, Any]] = []
node = head_action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
items.append(node)
@@ -544,7 +554,7 @@ def _chain_as_list(head_action):
return items
def _relink_chain(items):
def _relink_chain(items: list[dict[str, Any]]) -> dict[str, Any] | None:
items = [dict(n) for n in items]
for i in range(len(items) - 1):
items[i]["then"] = items[i + 1]
@@ -553,7 +563,7 @@ def _relink_chain(items):
return items[0] if items else None
def _move_in_action_tree(action, leaf_id, direction):
def _move_in_action_tree(action: Any, leaf_id: str, direction: str) -> dict[str, Any] | None:
"""Cherche, dans `action`, LA chaîne "then" qui contient `leaf_id`
(au premier niveau, sous une "interagir", ou dans une branche de
"condition"), et échange `leaf_id` avec son voisin immédiat
@@ -593,7 +603,7 @@ def _move_in_action_tree(action, leaf_id, direction):
return None
def move_action_in_rules(rules, leaf_id, direction):
def move_action_in_rules(rules: list[dict[str, Any]], leaf_id: str, direction: str) -> bool:
"""Déplace EN PLACE la feuille `leaf_id` d'un cran vers le haut
("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then" (jamais
au-delà de sa branche de condition/sous-action interagir — voir
@@ -611,11 +621,11 @@ def move_action_in_rules(rules, leaf_id, direction):
return False
def _generate_leaf_id():
def _generate_leaf_id() -> str:
return "d_" + uuid.uuid4().hex[:12]
def _backfill_leaf_ids(action):
def _backfill_leaf_ids(action: Any) -> bool:
"""Assure EN PLACE qu'un id STABLE existe sur toute feuille non-
"dialogue" (qui a déjà le sien via dialogue.id) qui n'en avait pas —
ex. une action "variable" posée AVANT que ce champ ne devienne
@@ -647,7 +657,7 @@ def _backfill_leaf_ids(action):
return changed
def backfill_missing_leaf_ids(rules):
def backfill_missing_leaf_ids(rules: list[dict[str, Any]]) -> bool:
"""Applique _backfill_leaf_ids à CHAQUE règle de `rules` (mutées EN
PLACE) — renvoie True si au moins une règle a été modifiée, auquel
cas l'appelant DOIT persister `rules` (voir routes/triggers/
@@ -663,7 +673,7 @@ def backfill_missing_leaf_ids(rules):
return changed
def resolve_collision_rules(obj):
def resolve_collision_rules(obj: dict[str, Any]) -> list[dict[str, Any]]:
"""Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement
inchangé pour tout objet déjà posé avant l'ajout de cet éditeur)."""
+9 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# Boîte de collision d'un objet de scène (voir screens/scenes/
# set_scene_object_collision.py) : posée AUTOMATIQUEMENT sur chaque objet
@@ -9,14 +10,18 @@ import json
# forme (rectangle/cercle) et taille/décalage propres, pour un sprite très
# paddé (ex. les frames CraftPix) dont la silhouette réelle est bien plus
# petite que son canevas.
DEFAULT_COLLISION_SETTINGS = {
"enabled": True, "shape": "rectangle", "width": None, "height": None,
"offset_x": 0, "offset_y": 0,
DEFAULT_COLLISION_SETTINGS: dict[str, Any] = {
"enabled": True,
"shape": "rectangle",
"width": None,
"height": None,
"offset_x": 0,
"offset_y": 0,
}
COLLISION_SHAPES = ["rectangle", "cercle"]
def resolve_collision_settings(obj):
def resolve_collision_settings(obj: dict[str, Any]) -> dict[str, Any]:
"""Décode attributes._collision en dict Python, jamais None — un
attribut absent/corrompu retombe sur le défaut (boîte de collision =
la boîte entière de l'objet, comme si ce réglage n'existait pas)."""
+8 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
@@ -7,7 +8,7 @@ import json
# static/js/play/dialogue-box-controller.js) — header (nom du
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
# entièrement stylisable par l'auteur.
DEFAULT_DIALOGUE_BOX_STYLE = {
DEFAULT_DIALOGUE_BOX_STYLE: dict[str, Any] = {
"font_family": "inherit",
"font_size": 14,
"font_weight": "normal",
@@ -20,14 +21,17 @@ DEFAULT_DIALOGUE_BOX_STYLE = {
FONT_WEIGHT_CHOICES = ["normal", "bold"]
def sanitize_dialogue_box_style(raw_style):
def sanitize_dialogue_box_style(raw_style: Any) -> dict[str, Any]:
style = dict(DEFAULT_DIALOGUE_BOX_STYLE)
if not isinstance(raw_style, dict):
return style
if isinstance(raw_style.get("font_family"), str) and raw_style["font_family"].strip():
style["font_family"] = raw_style["font_family"].strip()[:60]
raw_font_size = raw_style.get("font_size")
try:
style["font_size"] = max(8, min(48, int(raw_style.get("font_size"))))
if raw_font_size is None:
raise ValueError
style["font_size"] = max(8, min(48, int(raw_font_size)))
except (TypeError, ValueError):
pass
if raw_style.get("font_weight") in FONT_WEIGHT_CHOICES:
@@ -39,7 +43,7 @@ def sanitize_dialogue_box_style(raw_style):
return style
def resolve_dialogue_box_style(obj):
def resolve_dialogue_box_style(obj: dict[str, Any]) -> dict[str, Any]:
raw = (obj.get("attributes") or {}).get("_dialogue_box_style")
if not raw:
return dict(DEFAULT_DIALOGUE_BOX_STYLE)
@@ -1,16 +1,16 @@
from ..screens_repo.list_screens import list_screens
from ..scenes.list_scene_objects import list_scene_objects
from ..screens_repo.list_screens import list_screens
from .personnage_data import resolve_personnage_data
def list_used_forge_characters(slug):
def list_used_forge_characters(slug: str) -> set[str]:
"""Ensemble des slugs SPRITE_LIBRARY (screens.SPRITE_LIBRARY)
réellement référencés par CE jeu — tous écrans confondus. Utilisé par
publish/build_scorm_package.py pour ne copier dans le .zip exporté que les
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
used = set()
used: set[str] = set()
for s in list_screens(slug, include_templates=True):
for o in list_scene_objects(slug, s["id"]):
if o.get("kind") == "personnage":
+13 -6
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# ZQSD (convention AZERTY, comme le reste de l'éditeur) + E pour interagir
# — un objet de scène kind="personnage" se déplace et s'anime TOUT SEUL
@@ -11,9 +12,15 @@ import json
# "extra" : touches SUPPLÉMENTAIRES, chacune liée à une pose du personnage
# au choix (ex. "sauter" -> "jump") — au-delà des 4 touches de déplacement
# et de "interagir", qui restent les deux seules touches à usage FIXE.
DEFAULT_PERSONNAGE_COMMANDS = {
"haut": "z", "bas": "s", "gauche": "q", "droite": "d",
"interagir": "e", "axe": "libre", "vitesse": 4, "extra": [],
DEFAULT_PERSONNAGE_COMMANDS: dict[str, Any] = {
"haut": "z",
"bas": "s",
"gauche": "q",
"droite": "d",
"interagir": "e",
"axe": "libre",
"vitesse": 4,
"extra": [],
}
# Nombre maximum de touches supplémentaires — large marge pour un vrai
@@ -22,10 +29,10 @@ DEFAULT_PERSONNAGE_COMMANDS = {
_MAX_EXTRA_COMMANDS = 20
def sanitize_extra_commands(raw_extra):
def sanitize_extra_commands(raw_extra: Any) -> list[dict[str, str]]:
if not isinstance(raw_extra, list):
return []
cleaned = []
cleaned: list[dict[str, str]] = []
for item in raw_extra[:_MAX_EXTRA_COMMANDS]:
if not isinstance(item, dict):
continue
@@ -36,7 +43,7 @@ def sanitize_extra_commands(raw_extra):
return cleaned
def resolve_personnage_commands(el):
def resolve_personnage_commands(el: dict[str, Any]) -> dict[str, Any]:
"""Décode attributes._personnage_commandes (voir
screens/scenes/set_scene_object_commands.py) en dict Python, jamais
None — un attribut absent/corrompu retombe sur les touches par défaut
+6 -5
View File
@@ -1,7 +1,8 @@
import json
from typing import Any
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
# Résolution RUNTIME (jeu jouable) : le catalogue COMPLET, quel que soit le
# rôle du créateur — voir le plan "Bibliothèque de sprites animaux
@@ -13,7 +14,7 @@ _SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
def resolve_personnage_data(el):
def resolve_personnage_data(el: dict[str, Any]) -> dict[str, Any]:
"""Décode attributes._personnage_data (voir set_personnage_data.py) en
dict Python, jamais None — un attribut absent/corrompu retombe sur le
défaut posé à la création (default_style_for_widget.py)."""
@@ -27,18 +28,18 @@ def resolve_personnage_data(el):
return data if isinstance(data, dict) else dict(_DEFAULT_PERSONNAGE_DATA)
def resolve_personnage_animations(el):
def resolve_personnage_animations(el: dict[str, Any]) -> dict[str, list[str]]:
"""{nom_animation: [urls...]} pour cet élément personnage, quelle que
soit sa source — Forge (SPRITE_LIBRARY) ou sprites propres au créateur
(ses propres frames importées, voir personnage-panel.js)."""
data = resolve_personnage_data(el)
if data.get("source") == "custom":
return data.get("animations") or {}
character = _SPRITE_LIBRARY.get(data.get("forge_character"))
character = _SPRITE_LIBRARY.get(data.get("forge_character") or "")
return character["animations"] if character else {}
def resolve_personnage_idle_frame(el):
def resolve_personnage_idle_frame(el: dict[str, Any]) -> str:
"""Première image de l'animation "idle" (ou, à défaut, de la première
animation disponible) — utilisée pour le rendu serveur ET l'aperçu
animé de l'éditeur, jamais vide tant qu'au moins une frame existe."""
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
# Rôle d'un personnage posé sur une scène 2D (voir
# screens/scenes/set_scene_object_role.py) : seul un personnage "joueur"
# est déplacé/animé automatiquement au clavier et suivi par la caméra
@@ -11,7 +13,7 @@ PERSONNAGE_ROLE_LABELS = {"joueur": "Joueur", "ennemie": "Ennemi", "pnj": "PNJ"}
DEFAULT_PERSONNAGE_ROLE = "joueur"
def resolve_personnage_role(el):
def resolve_personnage_role(el: dict[str, Any]) -> str:
"""Décode attributes._personnage_role — un attribut absent/corrompu
retombe sur "joueur" (comportement inchangé pour tout personnage posé
avant l'ajout de ce réglage : c'était déjà, de fait, le seul rôle qui
+8 -4
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
# Réglages "❓ Boîte à quiz" propres au quiz AUTONOME/plein écran (demande
# explicite : "étendre les fonctionnalités de la boîte à quiz pour les RH
@@ -19,7 +20,7 @@ import json
# Les deux réglages sont conservés INDÉPENDAMMENT (jamais écrasés l'un par
# l'autre) : activer/désactiver le plein écran change juste lequel des
# deux s'applique, sans jamais perdre le choix de l'autre.
DEFAULT_QUIZ_BOX_CONFIG = {
DEFAULT_QUIZ_BOX_CONFIG: dict[str, Any] = {
"fullscreen": False,
"timer_mode": "aucun",
"timer_seconds": 20,
@@ -68,15 +69,18 @@ _MIN_TIMER_SECONDS = 3
_MAX_TIMER_SECONDS = 600
def sanitize_quiz_box_config(raw_config):
def sanitize_quiz_box_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_QUIZ_BOX_CONFIG)
if not isinstance(raw_config, dict):
return config
config["fullscreen"] = bool(raw_config.get("fullscreen"))
if raw_config.get("timer_mode") in TIMER_MODES:
config["timer_mode"] = raw_config["timer_mode"]
raw_timer_seconds = raw_config.get("timer_seconds")
try:
config["timer_seconds"] = max(_MIN_TIMER_SECONDS, min(_MAX_TIMER_SECONDS, int(raw_config.get("timer_seconds"))))
if raw_timer_seconds is None:
raise ValueError
config["timer_seconds"] = max(_MIN_TIMER_SECONDS, min(_MAX_TIMER_SECONDS, int(raw_timer_seconds)))
except (TypeError, ValueError):
pass
if raw_config.get("dialog_template") in QUIZ_BOX_DIALOG_TEMPLATES:
@@ -86,7 +90,7 @@ def sanitize_quiz_box_config(raw_config):
return config
def resolve_quiz_box_config(obj):
def resolve_quiz_box_config(obj: dict[str, Any]) -> dict[str, Any]:
raw = (obj.get("attributes") or {}).get("_quiz_box_config")
if not raw:
return dict(DEFAULT_QUIZ_BOX_CONFIG)
+53 -43
View File
@@ -1,4 +1,5 @@
import html as html_lib
from typing import Any, Callable
# ---------- "❓ Boîte à quiz" — DEUX catégories de modèles bien distinctes
# (voir screens/rendering/quiz_box_config.py) :
@@ -43,7 +44,7 @@ import html as html_lib
# render_quiz_box_defaut.
def _quiz_data_attrs(obj, config, active_template):
def _quiz_data_attrs(obj: dict[str, Any], config: dict[str, Any], active_template: str) -> str:
return (
f'data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
f'data-quiz-template="{active_template}" '
@@ -52,7 +53,7 @@ def _quiz_data_attrs(obj, config, active_template):
)
def _quiz_box_style(obj, style):
def _quiz_box_style(obj: dict[str, Any], style: dict[str, Any]) -> str:
return (
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
@@ -61,16 +62,16 @@ def _quiz_box_style(obj, style):
)
def _quiz_topbar_html():
def _quiz_topbar_html() -> str:
return (
'<div class="quizBoxTopBar" data-quiz-role="topbar">'
'<span class="quizBoxScoreDisplay" data-quiz-role="score">Score : 0</span>'
'<span class="quizBoxTimerDisplay" data-quiz-role="timer"></span>'
'</div>'
"</div>"
)
def _quiz_header_row_html(header_text, header_bg=None):
def _quiz_header_row_html(header_text: str, header_bg: str | None = None) -> str:
# Le score/minuteur (topbar) est un FRÈRE du header, jamais son enfant
# (voir le commentaire d'en-tête) — les deux partagent ici le MÊME
# bandeau (fond commun posé sur ".quizBoxHeaderRow", jamais sur le
@@ -79,29 +80,28 @@ def _quiz_header_row_html(header_text, header_bg=None):
# part au-dessus (demande explicite). `header_bg=None` : aucun fond
# posé, le bandeau reste transparent (repli disponible pour un futur
# modèle qui n'en aurait pas besoin).
bg_style = f' style="background:{html_lib.escape(header_bg)}"' if header_bg else ''
bg_style = f' style="background:{html_lib.escape(header_bg)}"' if header_bg else ""
return (
f'<div class="quizBoxHeaderRow"{bg_style}>'
f'<div class="quizBoxHeader" data-quiz-role="header">{header_text}</div>'
+ _quiz_topbar_html() +
'</div>'
f'<div class="quizBoxHeader" data-quiz-role="header">{header_text}</div>' + _quiz_topbar_html() + "</div>"
)
def render_quiz_box_defaut(obj, config, style):
def render_quiz_box_defaut(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
return (
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
+ _quiz_header_row_html("Quête : Titre de la quête", style["header_bg"]) +
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" '
f'{_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
+ _quiz_header_row_html("Quête : Titre de la quête", style["header_bg"])
+ f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
f'</div></div></div>'
f"</div></div></div>"
)
def render_quiz_box_manga_dialogue(obj, config, style):
def render_quiz_box_manga_dialogue(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Manga" (boîte de dialogue) — pendant, en petite carte flottante
# JAMAIS plein écran, du modèle "page de quiz" "Manga" (voir
# render_quiz_box_page_manga), validé par le créateur via une
@@ -123,10 +123,12 @@ def render_quiz_box_manga_dialogue(obj, config, style):
# doublerait dans l'aperçu éditeur (jamais réécrit par le JS avant le
# premier vrai jeu).
return (
f'<div class="quizBoxWidget quizBoxWidget--mangaDialogue" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
f'<div class="quizBoxWidget quizBoxWidget--mangaDialogue" role="dialog" aria-live="polite" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} '
f'style="{_quiz_box_style(obj, style)}">'
'<div class="quizMangaDialogueFrame">'
+ _quiz_header_row_html("Titre de la quête", style["header_bg"]) +
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
+ _quiz_header_row_html("Titre de la quête", style["header_bg"])
+ f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
f'<div class="quizBoxChoices" data-quiz-role="choices">'
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
@@ -138,8 +140,8 @@ def render_quiz_box_manga_dialogue(obj, config, style):
# demande explicite), donc ce repère est maintenant fidèle au
# comportement réel, pas juste à l'aperçu éditeur.
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
'</div></div>'
'</div>'
"</div></div>"
"</div>"
# Bandeau de pied de page TOUJOURS visible (comme la maquette
# validée) — purement décoratif (aria-hidden, jamais un
# data-quiz-role), le moteur JS partagé ne le touche JAMAIS : le
@@ -152,11 +154,11 @@ def render_quiz_box_manga_dialogue(obj, config, style):
# l'intérieur au lieu d'être positionnés par-dessus TOUT le
# widget (.quizBoxWidget--mangaDialogue est position:relative).
'<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>'
'</div>'
"</div>"
)
def render_quiz_box_page_manga(obj, config, style):
def render_quiz_box_page_manga(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Manga" — premier modèle "PAGE DE QUIZ" (voir screens/rendering/
# quiz_box_config.py::QUIZ_BOX_PAGE_TEMPLATES) : thème complet et
# autonome, validé par le créateur via une maquette interactive avant
@@ -185,18 +187,20 @@ def render_quiz_box_page_manga(obj, config, style):
# dédié pour le maintenir à jour question après question.
timer_placeholder = "Aucun" if config["timer_mode"] == "aucun" else ""
return (
f'<div class="quizBoxWidget quizBoxWidget--manga" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)}">'
f'<div class="quizBoxWidget quizBoxWidget--manga" role="dialog" aria-live="polite" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} '
f'style="{_quiz_box_style(obj, style)}">'
'<div class="mangaTopbar">'
'<div class="mangaPlate mangaPlate--timer">'
'<span class="mangaPlateLabel">Temps</span>'
f'<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
'</div>'
"</div>"
'<div class="mangaTitlemark"><span>Manga Quiz</span></div>'
'<div class="mangaPlate mangaPlate--score">'
'<span class="mangaPlateLabel">Score</span>'
'<span class="mangaPlateValue" data-quiz-role="score">Score : 0</span>'
'</div>'
'</div>'
"</div>"
"</div>"
# Barre d'étapes (voir static/js/play/quiz-box-templates.js::
# forgeQuizMangaOnShow) — remplie EN JEU (nombre de questions total
# connu seulement à l'exécution, jamais à la pose de l'objet) ;
@@ -206,11 +210,11 @@ def render_quiz_box_page_manga(obj, config, style):
'<div class="mangaPanelQuestion">'
'<span class="mangaKicker" data-quiz-role="header">Titre de la quête</span>'
'<p class="mangaQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
'</div>'
"</div>"
'<div class="mangaChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'</div>'
"</div>"
# Écran de résultat de fin de quiz — voir forgeQuizMangaOnQuizComplete
# (static/js/play/quiz-box-templates.js), même mécanique que
# "classique" (render_quiz_box_page_classique) mais dans le thème
@@ -221,11 +225,11 @@ def render_quiz_box_page_manga(obj, config, style):
# bandeau topbar/score + la barre d'étapes visibles. data-manga-
# role (pas data-quiz-role) : décoration propre à ce modèle.
'<div class="mangaResultZone" data-manga-role="result"></div>'
'</div>'
"</div>"
)
def render_quiz_box_page_classique(obj, config, style):
def render_quiz_box_page_classique(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
# "Classique" — modèle de PAGE DE QUIZ DE BASE (sobre/professionnel),
# validé par le créateur via une maquette interactive (formation
# sécurité incendie comme exemple). Reprend VOLONTAIREMENT les
@@ -249,23 +253,27 @@ def render_quiz_box_page_classique(obj, config, style):
# dans [data-quiz-role="choices"]) se pose en bandeau bas façon
# pied de page, dans la couleur configurée via "🎨 Style"
# (footer_bg), jamais une couleur figée en dur.
f'<div class="quizBoxWidget quizBoxWidget--classique" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)} --classique-footer-bg:{html_lib.escape(style["footer_bg"])}; --classique-body-bg:{html_lib.escape(style["body_bg"])};">'
f'<div class="quizBoxWidget quizBoxWidget--classique" role="dialog" aria-live="polite" '
f'aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} '
f'style="{_quiz_box_style(obj, style)} '
f"--classique-footer-bg:{html_lib.escape(style['footer_bg'])}; "
f'--classique-body-bg:{html_lib.escape(style["body_bg"])};">'
f'<div class="classiqueHeaderBar" style="background:{html_lib.escape(style["header_bg"])}">'
'<div class="classiqueHeaderTitleWrap">'
'<span class="classiqueHeaderEyebrow">❓ Quiz</span>'
f'<span class="classiqueHeaderTitle">{title}</span>'
'</div>'
"</div>"
'<div class="classiqueHeaderChips">'
'<div class="classiqueChip classiqueChip--timer">'
'<span class="classiqueChipLabel">Temps</span>'
f'<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
'</div>'
"</div>"
'<div class="classiqueChip classiqueChip--score">'
'<span class="classiqueChipLabel">Score</span>'
'<span class="classiqueChipValue" data-quiz-role="score">Score : 0</span>'
'</div>'
'</div>'
'</div>'
"</div>"
"</div>"
"</div>"
f'<div class="classiqueBody" style="background:{html_lib.escape(style["body_bg"])}">'
'<div class="classiqueContent">'
# Barre d'étapes — même principe que "manga" (voir
@@ -282,7 +290,7 @@ def render_quiz_box_page_classique(obj, config, style):
'<div class="classiqueChoices" data-quiz-role="choices">'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'</div>'
"</div>"
# Encart d'explication après réponse — RÉSERVÉ pour une
# fonctionnalité FUTURE, PAS ENCORE implémentée (demande
# explicite : "tu peut le mettre dans le modele mais ne
@@ -299,8 +307,8 @@ def render_quiz_box_page_classique(obj, config, style):
# CSS tout le reste : progression/question/choix). data-classique-
# role (pas data-quiz-role) : décoration propre à ce modèle.
'<div class="classiqueResultZone" data-classique-role="result"></div>'
'</div>'
'</div>'
"</div>"
"</div>"
# Bandeau de pied de page TOUJOURS visible (footer_bg, même
# esprit que header/body ci-dessus) — purement décoratif
# (aria-hidden, jamais un data-quiz-role), le moteur JS partagé
@@ -309,22 +317,24 @@ def render_quiz_box_page_classique(obj, config, style):
# (voir static/style.css), pas DANS ce bandeau — demande
# explicite : "il manque le footer foncé avec le bouton".
'<div class="classiqueFooterBand" aria-hidden="true"></div>'
'</div>'
"</div>"
)
QUIZ_BOX_DIALOG_RENDERERS = {
_QuizRenderer = Callable[[dict[str, Any], dict[str, Any], dict[str, Any]], str]
QUIZ_BOX_DIALOG_RENDERERS: dict[str, _QuizRenderer] = {
"defaut": render_quiz_box_defaut,
"manga_dialogue": render_quiz_box_manga_dialogue,
}
QUIZ_BOX_PAGE_RENDERERS = {
QUIZ_BOX_PAGE_RENDERERS: dict[str, _QuizRenderer] = {
"classique": render_quiz_box_page_classique,
"manga": render_quiz_box_page_manga,
}
def render_quiz_box(obj, config, style):
def render_quiz_box(obj: dict[str, Any], config: dict[str, Any], style: dict[str, Any]) -> str:
"""Point d'entrée UNIQUE (voir screens/scenes/render_scene_object.py)
— bascule entre les deux catégories de modèles selon `fullscreen`
(demande explicite : ces modèles ne se voient JAMAIS dans l'autre
+3 -3
View File
@@ -1,5 +1,5 @@
from ..screens_repo.list_screens import list_screens
from ..scenes.list_scene_objects import list_scene_objects
from ..screens_repo.list_screens import list_screens
# Le widget "💬 Boîte de dialogue" (kind="dialogue_box") n'est pas un
# acteur de la scène — il n'a pas de sens comme "qui parle" dans un
@@ -8,14 +8,14 @@ from ..scenes.list_scene_objects import list_scene_objects
_EXCLUDED_KINDS = {"dialogue_box"}
def list_named_scene_object_names(slug):
def list_named_scene_object_names(slug: str) -> list[str]:
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️
Informations", templates/scene_edit.html) — n'importe quel kind
(personnage, décor, fond), toutes scènes 2D du jeu confondues : un
déclencheur est GAME-WIDE, son dialogue peut donc mettre en scène un
objet posé sur une autre scène que celle où la règle de collision se
déclenche. Ordre alphabétique, jamais de doublon."""
names = set()
names: set[str] = set()
for s in list_screens(slug):
if s["kind"] != "jeu_2d":
continue
+3 -2
View File
@@ -1,4 +1,5 @@
import json
from typing import Any
from .collision_rules import sanitize_collision_rules
@@ -13,7 +14,7 @@ from .collision_rules import sanitize_collision_rules
TRIGGER_TYPES_SCREEN = ["affichage"]
def sanitize_screen_triggers(raw_rules):
def sanitize_screen_triggers(raw_rules: Any) -> list[dict[str, Any]]:
"""Valide/nettoie une liste de règles "déclencheur d'écran -> action"
postée par le client (voir routes/screens/screen_triggers.py) — même
garanties que sanitize_collision_rules, juste un vocabulaire de
@@ -21,7 +22,7 @@ def sanitize_screen_triggers(raw_rules):
return sanitize_collision_rules(raw_rules, allowed_triggers=TRIGGER_TYPES_SCREEN)
def resolve_screen_triggers(screen):
def resolve_screen_triggers(screen: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Décode _screens._screen_triggers en liste Python, jamais None — un
écran créé avant l'ajout de cette fonctionnalité (colonne NULL)
retombe sur aucune règle, même principe que resolve_collision_rules
-4
View File
@@ -1,4 +0,0 @@
def _trigger_for(widget_key, attributes):
if (attributes or {}).get("type") == "submit":
return "soumission"
return "clic"
+38 -31
View File
@@ -1,10 +1,15 @@
def _game_objects_with_thumbnails(slug, screens_mod):
from typing import Any
from .collision_rules import LEAF_ACTION_TYPES
def _game_objects_with_thumbnails(slug: str, screens_mod: Any) -> list[dict[str, Any]]:
"""Objets de TOUTES les scènes "jeu_2d" du jeu (contrairement à
screens.list_scene_objects, qui ne prend qu'un screen_id) —
rendered_html ajouté comme l'exigent screens.build_collision_rule_objects
(voir routes/scenes/scene_edit_view.py pour le même patron, une seule
scène)."""
result = []
result: list[dict[str, Any]] = []
for s in screens_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
@@ -17,14 +22,12 @@ def _game_objects_with_thumbnails(slug, screens_mod):
return result
from .collision_rules import LEAF_ACTION_TYPES
# Champs affichés pour chaque type de feuille (voir screens/rendering/
# collision_rules.py pour la forme exacte de chacune) — un simple filtre
# sur l'action déjà sanitizée, jamais une recopie de valeur : ajouter un
# type de feuille suffit à l'ajouter ici, sans dupliquer sa logique de
# sanitisation.
_LEAF_DISPLAY_FIELDS = {
_LEAF_DISPLAY_FIELDS: dict[str, tuple[str, ...]] = {
"dialogue": ("dialogue",),
"variable": ("id", "target_variable", "data_operation", "data_value"),
"surbrillance": ("id", "object_id", "valeur"),
@@ -36,21 +39,21 @@ _LEAF_DISPLAY_FIELDS = {
}
def _describe_leaf(action):
def _describe_leaf(action: dict[str, Any]) -> dict[str, Any]:
"""Une seule feuille (voir LEAF_ACTION_TYPES, screens/rendering/
collision_rules.py) décrite pour l'affichage, SANS son éventuel
"then" (voir _describe_chain, qui aplatit la chaîne complète)."""
action_type = action.get("type")
fields = _LEAF_DISPLAY_FIELDS.get(action_type, ())
fields = _LEAF_DISPLAY_FIELDS.get(action_type or "", ())
return {"type": action_type, **{f: action[f] for f in fields if f in action}}
def _describe_chain(action):
def _describe_chain(action: Any) -> list[dict[str, Any]]:
"""Aplatit une feuille + ses "then" successifs en une LISTE, dans
l'ordre d'exécution — voir maquette validée ("plusieurs actions
chaînées"). Liste vide si `action` n'est pas une feuille reconnue
(voir LEAF_ACTION_TYPES)."""
chain = []
chain: list[dict[str, Any]] = []
node = action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
chain.append(_describe_leaf(node))
@@ -58,13 +61,13 @@ def _describe_chain(action):
return chain
def _branch_info(branch):
def _branch_info(branch: Any) -> dict[str, Any] | None:
if not isinstance(branch, dict) or branch.get("type") not in LEAF_ACTION_TYPES:
return None
return {"chain": _describe_chain(branch)}
def _resolve_action_trigger(action, has_interagir=False):
def _resolve_action_trigger(action: Any, has_interagir: bool = False) -> dict[str, Any]:
"""Décrit ENTIÈREMENT un arbre d'action (voir screens/rendering/
collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" —
chaque feuille affiche DIRECTEMENT son propre contenu, où qu'elle soit
@@ -85,9 +88,11 @@ def _resolve_action_trigger(action, has_interagir=False):
return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True)
if action_type == "condition":
return {
"has_interagir": has_interagir, "chain": [],
"has_interagir": has_interagir,
"chain": [],
"condition": {
"variable": action.get("variable"), "operateur": action.get("operateur"),
"variable": action.get("variable"),
"operateur": action.get("operateur"),
"valeur": action.get("valeur"),
"si_vrai": _branch_info(action.get("si_vrai")),
"si_faux": _branch_info(action.get("si_faux")),
@@ -96,7 +101,7 @@ def _resolve_action_trigger(action, has_interagir=False):
return {"has_interagir": has_interagir, "chain": [], "condition": None}
def list_all_triggers(slug):
def list_all_triggers(slug: str) -> list[dict[str, Any]]:
"""TOUS les déclencheurs du jeu, un par occurrence de "À la collision"
posée sur un objet de scène — remplace l'ancienne recherche inversée
par quête : plus de filtrage par id, chaque déclencheur affiche
@@ -107,7 +112,7 @@ def list_all_triggers(slug):
objects = _game_objects_with_thumbnails(slug, screens_mod)
rule_objects = screens_mod.build_collision_rule_objects(objects)
result = []
result: list[dict[str, Any]] = []
for obj in rule_objects:
# Rétro-remplissage des feuilles posées avant que "id" ne devienne
# systématique (ex. par l'IA, ce champ restant optionnel dans son
@@ -120,19 +125,21 @@ def list_all_triggers(slug):
screens_mod.set_scene_object_collision_rules(slug, obj["id"], obj["rules"])
for rule in obj["rules"]:
info = _resolve_action_trigger(rule["action"])
result.append({
"object_id": obj["id"],
"object_name": obj["name"],
"thumbnail_html": obj["thumbnail_html"],
"has_interagir": info["has_interagir"],
"trigger": rule["trigger"],
"condition": info["condition"],
"chain": info["chain"],
})
result.append(
{
"object_id": obj["id"],
"object_name": obj["name"],
"thumbnail_html": obj["thumbnail_html"],
"has_interagir": info["has_interagir"],
"trigger": rule["trigger"],
"condition": info["condition"],
"chain": info["chain"],
}
)
return result
def _collect_dialogue_lines_from_action(action, acc):
def _collect_dialogue_lines_from_action(action: Any, acc: list[list[dict[str, Any]]]) -> None:
if not isinstance(action, dict):
return
action_type = action.get("type")
@@ -150,7 +157,7 @@ def _collect_dialogue_lines_from_action(action, acc):
_collect_dialogue_lines_from_action(action.get("si_faux"), acc)
def collect_all_dialogue_lines(slug):
def collect_all_dialogue_lines(slug: str) -> list[list[dict[str, Any]]]:
"""Toutes les répliques/questions de TOUS les dialogues embarqués dans
les règles de collision de TOUS les objets de scène du jeu (voir
screens/rendering/collision_rules.py::"dialogue".dialogue), y compris
@@ -160,7 +167,7 @@ def collect_all_dialogue_lines(slug):
publish/build_scorm_package.py)."""
import screens as screens_mod
acc = []
acc: list[list[dict[str, Any]]] = []
for s in screens_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
@@ -172,7 +179,7 @@ def collect_all_dialogue_lines(slug):
return acc
def _collect_completable_ids_from_action(action, acc):
def _collect_completable_ids_from_action(action: Any, acc: list[str]) -> None:
if not isinstance(action, dict):
return
action_type = action.get("type")
@@ -190,7 +197,7 @@ def _collect_completable_ids_from_action(action, acc):
_collect_completable_ids_from_action(action.get("si_faux"), acc)
def list_completable_dialogue_ids(slug):
def list_completable_dialogue_ids(slug: str) -> list[str]:
"""Tous les id de dialogue du jeu dont l'action porte
`mark_completed` (voir screens/rendering/collision_rules.py), y
compris au fil d'une chaîne "then" — utilisée par static/js/play/
@@ -199,7 +206,7 @@ def list_completable_dialogue_ids(slug):
aucune notion de quête."""
import screens as screens_mod
acc = []
acc: list[str] = []
for s in screens_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
@@ -211,7 +218,7 @@ def list_completable_dialogue_ids(slug):
return acc
def list_all_collision_objects(slug):
def list_all_collision_objects(slug: str) -> list[dict[str, Any]]:
"""Catalogue de TOUS les objets éligibles à une règle de collision,
toutes scènes "jeu_2d" confondues — pour peupler le sélecteur d'objet
de la modale d'ajout de déclencheur (voir routes/triggers/)."""