- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance, video, son, visibilite, indication, attendre comme actions, utilisables aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par Ruby (IA), avec blocs deplacables/supprimables dans une chaine. - Corrige plusieurs variantes du bug "impossible de poser un objet hors du champ de la camera" (troncature du chainage d'actions a 4 maillons, fond importe pose a 128x128 au lieu de sa taille reelle, decalage du fond au vrai glisser-depose, redimensionnement manuel jamais propage au monde). - Ajoute un vrai glisser-depose depuis la galerie vers la scene, la gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/ Videos, suppression, reclassement fond<->decor sans re-upload). - Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement, limite 5 min), avec validation de la duree reelle du fichier, et une replique audio optionnelle dans une bulle de dialogue. - Fixe la taille de pose d'un objet/decor importe a 200x200 avec une boite de collision de 150x150. - Filtre le selecteur de fichier des actions son/video par type reel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
743 lines
34 KiB
Python
743 lines
34 KiB
Python
"""Règles de collision (voir screens/rendering/collision_rules.py) : une
|
|
règle "déclencheur (collision) -> action (dialogue/interagir/condition)"
|
|
par objet de scène — toujours les AUTRES objets qui réagissent à la
|
|
présence du joueur, jamais l'inverse. Posées depuis l'onglet
|
|
"Déclencheurs" (voir tests/test_triggers.py pour le parcours complet côté
|
|
route) — ce fichier teste la couche de validation/lecture elle-même
|
|
(sanitize_collision_rules/resolve_collision_rules), plus l'exposition
|
|
dans full_game_payload."""
|
|
import db
|
|
import screens
|
|
|
|
|
|
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rules"):
|
|
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
|
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
|
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
|
return slug, screen_id
|
|
|
|
|
|
def _add_personnage(client, slug, screen_id, role=None):
|
|
import re
|
|
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
|
assert resp.status_code == 302, resp.data
|
|
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
|
if role:
|
|
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json")
|
|
return obj_id
|
|
|
|
|
|
def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
|
obj = screens.get_scene_object(slug, enemy_id)
|
|
assert screens.resolve_collision_rules(obj) == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup):
|
|
slug = tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "dialogue"}},
|
|
])
|
|
assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting():
|
|
""""interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
|
|
à l'appui — un seul niveau d'imbrication autorisé (voir
|
|
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
|
])
|
|
assert rules == [
|
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_rejects_interagir_nested_inside_interagir():
|
|
"""Une imbrication trop profonde (interagir -> interagir -> ...) est
|
|
ignorée plutôt que d'accepter une structure sans fin postée à la main."""
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}},
|
|
])
|
|
assert rules == [] # la règle entière est rejetée
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches():
|
|
""""condition" (losange à embranchement, maquette validée) : Vrai/Faux
|
|
peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
|
"si_vrai": {"type": "dialogue"}, "si_faux": None,
|
|
},
|
|
}])
|
|
assert rules == [{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
|
"si_vrai": {"type": "dialogue"}, "si_faux": None,
|
|
},
|
|
}]
|
|
|
|
|
|
def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator():
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "condition", "variable": "", "operateur": "superieur", "valeur": "0", "si_vrai": None, "si_faux": None}},
|
|
{"trigger": "collision", "action": {"type": "condition", "variable": "score", "operateur": "sorcellerie", "valeur": "0", "si_vrai": None, "si_faux": None}},
|
|
])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_rejects_interagir_nested_inside_a_condition_branch():
|
|
"""Une branche Vrai/Faux reste une FEUILLE — jamais une nouvelle
|
|
"interagir"/"condition" imbriquée (voir maquette validée : chaque
|
|
branche n'a que "Déclencher ce dialogue" ou "Ne rien faire")."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
|
"si_vrai": {"type": "interagir", "sub_action": {"type": "dialogue"}}, "si_faux": None,
|
|
},
|
|
}])
|
|
assert rules == [{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
|
"si_vrai": None, "si_faux": None,
|
|
},
|
|
}]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_interagir_wrapping_a_condition():
|
|
"""interagir -> condition -> dialogue (maquette validée : "Interagir avec
|
|
une touche" PUIS un test avant de déclencher le dialogue)."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "interagir",
|
|
"sub_action": {
|
|
"type": "condition", "variable": "score", "operateur": "superieur_egal", "valeur": "10",
|
|
"si_vrai": {"type": "dialogue"}, "si_faux": {"type": "dialogue"}},
|
|
},
|
|
}])
|
|
assert rules[0]["action"]["sub_action"]["type"] == "condition"
|
|
assert rules[0]["action"]["sub_action"]["si_vrai"] == {"type": "dialogue"}
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_valid_dialogue_and_mark_completed():
|
|
"""Chaque occurrence de "Déclencher ce dialogue" porte SON PROPRE
|
|
dialogue ({id, lines}, voir maquette validée : "un dialogue est un
|
|
ensemble de répliques regroupées par un id")."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue", "mark_completed": True,
|
|
"dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}, {"speaker": "x", "text": " "}]},
|
|
},
|
|
}])
|
|
assert rules == [{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue", "mark_completed": True,
|
|
"dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]},
|
|
},
|
|
}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_dialogue_without_a_valid_id():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": " ", "lines": [{"speaker": "pnj", "text": "Salut !"}]}},
|
|
}])
|
|
assert "dialogue" not in rules[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_dialogue_on_a_condition_branch():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
|
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}, "si_faux": None,
|
|
},
|
|
}])
|
|
assert rules[0]["action"]["si_vrai"]["dialogue"] == {"id": "d_vrai", "lines": []}
|
|
|
|
|
|
def test_set_dialogue_lines_in_rules_updates_the_matching_dialogue_wherever_it_is():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "interagir",
|
|
"sub_action": {
|
|
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
|
"si_vrai": None, "si_faux": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
},
|
|
}])
|
|
updated = screens.set_dialogue_lines_in_rules(rules, "d_1", [{"speaker": "pnj", "text": "Trouvé !"}])
|
|
assert updated is True
|
|
assert rules[0]["action"]["sub_action"]["si_faux"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}]
|
|
|
|
|
|
def test_set_dialogue_lines_in_rules_returns_false_for_an_unknown_dialogue_id():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}])
|
|
assert screens.set_dialogue_lines_in_rules(rules, "d_absent", [{"speaker": "pnj", "text": "x"}]) is False
|
|
assert rules[0]["action"]["dialogue"]["lines"] == [] # inchangé
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_variable_action():
|
|
""""Modifier une variable" (même vocabulaire que l'action du graphe de
|
|
logique document, voir screens/labels/data_operations.py) — utilisable
|
|
partout où une feuille est attendue : ici directement après une
|
|
collision."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "id": "d_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "5"},
|
|
}])
|
|
assert rules == [{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "id": "d_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "5"},
|
|
}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_variable_action_without_a_target_or_with_an_unknown_operation():
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "variable", "target_variable": "", "data_operation": "incrementer"}},
|
|
{"trigger": "collision", "action": {"type": "variable", "target_variable": "score", "data_operation": "sorcellerie"}},
|
|
])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_omits_data_value_for_operations_that_need_none():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "target_variable": "actif", "data_operation": "basculer_bool", "data_value": "peu importe"},
|
|
}])
|
|
assert "data_value" not in rules[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_variable_action_as_a_condition_branch():
|
|
"""Une branche Vrai/Faux peut être une feuille "variable" tout autant
|
|
qu'une feuille "dialogue" (voir maquette validée : chaque branche
|
|
reste une feuille, peu importe laquelle)."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
|
"si_vrai": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"}, "si_faux": None,
|
|
},
|
|
}])
|
|
assert rules[0]["action"]["si_vrai"]["type"] == "variable"
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves():
|
|
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
|
lui ajouter des actions") : dialogue -> then variable -> then dialogue,
|
|
plusieurs actions à la suite sur le MÊME déclencheur."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
|
"then": {
|
|
"type": "variable", "id": "d_2", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}},
|
|
},
|
|
},
|
|
}])
|
|
action = rules[0]["action"]
|
|
assert action["then"]["type"] == "variable"
|
|
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}}
|
|
|
|
|
|
def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
|
|
"""_MAX_THEN_CHAIN borne la chaîne — une chaîne trop longue postée à la
|
|
main est coupée plutôt que rejetée entièrement (même tolérance que le
|
|
reste de ce moteur). Bug corrigé : à 4 (l'ancienne valeur), un
|
|
créateur enchaînant "attendre -> dialogue -> surbrillance -> attendre"
|
|
(4 maillons, un scénario tout à fait normal) voyait déjà TOUT
|
|
5e maillon ajouté ensuite tronqué en silence — voir
|
|
screens/rendering/collision_rules.py::_MAX_THEN_CHAIN."""
|
|
chain = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
|
node = chain
|
|
for _ in range(50): # largement au-delà de _MAX_THEN_CHAIN (30)
|
|
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 == 30 # _MAX_THEN_CHAIN, jamais plus
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_then_chain_of_five_leaves_the_bug_that_broke_the_user():
|
|
"""Reproduction directe du bug signalé : attendre -> dialogue ->
|
|
surbrillance -> attendre -> surbrillance (5 maillons) doit survivre
|
|
intact à la resanitisation qui suit chaque écriture (voir
|
|
append_action_to_screen_trigger/set_then_action_in_rules)."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "attendre", "id": "a_1", "data_value": "2",
|
|
"then": {
|
|
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
|
"then": {
|
|
"type": "surbrillance", "id": "s_1", "object_id": 37, "valeur": "on",
|
|
"then": {
|
|
"type": "attendre", "id": "a_2", "data_value": "3",
|
|
"then": {"type": "surbrillance", "id": "s_2", "object_id": 37, "valeur": "off"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}])
|
|
depth = 0
|
|
node = rules[0]["action"]
|
|
while node:
|
|
depth += 1
|
|
node = node.get("then")
|
|
assert depth == 5
|
|
|
|
|
|
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_inserts_between_a_leaf_and_its_existing_then():
|
|
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
|
|
n'importe quel niveau du schéma" — la suite déjà posée (ici "score")
|
|
passe DERRIÈRE la nouvelle action insérée ("vies"), jamais écrasée
|
|
(comportement précédent : refuser purement et simplement)."""
|
|
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 True
|
|
action = rules[0]["action"]
|
|
assert action["then"]["target_variable"] == "vies"
|
|
assert action["then"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
|
|
|
|
|
def test_sanitize_collision_rules_accepts_clic_and_survol_triggers():
|
|
""""clic"/"survol" — nouveaux déclencheurs sans besoin d'un joueur,
|
|
voir screens/rendering/collision_rules.py."""
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "clic", "action": {"type": "dialogue"}},
|
|
{"trigger": "survol", "action": {"type": "dialogue"}},
|
|
])
|
|
assert [r["trigger"] for r in rules] == ["clic", "survol"]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_surbrillance_action():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "clic",
|
|
"action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"},
|
|
}])
|
|
assert rules == [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_surbrillance_action_with_an_unknown_valeur_or_object_id():
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "sorcellerie"}},
|
|
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": "pas-un-id", "valeur": "on"}},
|
|
])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_visibilite_action():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "visibilite", "object_id": 3, "valeur": "masque"},
|
|
}])
|
|
assert rules == [{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "masque"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_visibilite_action_with_an_unknown_valeur():
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "sorcellerie"}},
|
|
])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_son_action():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "clic",
|
|
"action": {"type": "son", "asset_url": "/assets/1/bip.mp3"},
|
|
}])
|
|
assert rules == [{"trigger": "clic", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_son_action_without_asset_url():
|
|
rules = screens.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_video_action():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "survol",
|
|
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
|
}])
|
|
assert rules == [{
|
|
"trigger": "survol",
|
|
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
|
}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_video_action_with_an_unknown_mode():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "survol",
|
|
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "sorcellerie"},
|
|
}])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_an_indication_action_with_and_without_duree_ms():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
|
|
}])
|
|
assert rules == [{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
|
|
}]
|
|
rules_no_duree = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !"},
|
|
}])
|
|
assert "duree_ms" not in rules_no_duree[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_an_indication_action_without_texte():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": " "},
|
|
}])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_chains_a_video_then_a_dialogue():
|
|
"""Une "video" reste une FEUILLE chaînable, comme dialogue/variable
|
|
(demande explicite : pouvoir enchaîner plusieurs actions)."""
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "video", "id": "v_1", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}])
|
|
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_an_attendre_action():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "attendre", "data_value": "2.5"},
|
|
}])
|
|
assert rules == [{"trigger": "collision", "action": {"type": "attendre", "data_value": "2.5"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_an_attendre_action_with_an_invalid_or_negative_value():
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "collision", "action": {"type": "attendre", "data_value": "pas-un-nombre"}},
|
|
{"trigger": "collision", "action": {"type": "attendre", "data_value": "-1"}},
|
|
])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_chains_an_attendre_then_a_dialogue():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "attendre", "id": "a_1", "data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}])
|
|
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_set_then_action_in_rules_addresses_a_surbrillance_leaf_by_its_own_id():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "clic",
|
|
"action": {"type": "surbrillance", "id": "s_1", "object_id": 7, "valeur": "on"},
|
|
}])
|
|
attached = screens.set_then_action_in_rules(rules, "s_1", {"type": "son", "asset_url": "/assets/1/bip.mp3"})
|
|
assert attached is True
|
|
assert rules[0]["action"]["then"] == {"type": "son", "asset_url": "/assets/1/bip.mp3"}
|
|
|
|
|
|
def test_remove_action_from_rules_removes_the_whole_rule_when_removing_a_single_head_leaf():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}])
|
|
removed = screens.remove_action_from_rules(rules, "d_1")
|
|
assert removed is True
|
|
assert rules == []
|
|
|
|
|
|
def test_remove_action_from_rules_promotes_the_then_when_removing_the_head_of_a_chain():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "attendre", "id": "a_1", "data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}])
|
|
removed = screens.remove_action_from_rules(rules, "a_1")
|
|
assert removed is True
|
|
assert rules == [{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}]
|
|
|
|
|
|
def test_remove_action_from_rules_removes_a_middle_leaf_and_reconnects_the_chain():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
|
"then": {
|
|
"type": "attendre", "id": "a_1", "data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
},
|
|
}])
|
|
removed = screens.remove_action_from_rules(rules, "a_1")
|
|
assert removed is True
|
|
action = rules[0]["action"]
|
|
assert action["type"] == "variable"
|
|
assert action["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_remove_action_from_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.remove_action_from_rules(rules, "d_absent") is False
|
|
assert len(rules) == 1
|
|
|
|
|
|
def test_remove_action_from_rules_removes_a_leaf_inside_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,
|
|
},
|
|
}])
|
|
removed = screens.remove_action_from_rules(rules, "d_vrai")
|
|
assert removed is True
|
|
assert rules[0]["action"]["si_vrai"] is None
|
|
|
|
|
|
def test_remove_action_from_rules_removes_interagir_entirely_when_its_only_sub_action_is_removed():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
|
}])
|
|
removed = screens.remove_action_from_rules(rules, "d_1")
|
|
assert removed is True
|
|
assert rules == []
|
|
|
|
|
|
def test_move_action_in_rules_swaps_adjacent_leaves_up_and_down():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
|
"then": {
|
|
"type": "attendre", "id": "a_1", "data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
},
|
|
}])
|
|
moved = screens.move_action_in_rules(rules, "a_1", "up")
|
|
assert moved is True
|
|
action = rules[0]["action"]
|
|
assert action["type"] == "attendre"
|
|
assert action["then"]["type"] == "variable"
|
|
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_move_action_in_rules_at_the_start_of_the_chain_moving_up_has_no_effect_but_still_found():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}])
|
|
moved = screens.move_action_in_rules(rules, "v_1", "up")
|
|
assert moved is True
|
|
assert rules[0]["action"]["target_variable"] == "score" # inchangé, déjà en tête
|
|
|
|
|
|
def test_move_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.move_action_in_rules(rules, "d_absent", "up") is False
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_assigns_a_stable_id_to_a_variable_leaf_without_one():
|
|
"""Bug corrigé : une action "variable" posée sans "id" (ex. par l'IA,
|
|
ce champ restant optionnel dans son schéma, ou une session
|
|
antérieure à cette convention) restait à jamais impossible à
|
|
adresser — "+ Ajouter une action" n'apparaissait jamais après elle."""
|
|
rules = [{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
|
}]
|
|
changed = screens.backfill_missing_leaf_ids(rules)
|
|
assert changed is True
|
|
assert rules[0]["action"]["id"]
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_reaches_inside_condition_branches_and_then_chains():
|
|
rules = [{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition", "variable": "x", "operateur": "egal", "valeur": "1",
|
|
"si_vrai": {
|
|
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
|
"then": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
|
},
|
|
"si_faux": None,
|
|
},
|
|
}]
|
|
changed = screens.backfill_missing_leaf_ids(rules)
|
|
assert changed is True
|
|
assert rules[0]["action"]["si_vrai"]["then"]["id"]
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_is_a_noop_when_every_leaf_already_has_one():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
|
}])
|
|
changed = screens.backfill_missing_leaf_ids(rules)
|
|
assert changed is False
|
|
assert rules[0]["action"]["id"] == "v_1"
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_never_touches_a_dialogue_leaf_which_already_has_its_own_id():
|
|
rules = screens.sanitize_collision_rules([{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}])
|
|
changed = screens.backfill_missing_leaf_ids(rules)
|
|
assert changed is False
|
|
assert "id" not in rules[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_malformed_or_unknown_rules():
|
|
rules = screens.sanitize_collision_rules([
|
|
{"trigger": "diagonale", "action": {"type": "dialogue"}},
|
|
{"trigger": "collision", "action": {"type": "sorcellerie"}},
|
|
{"trigger": "perimetre", "action": {"type": "dialogue"}}, # "perimetre" retiré (demande explicite)
|
|
"pas un objet",
|
|
])
|
|
assert rules == []
|
|
|
|
|
|
def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(client, tmp_game_slug_cleanup):
|
|
"""Toujours les AUTRES objets qui réagissent à la présence du joueur,
|
|
jamais l'inverse — le joueur lui-même et un fond n'ont pas de champ
|
|
"collision_rules" dans le payload."""
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
|
|
# "Joueur" est désormais toujours un choix explicite.
|
|
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
|
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
|
|
|
payload = screens.full_game_payload(slug)
|
|
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
|
|
player_payload = next(o for o in scene_screen["elements"] if o["id"] == player_id)
|
|
enemy_payload = next(o for o in scene_screen["elements"] if o["id"] == enemy_id)
|
|
assert "collision_rules" not in player_payload
|
|
assert enemy_payload["collision_rules"] == []
|
|
|
|
|
|
def test_build_collision_rule_objects_excludes_the_player(client, tmp_game_slug_cleanup):
|
|
"""screens.build_collision_rule_objects (voir screens/rendering/
|
|
trigger_graph.py pour son autre appelant, le listage de tous les
|
|
déclencheurs du jeu) : "Joueur" (le seul rôle exclu) est désormais
|
|
toujours un choix explicite."""
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
|
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
|
|
|
objects = screens.list_scene_objects(slug, screen_id)
|
|
for o in objects:
|
|
o["rendered_html"] = screens.render_scene_object(o)
|
|
rule_objects = screens.build_collision_rule_objects(objects)
|
|
ids = [o["id"] for o in rule_objects]
|
|
assert enemy_id in ids
|
|
assert player_id not in ids
|
|
|
|
|
|
def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(client, tmp_game_slug_cleanup):
|
|
"""Bug corrigé : compute-operation.js/apply-actions.js (qui portent
|
|
forgeApplyVariableActionOffline, appelée par collision-rules-controller.js
|
|
pour l'action "Modifier une variable") n'étaient chargés QUE derrière
|
|
`{% if offline_mode %}` dans templates/play.html — en aperçu créateur
|
|
(/game/<slug>/play, jamais offline_mode), l'action ne faisait donc
|
|
RIEN (fonction absente). Les deux scripts doivent être chargés
|
|
inconditionnellement (voir aussi test_export_scorm.py, qui vérifie
|
|
qu'ils restent bien présents dans le paquet exporté)."""
|
|
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
html = client.get(f"/game/{slug}/play").data.decode()
|
|
assert 'src="/static/js/play/offline/compute-operation.js"' in html
|
|
assert 'src="/static/js/play/offline/apply-actions.js"' in html
|
|
|
|
|
|
def test_add_scene_object_with_image_url_sets_src_without_background_library(client, tmp_game_slug_cleanup):
|
|
""""Mes assets"/IA (voir plan Phase 1, §1) : une image ARBITRAIRE,
|
|
jamais une clé de BACKGROUND_LIBRARY."""
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
object_id = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png")
|
|
obj = screens.get_scene_object(slug, object_id)
|
|
assert obj["attributes"]["src"] == "/assets/1/abc.png"
|
|
|
|
|
|
def test_add_scene_object_image_url_takes_priority_over_background_slug(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
object_id = screens.add_scene_object(
|
|
slug, screen_id, kind="fond", background_slug="ne-existe-pas", image_url="/assets/1/abc.png",
|
|
)
|
|
obj = screens.get_scene_object(slug, object_id)
|
|
assert obj["attributes"]["src"] == "/assets/1/abc.png"
|