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
+13 -10
View File
@@ -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
+53 -9
View File
@@ -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."""
+74
View File
@@ -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
-264
View File
@@ -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
+20 -16
View File
@@ -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
+356
View File
@@ -0,0 +1,356 @@
"""Déclencheurs de collision — onglet "Déclencheurs" (voir
screens/rendering/trigger_graph.py, routes/triggers/triggers.py) : TOUS
les déclencheurs du jeu, plus aucune notion de quête pour les regrouper —
un créateur peut faire un simple quiz sans jamais poser de personnage.
Chaque occurrence de "Déclencher ce dialogue" porte son propre dialogue
({id, lines}, voir screens/rendering/collision_rules.py::"dialogue".dialogue),
édité en ligne (voir routes POST .../triggers/<object_id>/dialogue
ci-dessous). Source de données UNIQUE avec l'éditeur de collision : toute
règle ajoutée/retirée/modifiée ici doit rester visible/cohérente via
screens.resolve_collision_rules directement sur l'objet."""
import re
import screens
from tests.test_auth import anon_client # noqa: F401
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_triggers"):
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def _add_personnage(client, slug, screen_id, role=None):
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
assert resp.status_code == 302, resp.data
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
if role:
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json")
return obj_id
def _set_rules(slug, object_id, rules):
"""Pose des règles directement (sans passer par une route HTTP —
l'ancienne route de sauvegarde, routes/scenes/scene_object_collision_rules.py,
a été retirée avec l'éditeur de collision séparé) pour préparer un
scénario de test."""
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules(rules))
def test_mark_completed_is_kept_when_true(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": True}}])
obj = screens.get_scene_object(slug, obj_id)
rules = screens.resolve_collision_rules(obj)
assert rules[0]["action"]["mark_completed"] is True
def test_mark_completed_is_dropped_when_false_or_absent(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": False}}])
obj = screens.get_scene_object(slug, obj_id)
rules = screens.resolve_collision_rules(obj)
assert "mark_completed" not in rules[0]["action"]
def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
nested_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, direct_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
_set_rules(slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}])
result = screens.list_all_triggers(slug)
ids = [o["object_id"] for o in result]
assert direct_id in ids
assert nested_id in ids
direct_entry = next(o for o in result if o["object_id"] == direct_id)
assert direct_entry["has_interagir"] is False
nested_entry = next(o for o in result if o["object_id"] == nested_id)
assert nested_entry["has_interagir"] is True
def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(client, tmp_game_slug_cleanup):
"""Losange à embranchement (maquette validée, lecture verticale) :
Vrai ET Faux peuvent chacun déclencher LEUR PROPRE dialogue,
indépendamment l'un de l'autre — plus de "branche qui mène à telle
quête" à choisir (voir static/js/triggers/trigger-editor.js::
triggerConditionColumnHtml pour son affichage)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}},
},
}])
result = screens.list_all_triggers(slug)
entry = next(o for o in result if o["object_id"] == obj_id)
assert entry["condition"]["variable"] == "inventaire_cle"
assert entry["condition"]["si_vrai"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}]}
assert entry["condition"]["si_faux"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}]}
def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "0",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, "si_faux": None,
},
}])
result = screens.list_all_triggers(slug)
entry = next(o for o in result if o["object_id"] == obj_id)
assert entry["condition"]["si_faux"] is None
def test_triggers_delete_removes_all_rules_on_the_object(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_collision_rules(obj) == []
def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
resp = client.post(
f"/game/{slug}/triggers",
json={
"object_id": obj_id,
"rule": {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}},
},
)
assert resp.status_code == 200
# Même donnée, visible directement via la couche collision (source unique).
obj = screens.get_scene_object(slug, obj_id)
rules = screens.resolve_collision_rules(obj)
assert rules == [{
"trigger": "collision",
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
}]
resp = client.get(f"/game/{slug}/triggers")
result = resp.get_json()
assert [o["object_id"] for o in result] == [obj_id]
assert result[0]["has_interagir"] is True
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_collision_rules(obj) == []
def test_list_all_triggers_surfaces_the_dialogue(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]}},
}])
result = screens.list_all_triggers(slug)
assert result[0]["chain"] == [{"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}}]
def test_list_all_triggers_surfaces_a_chain_of_actions(client, tmp_game_slug_cleanup):
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
lui ajouter des actions") : dialogue -> then variable, la chaîne
ENTIÈRE est aplatie dans l'ordre d'exécution."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
},
}])
result = screens.list_all_triggers(slug)
assert result[0]["chain"] == [
{"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
{"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
]
def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
cond_id = _add_personnage(client, slug, screen_id, role="pnj")
other_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, direct_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True},
}])
_set_rules(slug, cond_id, [{
"trigger": "collision",
"action": {
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True},
"si_faux": None,
},
}])
_set_rules(slug, other_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}},
}])
ids = screens.list_completable_dialogue_ids(slug)
assert set(ids) == {"d_direct", "d_cond"}
def test_trigger_dialogue_update_route_persists_the_new_lines(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/dialogue",
json={"dialogue_id": "d_1", "lines": [{"speaker": "pnj", "text": "Trouvé !"}]},
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
rules = screens.resolve_collision_rules(obj)
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}]
def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/dialogue",
json={"dialogue_id": "d_absent", "lines": [{"speaker": "pnj", "text": "x"}]},
)
assert resp.status_code == 404
def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game):
resp = client.post(
f"/game/{game}/triggers/999999/dialogue",
json={"dialogue_id": "d_1", "lines": []},
)
assert resp.status_code == 404
def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/append-action",
json={"after_id": "d_1", "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}},
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
rules = screens.resolve_collision_rules(obj)
assert rules[0]["action"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/append-action",
json={"after_id": "d_absent", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}},
)
assert resp.status_code == 404
def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
},
}])
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/append-action",
json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}},
)
assert resp.status_code == 404
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score" # inchangé
def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}})
assert resp.status_code == 404
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_personnage(client, slug, screen_id, role="pnj")
resp = client.get(f"/game/{slug}/triggers/objects-catalog")
assert resp.status_code == 200
catalog = resp.get_json()
assert len(catalog) == 1
assert catalog[0]["thumbnail_html"]
def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour ces routes."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "triggersisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers")
assert resp.status_code == 403
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers", json={})
assert resp.status_code == 403
resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1")
assert resp.status_code == 403
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/dialogue", json={})
assert resp.status_code == 403
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/append-action", json={})
assert resp.status_code == 403
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog")
assert resp.status_code == 403
finally:
_cleanup_project("triggersisolation@example.com")