"""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). 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 screens/rendering/collision_rules.py::_MAX_THEN_CHAIN.""" chain = {"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 = screens.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(): """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 = screens.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(): 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_inserts_between_a_leaf_and_its_existing_then(): """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 = 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 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(): """"clic"/"survol" — nouveaux déclencheurs sans besoin d'un joueur, voir screens/rendering/collision_rules.py.""" rules = screens.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(): rules = screens.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(): rules = screens.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(): rules = screens.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(): rules = screens.sanitize_collision_rules([ {"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "sorcellerie"}}, ]) assert rules == [] def test_sanitize_collision_rules_keeps_a_son_action(): rules = screens.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(): rules = screens.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}]) assert rules == [] def test_sanitize_collision_rules_keeps_a_video_action(): rules = screens.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(): rules = screens.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(): rules = screens.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 = screens.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(): rules = screens.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(): """Une "video" reste une FEUILLE chaînable, comme dialogue/variable (demande explicite : pouvoir enchaîner plusieurs actions).""" rules = screens.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(): rules = screens.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(): rules = screens.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(): rules = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "clic", "action": {"type": "surbrillance", "id": "s_1", "object_id": 7, "valeur": "on"}, }]) attached = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, }]) removed = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": { "type": "attendre", "id": "a_1", "data_value": "2", "then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, }, }]) removed = screens.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(): rules = screens.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 = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, }]) assert screens.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(): 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, }, }]) removed = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}, }]) removed = screens.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(): rules = screens.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 = screens.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(): rules = screens.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 = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, }]) assert screens.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(): """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 = [{ "trigger": "collision", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, }] changed = screens.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(): rules = [{ "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 = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}, }]) changed = screens.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(): rules = screens.sanitize_collision_rules([{ "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, }]) changed = screens.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(): 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 def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(client, tmp_game_slug_cleanup): """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/play.html — en aperçu créateur (/game//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/js/play/offline/compute-operation.js"' in html assert 'src="/static/js/play/offline/apply-actions.js"' in html def test_add_scene_object_with_image_url_sets_src_without_background_library(client, tmp_game_slug_cleanup): """"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 = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png") obj = screens.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, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) object_id = screens.add_scene_object( slug, screen_id, kind="fond", background_slug="ne-existe-pas", image_url="/assets/1/abc.png", ) obj = screens.get_scene_object(slug, object_id) assert obj["attributes"]["src"] == "/assets/1/abc.png"