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,15 @@
|
||||
"""Couche IA (voir plan Phase 1) — infra transverse, distincte de
|
||||
screens/ (rendu/données de jeu) : ai.tools définit les outils exposés à
|
||||
l'agent (Phase 2) sans dépendre du SDK anthropic lui-même, pour rester
|
||||
testable sans clé API."""
|
||||
|
||||
from .tools import TOOLS, dispatch_tool
|
||||
from .client import get_client, AnthropicNotConfiguredError, MODEL
|
||||
from .scenario_client import generate_image_url, ScenarioNotConfiguredError, ScenarioGenerationError
|
||||
from .chat import run_chat_turn
|
||||
|
||||
__all__ = [
|
||||
"TOOLS", "dispatch_tool", "get_client", "AnthropicNotConfiguredError", "MODEL",
|
||||
"generate_image_url", "ScenarioNotConfiguredError", "ScenarioGenerationError",
|
||||
"run_chat_turn",
|
||||
]
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
"""Boucle tool-use (voir plan Phase 2, §5) — Claude Sonnet 5 pilote
|
||||
UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base :
|
||||
tout ce qu'un tour de chat produit est donc TOUJOURS relisable/
|
||||
modifiable dans l'éditeur normal (même garantie que la Phase 1)."""
|
||||
import json
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .client import get_client, MODEL
|
||||
from .tools import TOOLS, dispatch_tool
|
||||
|
||||
# Borne dure : jamais une boucle sans fin qui dépenserait sans fin si
|
||||
# Claude s'entête à rappeler des outils (bug de prompt, tool qui échoue
|
||||
# en boucle, etc.) — un écran complet (fond, personnages, widgets,
|
||||
# déclencheur, ajustements) peut légitimement dépasser 8 appels.
|
||||
_MAX_TOOL_ITERATIONS = 12
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"Tu t'appelles Ruby, l'assistante IA de Forge Engine — présente-toi "
|
||||
"sous ce nom si on te le demande. "
|
||||
"Tu aides un créateur à construire UN SEUL écran d'un jeu de "
|
||||
"formation professionnelle 2D (Forge Engine), via les outils fournis "
|
||||
"— jamais autrement. Ne touche qu'à cet écran, jamais aux autres. "
|
||||
"Réutilise une variable globale déjà existante UNIQUEMENT si elle "
|
||||
"représente EXACTEMENT la même information (ex. un score total "
|
||||
"partagé par tout le jeu) — les variables sont globales à TOUT le "
|
||||
"jeu, visibles depuis n'importe quel écran, donc un drapeau \"terminé\" "
|
||||
"propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un "
|
||||
"autre dialogue/quiz, même similaire, même sur un autre écran (bug "
|
||||
"corrigé : un quiz réutilisait par erreur le drapeau \"terminé\" d'un "
|
||||
"AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à "
|
||||
"chaque nouveau drapeau un nom qui identifie clairement CE qu'il "
|
||||
"suit (ex. préfixé par le nom du personnage/objet concerné). "
|
||||
"N'utilise add_generated_image "
|
||||
"QUE pour des fonds ou des objets 2D isolés (meubles, plantes, décor) "
|
||||
"— jamais pour un personnage ou un sprite animé, la génération "
|
||||
"d'image n'est pas fiable pour ça. "
|
||||
"\n\n"
|
||||
"CE QUE TU NE PEUX PAS FAIRE : tes outils ne pilotent QUE la logique "
|
||||
"\"déclencheur de collision\" d'un objet de scène (dialogue, "
|
||||
"variable, interagir, condition). Forge Engine a un AUTRE système, "
|
||||
"séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, les "
|
||||
"minuteurs, jouer un son, une animation de sprite — tu n'as AUCUN "
|
||||
"outil pour ça. Si une demande a besoin de cette partie-là (\"passer "
|
||||
"à l'écran suivant\", \"jouer un son\", \"après 5 secondes\"...), fais "
|
||||
"quand même tout ce que tes outils permettent, puis DIS CLAIREMENT "
|
||||
"dans ta réponse texte ce que tu n'as pas pu faire et pourquoi "
|
||||
"(cette partie doit être ajoutée à la main dans l'onglet \"Flow\") — "
|
||||
"ne t'acharne JAMAIS à répéter des appels d'outils pour une chose "
|
||||
"qu'aucun outil ne permet, ça n'aboutira jamais."
|
||||
"\n\n"
|
||||
"ÉTAT DE LA SCÈNE : le message système de chaque tour te donne les "
|
||||
"dimensions de la caméra, la liste des objets déjà posés sur CET "
|
||||
"écran, et les variables globales déjà existantes — relis-les avant "
|
||||
"d'agir plutôt que de deviner (ne recrée jamais un objet ou une "
|
||||
"variable qui existe déjà, corrige/complète l'existant avec "
|
||||
"set_object_geometry/set_object_name/set_object_role/set_collision_rules)."
|
||||
"\n\n"
|
||||
"CAMÉRA ET POSITIONNEMENT : les dimensions indiquées sont la zone "
|
||||
"VISIBLE (coin haut-gauche à (0,0)) — place tout objet important "
|
||||
"(personnages, widgets d'interface) DANS cette zone par défaut. Le "
|
||||
"moteur RAMÈNE automatiquement dans le cadre toute position qui en "
|
||||
"sortirait (voir un éventuel champ \"note\" dans le résultat de "
|
||||
"set_object_geometry/add_scene_object — c'est déjà corrigé, rien à "
|
||||
"refaire), donc vise une position raisonnable sans stresser sur le "
|
||||
"pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées "
|
||||
"— espace-les clairement. Un personnage fraîchement posé fait "
|
||||
"128x128 px par défaut — garde des tailles cohérentes entre "
|
||||
"personnages sauf besoin explicite. RÈGLE FIXE pour un \"fond\" : "
|
||||
"redimensionne-le TOUJOURS automatiquement en 2000x1000 px "
|
||||
"(set_object_geometry, position (0,0)) juste après l'avoir posé, "
|
||||
"SANS que le créateur ait besoin de le demander à chaque fois — "
|
||||
"c'est le format standard de Forge Engine. Ne dépasse cette taille "
|
||||
"que si le créateur demande explicitement un monde à explorer plus "
|
||||
"grand."
|
||||
"\n\n"
|
||||
"JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle "
|
||||
"\"pnj\" par défaut, JAMAIS \"joueur\". Un déclencheur de type "
|
||||
"\"collision\" ne se déclenche QUE par le contact du personnage au "
|
||||
"rôle \"joueur\" — sans lui, la collision ne se déclenche jamais ET "
|
||||
"la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un "
|
||||
"déclencheur de collision (quiz, dialogue déclenché en marchant "
|
||||
"vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN "
|
||||
"personnage avec role=\"joueur\" (set_object_role) — jamais deux, "
|
||||
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
|
||||
"demandé."
|
||||
"\n\n"
|
||||
"QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une "
|
||||
"donnée consultée plus tard (progression, un choix du joueur, un "
|
||||
"drapeau \"terminé\") — JAMAIS pour un score de quiz (voir "
|
||||
"reward_amount plus haut, déjà automatique). Modifie une variable "
|
||||
"(action \"variable\") quand un événement doit changer durablement "
|
||||
"cet état (ex. marquer un drapeau \"xxx_termine\" à vrai une fois un "
|
||||
"quiz fini). Utilise une CONDITION quand le comportement doit "
|
||||
"VRAIMENT différer selon l'état actuel d'une variable — un simple "
|
||||
"enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais "
|
||||
"d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à "
|
||||
"USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être "
|
||||
"protégé par une CONDITION qui vérifie D'ABORD le drapeau "
|
||||
"\"xxx_termine\" — si faux (pas encore fait), lance le dialogue/quiz "
|
||||
"normal (branche si_faux) PUIS termine par une action \"variable\" "
|
||||
"qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une "
|
||||
"réplique courte différente (branche si_vrai) au lieu de rejouer "
|
||||
"tout le quiz à chaque collision. Utilise un DIALOGUE pour toute "
|
||||
"réplique ou question posée au joueur."
|
||||
"\n\n"
|
||||
"IMPORTANT — UN DIALOGUE/QUIZ/SCORE NE S'AFFICHE JAMAIS TOUT SEUL EN "
|
||||
"JEU : il faut TOUJOURS poser en plus le widget d'interface "
|
||||
"correspondant sur l'écran via add_scene_object, sinon rien n'apparaît "
|
||||
"à l'écran même si le déclencheur est correctement configuré. Une "
|
||||
"action \"dialogue\" dont les lignes sont de type \"replique\" a besoin "
|
||||
"d'un objet kind=\"dialogue_box\" ; une action \"dialogue\" dont les "
|
||||
"lignes sont de type \"question\" (quiz) a besoin d'un objet "
|
||||
"kind=\"quiz_box\" (pas dialogue_box) ; si un score/des points sont "
|
||||
"utilisés, ajoute aussi un objet kind=\"score_widget\" pour qu'il soit "
|
||||
"visible en continu. Positionne ces widgets à un endroit raisonnable "
|
||||
"de l'écran (ex. centré, ou en haut) via set_object_geometry après "
|
||||
"les avoir posés."
|
||||
"\n\n"
|
||||
"Ton professionnel, adapté à une formation d'entreprise. Réponds "
|
||||
"toujours en français, de façon concise, en confirmant ce que tu as "
|
||||
"posé."
|
||||
)
|
||||
|
||||
|
||||
def _describe_scene_state(slug, screen_id):
|
||||
"""Contexte dynamique (jamais mémorisé côté Claude entre les tours,
|
||||
voir run_chat_turn — seul le texte final est persisté) : sans ça,
|
||||
Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des
|
||||
objets au lieu de les corriger (bug observé : 2 PNJ identiques créés
|
||||
à la place d'un seul joueur + un pnj)."""
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
width, height = screen["scene_width"], screen["scene_height"]
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
# Répété ICI (pas seulement dans les instructions générales) avec les
|
||||
# VRAIS chiffres de cet écran — un rappel abstrait une seule fois dans
|
||||
# un long system prompt s'est montré insuffisant (bug observé deux
|
||||
# fois : personnage posé à des coordonnées bien au-delà de la caméra).
|
||||
lines = [
|
||||
f"Zone visible par la caméra CETTE ÉCRAN : x de 0 à {width}, y de 0 à {height} "
|
||||
f"(coin haut-gauche à (0,0)). RAPPEL : place tout personnage/widget d'interface "
|
||||
f"À L'INTÉRIEUR de ces bornes par défaut (ex. x autour de {width // 2}, "
|
||||
f"y autour de {height // 2} pour un centrage simple), sauf demande explicite d'un "
|
||||
"monde plus grand à explorer."
|
||||
]
|
||||
if not objects:
|
||||
lines.append("Aucun objet posé sur cet écran pour l'instant.")
|
||||
else:
|
||||
lines.append(f"{len(objects)} objet(s) déjà posé(s) sur cet écran :")
|
||||
for o in objects:
|
||||
desc = (
|
||||
f"- id={o['id']} kind={o['kind']} position=({int(o['x'])},{int(o['y'])}) "
|
||||
f"taille={int(o['width'])}x{int(o['height'])}"
|
||||
)
|
||||
if o.get("name"):
|
||||
desc += f" nom={o['name']!r}"
|
||||
if o["kind"] == "personnage":
|
||||
desc += f" rôle={screens.resolve_personnage_role(o)}"
|
||||
if (o.get("attributes") or {}).get("_collision_rules"):
|
||||
desc += " [déclencheur déjà configuré]"
|
||||
lines.append(desc)
|
||||
|
||||
variables = db.list_global_variables(slug)
|
||||
if variables:
|
||||
lines.append("Variables globales déjà existantes dans ce jeu (jamais en recréer une du même nom) :")
|
||||
for v in variables:
|
||||
lines.append(f"- {v['name']} ({v['type']}, valeur actuelle : {v['value']})")
|
||||
else:
|
||||
lines.append("Aucune variable globale n'existe encore dans ce jeu.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _history_to_messages(history):
|
||||
return [{"role": m["role"], "content": m["content"]} for m in history]
|
||||
|
||||
|
||||
def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
|
||||
"""Un tour complet : reprend l'historique persisté de CETTE
|
||||
conversation, ajoute le message du créateur, boucle tant que Claude
|
||||
appelle des outils, et renvoie le texte final. `screen_id` reste
|
||||
nécessaire pour les tools (voir ai/tools.py — chaque conversation
|
||||
reste scopée à SON écran, une conversation ne change jamais
|
||||
d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py,
|
||||
seul appelant, qui décide de ce qui est sauvegardé (même séparation
|
||||
que le reste du moteur : cette fonction ne fait que la logique IA)."""
|
||||
client = get_client() # AnthropicNotConfiguredError si pas de clé
|
||||
messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
|
||||
# Claude lui-même) — voir _describe_scene_state.
|
||||
system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id)
|
||||
|
||||
response = None
|
||||
for _ in range(_MAX_TOOL_ITERATIONS):
|
||||
response = client.messages.create(
|
||||
# 4096 était trop bas (bug corrigé) : la réflexion adaptative
|
||||
# partage le même budget que la réponse — sur une demande
|
||||
# riche (plusieurs objets + logique + texte), Claude pouvait
|
||||
# être coupé EN PLEINE RÉFLEXION, avant le moindre appel
|
||||
# d'outil (symptôme observé : aucune progression du tout,
|
||||
# "(pas de réponse textuelle)" dès le premier tour).
|
||||
model=MODEL, max_tokens=16000, system=system_prompt,
|
||||
tools=TOOLS, thinking={"type": "adaptive"}, messages=messages,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
tool_results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
try:
|
||||
result = dispatch_tool(slug, screen_id, user_id, block.name, block.input)
|
||||
except Exception as e:
|
||||
result = {"error": str(e)}
|
||||
tool_results.append({
|
||||
"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result),
|
||||
})
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
text = next((b.text for b in response.content if b.type == "text"), "")
|
||||
if text:
|
||||
return text
|
||||
if response.stop_reason == "max_tokens":
|
||||
return (
|
||||
"Ruby a été interrompue avant de terminer (demande trop complexe pour une seule "
|
||||
"réponse) — réessaie en la découpant en plusieurs étapes plus simples."
|
||||
)
|
||||
return "(pas de réponse textuelle)"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Client Claude (voir plan Phase 2, §2) — même politique que
|
||||
auth/send_email.py::EmailNotConfiguredError : une clé absente est une
|
||||
configuration incomplète, jamais un crash brut ni une clé en dur."""
|
||||
import os
|
||||
|
||||
import anthropic
|
||||
|
||||
MODEL = "claude-sonnet-5"
|
||||
|
||||
|
||||
class AnthropicNotConfiguredError(Exception):
|
||||
"""Levée quand ANTHROPIC_API_KEY est absente — voir .env.example.
|
||||
Le compte claude.ai Entreprise (chat en équipe) n'est PAS une clé API :
|
||||
il faut un compte Anthropic Console (console.anthropic.com) séparé."""
|
||||
|
||||
|
||||
def get_client():
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise AnthropicNotConfiguredError(
|
||||
"ANTHROPIC_API_KEY absente"
|
||||
)
|
||||
return anthropic.Anthropic(api_key=api_key)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Client Scenario (voir plan Phase 2, §3) — génération d'images (fonds/
|
||||
objets 2D uniquement, jamais de personnages/sprites — décision actée
|
||||
lors de l'étude préalable). API confirmée (docs.scenario.com) :
|
||||
authentification Basic (clé + secret), génération ASYNCHRONE par job —
|
||||
POST déclenche un job, GET /jobs/{id} jusqu'à "success"/"failure", puis
|
||||
GET /assets/{id} pour l'URL finale téléchargeable.
|
||||
|
||||
Ce module reste un simple client HTTP : il renvoie l'URL de l'image,
|
||||
jamais ne la télécharge/l'enregistre lui-même — ça reste le travail de
|
||||
ai/tools.py::_dispatch_add_generated_image (voir "Mes assets",
|
||||
auth/user_assets_dir.py), pour que ce module soit testable seul."""
|
||||
import os
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
_BASE_URL = "https://api.cloud.scenario.com/v1"
|
||||
|
||||
|
||||
class ScenarioNotConfiguredError(Exception):
|
||||
"""SCENARIO_API_KEY/SCENARIO_API_SECRET/SCENARIO_MODEL_ID absents —
|
||||
voir .env.example. SCENARIO_MODEL_ID est l'id du modèle entraîné sur
|
||||
le style graphique unique de Forge Engine (pas encore créé tant que
|
||||
l'entraînement Scenario n'a pas été fait)."""
|
||||
|
||||
|
||||
class ScenarioGenerationError(Exception):
|
||||
"""Le job Scenario a échoué ou n'a pas répondu à temps."""
|
||||
|
||||
|
||||
def _credentials():
|
||||
api_key = os.environ.get("SCENARIO_API_KEY")
|
||||
api_secret = os.environ.get("SCENARIO_API_SECRET")
|
||||
model_id = os.environ.get("SCENARIO_MODEL_ID")
|
||||
if not api_key or not api_secret or not model_id:
|
||||
raise ScenarioNotConfiguredError(
|
||||
"SCENARIO_API_KEY/SCENARIO_API_SECRET/SCENARIO_MODEL_ID absents — voir .env.example."
|
||||
)
|
||||
return (api_key, api_secret), model_id
|
||||
|
||||
|
||||
def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval=2):
|
||||
"""Lance une génération txt2img et attend le résultat — renvoie
|
||||
l'URL finale de l'image (hébergée par Scenario, à télécharger par
|
||||
l'appelant). Bloquant (poll_interval secondes entre chaque
|
||||
vérification), borné par `timeout` secondes au total."""
|
||||
auth, model_id = _credentials()
|
||||
response = requests.post(
|
||||
f"{_BASE_URL}/generate/txt2img", auth=auth, timeout=30,
|
||||
json={"prompt": prompt, "modelId": model_id, "width": width, "height": height, "numSamples": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
job_id = response.json()["job"]["jobId"]
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
status_response = requests.get(f"{_BASE_URL}/jobs/{job_id}", auth=auth, timeout=30)
|
||||
status_response.raise_for_status()
|
||||
job = status_response.json()["job"]
|
||||
if job["status"] == "success":
|
||||
asset_id = job["metadata"]["assetIds"][0]
|
||||
asset_response = requests.get(f"{_BASE_URL}/assets/{asset_id}", auth=auth, timeout=30)
|
||||
asset_response.raise_for_status()
|
||||
return asset_response.json()["asset"]["url"]
|
||||
if job["status"] == "failure":
|
||||
raise ScenarioGenerationError("Scenario a échoué à générer l'image.")
|
||||
time.sleep(poll_interval)
|
||||
raise ScenarioGenerationError("Scenario n'a pas répondu à temps.")
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
"""Outils exposés à l'agent IA (voir plan Phase 1, §4) — chaque tool
|
||||
appelle DIRECTEMENT une fonction déjà utilisée par l'éditeur no-code
|
||||
manuel, jamais une structure parallèle : ce qu'un créateur voit ensuite
|
||||
dans l'éditeur (Déclencheurs, Variables, panneau de propriétés) est donc
|
||||
TOUJOURS le résultat du même code, qu'il ait été posé à la main ou par
|
||||
l'IA.
|
||||
|
||||
Aucune dépendance au SDK anthropic ici (voir ai/__init__.py) — ce module
|
||||
ne fait que décrire les schémas et les relier à screens.*/db.* ; la
|
||||
boucle tool-use elle-même (Phase 2) l'utilisera tel quel.
|
||||
|
||||
Principe validé avec l'utilisateur : un NOUVEL outil reste une décision
|
||||
explicite (jamais de réflexion automatique sur tout screens/), mais une
|
||||
VALEUR interne à un outil existant (un type d'action, un opérateur de
|
||||
condition, une opération de variable) doit suivre automatiquement dès
|
||||
qu'elle est ajoutée côté moteur — d'où les schémas ci-dessous construits
|
||||
à partir des constantes existantes (ACTION_TYPES, CONDITION_OPERATORS,
|
||||
DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié
|
||||
(tests/test_ai_tools.py) vérifie que cette référence n'est jamais
|
||||
remplacée par une copie littérale."""
|
||||
|
||||
import os
|
||||
|
||||
import requests
|
||||
from flask import url_for
|
||||
|
||||
import auth
|
||||
import db
|
||||
import screens
|
||||
from db.dialogue_lines import QUESTION_REWARD_TYPES
|
||||
from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS
|
||||
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
from .scenario_client import generate_image_url
|
||||
|
||||
_CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS)
|
||||
_DATA_OPERATIONS = sorted(DATA_OPERATION_LABELS)
|
||||
_PERSONNAGE_ROLES = list(screens.PERSONNAGE_ROLES)
|
||||
_COLLISION_SHAPES = list(screens.COLLISION_SHAPES)
|
||||
_GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES)
|
||||
# Catalogue COMPLET (public + admin-only, voir core/sprite_gate.py) —
|
||||
# Claude doit connaître les slugs valides pour ne jamais en deviner un
|
||||
# qui retomberait silencieusement sur le personnage par défaut
|
||||
# (screens.add_scene_object). L'accès aux entrées admin-only reste
|
||||
# vérifié à l'exécution (voir _dispatch_add_scene_object), même garde
|
||||
# que la galerie manuelle.
|
||||
_FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY)
|
||||
_BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY)
|
||||
|
||||
_LEAF_ACTION_SCHEMA = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Une action FEUILLE (\"dialogue\" ou \"variable\", voir "
|
||||
"screens/rendering/collision_rules.py) — peut porter un champ "
|
||||
"optionnel \"then\" (une autre feuille, chaînage borné à 4)."
|
||||
),
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": list(LEAF_ACTION_TYPES)},
|
||||
"id": {"type": "string", "description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger."},
|
||||
"dialogue": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Pour type=dialogue : {id, lines}. Chaque élément de `lines` est SOIT une "
|
||||
"RÉPLIQUE {\"type\":\"dialogue\", \"speaker\": <qui parle>, \"text\": <texte>}, "
|
||||
"SOIT une QUESTION DE QUIZ {\"type\":\"question\", \"text\", \"choices\": "
|
||||
"[2 à 4 réponses], \"correct_index\": <index de la bonne réponse>, "
|
||||
f"\"reward_type\": {list(QUESTION_REWARD_TYPES)!r}, \"reward_amount\": <entier>}}. "
|
||||
"reward_amount alimente le SCORE NATIF du jeu (visible via un objet "
|
||||
"kind=\"score_widget\", nécessite aussi un objet kind=\"quiz_box\" posé pour que "
|
||||
"la question s'affiche) — crédité UNIQUEMENT si la réponse est correcte, sinon "
|
||||
"ignoré ; le joueur avance toujours à la ligne suivante, bonne réponse ou pas. "
|
||||
"N'invente JAMAIS une variable séparée pour suivre un score de quiz : "
|
||||
"reward_amount fait déjà tout, sans action \"variable\" supplémentaire."
|
||||
),
|
||||
"properties": {"id": {"type": "string"}, "lines": {"type": "array"}},
|
||||
},
|
||||
"mark_completed": {"type": "boolean"},
|
||||
"target_variable": {"type": "string", "description": "Pour type=variable : nom d'une variable globale existante."},
|
||||
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
|
||||
"data_value": {"type": "string", "description": "Absent pour definir_bool_vrai/definir_bool_faux/basculer_bool."},
|
||||
"then": {"description": "Feuille suivante (même forme), récursif."},
|
||||
},
|
||||
"required": ["type"],
|
||||
}
|
||||
|
||||
_ACTION_SCHEMA = {
|
||||
"type": "object",
|
||||
"description": "Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type.",
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": list(ACTION_TYPES)},
|
||||
"id": {"type": "string"},
|
||||
"dialogue": _LEAF_ACTION_SCHEMA["properties"]["dialogue"],
|
||||
"mark_completed": {"type": "boolean"},
|
||||
"target_variable": {"type": "string"},
|
||||
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
|
||||
"data_value": {"type": "string"},
|
||||
"then": {"description": "Feuille suivante (dialogue/variable), récursif."},
|
||||
"sub_action": {"description": "Pour type=interagir : une action (pas \"interagir\" à nouveau)."},
|
||||
"variable": {"type": "string", "description": "Pour type=condition."},
|
||||
"operateur": {"type": "string", "enum": _CONDITION_OPERATORS},
|
||||
"valeur": {"type": "string"},
|
||||
"si_vrai": {"description": "Pour type=condition : null ou une feuille."},
|
||||
"si_faux": {"description": "Pour type=condition : null ou une feuille."},
|
||||
},
|
||||
"required": ["type"],
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "add_scene_object",
|
||||
"description": (
|
||||
"Pose un nouvel objet sur l'écran en cours (personnage, décor, fond, "
|
||||
"ou widget d'interface). Pour une image générée/de \"Mes assets\", "
|
||||
"utiliser image_url (jamais générer un personnage/sprite par ce biais)."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"]},
|
||||
"forge_character": {"type": "string", "enum": _FORGE_CHARACTERS, "description": "Pour kind=personnage : slug d'un personnage de la bibliothèque Forge existante (jamais un sprite généré)."},
|
||||
"background_slug": {"type": "string", "enum": _BACKGROUNDS, "description": "Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge."},
|
||||
"image_url": {"type": "string", "description": "URL d'une image déjà uploadée/générée (voir \"Mes assets\") — kind decor/fond uniquement, prioritaire sur background_slug."},
|
||||
},
|
||||
"required": ["kind"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_object_geometry",
|
||||
"description": "Positionne/redimensionne un objet déjà posé sur l'écran, en pixels.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"x": {"type": "number"}, "y": {"type": "number"},
|
||||
"width": {"type": "number"}, "height": {"type": "number"},
|
||||
},
|
||||
"required": ["object_id", "x", "y", "width", "height"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_object_name",
|
||||
"description": (
|
||||
"Donne un nom à un objet de scène (ex. \"Stan\", \"Aka\") — affiché comme "
|
||||
"\"qui parle\" dans les dialogues qui lui sont attachés."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["object_id", "name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_object_role",
|
||||
"description": "Change le rôle d'un objet personnage (joueur / ennemie / pnj).",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"role": {"type": "string", "enum": _PERSONNAGE_ROLES},
|
||||
},
|
||||
"required": ["object_id", "role"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_object_collision",
|
||||
"description": "Règle la boîte de collision d'un objet (forme, taille, décalage, activée ou non).",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"shape": {"type": "string", "enum": _COLLISION_SHAPES},
|
||||
"width": {"type": "number"}, "height": {"type": "number"},
|
||||
"offset_x": {"type": "number"}, "offset_y": {"type": "number"},
|
||||
},
|
||||
"required": ["object_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_global_variable",
|
||||
"description": "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"var_type": {"type": "string", "enum": _GLOBAL_VARIABLE_TYPES},
|
||||
"default_value": {"type": "string"},
|
||||
"per_player": {"type": "boolean"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_collision_rules",
|
||||
"description": (
|
||||
"Remplace TOUTES les règles \"déclencheur -> action\" d'un objet. "
|
||||
"Sanitizé côté serveur (screens.sanitize_collision_rules) : toute "
|
||||
"valeur invalide est silencieusement retirée plutôt que rejetée."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trigger": {"type": "string"},
|
||||
"action": _ACTION_SCHEMA,
|
||||
},
|
||||
"required": ["trigger", "action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["object_id", "rules"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_generated_image",
|
||||
"description": (
|
||||
"Génère une image (fond ou objet 2D UNIQUEMENT, JAMAIS un "
|
||||
"personnage/sprite — la génération d'image n'est pas fiable pour "
|
||||
"ça) via Scenario, l'ajoute à \"Mes assets\" et la pose "
|
||||
"automatiquement sur l'écran en cours."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["decor", "fond"]},
|
||||
"prompt": {"type": "string", "description": "Description de l'image en langage naturel."},
|
||||
},
|
||||
"required": ["kind", "prompt"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "append_action_to_trigger",
|
||||
"description": (
|
||||
"Ajoute une action à la SUITE d'une chaîne déjà posée sur un "
|
||||
"déclencheur existant, sans reconstruire toute la règle — "
|
||||
"after_id désigne la dernière feuille de la chaîne."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"after_id": {"type": "string"},
|
||||
"action": _LEAF_ACTION_SCHEMA,
|
||||
},
|
||||
"required": ["object_id", "after_id", "action"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _clamp_to_camera(slug, screen_id, kind, x, y, width, height):
|
||||
"""Le placement précis par coordonnées s'est montré peu fiable pour
|
||||
l'IA malgré des instructions explicites ET un avertissement dans le
|
||||
résultat de l'outil (bug observé à répétition — l'IA n'arrivait
|
||||
toujours pas à replacer l'objet correctement, épuisant même parfois
|
||||
tout le budget d'itérations sans jamais y arriver). Plutôt que de
|
||||
compter sur elle pour se corriger, le moteur RAMÈNE automatiquement
|
||||
tout objet (hors "fond", volontairement plus grand que la caméra —
|
||||
voir screens.resolve_scene_world_size) à l'intérieur du cadre visible
|
||||
dès cet appel : garantit un résultat correct du premier coup, sans
|
||||
aller-retour. Renvoie (x, y, note) — note est None si aucun
|
||||
ajustement n'était nécessaire."""
|
||||
if kind == "fond":
|
||||
return x, y, None
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if not screen:
|
||||
return x, y, None
|
||||
scene_width, scene_height = screen["scene_width"], screen["scene_height"]
|
||||
clamped_x = min(max(x, 0), max(0, scene_width - width))
|
||||
clamped_y = min(max(y, 0), max(0, scene_height - height))
|
||||
if (clamped_x, clamped_y) == (x, y):
|
||||
return x, y, None
|
||||
note = (
|
||||
f"Position ajustée automatiquement de ({int(x)},{int(y)}) à "
|
||||
f"({int(clamped_x)},{int(clamped_y)}) pour rester dans le cadre visible par la "
|
||||
f"caméra (0,0)-({scene_width},{scene_height})."
|
||||
)
|
||||
return clamped_x, clamped_y, note
|
||||
|
||||
|
||||
def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=None, background_slug=None, image_url=None):
|
||||
# Même garde que la galerie manuelle (core/sprite_gate.py) — un
|
||||
# compte non-admin ne doit pas pouvoir poser un sprite/fond
|
||||
# admin-only via l'IA alors que la galerie de l'éditeur ne les lui
|
||||
# propose déjà pas.
|
||||
user = auth.get_user_by_id(user_id)
|
||||
is_admin = bool(user and user["role"] == "admin")
|
||||
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and not is_admin:
|
||||
forge_character = None
|
||||
if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and not is_admin:
|
||||
background_slug = None
|
||||
# screens.add_scene_object ignore SILENCIEUSEMENT un slug invalide
|
||||
# (comportement voulu pour l'éditeur manuel, où un select HTML ne
|
||||
# peut de toute façon proposer qu'un slug valide) — mais Claude, lui,
|
||||
# peut inventer/mal orthographier une valeur malgré l'enum du schéma
|
||||
# (pas de strict:true ici, voir ai/chat.py). Sans ce contrôle,
|
||||
# l'outil "réussissait" en silence et Ruby annonçait un fond/
|
||||
# personnage posé qui n'apparaissait jamais (bug corrigé). Lever une
|
||||
# erreur ici la remonte comme résultat d'outil (voir ai/chat.py) :
|
||||
# Claude la VOIT et peut se corriger dans le même tour.
|
||||
if forge_character and forge_character not in screens.SPRITE_LIBRARY:
|
||||
raise ValueError(f"forge_character inconnu : {forge_character!r} (voir la liste enum du tool)")
|
||||
if background_slug and background_slug not in screens.BACKGROUND_LIBRARY:
|
||||
raise ValueError(f"background_slug inconnu : {background_slug!r} (voir la liste enum du tool)")
|
||||
object_id = screens.add_scene_object(
|
||||
slug, screen_id, kind=kind, forge_character=forge_character,
|
||||
background_slug=background_slug, image_url=image_url,
|
||||
)
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
||||
# posée par screens.add_scene_object — reste passée par le même
|
||||
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
||||
clamped_x, clamped_y, note = _clamp_to_camera(slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"])
|
||||
if note:
|
||||
screens.update_scene_object_geometry(slug, object_id, clamped_x, clamped_y, obj["width"], obj["height"])
|
||||
result = {"object_id": object_id}
|
||||
if note:
|
||||
result["note"] = note
|
||||
return result
|
||||
|
||||
|
||||
def _dispatch_set_object_geometry(slug, screen_id, user_id, object_id, x, y, width, height):
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
x, y, note = _clamp_to_camera(slug, screen_id, obj["kind"] if obj else None, x, y, width, height)
|
||||
screens.update_scene_object_geometry(slug, object_id, x, y, width, height)
|
||||
result = {"ok": True}
|
||||
if note:
|
||||
result["note"] = note
|
||||
return result
|
||||
|
||||
|
||||
def _dispatch_set_object_name(slug, screen_id, user_id, object_id, name):
|
||||
screens.set_scene_object_name(slug, object_id, name)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dispatch_set_object_role(slug, screen_id, user_id, object_id, role):
|
||||
screens.set_scene_object_role(slug, object_id, role)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dispatch_set_object_collision(slug, screen_id, user_id, object_id, enabled=True, shape="rectangle",
|
||||
width=None, height=None, offset_x=0, offset_y=0):
|
||||
# Même forme que routes/scenes/scene_object_collision.py (remplacement
|
||||
# complet des réglages, jamais un merge partiel).
|
||||
settings = {
|
||||
"enabled": bool(enabled),
|
||||
"shape": shape if shape in _COLLISION_SHAPES else "rectangle",
|
||||
"width": width, "height": height,
|
||||
"offset_x": offset_x or 0, "offset_y": offset_y or 0,
|
||||
}
|
||||
screens.set_scene_object_collision(slug, object_id, settings)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True):
|
||||
variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player)
|
||||
return {"variable_id": variable_id}
|
||||
|
||||
|
||||
def _dispatch_set_collision_rules(slug, screen_id, user_id, object_id, rules):
|
||||
sanitized = screens.sanitize_collision_rules(rules)
|
||||
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return {"ok": True, "rules": sanitized}
|
||||
|
||||
|
||||
def _dispatch_append_action_to_trigger(slug, screen_id, user_id, object_id, after_id, action):
|
||||
ok = screens.append_action_to_trigger(slug, object_id, after_id, action)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt):
|
||||
"""Scenario -> "Mes assets" (auth.save_user_asset, source="ia") ->
|
||||
posée sur l'écran via LE MÊME chemin que Phase 1
|
||||
(screens.add_scene_object(image_url=...)) — jamais un chemin
|
||||
parallèle. Toute erreur (Scenario mal configuré, timeout, échec)
|
||||
remonte comme un résultat d'outil normal, voir ai/chat.py."""
|
||||
image_url = generate_image_url(prompt)
|
||||
downloaded = requests.get(image_url, timeout=60)
|
||||
downloaded.raise_for_status()
|
||||
ext = os.path.splitext(image_url.split("?")[0])[1] or ".png"
|
||||
asset_id, filename = auth.save_user_asset(user_id, downloaded.content, ext, original_name=prompt[:80], source="ia")
|
||||
served_url = url_for("serve_user_asset", user_id=user_id, filename=filename)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind=kind, image_url=served_url)
|
||||
return {"object_id": object_id, "asset_id": asset_id}
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"add_scene_object": _dispatch_add_scene_object,
|
||||
"set_object_geometry": _dispatch_set_object_geometry,
|
||||
"set_object_name": _dispatch_set_object_name,
|
||||
"set_object_role": _dispatch_set_object_role,
|
||||
"set_object_collision": _dispatch_set_object_collision,
|
||||
"create_global_variable": _dispatch_create_global_variable,
|
||||
"set_collision_rules": _dispatch_set_collision_rules,
|
||||
"append_action_to_trigger": _dispatch_append_action_to_trigger,
|
||||
"add_generated_image": _dispatch_add_generated_image,
|
||||
}
|
||||
|
||||
|
||||
def dispatch_tool(slug, screen_id, user_id, tool_name, tool_input):
|
||||
"""Point d'entrée UNIQUE utilisé par la boucle tool-use (Phase 2) —
|
||||
lève KeyError pour un nom d'outil inconnu (jamais silencieux : un tool
|
||||
annoncé par TOOLS mais absent d'ici serait un bug de ce module, pas
|
||||
une entrée utilisateur à tolérer). `user_id` : nécessaire pour "Mes
|
||||
assets" (scopé par compte, voir add_generated_image ci-dessus) —
|
||||
ignoré par les autres outils, qui n'agissent que sur l'écran/l'objet."""
|
||||
handler = _DISPATCH[tool_name]
|
||||
return handler(slug, screen_id, user_id, **tool_input)
|
||||
Reference in New Issue
Block a user