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>
357 lines
16 KiB
Python
357 lines
16 KiB
Python
"""Déclencheurs de collision — onglet "Déclencheurs" (voir
|
|
screens/rendering/trigger_graph.py, routes/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 screens/rendering/collision_rules.py::"dialogue".dialogue),
|
|
édité en ligne (voir routes POST .../triggers/<object_id>/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
|
|
screens.resolve_collision_rules directement sur l'objet."""
|
|
import re
|
|
|
|
import screens
|
|
from tests.test_auth import anon_client # noqa: F401
|
|
|
|
|
|
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_triggers"):
|
|
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):
|
|
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 _set_rules(slug, object_id, rules):
|
|
"""Pose des règles directement (sans passer par une route HTTP —
|
|
l'ancienne route de sauvegarde, routes/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."""
|
|
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules(rules))
|
|
|
|
|
|
def test_mark_completed_is_kept_when_true(client, tmp_game_slug_cleanup):
|
|
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 = screens.get_scene_object(slug, obj_id)
|
|
rules = screens.resolve_collision_rules(obj)
|
|
assert rules[0]["action"]["mark_completed"] is True
|
|
|
|
|
|
def test_mark_completed_is_dropped_when_false_or_absent(client, tmp_game_slug_cleanup):
|
|
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 = screens.get_scene_object(slug, obj_id)
|
|
rules = screens.resolve_collision_rules(obj)
|
|
assert "mark_completed" not in rules[0]["action"]
|
|
|
|
|
|
def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_cleanup):
|
|
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 = screens.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, tmp_game_slug_cleanup):
|
|
"""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/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 = screens.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, tmp_game_slug_cleanup):
|
|
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 = screens.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, tmp_game_slug_cleanup):
|
|
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 = screens.get_scene_object(slug, obj_id)
|
|
assert screens.resolve_collision_rules(obj) == []
|
|
|
|
|
|
def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
|
|
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 = screens.get_scene_object(slug, obj_id)
|
|
rules = screens.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 = screens.get_scene_object(slug, obj_id)
|
|
assert screens.resolve_collision_rules(obj) == []
|
|
|
|
|
|
def test_list_all_triggers_surfaces_the_dialogue(client, tmp_game_slug_cleanup):
|
|
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 = screens.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, tmp_game_slug_cleanup):
|
|
"""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 = screens.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, tmp_game_slug_cleanup):
|
|
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 = screens.list_completable_dialogue_ids(slug)
|
|
assert set(ids) == {"d_direct", "d_cond"}
|
|
|
|
|
|
def test_trigger_dialogue_update_route_persists_the_new_lines(client, tmp_game_slug_cleanup):
|
|
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 = screens.get_scene_object(slug, obj_id)
|
|
rules = screens.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, tmp_game_slug_cleanup):
|
|
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, game):
|
|
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, tmp_game_slug_cleanup):
|
|
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 = screens.get_scene_object(slug, obj_id)
|
|
rules = screens.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, tmp_game_slug_cleanup):
|
|
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_404s_when_the_leaf_already_has_a_then(client, tmp_game_slug_cleanup):
|
|
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 == 404
|
|
obj = screens.get_scene_object(slug, obj_id)
|
|
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
|
|
|
|
|
def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
|
|
resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
|
|
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):
|
|
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
|
aucune vérification supplémentaire écrite pour ces routes."""
|
|
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
|
|
|
|
_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.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog")
|
|
assert resp.status_code == 403
|
|
finally:
|
|
_cleanup_project("triggersisolation@example.com")
|