Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ab6635eee0
commit
b07b231a61
@@ -0,0 +1,315 @@
|
||||
"""Couche outils IA (voir plan Phase 1, §4 — ai/tools.py) : chaque
|
||||
dispatch doit appeler DIRECTEMENT la fonction screens.*/db.* existante
|
||||
(jamais une structure parallèle), et les schémas doivent suivre les
|
||||
constantes SOURCE plutôt qu'une copie littérale (principe validé avec
|
||||
l'utilisateur : une nouvelle valeur d'énum doit suivre automatiquement)."""
|
||||
import ai
|
||||
import auth
|
||||
import db
|
||||
import screens
|
||||
from ai.tools import TOOLS, dispatch_tool
|
||||
|
||||
|
||||
def _tool(name):
|
||||
return next(t for t in TOOLS if t["name"] == name)
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_ai_tools"):
|
||||
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 test_dispatch_add_scene_object_calls_the_real_function(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
result = dispatch_tool(slug, screen_id, 1, "add_scene_object", {"kind": "decor", "image_url": "/assets/1/x.png"})
|
||||
obj = screens.get_scene_object(slug, result["object_id"])
|
||||
assert obj["kind"] == "decor"
|
||||
assert obj["attributes"]["src"] == "/assets/1/x.png"
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_with_background_slug_uses_the_library_image(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
bg_slug = next(iter(screens.BACKGROUND_LIBRARY))
|
||||
result = dispatch_tool(slug, screen_id, admin_id, "add_scene_object", {"kind": "fond", "background_slug": bg_slug})
|
||||
obj = screens.get_scene_object(slug, result["object_id"])
|
||||
assert obj["attributes"]["src"] == screens.BACKGROUND_LIBRARY[bg_slug]["url"]
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_raises_for_an_unknown_background_slug(client, tmp_game_slug_cleanup):
|
||||
"""Bug corrigé : un slug inventé/mal orthographié par Claude était
|
||||
ignoré en silence (screens.add_scene_object, pensé pour un <select>
|
||||
HTML qui ne propose que des valeurs valides) — Ruby annonçait alors
|
||||
un fond posé qui n'apparaissait jamais en jeu. Lever une erreur ici
|
||||
la remonte comme résultat d'outil (voir ai/chat.py), visible par
|
||||
Claude plutôt qu'un faux succès."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
try:
|
||||
dispatch_tool(slug, screen_id, admin_id, "add_scene_object", {"kind": "fond", "background_slug": "ne-existe-pas"})
|
||||
assert False, "devrait lever ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_raises_for_an_unknown_forge_character(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
try:
|
||||
dispatch_tool(slug, screen_id, admin_id, "add_scene_object", {"kind": "personnage", "forge_character": "ne-existe-pas"})
|
||||
assert False, "devrait lever ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_forbids_admin_only_background_for_a_non_admin(client, user_client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with user_client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
bg_slug = next(iter(screens.ADMIN_ONLY_BACKGROUND_SLUGS))
|
||||
result = dispatch_tool(slug, screen_id, user_id, "add_scene_object", {"kind": "fond", "background_slug": bg_slug})
|
||||
obj = screens.get_scene_object(slug, result["object_id"])
|
||||
# Retombe sur le comportement par défaut (aucun src) plutôt que de
|
||||
# poser l'image admin-only — jamais une erreur qui casse le tour de chat.
|
||||
assert "src" not in obj["attributes"]
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_forbids_admin_only_character_for_a_non_admin(client, user_client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with user_client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
char_slug = next(iter(screens.ADMIN_ONLY_CHARACTER_SLUGS))
|
||||
result = dispatch_tool(slug, screen_id, user_id, "add_scene_object", {"kind": "personnage", "forge_character": char_slug})
|
||||
obj = screens.get_scene_object(slug, result["object_id"])
|
||||
import json
|
||||
assert json.loads(obj["attributes"]["_personnage_data"])["forge_character"] != char_slug
|
||||
|
||||
|
||||
def test_add_scene_object_forge_character_enum_matches_the_source_constant():
|
||||
schema = _tool("add_scene_object")["input_schema"]["properties"]["forge_character"]
|
||||
assert set(schema["enum"]) == set(screens.SPRITE_LIBRARY)
|
||||
|
||||
|
||||
def test_add_scene_object_background_slug_enum_matches_the_source_constant():
|
||||
schema = _tool("add_scene_object")["input_schema"]["properties"]["background_slug"]
|
||||
assert set(schema["enum"]) == set(screens.BACKGROUND_LIBRARY)
|
||||
|
||||
|
||||
def test_dispatch_set_object_geometry_updates_position_and_size(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="decor")
|
||||
dispatch_tool(slug, screen_id, 1, "set_object_geometry", {"object_id": object_id, "x": 10, "y": 20, "width": 64, "height": 96})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (10, 20, 64, 96)
|
||||
|
||||
|
||||
def test_dispatch_set_object_name_calls_the_real_function(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="personnage")
|
||||
dispatch_tool(slug, screen_id, 1, "set_object_name", {"object_id": object_id, "name": "Aka"})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert obj["name"] == "Aka"
|
||||
|
||||
|
||||
def test_dispatch_set_object_geometry_clamps_a_position_outside_the_camera(client, tmp_game_slug_cleanup):
|
||||
"""Bug corrigé à plusieurs reprises malgré des instructions explicites
|
||||
ET un avertissement dans le résultat de l'outil : l'IA n'arrivait
|
||||
toujours pas à se replacer correctement elle-même. Le moteur RAMÈNE
|
||||
maintenant automatiquement la position dans le cadre (voir
|
||||
_clamp_to_camera, ai/tools.py) — garanti dès le premier appel, sans
|
||||
compter sur une correction ultérieure de l'IA."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) # écran 960x540 par défaut
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
result = dispatch_tool(slug, screen_id, 1, "set_object_geometry", {"object_id": object_id, "x": 950, "y": 550, "width": 200, "height": 300})
|
||||
assert "note" in result
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert obj["x"] + obj["width"] <= 960
|
||||
assert obj["y"] + obj["height"] <= 540
|
||||
|
||||
|
||||
def test_dispatch_set_object_geometry_no_note_when_already_inside_the_camera(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="personnage")
|
||||
result = dispatch_tool(slug, screen_id, 1, "set_object_geometry", {"object_id": object_id, "x": 400, "y": 200, "width": 128, "height": 128})
|
||||
assert "note" not in result
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert (obj["x"], obj["y"]) == (400, 200)
|
||||
|
||||
|
||||
def test_dispatch_set_object_geometry_never_clamps_a_fond_bigger_than_the_camera(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")
|
||||
result = dispatch_tool(slug, screen_id, 1, "set_object_geometry", {"object_id": object_id, "x": 0, "y": 0, "width": 2000, "height": 1000})
|
||||
assert "note" not in result
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert (obj["width"], obj["height"]) == (2000, 1000)
|
||||
|
||||
|
||||
def test_dispatch_set_object_role_updates_the_role(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="personnage")
|
||||
dispatch_tool(slug, screen_id, 1, "set_object_role", {"object_id": object_id, "role": "ennemie"})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_personnage_role(obj) == "ennemie"
|
||||
|
||||
|
||||
def test_dispatch_set_object_collision_updates_the_settings(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="decor")
|
||||
dispatch_tool(slug, screen_id, 1, "set_object_collision", {"object_id": object_id, "enabled": False, "shape": "cercle"})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
settings = screens.resolve_collision_settings(obj)
|
||||
assert settings["enabled"] is False
|
||||
assert settings["shape"] == "cercle"
|
||||
|
||||
|
||||
def test_dispatch_create_global_variable_calls_db_directly(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
result = dispatch_tool(slug, screen_id, 1, "create_global_variable", {"name": "score_ia", "var_type": "nombre_entier"})
|
||||
assert result["variable_id"]
|
||||
names = [v["name"] for v in db.list_global_variables(slug)]
|
||||
assert "score_ia" in names
|
||||
|
||||
|
||||
def test_dispatch_set_collision_rules_sanitizes_and_stores(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="personnage")
|
||||
rules = [{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}]
|
||||
dispatch_tool(slug, screen_id, 1, "set_collision_rules", {"object_id": object_id, "rules": rules})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_dispatch_append_action_to_trigger_extends_an_existing_chain(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="personnage")
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "append_action_to_trigger", {
|
||||
"object_id": object_id, "after_id": "d_1",
|
||||
"action": {"type": "variable", "target_variable": "score_ia", "data_operation": "incrementer", "data_value": "1"},
|
||||
})
|
||||
assert result["ok"] is True
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score_ia"
|
||||
|
||||
|
||||
def test_dispatch_tool_raises_for_an_unknown_tool_name(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
try:
|
||||
dispatch_tool(slug, screen_id, 1, "does_not_exist", {})
|
||||
assert False, "devrait lever KeyError"
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeDownloadResponse:
|
||||
"""Mirroir minimal de requests.Response — voir
|
||||
test_dispatch_add_generated_image_* ci-dessous (jamais de vrai appel
|
||||
réseau, ni vers Scenario ni pour télécharger l'image résultante)."""
|
||||
content = b"fake-generated-image-bytes"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_dispatch_add_generated_image_saves_to_my_assets_and_poses_it(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
"""Scenario (mocké) -> "Mes assets" (source="ia") -> posée sur
|
||||
l'écran via LE MÊME add_scene_object que Phase 1 — jamais un chemin
|
||||
parallèle (voir plan Phase 2, §4)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
|
||||
monkeypatch.setattr("ai.tools.generate_image_url", lambda prompt: "https://scenario.example/image.png")
|
||||
monkeypatch.setattr("ai.tools.requests.get", lambda url, timeout=None: _FakeDownloadResponse())
|
||||
|
||||
result = dispatch_tool(slug, screen_id, user_id, "add_generated_image", {"kind": "decor", "prompt": "un bureau moderne"})
|
||||
obj = screens.get_scene_object(slug, result["object_id"])
|
||||
assert obj["kind"] == "decor"
|
||||
assert f"/assets/{user_id}/" in obj["attributes"]["src"]
|
||||
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["id"] == result["asset_id"] and a["source"] == "ia" for a in assets)
|
||||
|
||||
|
||||
def test_dispatch_add_generated_image_surfaces_scenario_errors_as_a_tool_error(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
"""Le chat (ai/chat.py) attrape déjà les exceptions de dispatch_tool
|
||||
au niveau de la boucle — mais un test direct de la propagation
|
||||
évite une régression silencieuse si ce comportement change."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
|
||||
def _raise(prompt):
|
||||
raise ai.ScenarioNotConfiguredError("clé absente")
|
||||
|
||||
monkeypatch.setattr("ai.tools.generate_image_url", _raise)
|
||||
try:
|
||||
dispatch_tool(slug, screen_id, 1, "add_generated_image", {"kind": "decor", "prompt": "un bureau"})
|
||||
assert False, "devrait lever ScenarioNotConfiguredError"
|
||||
except ai.ScenarioNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Anti-dérive : les énums viennent des constantes SOURCE ----------
|
||||
# (voir screens/rendering/collision_rules.py, screens/labels/data_operations.py,
|
||||
# db/constants.py) — jamais recopiées en dur dans ai/tools.py. Si ce test
|
||||
# échoue après l'ajout d'une nouvelle valeur côté moteur, c'est que
|
||||
# quelqu'un a remplacé la référence par une copie littérale : à corriger
|
||||
# dans ai/tools.py, pas dans ce test.
|
||||
|
||||
def test_set_collision_rules_action_type_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import ACTION_TYPES
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
assert action_schema["properties"]["type"]["enum"] == list(ACTION_TYPES)
|
||||
|
||||
|
||||
def test_set_collision_rules_condition_operator_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import CONDITION_OPERATOR_KEYS
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
assert set(action_schema["properties"]["operateur"]["enum"]) == CONDITION_OPERATOR_KEYS
|
||||
|
||||
|
||||
def test_set_collision_rules_data_operation_enum_matches_the_source_constant():
|
||||
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
assert set(action_schema["properties"]["data_operation"]["enum"]) == set(DATA_OPERATION_LABELS)
|
||||
|
||||
|
||||
def test_append_action_to_trigger_leaf_type_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import LEAF_ACTION_TYPES
|
||||
leaf_schema = _tool("append_action_to_trigger")["input_schema"]["properties"]["action"]
|
||||
assert leaf_schema["properties"]["type"]["enum"] == list(LEAF_ACTION_TYPES)
|
||||
|
||||
|
||||
def test_append_action_to_trigger_dialogue_description_documents_reward_amount():
|
||||
"""Bug corrigé : la forme d'une ligne de dialogue/question n'était pas
|
||||
décrite au tool, jusqu'à ce que Ruby invente une variable séparée
|
||||
pour un score de quiz au lieu d'utiliser reward_amount (déjà
|
||||
automatique, voir db/dialogue_lines.py). Vérifie que la RÉFÉRENCE aux
|
||||
valeurs valides de reward_type est bien utilisée (pas une copie)."""
|
||||
from db.dialogue_lines import QUESTION_REWARD_TYPES
|
||||
leaf_schema = _tool("append_action_to_trigger")["input_schema"]["properties"]["action"]
|
||||
dialogue_desc = leaf_schema["properties"]["dialogue"]["description"]
|
||||
assert "reward_amount" in dialogue_desc
|
||||
assert all(t in dialogue_desc for t in QUESTION_REWARD_TYPES)
|
||||
|
||||
|
||||
def test_create_global_variable_var_type_enum_matches_the_source_constant():
|
||||
schema = _tool("create_global_variable")["input_schema"]["properties"]["var_type"]
|
||||
assert set(schema["enum"]) == set(db.GLOBAL_VARIABLE_TYPES)
|
||||
|
||||
|
||||
def test_set_object_role_enum_matches_the_source_constant():
|
||||
schema = _tool("set_object_role")["input_schema"]["properties"]["role"]
|
||||
assert schema["enum"] == list(screens.PERSONNAGE_ROLES)
|
||||
|
||||
|
||||
def test_set_object_collision_shape_enum_matches_the_source_constant():
|
||||
schema = _tool("set_object_collision")["input_schema"]["properties"]["shape"]
|
||||
assert schema["enum"] == list(screens.COLLISION_SHAPES)
|
||||
Reference in New Issue
Block a user