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
+294 -99
View File
@@ -1,12 +1,11 @@
"""Éditeur de collision (onglet "🧩 Collision", voir
screens/rendering/collision_rules.py) : une règle "déclencheur (collision/
périmètre) -> action (quête/attaque/événement/interagir)" par carte
objet — toujours les AUTRES objets qui réagissent à la présence du
joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js
construit l'assistant "+ Action" ; ce fichier teste la couche serveur
(défauts, validation, exposition dans full_game_payload)."""
import re
"""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
@@ -19,6 +18,7 @@ def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rul
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))
@@ -27,13 +27,6 @@ def _add_personnage(client, slug, screen_id, role=None):
return obj_id
def _add_custom_event(client, slug, name="NEED_HELP"):
resp = client.post(f"/game/{slug}/events/new", data={"name": name}, follow_redirects=False)
assert resp.status_code == 302, resp.data
events = screens.list_custom_events(slug) if hasattr(screens, "list_custom_events") else __import__("db").list_custom_events(slug)
return next(e["id"] for e in events if e["name"] == name)
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")
@@ -41,93 +34,293 @@ def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_clea
assert screens.resolve_collision_rules(obj) == []
def test_rules_route_persists_a_collision_trigger_attack_action(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")
resp = client.post(
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
data='{"rules": [{"trigger": "collision", "action": {"type": "attaque"}}]}',
content_type="application/json",
)
assert resp.status_code == 200
assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "attaque"}}]
obj = screens.get_scene_object(slug, enemy_id)
assert screens.resolve_collision_rules(obj) == [{"trigger": "collision", "action": {"type": "attaque"}}]
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_rules_route_persists_a_perimeter_trigger_with_custom_radius(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")
resp = client.post(
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
data='{"rules": [{"trigger": "perimetre", "perimetre_px": 40, "action": {"type": "attaque"}}]}',
content_type="application/json",
)
rules = resp.get_json()["rules"]
assert rules == [{"trigger": "perimetre", "perimetre_px": 40.0, "action": {"type": "attaque"}}]
def test_rules_route_persists_a_quest_action_referencing_a_quest_id(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
quest = db.create_quest(slug, "Trouver la clé")
resp = client.post(
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": ' + str(quest["id"]) + '}}]}',
content_type="application/json",
)
assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": quest["id"]}}]
def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(client, tmp_game_slug_cleanup):
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)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
resp = client.post(
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}}]}',
content_type="application/json",
)
assert resp.get_json()["rules"] == [
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": 1}}},
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_rules_route_rejects_interagir_nested_inside_interagir(client, tmp_game_slug_cleanup):
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."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
pnj_id = _add_personnage(client, slug, screen_id, role="pnj")
resp = client.post(
f"/game/{slug}/scene-objects/{pnj_id}/collision-rules",
data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "attaque"}}}}]}',
content_type="application/json",
)
assert resp.get_json()["rules"] == [] # la règle entière est rejetée
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_rules_route_drops_malformed_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")
resp = client.post(
f"/game/{slug}/scene-objects/{enemy_id}/collision-rules",
data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}',
content_type="application/json",
)
assert resp.get_json()["rules"] == []
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_rules_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json")
assert resp.status_code == 404
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):
@@ -148,17 +341,19 @@ def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_obje
assert enemy_payload["collision_rules"] == []
def test_scene_edit_view_lists_eligible_objects_only(client, tmp_game_slug_cleanup):
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)
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
# "Joueur" (le seul rôle exclu de "🧩 Collision") 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")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
idx = html.find("var COLLISION_RULE_OBJECTS")
end = html.find(";", idx)
snippet = html[idx:end]
assert f'"id": {enemy_id}' in snippet
assert f'"id": {player_id}' not in snippet
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