Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
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:
co-authored by
Claude Sonnet 5
parent
c504ace167
commit
d664ed5637
+294
-99
@@ -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
|
||||
|
||||
+13
-10
@@ -1,11 +1,11 @@
|
||||
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
|
||||
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
|
||||
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en
|
||||
cours d'une quête déclenchée par une règle de collision "quete" (voir
|
||||
cours d'un dialogue déclenché par une règle de collision "dialogue" (voir
|
||||
static/js/play/dialogue-box-controller.js) — header/body/footer
|
||||
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
|
||||
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
|
||||
l'éditeur de dialogue de quête (screens/rendering/scene_object_names.py)."""
|
||||
l'éditeur de dialogue d'un déclencheur (screens/rendering/scene_object_names.py)."""
|
||||
import db
|
||||
import screens
|
||||
|
||||
@@ -44,10 +44,8 @@ def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup):
|
||||
assert f'data-object-id="{obj_id}"' in html
|
||||
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
|
||||
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
|
||||
# (forgeShowQuestOffer, static/js/play/dialogue-box-controller.js) le
|
||||
# cherche par ce sélecteur pour y injecter Accepter/Refuser en fin de
|
||||
# dialogue d'une quête "nouvelle" ; sans lui, l'écran d'offre gardait
|
||||
# silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons.
|
||||
# (forgeResetDialogueBoxFooter, static/js/play/dialogue-box-controller.js)
|
||||
# le cherche par ce sélecteur pour y (re)poser le bouton "Suivant".
|
||||
assert 'data-dialogue-role="header"' in html
|
||||
assert 'data-dialogue-role="body"' in html
|
||||
assert 'data-dialogue-role="footer"' in html
|
||||
@@ -95,11 +93,16 @@ def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanu
|
||||
assert box["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
|
||||
|
||||
|
||||
def test_full_game_payload_exposes_quests_game_wide(tmp_game_slug_cleanup):
|
||||
def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
db.create_quest(slug, "Trouver la clé")
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True},
|
||||
}]
|
||||
screens.set_scene_object_collision_rules(slug, obj_id, screens.sanitize_collision_rules(rules))
|
||||
payload = screens.full_game_payload(slug)
|
||||
assert [q["title"] for q in payload["quests"]] == ["Trouver la clé"]
|
||||
assert payload["completable_dialogue_ids"] == ["d_1"]
|
||||
|
||||
|
||||
# ---------- Nom d'un personnage ("ℹ️ Informations") ----------
|
||||
@@ -179,7 +182,7 @@ def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup):
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
html = screens.render_scene_object(obj)
|
||||
assert 'data-quiz-role="header"' in html # "Quête : <titre>", rempli en jeu
|
||||
assert 'data-quiz-role="header"' in html # nom de l'objet déclencheur, rempli en jeu
|
||||
assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps
|
||||
assert 'data-quiz-role="choices"' in html
|
||||
assert f'data-object-id="{obj_id}"' in html
|
||||
|
||||
@@ -6,9 +6,11 @@ vérifient l'assemblage (imsmanifest.xml, index.html en FORGE_OFFLINE,
|
||||
JS hors ligne présent), jamais un vrai import dans un LMS réel (voir le
|
||||
plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
|
||||
automatisée)."""
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
import db
|
||||
import screens
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
@@ -125,7 +127,7 @@ def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client, game):
|
||||
|
||||
|
||||
def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
|
||||
"""Sans quête/question : max=0 (SCORM 1.2/2004 n'ont alors rien à
|
||||
"""Sans déclencheur/question : max=0 (SCORM 1.2/2004 n'ont alors rien à
|
||||
barémer, voir static/js/play/offline/scorm-api.js)."""
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
@@ -133,20 +135,62 @@ def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
|
||||
|
||||
quest = db.create_quest(game, "Une quête")
|
||||
db.set_quest_dialogues(game, quest["id"], {
|
||||
"nouvelle": [], "terminee": [],
|
||||
"en_cours": [
|
||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
||||
],
|
||||
})
|
||||
# Les dialogues vivent dans les règles de collision d'un objet de
|
||||
# scène (voir screens/rendering/collision_rules.py::"dialogue".dialogue).
|
||||
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_1", "lines": [
|
||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
||||
]},
|
||||
},
|
||||
}]
|
||||
screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules))
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
|
||||
|
||||
|
||||
def test_export_scorm_index_html_embeds_the_max_quiz_score_across_a_then_chain(client, game):
|
||||
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
||||
lui ajouter des actions") : un dialogue -> then variable -> then
|
||||
dialogue — les DEUX dialogues de la chaîne comptent pour le score max,
|
||||
la feuille "variable" au milieu n'en interrompt pas la collecte."""
|
||||
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_1", "lines": [
|
||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
||||
]},
|
||||
"then": {
|
||||
"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_2", "lines": [
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
||||
]},
|
||||
},
|
||||
},
|
||||
},
|
||||
}]
|
||||
screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules))
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
|
||||
|
||||
|
||||
def test_export_scorm_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 cette route."""
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Onglet "Variables" de l'éditeur de scène (templates/scene_edit.html) —
|
||||
CRUD classique (formulaires POST/redirect, voir l'onglet "Événements"
|
||||
pour le même patron) sur les fonctions déjà existantes de db/global_vars/
|
||||
(list/create/update/delete). Le nom est la clé stable lue par
|
||||
static/js/play/conditions.js::readVariableValue — jamais modifiable après
|
||||
création (voir db/global_vars/update_global_variable.py)."""
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_create_global_var_route_persists_and_redirects_to_the_variables_tab(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/new",
|
||||
data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables")
|
||||
variables = db.list_global_variables(game)
|
||||
assert [v["name"] for v in variables] == ["score_total"]
|
||||
assert variables[0]["type"] == "nombre_entier"
|
||||
assert variables[0]["value"] == "0"
|
||||
|
||||
|
||||
def test_create_global_var_is_idempotent_by_name(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
assert len(db.list_global_variables(game)) == 1
|
||||
|
||||
|
||||
def test_global_var_edit_route_updates_type_and_value(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/{var_id}/edit",
|
||||
data={"type": "texte", "value": "beaucoup", "screen_id": screen_id},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables")
|
||||
variable = db.list_global_variables(game)[0]
|
||||
assert variable["type"] == "texte"
|
||||
assert variable["value"] == "beaucoup"
|
||||
assert variable["name"] == "score_total" # le nom ne change jamais
|
||||
|
||||
|
||||
def test_global_var_delete_route_removes_it(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(f"/game/{game}/variables/{var_id}/delete", data={"screen_id": screen_id}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith(f"/game/{game}/screens/{screen_id}/edit?tab=variables")
|
||||
assert db.list_global_variables(game) == []
|
||||
|
||||
|
||||
def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "5", "screen_id": screen_id})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "score_total" in html
|
||||
assert 'data-tab="variables"' in html
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Éditeur de quêtes (voir db/quests/, routes/quests/, onglet "🗺️ Quêtes"
|
||||
de l'éditeur de scène 2D, templates/scene_edit.html) : id, titre, objectif, récompense
|
||||
(score), statut (nouvelle/en_cours/terminee), résultat (reussi/echoue),
|
||||
et un dialogue par statut (3 colonnes — voir static/js/quests/
|
||||
quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le
|
||||
jeu, jamais par écran (mêmes conventions que les événements
|
||||
personnalisés, db/custom_events/)."""
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _make_game(tmp_game_slug_cleanup, name="pytest_quests"):
|
||||
return tmp_game_slug_cleanup(db.create_game(name))
|
||||
|
||||
|
||||
def test_create_quest_has_sensible_defaults(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
assert quest["title"] == "Trouver la clé"
|
||||
assert quest["objectif"] == ""
|
||||
assert quest["recompense_score"] == 0
|
||||
assert quest["status"] == "nouvelle"
|
||||
assert quest["resultat"] is None
|
||||
assert quest["dialogues"] == {}
|
||||
|
||||
|
||||
def test_create_quest_falls_back_to_default_title_when_blank(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, " ")
|
||||
assert quest["title"] == "Nouvelle quête"
|
||||
|
||||
|
||||
def test_list_quests_orders_most_recent_first(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
first = db.create_quest(slug, "Première")
|
||||
second = db.create_quest(slug, "Seconde")
|
||||
ids = [q["id"] for q in db.list_quests(slug)]
|
||||
assert ids[:2] == [second["id"], first["id"]]
|
||||
|
||||
|
||||
def test_update_quest_only_applies_known_valid_fields(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
updated = db.update_quest(slug, quest["id"], {
|
||||
"title": "Trouver la clé dorée", "objectif": "Fouiller le donjon",
|
||||
"recompense_score": 50, "status": "en_cours", "resultat": "reussi",
|
||||
"unknown_field": "ignoré", "id": 999999,
|
||||
})
|
||||
assert updated["title"] == "Trouver la clé dorée"
|
||||
assert updated["objectif"] == "Fouiller le donjon"
|
||||
assert updated["recompense_score"] == 50
|
||||
assert updated["status"] == "en_cours"
|
||||
assert updated["resultat"] == "reussi"
|
||||
assert updated["id"] == quest["id"] # "id" dans fields est ignoré, jamais modifiable
|
||||
|
||||
|
||||
def test_update_quest_rejects_invalid_status_and_blank_title(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
updated = db.update_quest(slug, quest["id"], {"status": "en_pause", "title": " "})
|
||||
assert updated["status"] == "nouvelle" # inchangé, valeur invalide ignorée
|
||||
assert updated["title"] == "Trouver la clé" # inchangé, titre vide ignoré
|
||||
|
||||
|
||||
def test_update_quest_returns_none_for_missing_quest(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
assert db.update_quest(slug, 999999, {"title": "x"}) is None
|
||||
|
||||
|
||||
def test_delete_quest_removes_it(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.delete_quest(slug, quest["id"])
|
||||
assert db.get_quest(slug, quest["id"]) is None
|
||||
|
||||
|
||||
def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status():
|
||||
"""speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations",
|
||||
screens/rendering/scene_object_names.py) — "Le Sorcier" est donc un nom
|
||||
valide comme n'importe quel autre, seul un speaker/texte VIDE ou un
|
||||
élément mal formé est rejeté."""
|
||||
raw = {
|
||||
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}],
|
||||
"en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"],
|
||||
"terminee": "pas une liste",
|
||||
"statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
assert cleaned == {
|
||||
"nouvelle": [{"type": "dialogue", "speaker": "Le Sorcier", "text": "Bonjour !"}],
|
||||
"en_cours": [],
|
||||
"terminee": [],
|
||||
}
|
||||
|
||||
|
||||
def test_sanitize_quest_dialogues_accepts_a_valid_question_and_rejects_malformed_ones():
|
||||
""""❓ Question" (voir static/js/quests/quest-editor.js) : une bulle
|
||||
jaune avec plusieurs choix, une seule bonne réponse, une récompense —
|
||||
même chaîne qu'une réplique de dialogue, un autre "type" de ligne."""
|
||||
raw = {
|
||||
"nouvelle": [
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||||
{"type": "question", "text": "Une seule option", "choices": ["seule"], "correct_index": 0}, # < 2 choix -> rejetée
|
||||
{"type": "question", "text": "index hors bornes", "choices": ["a", "b"], "correct_index": 5}, # rejetée
|
||||
{"type": "question", "text": " ", "choices": ["a", "b"], "correct_index": 0}, # texte vide -> rejetée
|
||||
{"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "vie"},
|
||||
],
|
||||
"en_cours": [], "terminee": [],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
assert cleaned["nouvelle"] == [
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||||
# reward_type invalide -> repli sur le premier type connu ("score"), jamais rejetée pour ça seul
|
||||
{"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "score", "reward_amount": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_set_quest_dialogues_persists_all_three_columns(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
dialogues = {
|
||||
"nouvelle": [
|
||||
{"type": "dialogue", "speaker": "pnj", "text": "Peux-tu m'aider ?"},
|
||||
{"type": "dialogue", "speaker": "joueur", "text": "Bien sûr !"},
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_type": "score", "reward_amount": 2},
|
||||
],
|
||||
"en_cours": [{"type": "dialogue", "speaker": "pnj", "text": "Alors, cette clé ?"}],
|
||||
"terminee": [{"type": "dialogue", "speaker": "pnj", "text": "Merci beaucoup !"}],
|
||||
}
|
||||
db.set_quest_dialogues(slug, quest["id"], dialogues)
|
||||
reloaded = db.get_quest(slug, quest["id"])
|
||||
assert reloaded["dialogues"] == dialogues
|
||||
|
||||
|
||||
def test_set_quest_dialogues_returns_none_for_missing_quest(tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
assert db.set_quest_dialogues(slug, 999999, {}) is None
|
||||
|
||||
|
||||
# ---------- Routes ----------
|
||||
|
||||
def test_quest_new_route_creates_and_returns_json(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
resp = client.post(f"/game/{slug}/quests/new", data='{"title": "Nouvelle quête du donjon"}', content_type="application/json")
|
||||
assert resp.status_code == 200
|
||||
quest = resp.get_json()["quest"]
|
||||
assert quest["title"] == "Nouvelle quête du donjon"
|
||||
assert db.get_quest(slug, quest["id"]) is not None
|
||||
|
||||
|
||||
def test_quests_list_json_route_returns_all_quests(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
db.create_quest(slug, "A")
|
||||
db.create_quest(slug, "B")
|
||||
resp = client.get(f"/game/{slug}/quests/list.json")
|
||||
titles = {q["title"] for q in resp.get_json()["quests"]}
|
||||
assert titles == {"A", "B"}
|
||||
|
||||
|
||||
def test_quest_update_route_persists_fields(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.post(f"/game/{slug}/quests/{quest['id']}", data='{"recompense_score": 100, "status": "terminee"}', content_type="application/json")
|
||||
assert resp.get_json()["quest"]["recompense_score"] == 100
|
||||
assert resp.get_json()["quest"]["status"] == "terminee"
|
||||
|
||||
|
||||
def test_quest_update_route_404s_for_missing_quest(client, game):
|
||||
resp = client.post(f"/game/{game}/quests/999999", data="{}", content_type="application/json")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_quest_delete_route_removes_it(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.post(f"/game/{slug}/quests/{quest['id']}/delete")
|
||||
assert resp.get_json() == {"ok": True}
|
||||
assert db.get_quest(slug, quest["id"]) is None
|
||||
|
||||
|
||||
def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||
data='{"dialogues": {"nouvelle": [{"speaker": "pnj", "text": "Salut !"}], "en_cours": [], "terminee": []}}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.get_json()["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]
|
||||
assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]
|
||||
|
||||
|
||||
def test_quest_dialogues_route_404s_for_missing_quest(client, game):
|
||||
resp = client.post(f"/game/{game}/quests/999999/dialogues", data='{"dialogues": {}}', content_type="application/json")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def _question_line(reward_amount, correct_index=0):
|
||||
return {
|
||||
"type": "question", "text": "2+2 ?", "choices": ["3", "4"],
|
||||
"correct_index": correct_index, "reward_type": "score", "reward_amount": reward_amount,
|
||||
}
|
||||
|
||||
|
||||
def test_quest_dialogues_route_rejects_questions_totaling_more_than_the_reward(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||
data=json.dumps({"dialogues": {"nouvelle": [_question_line(3), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "pts" in resp.get_json()["error"]
|
||||
# Rien n'a été enregistré — la répartition invalide n'est jamais persistée.
|
||||
assert db.get_quest(slug, quest["id"])["dialogues"] == {}
|
||||
|
||||
|
||||
def test_quest_dialogues_route_accepts_questions_totaling_exactly_the_reward(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.update_quest(slug, quest["id"], {"recompense_score": 5})
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}/dialogues",
|
||||
data=json.dumps({"dialogues": {"nouvelle": [_question_line(2), _question_line(3)], "en_cours": [], "terminee": []}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_quest_update_route_rejects_lowering_reward_below_already_allocated_questions(client, tmp_game_slug_cleanup):
|
||||
slug = _make_game(tmp_game_slug_cleanup)
|
||||
quest = db.create_quest(slug, "Trouver la clé")
|
||||
db.update_quest(slug, quest["id"], {"recompense_score": 10})
|
||||
db.set_quest_dialogues(slug, quest["id"], {"nouvelle": [_question_line(6)], "en_cours": [], "terminee": []})
|
||||
resp = client.post(
|
||||
f"/game/{slug}/quests/{quest['id']}",
|
||||
data=json.dumps({"recompense_score": 3}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "pts" in resp.get_json()["error"]
|
||||
assert db.get_quest(slug, quest["id"])["recompense_score"] == 10 # inchangé
|
||||
|
||||
|
||||
def test_sanitize_quest_dialogues_caps_choices_at_four():
|
||||
raw = {
|
||||
"nouvelle": [{"type": "question", "text": "Q", "choices": ["a", "b", "c", "d", "e"], "correct_index": 4, "reward_type": "score", "reward_amount": 1}],
|
||||
"en_cours": [], "terminee": [],
|
||||
}
|
||||
cleaned = db.sanitize_quest_dialogues(raw)
|
||||
# Coupé à 4 choix -> l'index 4 (qui pointait sur le 5e, retiré) sort des bornes -> ligne rejetée.
|
||||
assert cleaned["nouvelle"] == []
|
||||
|
||||
|
||||
def test_quest_dialogues_total_reward_sums_questions_across_all_statuses():
|
||||
dialogues = {
|
||||
"nouvelle": [_question_line(2)],
|
||||
"en_cours": [_question_line(3), {"type": "dialogue", "speaker": "pnj", "text": "x"}],
|
||||
"terminee": [],
|
||||
}
|
||||
assert db.quest_dialogues_total_reward(dialogues) == 5
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Liste "🧩 Collision" à jour en AJAX (voir routes/scenes/
|
||||
scene_collision_rule_objects.py, refreshCollisionRuleObjects() dans
|
||||
static/js/scenes/scene-editor.js) : appelée après ajout/suppression d'un
|
||||
objet de scène ou changement de rôle/nom, pour ne jamais recharger la page
|
||||
alors que cette liste doit refléter le changement (un objet apparaît/
|
||||
disparaît selon son rôle, ou change de libellé affiché)."""
|
||||
"""screens.build_collision_rule_objects (voir screens/scenes/
|
||||
build_collision_rule_objects.py) : la liste d'objets éligibles à un
|
||||
déclencheur de collision, réutilisée par le listage de TOUS les
|
||||
déclencheurs du jeu (screens/rendering/trigger_graph.py, voir
|
||||
tests/test_triggers.py) — un objet apparaît/disparaît selon son
|
||||
rôle, ou change de libellé affiché selon son nom. L'ancien endpoint AJAX
|
||||
dédié (routes/scenes/scene_collision_rule_objects.py) a été retiré avec
|
||||
l'onglet "🧩 Collision" séparé — ce fichier teste directement les
|
||||
fonctions Python plutôt qu'une route HTTP désormais inexistante."""
|
||||
import screens
|
||||
|
||||
|
||||
@@ -21,16 +24,17 @@ def _add_ajax(client, slug, screen_id, **data):
|
||||
).get_json()
|
||||
|
||||
|
||||
def _list(client, slug, screen_id):
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id}/collision-rule-objects")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_json()
|
||||
def _list(slug, screen_id):
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
return screens.build_collision_rule_objects(objects)
|
||||
|
||||
|
||||
def test_freshly_added_personnage_is_pnj_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["id"] == obj_id
|
||||
assert entries[0]["role"] == "pnj"
|
||||
@@ -40,7 +44,7 @@ def test_role_change_to_joueur_removes_it_from_the_list(client, tmp_game_slug_cl
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
|
||||
assert _list(client, slug, screen_id) == []
|
||||
assert _list(slug, screen_id) == []
|
||||
|
||||
|
||||
def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cleanup):
|
||||
@@ -48,7 +52,7 @@ def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cle
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json")
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["role"] == "ennemie"
|
||||
|
||||
@@ -57,7 +61,7 @@ def test_name_change_is_reflected_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert entries[0]["name"] == "Le Sorcier"
|
||||
|
||||
|
||||
@@ -65,11 +69,11 @@ def test_deleted_object_is_removed_from_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", headers={"X-Requested-With": "fetch"})
|
||||
assert _list(client, slug, screen_id) == []
|
||||
assert _list(slug, screen_id) == []
|
||||
|
||||
|
||||
def test_decor_has_no_role(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_ajax(client, slug, screen_id, kind="decor")
|
||||
entries = _list(client, slug, screen_id)
|
||||
entries = _list(slug, screen_id)
|
||||
assert entries[0]["role"] is None
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user