Files
Forge-Engine/tests/test_collision_rules.py
T
williamandClaude Sonnet 5 d664ed5637
Build and deploy / test-python (push) Successful in 8m45s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 22:05:06 +02:00

360 lines
17 KiB
Python

"""Règles de collision (voir screens/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/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."""
import db
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rules"):
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def _add_personnage(client, slug, screen_id, role=None):
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(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, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
obj = screens.get_scene_object(slug, enemy_id)
assert screens.resolve_collision_rules(obj) == []
def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
rules = screens.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():
""""interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
à l'appui — un seul niveau d'imbrication autorisé (voir
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
rules = screens.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():
"""Une imbrication trop profonde (interagir -> interagir -> ...) est
ignorée plutôt que d'accepter une structure sans fin postée à la main."""
rules = screens.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():
""""condition" (losange à embranchement, maquette validée) : Vrai/Faux
peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
rules = screens.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():
rules = screens.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():
"""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 = screens.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():
"""interagir -> condition -> dialogue (maquette validée : "Interagir avec
une touche" PUIS un test avant de déclencher le dialogue)."""
rules = screens.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():
"""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 = screens.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():
rules = screens.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():
rules = screens.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():
rules = screens.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 = screens.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():
rules = screens.sanitize_collision_rules([{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
assert screens.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():
""""Modifier une variable" (même vocabulaire que l'action du graphe de
logique document, voir screens/labels/data_operations.py) — utilisable
partout où une feuille est attendue : ici directement après une
collision."""
rules = screens.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():
rules = screens.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():
rules = screens.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():
"""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 = screens.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():
"""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 = screens.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():
"""_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)."""
chain = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
node = chain
for _ in range(10): # largement au-delà de _MAX_THEN_CHAIN (4)
node["then"] = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
node = node["then"]
rules = screens.sanitize_collision_rules([{"trigger": "collision", "action": chain}])
depth = 0
node = rules[0]["action"]
while node:
depth += 1
node = node.get("then")
assert depth == 4 # _MAX_THEN_CHAIN, jamais plus
def test_set_then_action_in_rules_attaches_a_new_leaf_at_the_end_of_a_dialogue_chain():
rules = screens.sanitize_collision_rules([{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
attached = screens.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():
rules = screens.sanitize_collision_rules([{
"trigger": "collision",
"action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
}])
attached = screens.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():
rules = screens.sanitize_collision_rules([{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
assert screens.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_never_overwrites_an_existing_then():
rules = screens.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 = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"})
assert attached is False
assert rules[0]["action"]["then"]["target_variable"] == "score" # inchangé
def test_sanitize_collision_rules_drops_malformed_or_unknown_rules():
rules = screens.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, tmp_game_slug_cleanup):
"""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 = screens.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, tmp_game_slug_cleanup):
"""screens.build_collision_rule_objects (voir screens/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 = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
rule_objects = screens.build_collision_rule_objects(objects)
ids = [o["id"] for o in rule_objects]
assert enemy_id in ids
assert player_id not in ids