Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
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

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>
This commit is contained in:
william
2026-09-07 22:05:06 +02:00
co-authored by Claude Sonnet 5
parent c504ace167
commit d664ed5637
62 changed files with 3129 additions and 2266 deletions
+356
View File
@@ -0,0 +1,356 @@
"""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")