Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- 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>
This commit is contained in:
william
2026-09-12 00:28:46 +02:00
co-authored by Claude Sonnet 5
parent b07b231a61
commit 559331f9cf
67 changed files with 4739 additions and 306 deletions
+356 -6
View File
@@ -257,10 +257,14 @@ def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves():
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)."""
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(10): # largement au-delà de _MAX_THEN_CHAIN (4)
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}])
@@ -269,7 +273,36 @@ def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
while node:
depth += 1
node = node.get("then")
assert depth == 4 # _MAX_THEN_CHAIN, jamais plus
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():
@@ -300,7 +333,11 @@ def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id():
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():
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": {
@@ -309,8 +346,321 @@ def test_set_then_action_in_rules_never_overwrites_an_existing_then():
},
}])
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é
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():