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>
360 lines
17 KiB
Python
360 lines
17 KiB
Python
"""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)."""
|
|
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):
|
|
"""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
|