"""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