Files
Forge-Engine/tests/game/test_collision_rules.py
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

1178 lines
42 KiB
Python

"""Règles de collision (voir game_engine/rendering/collision_rules.py) : une
règle "déclencheur (collision) -> action (dialogue/interagir/condition)"
par objet de scène — toujours les AUTRES objets qui réagissent à la
présence du joueur, jamais l'inverse. Posées depuis l'onglet
"Déclencheurs" (voir tests/game/test_triggers.py pour le parcours complet côté
route) — ce fichier teste la couche de validation/lecture elle-même
(sanitize_collision_rules/resolve_collision_rules), plus l'exposition
dans full_game_payload."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import db
import game_engine
from tests.conftest import not_none
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_collision_rules"
) -> Any:
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = game_engine.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def _add_personnage(client: FlaskClient, slug: str, screen_id: int, role: str | None = None) -> Any:
import re
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
)
assert resp.status_code == 302, resp.data
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
if role:
client.post(
f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json"
)
return obj_id
def test_freshly_placed_object_has_no_collision_rules(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
obj = not_none(game_engine.get_scene_object(slug, enemy_id))
assert game_engine.resolve_collision_rules(obj) == []
def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "dialogue"}},
]
)
assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}]
def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting() -> None:
""" "interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
à l'appui — un seul niveau d'imbrication autorisé (voir
game_engine/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
]
)
assert rules == [
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
]
def test_sanitize_collision_rules_rejects_interagir_nested_inside_interagir() -> None:
"""Une imbrication trop profonde (interagir -> interagir -> ...) est
ignorée plutôt que d'accepter une structure sans fin postée à la main."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "interagir",
"sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}},
},
},
]
)
assert rules == [] # la règle entière est rejetée
def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches() -> None:
""" "condition" (losange à embranchement, maquette validée) : Vrai/Faux
peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "inventaire_cle",
"operateur": "superieur",
"valeur": "0",
"si_vrai": {"type": "dialogue"},
"si_faux": None,
},
}
]
)
assert rules == [
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "inventaire_cle",
"operateur": "superieur",
"valeur": "0",
"si_vrai": {"type": "dialogue"},
"si_faux": None,
},
}
]
def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "",
"operateur": "superieur",
"valeur": "0",
"si_vrai": None,
"si_faux": None,
},
},
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "sorcellerie",
"valeur": "0",
"si_vrai": None,
"si_faux": None,
},
},
]
)
assert rules == []
def test_sanitize_collision_rules_rejects_interagir_nested_inside_a_condition_branch() -> None:
"""Une branche Vrai/Faux reste une FEUILLE — jamais une nouvelle
"interagir"/"condition" imbriquée (voir maquette validée : chaque
branche n'a que "Déclencher ce dialogue" ou "Ne rien faire")."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": {"type": "interagir", "sub_action": {"type": "dialogue"}},
"si_faux": None,
},
}
]
)
assert rules == [
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": None,
"si_faux": None,
},
}
]
def test_sanitize_collision_rules_keeps_interagir_wrapping_a_condition() -> None:
"""interagir -> condition -> dialogue (maquette validée : "Interagir avec
une touche" PUIS un test avant de déclencher le dialogue)."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "interagir",
"sub_action": {
"type": "condition",
"variable": "score",
"operateur": "superieur_egal",
"valeur": "10",
"si_vrai": {"type": "dialogue"},
"si_faux": {"type": "dialogue"},
},
},
}
]
)
assert rules[0]["action"]["sub_action"]["type"] == "condition"
assert rules[0]["action"]["sub_action"]["si_vrai"] == {"type": "dialogue"}
def test_sanitize_collision_rules_keeps_a_valid_dialogue_and_mark_completed() -> None:
"""Chaque occurrence de "Déclencher ce dialogue" porte SON PROPRE
dialogue ({id, lines}, voir maquette validée : "un dialogue est un
ensemble de répliques regroupées par un id")."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"mark_completed": True,
"dialogue": {
"id": "d_1",
"lines": [{"speaker": "pnj", "text": "Salut !"}, {"speaker": "x", "text": " "}],
},
},
}
]
)
assert rules == [
{
"trigger": "collision",
"action": {
"type": "dialogue",
"mark_completed": True,
"dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]},
},
}
]
def test_sanitize_collision_rules_drops_a_dialogue_without_a_valid_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": " ", "lines": [{"speaker": "pnj", "text": "Salut !"}]},
},
}
]
)
assert "dialogue" not in rules[0]["action"]
def test_sanitize_collision_rules_keeps_a_dialogue_on_a_condition_branch() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
"si_faux": None,
},
}
]
)
assert rules[0]["action"]["si_vrai"]["dialogue"] == {"id": "d_vrai", "lines": []}
def test_set_dialogue_lines_in_rules_updates_the_matching_dialogue_wherever_it_is() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "interagir",
"sub_action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": None,
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
},
}
]
)
updated = game_engine.set_dialogue_lines_in_rules(rules, "d_1", [{"speaker": "pnj", "text": "Trouvé !"}])
assert updated is True
assert rules[0]["action"]["sub_action"]["si_faux"]["dialogue"]["lines"] == [
{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}
]
def test_set_dialogue_lines_in_rules_returns_false_for_an_unknown_dialogue_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
assert game_engine.set_dialogue_lines_in_rules(rules, "d_absent", [{"speaker": "pnj", "text": "x"}]) is False
assert rules[0]["action"]["dialogue"]["lines"] == [] # inchangé
def test_sanitize_collision_rules_keeps_a_variable_action() -> None:
""" "Modifier une variable" (même vocabulaire que l'action du graphe de
logique document, voir game_engine/labels/data_operations.py) — utilisable
partout où une feuille est attendue : ici directement après une
collision."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "d_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "5",
},
}
]
)
assert rules == [
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "d_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "5",
},
}
]
def test_sanitize_collision_rules_drops_a_variable_action_without_a_target_or_with_an_unknown_operation() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "variable", "target_variable": "", "data_operation": "incrementer"},
},
{
"trigger": "collision",
"action": {"type": "variable", "target_variable": "score", "data_operation": "sorcellerie"},
},
]
)
assert rules == []
def test_sanitize_collision_rules_omits_data_value_for_operations_that_need_none() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"target_variable": "actif",
"data_operation": "basculer_bool",
"data_value": "peu importe",
},
}
]
)
assert "data_value" not in rules[0]["action"]
def test_sanitize_collision_rules_keeps_a_variable_action_as_a_condition_branch() -> None:
"""Une branche Vrai/Faux peut être une feuille "variable" tout autant
qu'une feuille "dialogue" (voir maquette validée : chaque branche
reste une feuille, peu importe laquelle)."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": {
"type": "variable",
"target_variable": "vies",
"data_operation": "decrementer",
"data_value": "1",
},
"si_faux": None,
},
}
]
)
assert rules[0]["action"]["si_vrai"]["type"] == "variable"
def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves() -> None:
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
lui ajouter des actions") : dialogue -> then variable -> then dialogue,
plusieurs actions à la suite sur le MÊME déclencheur."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": []},
"then": {
"type": "variable",
"id": "d_2",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
"then": {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}},
},
},
}
]
)
action = rules[0]["action"]
assert action["then"]["type"] == "variable"
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}}
def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long() -> None:
"""_MAX_THEN_CHAIN borne la chaîne — une chaîne trop longue postée à la
main est coupée plutôt que rejetée entièrement (même tolérance que le
reste de ce moteur). Bug corrigé : à 4 (l'ancienne valeur), un
créateur enchaînant "attendre -> dialogue -> surbrillance -> attendre"
(4 maillons, un scénario tout à fait normal) voyait déjà TOUT
5e maillon ajouté ensuite tronqué en silence — voir
game_engine/rendering/collision_rules.py::_MAX_THEN_CHAIN."""
chain: dict[str, Any] = {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
}
node = chain
for _ in range(50): # largement au-delà de _MAX_THEN_CHAIN (30)
node["then"] = {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
}
node = node["then"]
rules = game_engine.sanitize_collision_rules([{"trigger": "collision", "action": chain}])
depth = 0
node = rules[0]["action"]
while node:
depth += 1
node = node.get("then")
assert depth == 30 # _MAX_THEN_CHAIN, jamais plus
def test_sanitize_collision_rules_keeps_a_then_chain_of_five_leaves_the_bug_that_broke_the_user() -> None:
"""Reproduction directe du bug signalé : attendre -> dialogue ->
surbrillance -> attendre -> surbrillance (5 maillons) doit survivre
intact à la resanitisation qui suit chaque écriture (voir
append_action_to_screen_trigger/set_then_action_in_rules)."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": []},
"then": {
"type": "surbrillance",
"id": "s_1",
"object_id": 37,
"valeur": "on",
"then": {
"type": "attendre",
"id": "a_2",
"data_value": "3",
"then": {"type": "surbrillance", "id": "s_2", "object_id": 37, "valeur": "off"},
},
},
},
},
}
]
)
depth = 0
node = rules[0]["action"]
while node:
depth += 1
node = node.get("then")
assert depth == 5
def test_set_then_action_in_rules_attaches_a_new_leaf_at_the_end_of_a_dialogue_chain() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
attached = game_engine.set_then_action_in_rules(
rules,
"d_1",
{"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
)
assert attached is True
assert rules[0]["action"]["then"] == {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
}
def test_set_then_action_in_rules_addresses_a_variable_leaf_by_its_own_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
}
]
)
attached = game_engine.set_then_action_in_rules(
rules, "v_1", {"type": "dialogue", "dialogue": {"id": "d_2", "lines": []}}
)
assert attached is True
assert rules[0]["action"]["then"]["dialogue"]["id"] == "d_2"
def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
assert (
game_engine.set_then_action_in_rules(
rules, "d_absent", {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}
)
is False
)
def test_set_then_action_in_rules_inserts_between_a_leaf_and_its_existing_then() -> None:
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
n'importe quel niveau du schéma" — la suite déjà posée (ici "score")
passe DERRIÈRE la nouvelle action insérée ("vies"), jamais écrasée
(comportement précédent : refuser purement et simplement)."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": []},
"then": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
},
}
]
)
attached = game_engine.set_then_action_in_rules(
rules,
"d_1",
{"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"},
)
assert attached is True
action = rules[0]["action"]
assert action["then"]["target_variable"] == "vies"
assert action["then"]["then"] == {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
}
def test_sanitize_collision_rules_accepts_clic_and_survol_triggers() -> None:
""" "clic"/"survol" — nouveaux déclencheurs sans besoin d'un joueur,
voir game_engine/rendering/collision_rules.py."""
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "clic", "action": {"type": "dialogue"}},
{"trigger": "survol", "action": {"type": "dialogue"}},
]
)
assert [r["trigger"] for r in rules] == ["clic", "survol"]
def test_sanitize_collision_rules_keeps_a_surbrillance_action() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "clic",
"action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"},
}
]
)
assert rules == [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"}}]
def test_sanitize_collision_rules_drops_a_surbrillance_action_with_an_unknown_valeur_or_object_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "sorcellerie"}},
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": "pas-un-id", "valeur": "on"}},
]
)
assert rules == []
def test_sanitize_collision_rules_keeps_a_visibilite_action() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "visibilite", "object_id": 3, "valeur": "masque"},
}
]
)
assert rules == [{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "masque"}}]
def test_sanitize_collision_rules_drops_a_visibilite_action_with_an_unknown_valeur() -> None:
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "sorcellerie"}},
]
)
assert rules == []
def test_sanitize_collision_rules_keeps_a_son_action() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "clic",
"action": {"type": "son", "asset_url": "/assets/1/bip.mp3"},
}
]
)
assert rules == [{"trigger": "clic", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}}]
def test_sanitize_collision_rules_drops_a_son_action_without_asset_url() -> None:
rules = game_engine.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}])
assert rules == []
def test_sanitize_collision_rules_keeps_a_video_action() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "survol",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
}
]
)
assert rules == [
{
"trigger": "survol",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
}
]
def test_sanitize_collision_rules_drops_a_video_action_with_an_unknown_mode() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "survol",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "sorcellerie"},
}
]
)
assert rules == []
def test_sanitize_collision_rules_keeps_an_indication_action_with_and_without_duree_ms() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
}
]
)
assert rules == [
{
"trigger": "collision",
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
}
]
rules_no_duree = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !"},
}
]
)
assert "duree_ms" not in rules_no_duree[0]["action"]
def test_sanitize_collision_rules_drops_an_indication_action_without_texte() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "indication", "object_id": 5, "texte": " "},
}
]
)
assert rules == []
def test_sanitize_collision_rules_chains_a_video_then_a_dialogue() -> None:
"""Une "video" reste une FEUILLE chaînable, comme dialogue/variable
(demande explicite : pouvoir enchaîner plusieurs actions)."""
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "video",
"id": "v_1",
"asset_url": "/assets/1/intro.mp4",
"mode": "plein_ecran",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
)
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
def test_sanitize_collision_rules_keeps_an_attendre_action() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "attendre", "data_value": "2.5"},
}
]
)
assert rules == [{"trigger": "collision", "action": {"type": "attendre", "data_value": "2.5"}}]
def test_sanitize_collision_rules_drops_an_attendre_action_with_an_invalid_or_negative_value() -> None:
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "attendre", "data_value": "pas-un-nombre"}},
{"trigger": "collision", "action": {"type": "attendre", "data_value": "-1"}},
]
)
assert rules == []
def test_sanitize_collision_rules_chains_an_attendre_then_a_dialogue() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
)
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
def test_set_then_action_in_rules_addresses_a_surbrillance_leaf_by_its_own_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "clic",
"action": {"type": "surbrillance", "id": "s_1", "object_id": 7, "valeur": "on"},
}
]
)
attached = game_engine.set_then_action_in_rules(rules, "s_1", {"type": "son", "asset_url": "/assets/1/bip.mp3"})
assert attached is True
assert rules[0]["action"]["then"] == {"type": "son", "asset_url": "/assets/1/bip.mp3"}
def test_remove_action_from_rules_removes_the_whole_rule_when_removing_a_single_head_leaf() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
removed = game_engine.remove_action_from_rules(rules, "d_1")
assert removed is True
assert rules == []
def test_remove_action_from_rules_promotes_the_then_when_removing_the_head_of_a_chain() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
)
removed = game_engine.remove_action_from_rules(rules, "a_1")
assert removed is True
assert rules == [{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}]
def test_remove_action_from_rules_removes_a_middle_leaf_and_reconnects_the_chain() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
"then": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
},
}
]
)
removed = game_engine.remove_action_from_rules(rules, "a_1")
assert removed is True
action = rules[0]["action"]
assert action["type"] == "variable"
assert action["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
def test_remove_action_from_rules_returns_false_for_an_unknown_leaf_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
assert game_engine.remove_action_from_rules(rules, "d_absent") is False
assert len(rules) == 1
def test_remove_action_from_rules_removes_a_leaf_inside_a_condition_branch() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
"si_faux": None,
},
}
]
)
removed = game_engine.remove_action_from_rules(rules, "d_vrai")
assert removed is True
assert rules[0]["action"]["si_vrai"] is None
def test_remove_action_from_rules_removes_interagir_entirely_when_its_only_sub_action_is_removed() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "interagir",
"sub_action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
)
removed = game_engine.remove_action_from_rules(rules, "d_1")
assert removed is True
assert rules == []
def test_move_action_in_rules_swaps_adjacent_leaves_up_and_down() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
"then": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
},
}
]
)
moved = game_engine.move_action_in_rules(rules, "a_1", "up")
assert moved is True
action = rules[0]["action"]
assert action["type"] == "attendre"
assert action["then"]["type"] == "variable"
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
def test_move_action_in_rules_at_the_start_of_the_chain_moving_up_has_no_effect_but_still_found() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
)
moved = game_engine.move_action_in_rules(rules, "v_1", "up")
assert moved is True
assert rules[0]["action"]["target_variable"] == "score" # inchangé, déjà en tête
def test_move_action_in_rules_returns_false_for_an_unknown_leaf_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
assert game_engine.move_action_in_rules(rules, "d_absent", "up") is False
def test_backfill_missing_leaf_ids_assigns_a_stable_id_to_a_variable_leaf_without_one() -> None:
"""Bug corrigé : une action "variable" posée sans "id" (ex. par l'IA,
ce champ restant optionnel dans son schéma, ou une session
antérieure à cette convention) restait à jamais impossible à
adresser — "+ Ajouter une action" n'apparaissait jamais après elle."""
rules: list[dict[str, Any]] = [
{
"trigger": "collision",
"action": {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
}
]
changed = game_engine.backfill_missing_leaf_ids(rules)
assert changed is True
assert rules[0]["action"]["id"]
def test_backfill_missing_leaf_ids_reaches_inside_condition_branches_and_then_chains() -> None:
rules: list[dict[str, Any]] = [
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "x",
"operateur": "egal",
"valeur": "1",
"si_vrai": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": []},
"then": {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
},
"si_faux": None,
},
}
]
changed = game_engine.backfill_missing_leaf_ids(rules)
assert changed is True
assert rules[0]["action"]["si_vrai"]["then"]["id"]
def test_backfill_missing_leaf_ids_is_a_noop_when_every_leaf_already_has_one() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
}
]
)
changed = game_engine.backfill_missing_leaf_ids(rules)
assert changed is False
assert rules[0]["action"]["id"] == "v_1"
def test_backfill_missing_leaf_ids_never_touches_a_dialogue_leaf_which_already_has_its_own_id() -> None:
rules = game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
changed = game_engine.backfill_missing_leaf_ids(rules)
assert changed is False
assert "id" not in rules[0]["action"]
def test_sanitize_collision_rules_drops_malformed_or_unknown_rules() -> None:
rules = game_engine.sanitize_collision_rules(
[
{"trigger": "diagonale", "action": {"type": "dialogue"}},
{"trigger": "collision", "action": {"type": "sorcellerie"}},
{"trigger": "perimetre", "action": {"type": "dialogue"}}, # "perimetre" retiré (demande explicite)
"pas un objet",
]
)
assert rules == []
def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Toujours les AUTRES objets qui réagissent à la présence du joueur,
jamais l'inverse — le joueur lui-même et un fond n'ont pas de champ
"collision_rules" dans le payload."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
# "Joueur" est désormais toujours un choix explicite.
player_id = _add_personnage(client, slug, screen_id, role="joueur")
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
payload = game_engine.full_game_payload(slug)
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
player_payload = next(o for o in scene_screen["elements"] if o["id"] == player_id)
enemy_payload = next(o for o in scene_screen["elements"] if o["id"] == enemy_id)
assert "collision_rules" not in player_payload
assert enemy_payload["collision_rules"] == []
def test_build_collision_rule_objects_excludes_the_player(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""game_engine.build_collision_rule_objects (voir game_engine/rendering/
trigger_graph.py pour son autre appelant, le listage de tous les
déclencheurs du jeu) : "Joueur" (le seul rôle exclu) est désormais
toujours un choix explicite."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
player_id = _add_personnage(client, slug, screen_id, role="joueur")
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
objects = game_engine.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = game_engine.render_scene_object(o)
rule_objects = game_engine.build_collision_rule_objects(objects)
ids = [o["id"] for o in rule_objects]
assert enemy_id in ids
assert player_id not in ids
def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé : compute-operation.js/apply-actions.js (qui portent
forgeApplyVariableActionOffline, appelée par collision-rules-controller.js
pour l'action "Modifier une variable") n'étaient chargés QUE derrière
`{% if offline_mode %}` dans templates/game/play.html — en aperçu créateur
(/game/<slug>/play, jamais offline_mode), l'action ne faisait donc
RIEN (fonction absente). Les deux scripts doivent être chargés
inconditionnellement (voir aussi test_export_scorm.py, qui vérifie
qu'ils restent bien présents dans le paquet exporté)."""
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
html = client.get(f"/game/{slug}/play").data.decode()
assert 'src="/static/game/js/play/offline/compute-operation.js"' in html
assert 'src="/static/game/js/play/offline/apply-actions.js"' in html
def test_add_scene_object_with_image_url_sets_src_without_background_library(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
""" "Mes assets"/IA (voir plan Phase 1, §1) : une image ARBITRAIRE,
jamais une clé de BACKGROUND_LIBRARY."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png")
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert obj["attributes"]["src"] == "/assets/1/abc.png"
def test_add_scene_object_image_url_takes_priority_over_background_slug(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(
slug,
screen_id,
kind="fond",
background_slug="ne-existe-pas",
image_url="/assets/1/abc.png",
)
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert obj["attributes"]["src"] == "/assets/1/abc.png"