"""Déclencheurs de collision — onglet "Déclencheurs" (voir game_engine/rendering/trigger_graph.py, routes/game/triggers/triggers.py) : TOUS les déclencheurs du jeu, plus aucune notion de quête pour les regrouper — un créateur peut faire un simple quiz sans jamais poser de personnage. Chaque occurrence de "Déclencher ce dialogue" porte son propre dialogue ({id, lines}, voir game_engine/rendering/collision_rules.py::"dialogue".dialogue), édité en ligne (voir routes POST .../triggers//dialogue ci-dessous). Source de données UNIQUE avec l'éditeur de collision : toute règle ajoutée/retirée/modifiée ici doit rester visible/cohérente via game_engine.resolve_collision_rules directement sur l'objet.""" import re from collections.abc import Callable from typing import Any from flask.testing import FlaskClient import game_engine from tests.conftest import not_none from tests.test_auth import anon_client # noqa: F401 def _create_jeu2d_game( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_triggers" ) -> 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: 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 _set_rules(slug: str, object_id: int, rules: Any) -> None: """Pose des règles directement (sans passer par une route HTTP — l'ancienne route de sauvegarde, routes/game/scenes/scene_object_collision_rules.py, a été retirée avec l'éditeur de collision séparé) pour préparer un scénario de test.""" game_engine.set_scene_object_collision_rules(slug, object_id, game_engine.sanitize_collision_rules(rules)) def test_mark_completed_is_kept_when_true(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": True}}]) obj = not_none(game_engine.get_scene_object(slug, obj_id)) rules = game_engine.resolve_collision_rules(obj) assert rules[0]["action"]["mark_completed"] is True def test_mark_completed_is_dropped_when_false_or_absent( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": False}}]) obj = not_none(game_engine.get_scene_object(slug, obj_id)) rules = game_engine.resolve_collision_rules(obj) assert "mark_completed" not in rules[0]["action"] def test_list_all_triggers_finds_direct_and_nested_rules( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) direct_id = _add_personnage(client, slug, screen_id, role="pnj") nested_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules(slug, direct_id, [{"trigger": "collision", "action": {"type": "dialogue"}}]) _set_rules( slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}] ) result = game_engine.list_all_triggers(slug) ids = [o["object_id"] for o in result] assert direct_id in ids assert nested_id in ids direct_entry = next(o for o in result if o["object_id"] == direct_id) assert direct_entry["has_interagir"] is False nested_entry = next(o for o in result if o["object_id"] == nested_id) assert nested_entry["has_interagir"] is True def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: """Losange à embranchement (maquette validée, lecture verticale) : Vrai ET Faux peuvent chacun déclencher LEUR PROPRE dialogue, indépendamment l'un de l'autre — plus de "branche qui mène à telle quête" à choisir (voir static/game/js/triggers/trigger-editor.js:: triggerConditionColumnHtml pour son affichage).""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": { "type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0", "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}, "si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}, }, } ], ) result = game_engine.list_all_triggers(slug) entry = next(o for o in result if o["object_id"] == obj_id) assert entry["condition"]["variable"] == "inventaire_cle" assert entry["condition"]["si_vrai"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}]} assert entry["condition"]["si_faux"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}]} def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": { "type": "condition", "variable": "score", "operateur": "egal", "valeur": "0", "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, "si_faux": None, }, } ], ) result = game_engine.list_all_triggers(slug) entry = next(o for o in result if o["object_id"] == obj_id) assert entry["condition"]["si_faux"] is None def test_triggers_delete_removes_all_rules_on_the_object( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue"}}]) resp = client.delete(f"/game/{slug}/triggers/{obj_id}") assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) assert game_engine.resolve_collision_rules(obj) == [] def test_triggers_route_add_then_get_then_delete( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") resp = client.post( f"/game/{slug}/triggers", json={ "object_id": obj_id, "rule": { "trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}, }, }, ) assert resp.status_code == 200 # Même donnée, visible directement via la couche collision (source unique). obj = not_none(game_engine.get_scene_object(slug, obj_id)) rules = game_engine.resolve_collision_rules(obj) assert rules == [ { "trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}, } ] resp = client.get(f"/game/{slug}/triggers") result = resp.get_json() assert [o["object_id"] for o in result] == [obj_id] assert result[0]["has_interagir"] is True resp = client.delete(f"/game/{slug}/triggers/{obj_id}") assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) assert game_engine.resolve_collision_rules(obj) == [] def test_list_all_triggers_surfaces_the_dialogue( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": { "type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]}, }, } ], ) result = game_engine.list_all_triggers(slug) assert result[0]["chain"] == [ { "type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}, } ] def test_list_all_triggers_surfaces_a_chain_of_actions( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: """Chaînage (demande explicite : "pouvoir modifier un déclencheur pour lui ajouter des actions") : dialogue -> then variable, la chaîne ENTIÈRE est aplatie dans l'ordre d'exécution.""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "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", }, }, } ], ) result = game_engine.list_all_triggers(slug) assert result[0]["chain"] == [ {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, { "type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1", }, ] def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) direct_id = _add_personnage(client, slug, screen_id, role="pnj") cond_id = _add_personnage(client, slug, screen_id, role="pnj") other_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, direct_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True}, } ], ) _set_rules( slug, cond_id, [ { "trigger": "collision", "action": { "type": "condition", "variable": "score", "operateur": "egal", "valeur": "1", "si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True}, "si_faux": None, }, } ], ) _set_rules( slug, other_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}}, } ], ) ids = game_engine.list_completable_dialogue_ids(slug) assert set(ids) == {"d_direct", "d_cond"} def test_trigger_dialogue_update_route_persists_the_new_lines( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.post( f"/game/{slug}/triggers/{obj_id}/dialogue", json={"dialogue_id": "d_1", "lines": [{"speaker": "pnj", "text": "Trouvé !"}]}, ) assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) rules = game_engine.resolve_collision_rules(obj) assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}] def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.post( f"/game/{slug}/triggers/{obj_id}/dialogue", json={"dialogue_id": "d_absent", "lines": [{"speaker": "pnj", "text": "x"}]}, ) assert resp.status_code == 404 def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client: FlaskClient, game: str) -> None: resp = client.post( f"/game/{game}/triggers/999999/dialogue", json={"dialogue_id": "d_1", "lines": []}, ) assert resp.status_code == 404 def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.post( f"/game/{slug}/triggers/{obj_id}/append-action", json={ "after_id": "d_1", "action": { "type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1", }, }, ) assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) rules = game_engine.resolve_collision_rules(obj) assert rules[0]["action"]["then"] == { "type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1", } def test_trigger_append_action_route_404s_for_an_unknown_after_id( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.post( f"/game/{slug}/triggers/{obj_id}/append-action", json={ "after_id": "d_absent", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}, }, ) assert resp.status_code == 404 def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: """Demande explicite : "pouvoir ajouter n'importe quelle action à n'importe quel niveau du schéma" — poser "+ Ajouter une action" sur un bloc qui a déjà une suite INSÈRE la nouvelle action devant cette suite plutôt que d'échouer (comportement précédent).""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "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", }, }, } ], ) resp = client.post( f"/game/{slug}/triggers/{obj_id}/append-action", json={ "after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}, }, ) assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) action = game_engine.resolve_collision_rules(obj)[0]["action"] assert action["then"]["target_variable"] == "vies" assert action["then"]["then"]["target_variable"] == "score" def test_trigger_append_action_route_404s_for_an_unknown_object(client: FlaskClient, game: str) -> None: resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}}) assert resp.status_code == 404 def test_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_1") assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) assert game_engine.resolve_collision_rules(obj) == [] def test_trigger_remove_action_route_404s_for_an_unknown_leaf_id( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_absent") assert resp.status_code == 404 def test_trigger_move_action_route_swaps_a_leaf_up_in_its_chain( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "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": []}}, }, } ], ) resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "up"}) assert resp.status_code == 200 obj = not_none(game_engine.get_scene_object(slug, obj_id)) action = game_engine.resolve_collision_rules(obj)[0]["action"] assert action["type"] == "dialogue" assert action["then"]["target_variable"] == "score" def test_trigger_move_action_route_400s_for_an_invalid_direction( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, } ], ) resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "sideways"}) assert resp.status_code == 400 def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: """Bug corrigé : une action "variable" posée sans "id" (par une session antérieure ou par l'IA) restait à jamais impossible à adresser via append-action — le premier GET /triggers doit la rendre adressable, DE FAÇON STABLE (persisté, pas régénéré à chaque appel).""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = _add_personnage(client, slug, screen_id, role="pnj") _set_rules( slug, obj_id, [ { "trigger": "collision", "action": { "type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1", }, } ], ) resp1 = client.get(f"/game/{slug}/triggers") leaf_id = resp1.get_json()[0]["chain"][0]["id"] assert leaf_id resp2 = client.get(f"/game/{slug}/triggers") assert resp2.get_json()[0]["chain"][0]["id"] == leaf_id # stable, pas régénéré append_resp = client.post( f"/game/{slug}/triggers/{obj_id}/append-action", json={"after_id": leaf_id, "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}, ) assert append_resp.status_code == 200 def test_objects_catalog_lists_eligible_objects_with_thumbnails( client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str] ) -> None: slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) _add_personnage(client, slug, screen_id, role="pnj") resp = client.get(f"/game/{slug}/triggers/objects-catalog") assert resp.status_code == 200 catalog = resp.get_json() assert len(catalog) == 1 assert catalog[0]["thumbnail_html"] def test_triggers_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre """Même garde d'accès que /game//publish (core/auth_guard.py) — aucune vérification supplémentaire écrite pour ces routes.""" from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register _register(anon_client, "triggersisolation@example.com") _confirm_2fa(anon_client) _complete_onboarding(anon_client) try: resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers") assert resp.status_code == 403 resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers", json={}) assert resp.status_code == 403 resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1") assert resp.status_code == 403 resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/dialogue", json={}) assert resp.status_code == 403 resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/append-action", json={}) assert resp.status_code == 403 resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1") assert resp.status_code == 403 resp = anon_client.post( "/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1/move", json={"direction": "up"} ) assert resp.status_code == 403 resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog") assert resp.status_code == 403 finally: _cleanup_project("triggersisolation@example.com")