Files
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

233 lines
11 KiB
Python

from typing import Any
from .collision_rules import LEAF_ACTION_TYPES
def _game_objects_with_thumbnails(slug: str, game_engine_mod: Any) -> list[dict[str, Any]]:
"""Objets de TOUTES les scènes "jeu_2d" du jeu (contrairement à
game_engine.list_scene_objects, qui ne prend qu'un screen_id) —
rendered_html ajouté comme l'exigent game_engine.build_collision_rule_objects
(voir routes/game/scenes/scene_edit_view.py pour le même patron, une seule
scène)."""
result: list[dict[str, Any]] = []
for s in game_engine_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
objects = game_engine_mod.list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = game_engine_mod.render_scene_object(o)
o["screen_id"] = s["id"]
o["screen_name"] = s["name"]
result.extend(objects)
return result
# Champs affichés pour chaque type de feuille (voir game_engine/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: dict[str, tuple[str, ...]] = {
"dialogue": ("dialogue",),
"variable": ("id", "target_variable", "data_operation", "data_value"),
"surbrillance": ("id", "object_id", "valeur"),
"visibilite": ("id", "object_id", "valeur"),
"son": ("id", "asset_url"),
"video": ("id", "asset_url", "mode"),
"indication": ("id", "object_id", "texte", "duree_ms"),
"attendre": ("id", "data_value"),
}
def _describe_leaf(action: dict[str, Any]) -> dict[str, Any]:
"""Une seule feuille (voir LEAF_ACTION_TYPES, game_engine/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 or "", ())
return {"type": action_type, **{f: action[f] for f in fields if f in 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: list[dict[str, Any]] = []
node = action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
chain.append(_describe_leaf(node))
node = node.get("then")
return chain
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: Any, has_interagir: bool = False) -> dict[str, Any]:
"""Décrit ENTIÈREMENT un arbre d'action (voir game_engine/rendering/
collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" —
chaque feuille affiche DIRECTEMENT son propre contenu, où qu'elle soit
dans l'arbre (y compris CHAQUE branche d'une "condition",
indépendamment l'une de l'autre), et sa chaîne "then" complète (voir
_describe_chain). Retourne {has_interagir, chain, condition} —
`chain` est une LISTE (jamais None, vide si l'action ne se termine
par aucune feuille reconnue) ; `condition` est None sauf pour une
action "condition", auquel cas il porte {variable, operateur, valeur,
si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne rien
faire") ou {chain: [...]}."""
if not isinstance(action, dict):
return {"has_interagir": has_interagir, "chain": [], "condition": None}
action_type = action.get("type")
if action_type in LEAF_ACTION_TYPES:
return {"has_interagir": has_interagir, "chain": _describe_chain(action), "condition": None}
if action_type == "interagir":
return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True)
if action_type == "condition":
return {
"has_interagir": has_interagir,
"chain": [],
"condition": {
"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")),
},
}
return {"has_interagir": has_interagir, "chain": [], "condition": None}
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
directement sa propre logique (voir onglet "Déclencheurs",
static/game/js/triggers/trigger-editor.js)."""
import game_engine as game_engine_mod
objects = _game_objects_with_thumbnails(slug, game_engine_mod)
rule_objects = game_engine_mod.build_collision_rule_objects(objects)
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
# schéma) — sans ça, "+ Ajouter une action"/le retrait/le
# déplacement restent silencieusement indisponibles sur ce bloc
# précis (voir game_engine/rendering/collision_rules.py::
# backfill_missing_leaf_ids). Persisté dès ce premier affichage,
# jamais régénéré ensuite.
if game_engine_mod.backfill_missing_leaf_ids(obj["rules"]):
game_engine_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"],
}
)
return result
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")
if action_type == "dialogue":
dialogue = action.get("dialogue")
if isinstance(dialogue, dict):
acc.append(dialogue.get("lines") or [])
_collect_dialogue_lines_from_action(action.get("then"), acc)
elif action_type in LEAF_ACTION_TYPES:
_collect_dialogue_lines_from_action(action.get("then"), acc)
elif action_type == "interagir":
_collect_dialogue_lines_from_action(action.get("sub_action"), acc)
elif action_type == "condition":
_collect_dialogue_lines_from_action(action.get("si_vrai"), acc)
_collect_dialogue_lines_from_action(action.get("si_faux"), acc)
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
game_engine/rendering/collision_rules.py::"dialogue".dialogue), y compris
au fil d'une chaîne "then" — une liste de listes de lignes (une par
dialogue trouvé), jamais fusionnée ici. Utilisée pour calculer le
score max possible du quiz à l'export SCORM (voir
publish/build_scorm_package.py)."""
import game_engine as game_engine_mod
acc: list[list[dict[str, Any]]] = []
for s in game_engine_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
for obj in game_engine_mod.list_scene_objects(slug, s["id"]):
for rule in game_engine_mod.resolve_collision_rules(obj):
_collect_dialogue_lines_from_action(rule.get("action"), acc)
for rule in game_engine_mod.resolve_screen_triggers(s):
_collect_dialogue_lines_from_action(rule.get("action"), acc)
return acc
def _collect_completable_ids_from_action(action: Any, acc: list[str]) -> None:
if not isinstance(action, dict):
return
action_type = action.get("type")
if action_type == "dialogue":
dialogue = action.get("dialogue")
if action.get("mark_completed") and isinstance(dialogue, dict) and dialogue.get("id"):
acc.append(dialogue["id"])
_collect_completable_ids_from_action(action.get("then"), acc)
elif action_type in LEAF_ACTION_TYPES:
_collect_completable_ids_from_action(action.get("then"), acc)
elif action_type == "interagir":
_collect_completable_ids_from_action(action.get("sub_action"), acc)
elif action_type == "condition":
_collect_completable_ids_from_action(action.get("si_vrai"), acc)
_collect_completable_ids_from_action(action.get("si_faux"), acc)
def list_completable_dialogue_ids(slug: str) -> list[str]:
"""Tous les id de dialogue du jeu dont l'action porte
`mark_completed` (voir game_engine/rendering/collision_rules.py), y
compris au fil d'une chaîne "then" — utilisée par static/game/js/play/
dialogue-box-controller.js::forgeSyncAllDialoguesCompletionToScorm
pour savoir quand la partie est terminée (TOUS joués), sans plus
aucune notion de quête."""
import game_engine as game_engine_mod
acc: list[str] = []
for s in game_engine_mod.list_screens(slug):
if s["kind"] != "jeu_2d":
continue
for obj in game_engine_mod.list_scene_objects(slug, s["id"]):
for rule in game_engine_mod.resolve_collision_rules(obj):
_collect_completable_ids_from_action(rule.get("action"), acc)
for rule in game_engine_mod.resolve_screen_triggers(s):
_collect_completable_ids_from_action(rule.get("action"), acc)
return acc
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/game/triggers/)."""
import game_engine as game_engine_mod
objects = _game_objects_with_thumbnails(slug, game_engine_mod)
rule_objects = game_engine_mod.build_collision_rule_objects(objects)
return [
{"id": o["id"], "name": o["name"], "kind": o["kind"], "thumbnail_html": o["thumbnail_html"]}
for o in rule_objects
]