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:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -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)."""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
def _trigger_for(widget_key, attributes):
|
||||
if (attributes or {}).get("type") == "submit":
|
||||
return "soumission"
|
||||
return "clic"
|
||||
@@ -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/)."""
|
||||
|
||||
Reference in New Issue
Block a user