Dev #16
+26
-4
@@ -1,7 +1,13 @@
|
||||
# Ce fichier ne configure PAS l'application (elle ne lit aucune variable
|
||||
# d'environnement) — il configure uniquement docker-compose.prod.yml sur le
|
||||
# serveur de production. Copier en ".env" à côté de docker-compose.prod.yml
|
||||
# sur le serveur et adapter les valeurs ; ne jamais committer ce ".env".
|
||||
# Deux usages distincts pour ce fichier, jamais mélangés :
|
||||
# - En LOCAL (poste de dev) : copier en ".env" à la racine du dépôt et
|
||||
# renseigner les clés IA ci-dessous — core/flask_app.py les charge lui-
|
||||
# même (python-dotenv), rien d'autre à faire. Jamais committer ce ".env"
|
||||
# (déjà dans .gitignore).
|
||||
# - En PRODUCTION : ce fichier configure docker-compose.prod.yml (image,
|
||||
# port) — copier en ".env" à côté de docker-compose.prod.yml sur le
|
||||
# serveur. L'app elle-même n'y lit AUCUN fichier .env en production
|
||||
# (aucun n'y est déployé) : les variables y sont posées directement sur
|
||||
# l'hôte/le conteneur, docker-compose.prod.yml les lui transmettant.
|
||||
|
||||
# Adresse complète de l'image dans le registre Gitea, ex :
|
||||
# gitea.exemple.com/mon-compte/forge-engine
|
||||
@@ -23,3 +29,19 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Onglet "IA" (voir ai/client.py) — clé du compte Anthropic Console
|
||||
# (console.anthropic.com), PAS l'abonnement claude.ai Entreprise (deux
|
||||
# systèmes de facturation séparés). Laisser vide désactive le chat IA
|
||||
# (message d'erreur clair affiché au créateur, jamais une 500).
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Génération d'images (voir ai/scenario_client.py) — compte Scenario
|
||||
# (scenario.com), clé + secret (authentification Basic), et l'id du
|
||||
# modèle entraîné sur le style graphique unique de Forge Engine (voir
|
||||
# l'étude préalable : un seul style pour tout le moteur, pas par projet).
|
||||
# Laisser vide désactive juste la génération d'images (le reste du chat
|
||||
# IA continue de fonctionner).
|
||||
SCENARIO_API_KEY=
|
||||
SCENARIO_API_SECRET=
|
||||
SCENARIO_MODEL_ID=
|
||||
|
||||
+11
@@ -18,6 +18,10 @@ regles/
|
||||
# Jeux créés par les utilisateurs (données runtime, pas du code)
|
||||
projects/
|
||||
|
||||
# "Mes assets" — images par compte (données runtime, voir
|
||||
# auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR)
|
||||
user_assets/
|
||||
|
||||
# Comptes utilisateurs (base SQLite + clé de session) — données runtime,
|
||||
# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
|
||||
data/
|
||||
@@ -38,6 +42,13 @@ Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Journaux de diagnostic ad-hoc (redirection stdout/stderr d'un serveur
|
||||
# de dev lancé pour déboguer) — jamais du code.
|
||||
_srv_out.txt
|
||||
_srv_err.txt
|
||||
_diag_out.txt
|
||||
_diag_err.txt
|
||||
|
||||
# Documents internes/business (cadrage produit...) et état local de
|
||||
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
|
||||
.claude/
|
||||
|
||||
@@ -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)
|
||||
@@ -30,6 +30,13 @@ from .update_profile import update_profile
|
||||
from .count_admins import count_admins
|
||||
from .delete_user import delete_user
|
||||
from .update_email import update_email, EmailUpdateError
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
from .user_assets_dir import user_assets_dir
|
||||
from .create_user_asset import create_user_asset
|
||||
from .list_user_assets import list_user_assets
|
||||
from .get_user_asset import get_user_asset
|
||||
from .delete_user_asset import delete_user_asset
|
||||
from .save_user_asset import save_user_asset
|
||||
|
||||
__all__ = [
|
||||
"users_db_path", "ensure_users_schema", "is_first_user",
|
||||
@@ -46,4 +53,6 @@ __all__ = [
|
||||
"send_password_reset_email", "EmailNotConfiguredError",
|
||||
"update_profile", "count_admins", "delete_user",
|
||||
"update_email", "EmailUpdateError",
|
||||
"ensure_user_assets_schema", "user_assets_dir", "create_user_asset",
|
||||
"list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def create_user_asset(user_id, filename, original_name=None, source="upload"):
|
||||
"""Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur
|
||||
disque (voir user_assets_dir.py) — le fichier lui-même est écrit par
|
||||
l'appelant (route d'upload, ou plus tard la génération IA), cette
|
||||
fonction ne fait que la partie base de données, comme
|
||||
db.create_global_variable pour le reste du moteur."""
|
||||
ensure_user_assets_schema()
|
||||
conn = connect()
|
||||
conn.execute(
|
||||
"INSERT INTO _user_assets (user_id, filename, original_name, source) VALUES (?, ?, ?, ?)",
|
||||
(user_id, filename, original_name, source),
|
||||
)
|
||||
asset_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return asset_id
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
from .user_assets_dir import user_assets_dir
|
||||
|
||||
|
||||
def delete_user_asset(asset_id, user_id):
|
||||
"""N'efface que si asset_id APPARTIENT à user_id (jamais fournie par
|
||||
l'appelant sans vérification — voir get_user_asset.py) : renvoie False
|
||||
sans rien faire sinon, plutôt que de lever une erreur, même patron que
|
||||
screens.delete_scene_object pour un id introuvable."""
|
||||
ensure_user_assets_schema()
|
||||
conn = connect()
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM _user_assets WHERE id = ? AND user_id = ?", (asset_id, user_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return False
|
||||
conn.execute("DELETE FROM _user_assets WHERE id = ?", (asset_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
path = os.path.join(user_assets_dir(user_id), row["filename"])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return True
|
||||
@@ -0,0 +1,23 @@
|
||||
from .connection import connect
|
||||
|
||||
|
||||
def ensure_user_assets_schema():
|
||||
""""Mes assets" (voir user_assets_dir.py) : une image appartient à un
|
||||
COMPTE, jamais à un projet — table dans la base de comptes partagée
|
||||
(auth/connection.py), pas dans le game.db d'un jeu (db/connection.py),
|
||||
pour rester utilisable d'un projet à l'autre du même compte."""
|
||||
conn = connect()
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS _user_assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'upload' CHECK (source IN ('upload', 'ia')),
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def get_user_asset(asset_id):
|
||||
"""Renvoie aussi user_id — indispensable pour vérifier la PROPRIÉTÉ
|
||||
avant de servir/poser un asset (voir routes/assets/serve_user_asset.py,
|
||||
routes/scenes/scene_object_add.py), même esprit que l'isolation par
|
||||
propriétaire déjà en place sur les projets (core/auth_guard.py)."""
|
||||
ensure_user_assets_schema()
|
||||
conn = connect()
|
||||
row = conn.execute(
|
||||
"SELECT id, user_id, filename, original_name, source, created_at FROM _user_assets WHERE id = ?",
|
||||
(asset_id,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,17 @@
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def list_user_assets(user_id):
|
||||
""""Mes assets" d'un compte, plus récent en premier — voir
|
||||
templates/scene_edit.html, bloc "Mes assets" du panneau d'ajout
|
||||
d'élément."""
|
||||
ensure_user_assets_schema()
|
||||
conn = connect()
|
||||
rows = conn.execute(
|
||||
"SELECT id, user_id, filename, original_name, source, created_at "
|
||||
"FROM _user_assets WHERE user_id = ? ORDER BY id DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from .create_user_asset import create_user_asset
|
||||
from .user_assets_dir import user_assets_dir
|
||||
|
||||
|
||||
def save_user_asset(user_id, content, ext, original_name=None, source="upload"):
|
||||
"""Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" —
|
||||
factorisé pour être partagé par l'upload manuel
|
||||
(routes/assets/upload_user_asset.py) ET la génération IA
|
||||
(ai/tools.py::_dispatch_add_generated_image), jamais dupliqué entre
|
||||
les deux. Renvoie (asset_id, filename)."""
|
||||
filename = uuid.uuid4().hex + ext
|
||||
asset_dir = user_assets_dir(user_id)
|
||||
os.makedirs(asset_dir, exist_ok=True)
|
||||
with open(os.path.join(asset_dir, filename), "wb") as f:
|
||||
f.write(content)
|
||||
asset_id = create_user_asset(user_id, filename, original_name=original_name, source=source)
|
||||
return asset_id, filename
|
||||
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
|
||||
from db.constants import USER_ASSETS_DIR
|
||||
|
||||
|
||||
def user_assets_dir(user_id):
|
||||
"""Dossier disque des images d'un compte (voir ensure_user_assets_schema.py
|
||||
pour les métadonnées) — mirroir de db/game_dir.py, mais indexé par
|
||||
utilisateur plutôt que par slug de projet."""
|
||||
return os.path.join(USER_ASSETS_DIR, str(user_id))
|
||||
@@ -1,8 +1,20 @@
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask
|
||||
|
||||
# Charge .env AVANT tout le reste (voir .env.example) — ce module est le
|
||||
# tout premier import interne de app.py, donc les variables sont posées
|
||||
# avant qu'un `import db`/`auth` ne les résolve à l'import (ex.
|
||||
# db/constants.py::PROJECTS_DIR). Ne remplace JAMAIS une variable déjà
|
||||
# présente dans os.environ (comportement par défaut de load_dotenv) —
|
||||
# tests/conftest.py, qui pose ses propres variables en Python avant tout
|
||||
# import, reste donc isolé d'un .env local même s'il en existe un sur le
|
||||
# poste. Aucun effet en production (pas de fichier .env sur le serveur,
|
||||
# les variables y sont posées directement sur l'hôte/le conteneur).
|
||||
load_dotenv()
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
|
||||
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
|
||||
|
||||
@@ -10,6 +10,15 @@ PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
|
||||
)
|
||||
|
||||
# "Mes assets" (voir auth/user_assets/) : stockage PAR UTILISATEUR, pas
|
||||
# par jeu — distinct de PROJECTS_DIR/<owner>/<projet>/uploads (voir
|
||||
# routes/uploads/upload_file.py), pour qu'une image reste réutilisable
|
||||
# d'un projet à l'autre du même compte. Même schéma de surcharge que
|
||||
# PROJECTS_DIR ci-dessus.
|
||||
USER_ASSETS_DIR = os.environ.get("FORGE_USER_ASSETS_DIR") or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "user_assets"
|
||||
)
|
||||
|
||||
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
|
||||
FIELD_TYPES = {
|
||||
"texte": {"label": "Texte court", "sql": "TEXT"},
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
Flask==3.0.3
|
||||
pyotp==2.10.0
|
||||
qrcode==8.2
|
||||
# Onglet "IA" (voir ai/) : anthropic pour la boucle tool-use (ai/chat.py),
|
||||
# requests pour le client Scenario (ai/scenario_client.py, seule
|
||||
# dépendance HTTP du projet), python-dotenv pour charger .env en local
|
||||
# (core/flask_app.py) — jamais utilisé en production, où les variables
|
||||
# sont déjà posées sur l'hôte/le conteneur (voir .env.example).
|
||||
anthropic==1.4.0
|
||||
requests==2.34.2
|
||||
python-dotenv==1.2.3
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, triggers
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, triggers, assets, ia
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import upload_user_asset, serve_user_asset
|
||||
@@ -0,0 +1,15 @@
|
||||
from flask import g, send_from_directory, abort
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/assets/<int:user_id>/<path:filename>")
|
||||
def serve_user_asset(user_id, filename):
|
||||
"""403 si le compte connecté n'est pas le propriétaire — même esprit
|
||||
que l'isolation par propriétaire des projets (core/auth_guard.py) :
|
||||
"Mes assets" ne doit jamais fuiter d'un compte à l'autre."""
|
||||
if user_id != g.current_user["id"]:
|
||||
abort(403)
|
||||
return send_from_directory(auth.user_assets_dir(user_id), filename)
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from flask import g, request, jsonify, url_for
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/assets/upload", methods=["POST"])
|
||||
def upload_user_asset():
|
||||
""""Mes assets" (voir auth/user_assets_dir.py) : mirroir de
|
||||
routes/uploads/upload_file.py, mais stocké PAR COMPTE plutôt que par
|
||||
jeu, pour être réutilisable dans n'importe quel projet du même
|
||||
compte. Écriture disque + ligne base factorisées dans
|
||||
auth.save_user_asset (partagé avec la génération IA, voir
|
||||
ai/tools.py::_dispatch_add_generated_image)."""
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "Aucun fichier reçu"}), 400
|
||||
ext = "".join(c for c in os.path.splitext(f.filename)[1].lower() if c.isalnum() or c == ".")[:10]
|
||||
user_id = g.current_user["id"]
|
||||
asset_id, filename = auth.save_user_asset(user_id, f.read(), ext, original_name=f.filename, source="upload")
|
||||
return jsonify({
|
||||
"id": asset_id,
|
||||
"url": url_for("serve_user_asset", user_id=user_id, filename=filename),
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, render_template, url_for, jsonify
|
||||
|
||||
import db
|
||||
|
||||
@@ -9,7 +9,10 @@ from core.flask_app import app
|
||||
def create_global_var(slug):
|
||||
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
|
||||
— idempotent par nom (voir db.create_global_variable), donc sans
|
||||
risque en cas de double soumission."""
|
||||
risque en cas de double soumission. Réponse AJAX (voir
|
||||
static/js/scenes/global-variables.js) : renvoie le HTML de la LIGNE
|
||||
créée (templates/_global_variable_row.html, partagé avec le rendu
|
||||
initial de la page — jamais dupliqué), que le JS insère sans recharger."""
|
||||
name = (request.form.get("name") or "").strip()
|
||||
var_type = request.form.get("type") or "texte"
|
||||
default_value = request.form.get("default_value") or ""
|
||||
@@ -21,6 +24,14 @@ def create_global_var(slug):
|
||||
per_player = request.form.get("per_player", "1") == "1"
|
||||
if name:
|
||||
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
row_html = ""
|
||||
if name:
|
||||
variable = db.get_global_variable(slug, name)
|
||||
row_html = render_template(
|
||||
"_global_variable_row.html", v=variable, global_variable_types=db.GLOBAL_VARIABLE_TYPES,
|
||||
)
|
||||
return jsonify({"ok": True, "row_html": row_html})
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
|
||||
import db
|
||||
|
||||
@@ -7,8 +7,13 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
||||
def global_var_delete(slug, var_id):
|
||||
"""Suppression en AJAX (voir static/js/scenes/global-variables.js) —
|
||||
même raison que global_var_edit.py. Redirection classique conservée
|
||||
en secours pour tout appelant non-JS."""
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
db.delete_global_variable_by_id(slug, var_id)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": True})
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
|
||||
import db
|
||||
|
||||
@@ -7,10 +7,16 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
|
||||
def global_var_edit(slug, var_id):
|
||||
"""Enregistrement en AJAX (voir static/js/scenes/global-variables.js) —
|
||||
demande explicite : modifier/supprimer une variable ne doit plus
|
||||
recharger toute la page. Redirection classique conservée en secours
|
||||
pour tout appelant non-JS (même patron que scene_object_add.py)."""
|
||||
var_type = request.form.get("type") or "texte"
|
||||
value = request.form.get("value") or ""
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
db.update_global_variable(slug, var_id, var_type, value)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": True})
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import ia_chat
|
||||
@@ -0,0 +1,85 @@
|
||||
from flask import g, jsonify, request
|
||||
|
||||
import ai
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
_CONVERSATION_NOT_FOUND = {"error": "conversation introuvable"}
|
||||
|
||||
|
||||
def _get_owned_conversation(slug, conversation_id):
|
||||
"""La portée par SLUG (db.connect(slug), voir screens/ia/) suffit
|
||||
déjà à isoler les conversations d'un jeu à l'autre — un id d'une
|
||||
autre base n'existe simplement pas ici, même patron que
|
||||
screens.get_scene_object. Renvoie None si absente."""
|
||||
return screens.get_ia_conversation(slug, conversation_id)
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/ia-conversations", methods=["GET"])
|
||||
def ia_conversations_list(slug, screen_id):
|
||||
"""Colonne de gauche de l'onglet "Générer avec l'IA" — toutes les
|
||||
conversations de cet écran, plus récente en premier."""
|
||||
return jsonify(screens.list_ia_conversations(slug, screen_id))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/ia-conversations", methods=["POST"])
|
||||
def ia_conversations_create(slug, screen_id):
|
||||
""""+ Nouvelle conversation" — créée immédiatement, vide."""
|
||||
conversation_id = screens.create_ia_conversation(slug, screen_id)
|
||||
conversation = screens.get_ia_conversation(slug, conversation_id)
|
||||
return jsonify(conversation)
|
||||
|
||||
|
||||
@app.route("/game/<slug>/ia-conversations/<int:conversation_id>", methods=["DELETE"])
|
||||
def ia_conversation_delete(slug, conversation_id):
|
||||
if not _get_owned_conversation(slug, conversation_id):
|
||||
return jsonify(_CONVERSATION_NOT_FOUND), 404
|
||||
screens.delete_ia_conversation(slug, conversation_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/ia-conversations/<int:conversation_id>/messages", methods=["GET"])
|
||||
def ia_conversation_messages(slug, conversation_id):
|
||||
"""Historique complet d'UNE conversation — chargé par
|
||||
static/js/ia/ia-chat.js à sa sélection dans la colonne."""
|
||||
if not _get_owned_conversation(slug, conversation_id):
|
||||
return jsonify(_CONVERSATION_NOT_FOUND), 404
|
||||
return jsonify(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/ia-conversations/<int:conversation_id>/messages", methods=["POST"])
|
||||
def ia_conversation_send(slug, conversation_id):
|
||||
"""Envoie un message dans une conversation — body {message}. Persiste
|
||||
le message du créateur et la réponse, renvoie les deux.
|
||||
|
||||
ai.run_chat_turn lit l'historique déjà persisté et y ajoute le
|
||||
message courant EN MÉMOIRE seulement — persisté ici, APRÈS l'appel,
|
||||
jamais avant (sinon il apparaîtrait deux fois dans l'historique que
|
||||
la boucle envoie à Claude)."""
|
||||
conversation = _get_owned_conversation(slug, conversation_id)
|
||||
if not conversation:
|
||||
return jsonify(_CONVERSATION_NOT_FOUND), 404
|
||||
|
||||
message = (request.get_json(silent=True) or {}).get("message", "")
|
||||
message = message.strip() if isinstance(message, str) else ""
|
||||
if not message:
|
||||
return jsonify({"error": "message vide"}), 400
|
||||
|
||||
try:
|
||||
reply = ai.run_chat_turn(slug, conversation["screen_id"], conversation_id, g.current_user["id"], message)
|
||||
except (ai.AnthropicNotConfiguredError, ai.ScenarioNotConfiguredError, ai.ScenarioGenerationError) as e:
|
||||
reply = f"Configuration IA incomplète : {e}"
|
||||
except Exception:
|
||||
# Jamais le détail brut d'une erreur inattendue au créateur (même
|
||||
# politique que auth/send_email.py) — journalisée côté serveur
|
||||
# par le logger par défaut de Flask (DEBUG/erreur non interceptée).
|
||||
app.logger.exception("Erreur inattendue dans ai.run_chat_turn (%s, conversation %s)", slug, conversation_id)
|
||||
reply = "Une erreur inattendue est survenue — réessaie dans un instant."
|
||||
|
||||
user_message_id = screens.add_ia_chat_message(slug, conversation_id, "user", message)
|
||||
assistant_message_id = screens.add_ia_chat_message(slug, conversation_id, "assistant", reply)
|
||||
return jsonify({
|
||||
"user_message": {"id": user_message_id, "role": "user", "content": message},
|
||||
"assistant_message": {"id": assistant_message_id, "role": "assistant", "content": reply},
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
from flask import render_template, request, abort, g
|
||||
|
||||
import auth
|
||||
import db
|
||||
import screens
|
||||
|
||||
@@ -81,6 +82,10 @@ def render_scene_edit(slug, screen_id, game):
|
||||
# Images de fond CraftPix (screens.BACKGROUND_LIBRARY) : même règle
|
||||
# d'accès que les sprites animaux — réservées au rôle admin.
|
||||
background_library = screens.BACKGROUND_LIBRARY if g.current_user["role"] == "admin" else {}
|
||||
# "Mes assets" (voir auth/user_assets_dir.py) : par COMPTE, jamais
|
||||
# filtré par rôle contrairement à sprite_library/background_library
|
||||
# ci-dessus (rien à voir avec le catalogue Forge réservé à l'admin).
|
||||
user_assets = auth.list_user_assets(g.current_user["id"])
|
||||
|
||||
return render_template(
|
||||
"scene_edit.html",
|
||||
@@ -96,6 +101,7 @@ def render_scene_edit(slug, screen_id, game):
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
background_library=background_library,
|
||||
user_assets=user_assets,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
from flask import g, request, redirect, url_for, jsonify, abort
|
||||
|
||||
import auth
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
@@ -27,12 +28,23 @@ def scene_object_add(slug, screen_id):
|
||||
kind = request.form.get("kind", "personnage")
|
||||
forge_character = request.form.get("forge_character")
|
||||
background_slug = request.form.get("background_slug")
|
||||
asset_id = request.form.get("asset_id", type=int)
|
||||
if forge_character:
|
||||
forbid_admin_only_character(forge_character)
|
||||
if background_slug:
|
||||
forbid_admin_only_background(background_slug)
|
||||
image_url = None
|
||||
if asset_id:
|
||||
# "Mes assets" (voir auth/user_assets_dir.py) : isolation stricte
|
||||
# par propriétaire, même esprit que les projets (core/auth_guard.py)
|
||||
# — jamais poser l'asset d'un autre compte, même en devinant son id.
|
||||
asset = auth.get_user_asset(asset_id)
|
||||
if not asset or asset["user_id"] != g.current_user["id"]:
|
||||
abort(403)
|
||||
image_url = url_for("serve_user_asset", user_id=asset["user_id"], filename=asset["filename"])
|
||||
new_id = screens.add_scene_object(
|
||||
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
|
||||
slug, screen_id, kind=kind, forge_character=forge_character,
|
||||
background_slug=background_slug, image_url=image_url,
|
||||
)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
obj = screens.get_scene_object(slug, new_id)
|
||||
|
||||
@@ -96,17 +96,15 @@ def trigger_append_action(slug, object_id):
|
||||
DERNIÈRE feuille d'une chaîne. 404 si `after_id` introuvable ou si
|
||||
cette feuille a déjà un "then" (jamais écrasé par cette route — un
|
||||
nouvel appel doit d'abord recharger la chaîne à jour)."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
if screens.get_scene_object(slug, object_id) is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
after_id = body.get("after_id")
|
||||
if not isinstance(after_id, str) or not after_id.strip():
|
||||
return jsonify({"error": "after_id invalide"}), 400
|
||||
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
if not screens.set_then_action_in_rules(rules, after_id, body.get("action")):
|
||||
# Chemin d'écriture PARTAGÉ avec l'agent IA (voir ai/tools.py) —
|
||||
# ni cette route ni l'agent n'écrivent _collision_rules directement.
|
||||
if not screens.append_action_to_trigger(slug, object_id, after_id, body.get("action")):
|
||||
return jsonify({"error": "action précédente introuvable ou déjà chaînée"}), 404
|
||||
sanitized = screens.sanitize_collision_rules(rules)
|
||||
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@@ -87,6 +87,7 @@ from .scenes.set_scene_object_name import set_scene_object_name
|
||||
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||
from .scenes.render_scene_object import render_scene_object
|
||||
from .scenes.build_collision_rule_objects import build_collision_rule_objects
|
||||
from .scenes.append_action_to_trigger import append_action_to_trigger
|
||||
from .rendering.trigger_graph import (
|
||||
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
|
||||
list_completable_dialogue_ids,
|
||||
@@ -128,3 +129,11 @@ from .custom_events.list_custom_event_usages import list_custom_event_usages
|
||||
from .custom_events.delete_custom_event import delete_custom_event
|
||||
|
||||
from .payload.full_game_payload import full_game_payload
|
||||
|
||||
from .ia.ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
from .ia.list_ia_chat_messages import list_ia_chat_messages
|
||||
from .ia.add_ia_chat_message import add_ia_chat_message
|
||||
from .ia.create_ia_conversation import create_ia_conversation
|
||||
from .ia.list_ia_conversations import list_ia_conversations
|
||||
from .ia.get_ia_conversation import get_ia_conversation
|
||||
from .ia.delete_ia_conversation import delete_ia_conversation
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import db
|
||||
|
||||
from .ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
|
||||
_DEFAULT_TITLE = "Nouvelle conversation"
|
||||
_TITLE_MAX_LENGTH = 50
|
||||
|
||||
|
||||
def add_ia_chat_message(slug, conversation_id, role, content):
|
||||
"""Ajoute un message (role "user" ou "assistant") à une conversation
|
||||
— voir routes/ia/ia_chat.py, seul appelant.
|
||||
|
||||
Titre automatique (comme claude.ai) : le premier message "user" d'une
|
||||
conversation encore au titre par défaut renomme la conversation avec
|
||||
ses ~50 premiers caractères, pour que la colonne de gauche affiche
|
||||
autre chose que "Nouvelle conversation" pour toutes les entrées."""
|
||||
ensure_ia_chat_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"INSERT INTO _ia_chat_messages (conversation_id, role, content) VALUES (?, ?, ?)",
|
||||
(conversation_id, role, content),
|
||||
)
|
||||
message_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
if role == "user":
|
||||
conversation = conn.execute(
|
||||
"SELECT title FROM _ia_conversations WHERE id = ?", (conversation_id,)
|
||||
).fetchone()
|
||||
if conversation and conversation["title"] == _DEFAULT_TITLE:
|
||||
title = content.strip()[:_TITLE_MAX_LENGTH]
|
||||
conn.execute("UPDATE _ia_conversations SET title = ? WHERE id = ?", (title, conversation_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return message_id
|
||||
@@ -0,0 +1,16 @@
|
||||
import db
|
||||
|
||||
from .ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
|
||||
|
||||
def create_ia_conversation(slug, screen_id):
|
||||
""""+ Nouvelle conversation" (voir static/js/ia/ia-chat.js) : créée
|
||||
IMMÉDIATEMENT, vide — visible tout de suite dans la colonne, pas
|
||||
besoin d'attendre un premier message."""
|
||||
ensure_ia_chat_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute("INSERT INTO _ia_conversations (screen_id) VALUES (?)", (screen_id,))
|
||||
conversation_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return conversation_id
|
||||
@@ -0,0 +1,13 @@
|
||||
import db
|
||||
|
||||
from .ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
|
||||
|
||||
def delete_ia_conversation(slug, conversation_id):
|
||||
"""Cascade sur les messages (ON DELETE CASCADE, voir
|
||||
ensure_ia_chat_schema.py)."""
|
||||
ensure_ia_chat_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute("DELETE FROM _ia_conversations WHERE id = ?", (conversation_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,38 @@
|
||||
import db
|
||||
|
||||
|
||||
def ensure_ia_chat_schema(slug):
|
||||
"""Chat "Générer avec l'IA" — plusieurs CONVERSATIONS par écran
|
||||
(demande explicite : gérer plusieurs fils, comme claude.ai), chacune
|
||||
portant sa propre liste de messages. ON DELETE CASCADE à chaque
|
||||
niveau (écran supprimé -> ses conversations -> leurs messages),
|
||||
même patron que _scene_objects (screens/scenes/ensure_scene_schema.py)."""
|
||||
conn = db.connect(slug)
|
||||
# _ia_chat_messages existait déjà (phase précédente, une seule
|
||||
# conversation par écran, colonne screen_id directe) sur les projets
|
||||
# déjà testés avant ce chantier — CREATE TABLE IF NOT EXISTS ne
|
||||
# migrerait pas son schéma. Aucune donnée à préserver (fonctionnalité
|
||||
# tout juste testée manuellement) : on repart d'une table neuve plutôt
|
||||
# que d'écrire une vraie migration pour quelques messages de test.
|
||||
cols = {row["name"] for row in conn.execute("PRAGMA table_info(_ia_chat_messages)").fetchall()}
|
||||
if cols and "conversation_id" not in cols:
|
||||
conn.execute("DROP TABLE _ia_chat_messages")
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS _ia_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT 'Nouvelle conversation',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS _ia_chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES _ia_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,16 @@
|
||||
import db
|
||||
|
||||
from .ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
|
||||
|
||||
def get_ia_conversation(slug, conversation_id):
|
||||
"""Renvoie aussi screen_id — indispensable pour vérifier
|
||||
l'appartenance à CE jeu avant de supprimer/lire/écrire (voir
|
||||
routes/ia/ia_chat.py), même patron que auth.get_user_asset."""
|
||||
ensure_ia_chat_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute(
|
||||
"SELECT id, screen_id, title, created_at FROM _ia_conversations WHERE id = ?", (conversation_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,16 @@
|
||||
import db
|
||||
|
||||
from .ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
|
||||
|
||||
def list_ia_chat_messages(slug, conversation_id):
|
||||
"""Historique d'UNE conversation, dans l'ordre chronologique (voir
|
||||
static/js/ia/ia-chat.js, qui les affiche tels quels)."""
|
||||
ensure_ia_chat_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT id, role, content, created_at FROM _ia_chat_messages WHERE conversation_id = ? ORDER BY id ASC",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
@@ -0,0 +1,16 @@
|
||||
import db
|
||||
|
||||
from .ensure_ia_chat_schema import ensure_ia_chat_schema
|
||||
|
||||
|
||||
def list_ia_conversations(slug, screen_id):
|
||||
"""Conversations d'un écran, plus récente en premier (voir la
|
||||
colonne de gauche de l'onglet "Générer avec l'IA")."""
|
||||
ensure_ia_chat_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, created_at FROM _ia_conversations WHERE screen_id = ? ORDER BY id DESC",
|
||||
(screen_id,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
@@ -15,7 +15,7 @@ _QUIZ_BOX_DEFAULT_SIZE = (340, 240)
|
||||
_SCORE_WIDGET_DEFAULT_SIZE = (140, 50)
|
||||
|
||||
|
||||
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
|
||||
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None, image_url=None):
|
||||
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
|
||||
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
|
||||
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
|
||||
@@ -28,7 +28,14 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
static/style.css .sceneBackground) et à la taille RÉELLE de l'image
|
||||
choisie (voir screens/labels/background_library.py) par défaut — une
|
||||
image plus grande que la scène devient alors le "monde" que la caméra
|
||||
suit en défilant (voir static/js/play/personnage-controller.js)."""
|
||||
suit en défilant (voir static/js/play/personnage-controller.js).
|
||||
|
||||
image_url (kind "fond"/"decor" uniquement) : une image ARBITRAIRE
|
||||
(voir "Mes assets", auth/user_assets/) plutôt qu'une clé de
|
||||
BACKGROUND_LIBRARY — prioritaire sur background_slug si les deux sont
|
||||
fournis. Pas de largeur/hauteur connues d'avance ici (contrairement à
|
||||
BACKGROUND_LIBRARY qui les documente) : retombe sur les colonnes par
|
||||
défaut de _scene_objects, exactement comme un "decor" ordinaire."""
|
||||
ensure_scene_schema(slug)
|
||||
kind = kind if kind in _KINDS else "personnage"
|
||||
attributes = {}
|
||||
@@ -52,6 +59,8 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
# et disparaîtrait à tort de "🧩 Collision" (voir scene_edit_view.py,
|
||||
# qui exclut toujours le joueur).
|
||||
attributes["_personnage_role"] = "pnj"
|
||||
elif kind in ("fond", "decor") and image_url:
|
||||
attributes["src"] = image_url
|
||||
elif kind == "fond" and background_slug:
|
||||
from ..labels.background_library import BACKGROUND_LIBRARY
|
||||
background = BACKGROUND_LIBRARY.get(background_slug)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from .get_scene_object import get_scene_object
|
||||
from .set_scene_object_collision_rules import set_scene_object_collision_rules
|
||||
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, set_then_action_in_rules
|
||||
|
||||
|
||||
def append_action_to_trigger(slug, object_id, after_id, raw_action):
|
||||
""""+ Ajouter une action" en bout de chaîne — extrait de
|
||||
routes/triggers/triggers.py::trigger_append_action pour être
|
||||
l'UNIQUE chemin d'écriture, partagé par la route HTTP ET par l'agent
|
||||
IA (voir ai/tools.py) : ni l'un ni l'autre n'écrit `_collision_rules`
|
||||
directement, tous deux passent par ici. `after_id` : dialogue.id
|
||||
(feuille "dialogue") ou id (feuille "variable") de la DERNIÈRE
|
||||
feuille d'une chaîne. Renvoie False si l'objet, `after_id`, ou
|
||||
`raw_action` sont invalides, ou si la feuille visée a déjà un "then"
|
||||
(jamais écrasé par cette fonction — set_then_action_in_rules ne fait
|
||||
qu'ajouter en bout de chaîne réelle)."""
|
||||
obj = get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return False
|
||||
rules = resolve_collision_rules(obj)
|
||||
if not set_then_action_in_rules(rules, after_id, raw_action):
|
||||
return False
|
||||
sanitized = sanitize_collision_rules(rules)
|
||||
set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return True
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ruby">
|
||||
<!-- Carte de fond — même mark que Forge (forge-logo.svg), recoloré pour
|
||||
l'identité de l'IA "Ruby" (rouge/rose pâle au lieu d'orange/ambre) —
|
||||
jamais un logo différent, juste sa palette. -->
|
||||
<rect width="120" height="120" rx="16" fill="#161b25"/>
|
||||
<!-- Facette gauche — rouge rubis -->
|
||||
<polygon points="60,15 30,75 60,105" fill="#e0263f"/>
|
||||
<!-- Facette droite — rose pâle -->
|
||||
<polygon points="60,15 90,75 60,105" fill="#ffb8c9"/>
|
||||
<!-- Trois chevrons — entailles de la couleur de la carte -->
|
||||
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,334 @@
|
||||
// ---------- Onglet "Générer avec l'IA" — chat scopé à L'ÉCRAN OUVERT ----------
|
||||
// Persiste la conversation (routes/ia/ia_chat.py) et pilote l'agent
|
||||
// Ruby (Claude Sonnet 5) via des tools qui appellent les mêmes fonctions
|
||||
// que l'éditeur manuel (voir ai/tools.py) — ce que l'IA pose doit donc
|
||||
// apparaître ICI sans recharger la page, sinon on croit que ça ne marche
|
||||
// pas (demande explicite) : voir iaChatRefreshAfterTurn() plus bas.
|
||||
//
|
||||
// Plusieurs CONVERSATIONS par écran (demande explicite : gérer/créer/
|
||||
// supprimer des fils, comme claude.ai) — _iaActiveConversationId retient
|
||||
// laquelle est affichée dans le fil de droite.
|
||||
var _iaActiveConversationId = null;
|
||||
|
||||
function iaChatEmptyStateHtml() {
|
||||
return (
|
||||
'<div class="iaChatEmpty">' +
|
||||
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar iaChatAvatar-large">' +
|
||||
'<strong>Décris ce que tu veux à Ruby</strong>' +
|
||||
'<span>Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.</span>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// #iaChatLog défile lui-même (voir static/style.css — maquette validée :
|
||||
// header/body/footer par colonne, seul le BODY de chaque colonne défile,
|
||||
// jamais l'onglet entier).
|
||||
function iaChatScrollToBottom() {
|
||||
var log = document.getElementById('iaChatLog');
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// "Ruby" — identité visuelle de l'IA (demande explicite) : même mark que
|
||||
// Forge (static/branding/forge-logo.svg), recoloré rouge/rose pâle (voir
|
||||
// static/branding/ruby-logo.svg), jamais un logo différent.
|
||||
function iaChatSenderHtml(isUser) {
|
||||
if (isUser) return 'Toi';
|
||||
return '<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar"> Ruby';
|
||||
}
|
||||
|
||||
function iaChatRowHtml(message) {
|
||||
var isUser = message.role === 'user';
|
||||
var row = document.createElement('div');
|
||||
row.className = 'iaChatRow ' + (isUser ? 'iaChatRow-user' : 'iaChatRow-assistant');
|
||||
var sender = document.createElement('div');
|
||||
sender.className = 'iaChatSender';
|
||||
sender.innerHTML = iaChatSenderHtml(isUser);
|
||||
var bubble = document.createElement('div');
|
||||
bubble.className = 'iaChatBubble';
|
||||
bubble.textContent = message.content;
|
||||
row.appendChild(sender);
|
||||
row.appendChild(bubble);
|
||||
return row;
|
||||
}
|
||||
|
||||
// Affichée à la place de la réponse le temps que Ruby/les outils
|
||||
// travaillent (peut prendre plusieurs secondes, un écran complet enchaîne
|
||||
// plusieurs appels d'outils) — sans ça, un créateur qui ne voit rien
|
||||
// bouger clique à nouveau, pensant que ça n'a pas marché.
|
||||
function iaChatLoaderRowHtml() {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'iaChatRow iaChatRow-assistant';
|
||||
var sender = document.createElement('div');
|
||||
sender.className = 'iaChatSender';
|
||||
sender.innerHTML = iaChatSenderHtml(false);
|
||||
var bubble = document.createElement('div');
|
||||
bubble.className = 'iaChatBubble iaChatTyping';
|
||||
bubble.innerHTML = '<span></span><span></span><span></span>';
|
||||
row.appendChild(sender);
|
||||
row.appendChild(bubble);
|
||||
return row;
|
||||
}
|
||||
|
||||
function iaChatAutoGrow(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
}
|
||||
|
||||
// Un tour peut poser des objets/variables/déclencheurs (voir ai/tools.py)
|
||||
// — jamais su à l'avance lesquels depuis la seule réponse texte, donc on
|
||||
// rafraîchit systématiquement les 3 zones que l'IA peut modifier :
|
||||
// Déclencheurs (déjà un composant JS, triggerLoadAll() suffit), Variables
|
||||
// et la scène (tous deux rendus côté serveur — on re-télécharge CETTE
|
||||
// MÊME page et on ne récupère que ces deux fragments, jamais un
|
||||
// rechargement visible/perte de l'onglet IA en cours).
|
||||
function iaChatRefreshAfterTurn() {
|
||||
if (typeof triggerLoadAll === 'function') triggerLoadAll();
|
||||
|
||||
fetch(window.location.href)
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
var newCanvasFrame = doc.querySelector('.canvasFrame');
|
||||
var oldCanvasFrame = document.querySelector('.canvasFrame');
|
||||
if (newCanvasFrame && oldCanvasFrame) {
|
||||
oldCanvasFrame.innerHTML = newCanvasFrame.innerHTML;
|
||||
// Bug corrigé ("plus moyen de rien poser hors du champ de la
|
||||
// caméra") : SCENE_WIDTH/SCENE_HEIGHT (posées une seule fois à
|
||||
// l'ouverture de la page, voir templates/scene_edit.html) ne sont
|
||||
// jamais rejouées par ce rafraîchissement partiel — un fond
|
||||
// agrandi par l'IA (le "monde" devient plus grand que la caméra
|
||||
// nominale) laissait ces globales à leur ANCIENNE valeur, et le
|
||||
// glisser-déposer (scene-editor.js) continuait de tout borner à
|
||||
// l'ancien cadre. #canvas vient d'être remplacé avec sa VRAIE
|
||||
// taille à jour (voir world_width/world_height,
|
||||
// routes/scenes/scene_edit_view.py) — on la relit directement
|
||||
// dessus plutôt que de rejouer un script inline.
|
||||
var canvas = document.getElementById('canvas');
|
||||
if (canvas) {
|
||||
var newWidth = parseFloat(canvas.style.width);
|
||||
var newHeight = parseFloat(canvas.style.height);
|
||||
if (!isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
||||
if (!isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
||||
}
|
||||
}
|
||||
|
||||
var newVariablesPanel = doc.getElementById('variablesTabPanel');
|
||||
var oldVariablesPanel = document.getElementById('variablesTabPanel');
|
||||
if (newVariablesPanel && oldVariablesPanel) oldVariablesPanel.innerHTML = newVariablesPanel.innerHTML;
|
||||
|
||||
// Relance les aperçus animés (voir personnage-preview.js) — un
|
||||
// personnage tout juste posé par l'IA reste sur sa frame statique
|
||||
// (ELEMENT_ANIMATIONS_MAP n'est mis à jour qu'au vrai rechargement
|
||||
// de page), léger mais sans conséquence fonctionnelle.
|
||||
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatSend() {
|
||||
var input = document.getElementById('iaChatInput');
|
||||
var sendBtn = document.getElementById('iaChatSendBtn');
|
||||
if (sendBtn.disabled) return; // déjà une requête en cours — jamais spammer l'IA
|
||||
var message = (input.value || '').trim();
|
||||
if (!message || !_iaActiveConversationId) return;
|
||||
var log = document.getElementById('iaChatLog');
|
||||
var emptyState = log.querySelector('.iaChatEmpty');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
// Le message du créateur s'affiche TOUT DE SUITE (avant même la
|
||||
// requête) — sinon, tant que l'IA travaille, l'écran semble ignorer ce
|
||||
// qu'on vient de taper.
|
||||
log.appendChild(iaChatRowHtml({ role: 'user', content: message }));
|
||||
var loaderRow = iaChatLoaderRowHtml();
|
||||
log.appendChild(loaderRow);
|
||||
iaChatScrollToBottom();
|
||||
|
||||
input.value = '';
|
||||
iaChatAutoGrow(input);
|
||||
input.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
|
||||
var conversationId = _iaActiveConversationId;
|
||||
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: message }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
loaderRow.remove();
|
||||
if (json.assistant_message) log.appendChild(iaChatRowHtml(json.assistant_message));
|
||||
iaChatScrollToBottom();
|
||||
iaChatRefreshAfterTurn();
|
||||
// Le titre de la conversation vient de se dériver de ce premier
|
||||
// message (voir screens/ia/add_ia_chat_message.py) — recharge la
|
||||
// colonne pour l'afficher sans attendre un changement d'onglet.
|
||||
iaChatLoadConversationsList(conversationId);
|
||||
})
|
||||
.catch(function () {
|
||||
loaderRow.remove();
|
||||
log.appendChild(iaChatRowHtml({ role: 'assistant', content: 'Erreur réseau — réessaie dans un instant.' }));
|
||||
iaChatScrollToBottom();
|
||||
})
|
||||
.finally(function () {
|
||||
input.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatInputKeydown(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
iaChatSend();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Colonne "Conversations" (CRUD, comme claude.ai) ----------
|
||||
|
||||
function iaChatConversationItemHtml(conversation) {
|
||||
var active = conversation.id === _iaActiveConversationId;
|
||||
return (
|
||||
'<button type="button" class="iaConversationItem' + (active ? ' active' : '') + '" ' +
|
||||
'data-conversation-id="' + conversation.id + '" onclick="iaChatSelectConversation(' + conversation.id + ')">' +
|
||||
'<span class="iaConversationTitle">' + _iaEscapeHtml(conversation.title) + '</span>' +
|
||||
'<span class="iaConversationDelete" title="Supprimer" ' +
|
||||
'onclick="iaChatDeleteConversation(event, ' + conversation.id + ')">🗑️</span>' +
|
||||
'</button>'
|
||||
);
|
||||
}
|
||||
|
||||
function _iaEscapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function iaChatRenderConversationsList(conversations) {
|
||||
var list = document.getElementById('iaConversationsList');
|
||||
if (!list) return;
|
||||
list.innerHTML = conversations.map(iaChatConversationItemHtml).join('');
|
||||
}
|
||||
|
||||
function iaChatLoadConversationsList(activeIdOverride) {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversations) {
|
||||
if (activeIdOverride) _iaActiveConversationId = activeIdOverride;
|
||||
iaChatRenderConversationsList(conversations);
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatLoadMessages(conversationId) {
|
||||
var log = document.getElementById('iaChatLog');
|
||||
log.innerHTML = '';
|
||||
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (messages) {
|
||||
if (!messages || !messages.length) {
|
||||
log.innerHTML = iaChatEmptyStateHtml();
|
||||
return;
|
||||
}
|
||||
messages.forEach(function (message) { log.appendChild(iaChatRowHtml(message)); });
|
||||
iaChatScrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatSelectConversation(conversationId) {
|
||||
_iaActiveConversationId = conversationId;
|
||||
document.querySelectorAll('.iaConversationItem').forEach(function (el) { el.classList.remove('active'); });
|
||||
var item = document.querySelector('.iaConversationItem[data-conversation-id="' + conversationId + '"]');
|
||||
if (item) item.classList.add('active');
|
||||
iaChatLoadMessages(conversationId);
|
||||
}
|
||||
|
||||
function iaChatCreateConversation() {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversation) {
|
||||
_iaActiveConversationId = conversation.id;
|
||||
iaChatLoadConversationsList(conversation.id);
|
||||
var log = document.getElementById('iaChatLog');
|
||||
log.innerHTML = iaChatEmptyStateHtml();
|
||||
document.getElementById('iaChatInput').focus();
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatDeleteConversation(event, conversationId) {
|
||||
event.stopPropagation(); // jamais sélectionner la conversation qu'on supprime
|
||||
if (!confirm('Supprimer cette conversation ? Cette action est irréversible.')) return;
|
||||
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId, { method: 'DELETE' })
|
||||
.then(function () { return fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations'); })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversations) {
|
||||
if (conversationId === _iaActiveConversationId) {
|
||||
if (conversations.length) {
|
||||
_iaActiveConversationId = conversations[0].id;
|
||||
iaChatLoadMessages(_iaActiveConversationId);
|
||||
iaChatRenderConversationsList(conversations);
|
||||
} else {
|
||||
// Plus aucune conversation — en recrée une tout de suite (le
|
||||
// fil de droite doit toujours pouvoir recevoir un message).
|
||||
iaChatCreateConversation();
|
||||
}
|
||||
} else {
|
||||
iaChatRenderConversationsList(conversations);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Même patron que initTriggersTab() (trigger-editor.js) : appelé
|
||||
// directement, pas de DOMContentLoaded — rejoué par pjax.js à chaque
|
||||
// remplacement de <main>.
|
||||
function initIaTab() {
|
||||
var body = document.getElementById('iaTabPanelBody');
|
||||
if (!body) return;
|
||||
// Maquette validée (Artifact "Maquette Ruby") : une div pleine
|
||||
// hauteur/largeur de l'onglet, 2 colonnes (gauche 40%, droite 60%),
|
||||
// chacune header/body/footer. Les deux zones non précisées (footer
|
||||
// gauche, header droite) restent vides pour l'instant.
|
||||
body.innerHTML =
|
||||
'<div class="iaTabLayout">' +
|
||||
'<div class="iaCol iaCol-left">' +
|
||||
'<div class="iaColHeader">' +
|
||||
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaHeaderLogo">' +
|
||||
'<span class="iaHeaderName">Ruby</span>' +
|
||||
'</div>' +
|
||||
'<div class="iaColBody">' +
|
||||
'<button type="button" class="button is-small primary iaNewConvBtn" onclick="iaChatCreateConversation()">+ Nouvelle conversation</button>' +
|
||||
'<div class="iaConversationsList" id="iaConversationsList"></div>' +
|
||||
'</div>' +
|
||||
'<div class="iaColFooter"></div>' +
|
||||
'</div>' +
|
||||
'<div class="iaCol iaCol-right">' +
|
||||
'<div class="iaColHeader"></div>' +
|
||||
'<div class="iaColBody">' +
|
||||
'<div class="iaChatLog" id="iaChatLog"></div>' +
|
||||
'</div>' +
|
||||
'<div class="iaColFooter">' +
|
||||
'<div class="iaChatInputBar">' +
|
||||
'<textarea id="iaChatInput" rows="1" placeholder="Décris ce que tu veux à Ruby…" ' +
|
||||
'oninput="iaChatAutoGrow(this)" onkeydown="iaChatInputKeydown(event)"></textarea>' +
|
||||
'<button type="button" class="iaChatSendBtn" id="iaChatSendBtn" onclick="iaChatSend()" title="Envoyer" aria-label="Envoyer">➤</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversations) {
|
||||
if (!conversations.length) {
|
||||
// Premier passage sur cet écran : aucune conversation encore —
|
||||
// en crée une tout de suite, jamais un onglet vide sans rien à
|
||||
// cliquer.
|
||||
iaChatCreateConversation();
|
||||
return;
|
||||
}
|
||||
_iaActiveConversationId = conversations[0].id; // la plus récente
|
||||
iaChatRenderConversationsList(conversations);
|
||||
iaChatLoadMessages(_iaActiveConversationId);
|
||||
});
|
||||
}
|
||||
|
||||
initIaTab();
|
||||
@@ -41,12 +41,29 @@ test('compareValues — repli sur comparaison de chaînes si non numérique', ()
|
||||
assert.equal(compareValues('abc', 'different', 'xyz'), true);
|
||||
});
|
||||
|
||||
test('compareValues — "egal"/"different" sur du texte, insensible à la casse (bug corrigé)', () => {
|
||||
assert.equal(compareValues('Vrai', 'egal', 'vrai'), true);
|
||||
assert.equal(compareValues('VRAI', 'egal', 'vrai'), true);
|
||||
assert.equal(compareValues('Vrai', 'different', 'vrai'), false);
|
||||
assert.equal(compareValues('Vrai', 'egal', 'faux'), false);
|
||||
});
|
||||
|
||||
test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => {
|
||||
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
|
||||
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
|
||||
assert.equal(compareValues(true, 'different', 'non', 'booleen'), true);
|
||||
});
|
||||
|
||||
test('compareValues — champ booléen, la valeur RÉELLE stockée en texte "faux" est bien fausse (bug corrigé)', () => {
|
||||
// Une variable booléenne est stockée en texte ("vrai"/"faux") — la
|
||||
// chaîne "faux" est non vide, donc VRAIE au sens JS naïf : sans
|
||||
// normalisation des DEUX côtés, "faux" == "Vrai" ressortait vrai à tort.
|
||||
assert.equal(compareValues('faux', 'egal', 'Vrai', 'booleen'), false);
|
||||
assert.equal(compareValues('faux', 'different', 'Vrai', 'booleen'), true);
|
||||
assert.equal(compareValues('vrai', 'egal', 'Vrai', 'booleen'), true);
|
||||
assert.equal(compareValues('non', 'egal', 'oui', 'booleen'), false);
|
||||
});
|
||||
|
||||
test('resolveVariablePath — chemin vide renvoie la valeur brute', () => {
|
||||
assert.equal(resolveVariablePath('42', ''), '42');
|
||||
});
|
||||
|
||||
@@ -25,14 +25,22 @@ function readFieldValue(definitionId, rowId, fieldName) {
|
||||
|
||||
function compareValues(actual, operator, expected, fieldType) {
|
||||
if (fieldType === 'booleen') {
|
||||
const a = actual ? 1 : 0;
|
||||
// Bug corrigé : une variable booléenne est stockée en TEXTE
|
||||
// ("vrai"/"faux", voir gameData.variables) — `actual ? 1 : 0` prenait
|
||||
// n'importe quelle chaîne non vide pour vraie, y compris la chaîne
|
||||
// "faux" elle-même ! Les deux côtés doivent passer par la MÊME
|
||||
// normalisation texte->booléen, pas seulement le côté "expected".
|
||||
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
||||
// partout ailleurs pour un champ booléen — une valeur de comparaison
|
||||
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
|
||||
// seulement "1"/"true" (et insensible à la casse, aligné avec
|
||||
// _compare() côté Python, voir filter_repeater_rows.py).
|
||||
const expectedStr = String(expected).trim().toLowerCase();
|
||||
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
|
||||
// partout ailleurs pour un champ booléen — une valeur tapée "Oui"
|
||||
// doit donc être reconnue vraie ici aussi, pas seulement "1"/"true"
|
||||
// (et insensible à la casse, aligné avec _compare() côté Python).
|
||||
const normalizeBool = function (v) {
|
||||
if (v === true) return 1;
|
||||
if (v === false || v === null || v === undefined) return 0;
|
||||
return ['1', 'true', 'vrai', 'oui'].includes(String(v).trim().toLowerCase()) ? 1 : 0;
|
||||
};
|
||||
const a = normalizeBool(actual);
|
||||
const e = normalizeBool(expected);
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual), en = parseFloat(expected);
|
||||
@@ -49,9 +57,16 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
}
|
||||
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
||||
const es = (expected === undefined || expected === null) ? '' : String(expected);
|
||||
// "egal"/"different" insensibles à la casse (bug corrigé : une valeur de
|
||||
// comparaison texte tapée avec une casse différente de la valeur réelle
|
||||
// — ex. "Vrai" vs "vrai" — était jugée différente à tort, alors que le
|
||||
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
|
||||
// superieur/inferieur restent une comparaison lexicographique brute
|
||||
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
|
||||
const asLower = as.toLowerCase(), esLower = es.toLowerCase();
|
||||
switch (operator) {
|
||||
case 'egal': return as === es;
|
||||
case 'different': return as !== es;
|
||||
case 'egal': return asLower === esLower;
|
||||
case 'different': return asLower !== esLower;
|
||||
case 'superieur': return as > es;
|
||||
case 'inferieur': return as < es;
|
||||
case 'superieur_egal': return as >= es;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// ---------- Onglet "Variables" — CRUD en AJAX ----------
|
||||
// Demande explicite : modifier/supprimer une variable rechargeait toute
|
||||
// la page (formulaires classiques + navigation pjax) — remplacé par de
|
||||
// vrais appels fetch(), sans jamais recharger ni naviguer.
|
||||
|
||||
// Enregistre automatiquement dès qu'un champ (type/valeur) change — plus
|
||||
// besoin d'un bouton "Enregistrer" séparé (demande explicite : "que la
|
||||
// modification s'enregistre automatiquement").
|
||||
function saveGlobalVariable(varId) {
|
||||
var row = document.querySelector('tr[data-var-row="' + varId + '"]');
|
||||
if (!row) return;
|
||||
var type = row.querySelector('select[name="type"]').value;
|
||||
var value = row.querySelector('input[name="value"]').value;
|
||||
var status = document.getElementById('varSaveStatus' + varId);
|
||||
var body = new FormData();
|
||||
body.append('type', type);
|
||||
body.append('value', value);
|
||||
fetch('/game/' + GAME_SLUG + '/variables/' + varId + '/edit', {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) { window.location.reload(); return; }
|
||||
if (status) {
|
||||
status.textContent = 'Enregistré ✓';
|
||||
setTimeout(function () { status.textContent = ''; }, 2000);
|
||||
}
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
function deleteGlobalVariable(varId, name) {
|
||||
if (!confirm('Supprimer la variable « ' + name + ' » ? Toute condition/action qui la référence encore cessera de fonctionner.')) return;
|
||||
var body = new FormData();
|
||||
fetch('/game/' + GAME_SLUG + '/variables/' + varId + '/delete', {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) { window.location.reload(); return; }
|
||||
var row = document.querySelector('tr[data-var-row="' + varId + '"]');
|
||||
if (row) row.remove();
|
||||
var body2 = document.getElementById('globalVariablesBody');
|
||||
var emptyHint = document.getElementById('globalVariablesEmptyHint');
|
||||
if (body2 && emptyHint) emptyHint.style.display = body2.children.length ? 'none' : '';
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
function createGlobalVariable(event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
var body = new FormData(form);
|
||||
fetch('/game/' + GAME_SLUG + '/variables/new', {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function (json) {
|
||||
if (!json.row_html) { window.location.reload(); return; }
|
||||
var tbody = document.getElementById('globalVariablesBody');
|
||||
tbody.insertAdjacentHTML('beforeend', json.row_html);
|
||||
var emptyHint = document.getElementById('globalVariablesEmptyHint');
|
||||
if (emptyHint) emptyHint.style.display = 'none';
|
||||
form.reset();
|
||||
form.style.display = 'none'; // même comportement que toggleDashCreate() après création
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
return false;
|
||||
}
|
||||
@@ -16,11 +16,14 @@
|
||||
// automatiquement sélectionné/ouvert dans le panneau de propriétés (voir
|
||||
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
|
||||
// posé sur le canevas, cliquable ensuite comme n'importe quel autre.
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug) {
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
var body = new FormData();
|
||||
body.append('kind', kind);
|
||||
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
||||
if (backgroundSlug) body.append('background_slug', backgroundSlug);
|
||||
// "Mes assets" (voir auth/user_assets_dir.py) : une image importée par
|
||||
// le créateur ou générée par l'IA, distincte de BACKGROUND_LIBRARY.
|
||||
if (assetId) body.append('asset_id', assetId);
|
||||
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add", {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
})
|
||||
@@ -62,6 +65,21 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// "Mes assets" (voir routes/assets/upload_user_asset.py, panneau "Mes
|
||||
// assets" de scene_edit.html) : un simple rechargement après l'upload —
|
||||
// la galerie est rendue côté serveur (user_assets), pas la peine de la
|
||||
// reconstruire en JS pour un flux qui n'arrive pas souvent.
|
||||
function uploadUserAsset(inputEl) {
|
||||
var file = inputEl.files && inputEl.files[0];
|
||||
if (!file) return;
|
||||
var body = new FormData();
|
||||
body.append('file', file);
|
||||
fetch('/assets/upload', { method: 'POST', body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () { window.location.reload(); })
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// AJAX (voir routes/scenes/scene_object_delete.py) : bouton 🗑️ du panneau
|
||||
// de propriétés (toujours l'objet SÉLECTIONNÉ, seul cas où ce bouton est
|
||||
// affiché) — supprime l'objet SANS recharger la page ni même redemander
|
||||
|
||||
@@ -556,6 +556,13 @@ function refreshBuilderPanel(selectedId) {
|
||||
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
|
||||
// document, où cette fonction n'existe simplement pas.
|
||||
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
|
||||
// Bug corrigé : #builder3 contient AUSSI les onglets "Déclencheurs"
|
||||
// et "Générer avec l'IA" (composants JS, pas de simple HTML statique)
|
||||
// — curBuilder.innerHTML = ... ne réexécute aucun script, et sans cet
|
||||
// appel explicite leur contenu restait vide (jusqu'à un vrai
|
||||
// rechargement de page) dès qu'on sélectionnait un objet de la scène.
|
||||
if (typeof initTriggersTab === "function") initTriggersTab();
|
||||
if (typeof initIaTab === "function") initIaTab();
|
||||
})
|
||||
.catch(function () {
|
||||
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
}
|
||||
|
||||
function runScriptsIn(container) {
|
||||
// Bug corrigé : un <script src="..."> créé via document.createElement
|
||||
// est ASYNC PAR DÉFAUT (contrairement à un <script> du HTML source,
|
||||
// exécuté dans l'ordre du document) — plusieurs scripts de page
|
||||
// (variables globales GAME_SLUG/SCREEN_ID posées en inline, puis
|
||||
// scene-editor.js/trigger-editor.js/ia-chat.js qui en dépendent
|
||||
// immédiatement) pouvaient donc s'exécuter dans le désordre après une
|
||||
// navigation pjax, selon l'ordre d'arrivée réseau — jamais après un
|
||||
// vrai rechargement de page (où l'ordre du document est respecté).
|
||||
// Symptôme observé : l'onglet "Générer avec l'IA" se retrouvait vide
|
||||
// après le moindre changement ailleurs (variable, déclencheur), un
|
||||
// rechargement complet le "réparant" — en réalité ia-chat.js avait dû
|
||||
// s'exécuter avant que GAME_SLUG/SCREEN_ID existent. `async = false`
|
||||
// restaure l'exécution dans l'ordre du document pour CHAQUE script
|
||||
// externe, exactement comme à l'analyse initiale du HTML.
|
||||
var scripts = container.querySelectorAll("script");
|
||||
scripts.forEach(function (old) {
|
||||
var fresh = document.createElement("script");
|
||||
@@ -38,6 +52,7 @@
|
||||
var attr = old.attributes[i];
|
||||
fresh.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
if (fresh.src) fresh.async = false;
|
||||
fresh.textContent = old.textContent;
|
||||
old.replaceWith(fresh);
|
||||
});
|
||||
|
||||
@@ -1110,6 +1110,111 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
}
|
||||
.questTriggerAddChainedActionBtn:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
|
||||
/* Onglet "Générer avec l'IA" (voir static/js/ia/ia-chat.js) — maquette
|
||||
validée (Artifact "Maquette Ruby") : une div occupe TOUTE la hauteur/
|
||||
largeur de l'onglet, coupée en 2 colonnes (gauche 40%, droite 60%),
|
||||
chacune header/body/footer. #iaTabPanelBody remplit exactement
|
||||
#iaTabPanel (déjà display:flex/min-height:0 quand actif, voir
|
||||
.builderTabPanel plus haut) pour que CET onglet précis n'ait jamais
|
||||
rien à faire défiler lui-même — seuls les .iaColBody de chaque colonne
|
||||
défilent, indépendamment l'un de l'autre. */
|
||||
#iaTabPanelBody{ display:flex; flex-direction:column; flex:1 1 auto; min-height:0; }
|
||||
.iaTabLayout{
|
||||
display:flex; width:100%; flex:1 1 auto; min-height:0;
|
||||
border:1px solid var(--border); border-radius:10px; overflow:hidden;
|
||||
}
|
||||
.iaCol{ display:flex; flex-direction:column; min-width:0; min-height:0; }
|
||||
.iaCol-left{ flex:0 0 40%; border-right:1px solid var(--border); }
|
||||
.iaCol-right{ flex:1 1 60%; }
|
||||
.iaColHeader{
|
||||
flex:0 0 auto; min-height:52px; display:flex; align-items:center; gap:10px;
|
||||
padding:0 16px; border-bottom:1px solid var(--border);
|
||||
}
|
||||
.iaColBody{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; }
|
||||
.iaColFooter{ flex:0 0 auto; border-top:1px solid var(--border); }
|
||||
|
||||
/* Header gauche : logo + nom (identité "Ruby") */
|
||||
.iaHeaderLogo{ width:26px; height:26px; border-radius:6px; }
|
||||
.iaHeaderName{ font-size:15px; font-weight:700; }
|
||||
|
||||
/* Body gauche : bouton "+ Nouvelle conversation" (fixe) puis la liste,
|
||||
seule à défiler (CRUD des conversations, comme claude.ai). */
|
||||
.iaCol-left .iaColBody{ padding:14px; gap:10px; }
|
||||
.iaNewConvBtn{ flex:0 0 auto; }
|
||||
.iaConversationsList{ flex:1 1 auto; min-height:0; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
|
||||
.iaConversationItem{
|
||||
display:flex; align-items:center; justify-content:space-between; gap:6px; width:100%;
|
||||
padding:8px 10px; border-radius:8px; border:1px solid transparent; background:transparent;
|
||||
color:var(--text-dim); text-align:left; cursor:pointer; font-size:13px;
|
||||
}
|
||||
.iaConversationItem:hover{ background:var(--panel2); }
|
||||
.iaConversationItem.active{ background:var(--panel2); border-color:var(--accent); color:var(--text); }
|
||||
.iaConversationTitle{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
|
||||
.iaConversationDelete{ opacity:0; flex-shrink:0; }
|
||||
.iaConversationItem:hover .iaConversationDelete, .iaConversationItem.active .iaConversationDelete{ opacity:1; }
|
||||
|
||||
/* Body droite : le fil de dialogue, seul à défiler ici. */
|
||||
.iaChatLog{
|
||||
flex:1 1 auto; min-height:0; overflow-y:auto;
|
||||
display:flex; flex-direction:column; gap:16px; padding:18px;
|
||||
}
|
||||
.iaChatEmpty{
|
||||
margin:auto; text-align:center; color:var(--text-dim); max-width:360px;
|
||||
display:flex; flex-direction:column; align-items:center; gap:8px;
|
||||
}
|
||||
.iaChatEmpty strong{ color:var(--text); font-size:15px; }
|
||||
.iaChatAvatar{ width:16px; height:16px; border-radius:4px; vertical-align:-3px; margin-right:4px; }
|
||||
.iaChatAvatar-large{ width:40px; height:40px; border-radius:10px; margin:0; }
|
||||
.iaChatRow{ display:flex; flex-direction:column; max-width:78%; }
|
||||
.iaChatRow-user{ align-self:flex-end; align-items:flex-end; }
|
||||
.iaChatRow-assistant{ align-self:flex-start; align-items:flex-start; }
|
||||
.iaChatSender{
|
||||
font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em;
|
||||
color:var(--text-dim); margin:0 4px 4px; display:flex; align-items:center;
|
||||
}
|
||||
.iaChatBubble{
|
||||
padding:10px 14px; border-radius:14px; white-space:pre-wrap; line-height:1.5; font-size:14px;
|
||||
}
|
||||
.iaChatRow-user .iaChatBubble{ background:var(--accent); color:#fff; border-bottom-right-radius:4px; }
|
||||
.iaChatRow-assistant .iaChatBubble{ background:var(--panel2); color:var(--text); border:1px solid var(--border); border-bottom-left-radius:4px; }
|
||||
/* Footer droite : le champ de saisie prend tout le footer. */
|
||||
.iaCol-right .iaColFooter{ padding:12px 16px; }
|
||||
.iaChatInputBar{
|
||||
display:flex; align-items:flex-end; gap:8px; background:var(--panel);
|
||||
border:1px solid var(--border); border-radius:16px; padding:8px 8px 8px 16px;
|
||||
width:100%;
|
||||
}
|
||||
.iaChatInputBar textarea{
|
||||
flex:1; background:transparent; border:none; color:var(--text); resize:none;
|
||||
min-height:24px; max-height:200px; padding:8px 0; font:inherit; line-height:1.5;
|
||||
}
|
||||
.iaChatInputBar textarea:focus{ outline:none; }
|
||||
.iaChatSendBtn{
|
||||
flex-shrink:0; width:36px; height:36px; border-radius:50%; border:none; cursor:pointer;
|
||||
background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center;
|
||||
font-size:16px; line-height:1;
|
||||
}
|
||||
.iaChatSendBtn:disabled{ opacity:.5; cursor:default; }
|
||||
.iaChatSendBtn:hover:not(:disabled){ background:var(--accent-2); }
|
||||
|
||||
/* Loader "IA en train d'écrire" (demande explicite : sans ça, un
|
||||
créateur qui ne voit rien bouger pendant qu'un écran complet s'enchaîne
|
||||
sur plusieurs appels d'outils reclique, pensant que ça n'a pas marché). */
|
||||
.iaChatTyping{ display:flex; gap:4px; padding:14px; align-items:center; }
|
||||
.iaChatTyping span{
|
||||
width:6px; height:6px; border-radius:50%; background:var(--text-dim);
|
||||
animation:iaChatTypingBounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.iaChatTyping span:nth-child(2){ animation-delay:.15s; }
|
||||
.iaChatTyping span:nth-child(3){ animation-delay:.3s; }
|
||||
@keyframes iaChatTypingBounce{
|
||||
0%, 60%, 100%{ opacity:.3; transform:translateY(0); }
|
||||
30%{ opacity:1; transform:translateY(-3px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.iaChatTyping span{ animation:none; opacity:.6; }
|
||||
}
|
||||
|
||||
/* Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
|
||||
"il faut tout mettre verticalement", le losange lui-même ne change
|
||||
pas) : le losange reste petit et centré, la branche qui MÈNE à cette
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!-- Une ligne du tableau "Variables" (templates/scene_edit.html) — extraite
|
||||
ici pour être réutilisée TELLE QUELLE par create_global_var.py (voir
|
||||
static/js/scenes/global-variables.js) quand une nouvelle variable est
|
||||
créée en AJAX, sans dupliquer ce balisage. Enregistrement en AJAX
|
||||
(onchange) — demande explicite : plus de rechargement de page pour
|
||||
modifier/supprimer une variable. -->
|
||||
<tr data-var-row="{{ v.id }}">
|
||||
<td><code>{{ v.name }}</code></td>
|
||||
<td>
|
||||
<select name="type" aria-label="Type de « {{ v.name }} »" onchange="saveGlobalVariable({{ v.id }})">
|
||||
{% for key, t in global_variable_types.items() %}
|
||||
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ t.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="value" value="{{ v.value or '' }}" placeholder="Valeur" aria-label="Valeur de « {{ v.name }} »" onchange="saveGlobalVariable({{ v.id }})"></td>
|
||||
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non (partagée)' }}</span></td>
|
||||
<td class="fieldsTableActions">
|
||||
<span class="hint" id="varSaveStatus{{ v.id }}"></span>
|
||||
<button type="button" class="button is-small is-danger" title="Supprimer" onclick="deleteGlobalVariable({{ v.id }}, '{{ v.name|e }}')">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
+43
-33
@@ -65,11 +65,37 @@
|
||||
sélectionnable en cliquant directement dessus sur le canevas
|
||||
(voir onSceneObjectMouseDown, static/js/scenes/scene-editor.js). -->
|
||||
|
||||
<!-- "Mes assets" (voir auth/user_assets_dir.py) : bibliothèque PAR
|
||||
COMPTE (pas par jeu) — upload manuel ou généré par l'IA (onglet
|
||||
"IA", Phase 2), réutilisable d'un projet à l'autre du même
|
||||
compte. Posée comme un "decor"/"fond" ordinaire (voir
|
||||
screens/scenes/add_scene_object.py::image_url). PREMIER menu du
|
||||
panneau (demande explicite) : le point d'entrée le plus direct
|
||||
pour un créateur qui vient d'importer/générer une image. -->
|
||||
<details class="propGroup" open>
|
||||
<summary>Mes assets</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for asset in user_assets %}
|
||||
<button type="button" class="iconGalleryTile" title="{{ asset.original_name or 'Asset' }}"
|
||||
onclick="addSceneObject('decor', null, null, {{ asset.id }})">
|
||||
<img src="{{ url_for('serve_user_asset', user_id=asset.user_id, filename=asset.filename) }}"
|
||||
alt="{{ asset.original_name or 'Asset' }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label class="button is-small is-fullwidth mt-2">
|
||||
+ Importer une image
|
||||
<input type="file" accept="image/*" style="display:none" onchange="uploadUserAsset(this)">
|
||||
</label>
|
||||
<p class="help">Réutilisable dans tous tes projets — jamais visible par les autres comptes.</p>
|
||||
</div>
|
||||
</details>
|
||||
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
|
||||
tuile pose un personnage déjà configuré, animé immédiatement (voir
|
||||
personnage-preview.js, réutilisé tel quel : ne dépend que de
|
||||
data-personnage, pas de la table d'origine). -->
|
||||
<details class="propGroup" open>
|
||||
<details class="propGroup">
|
||||
<summary>Personnages</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="iconGallery personnageGallery">
|
||||
@@ -126,6 +152,7 @@
|
||||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">Scène</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="triggers" onclick="switchBuilderTab('triggers')">Déclencheurs</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="variables" onclick="switchBuilderTab('variables')">Variables</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="ia" onclick="switchBuilderTab('ia')">Générer avec l'IA</button>
|
||||
{% if game.onboarding_type != 'rpg' %}
|
||||
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
|
||||
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le
|
||||
@@ -203,8 +230,7 @@
|
||||
<button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</button>
|
||||
</div>
|
||||
<p class="hint">Accessibles depuis n'importe quelle scène (conditions, actions "Modifier une variable"). Le nom ne peut plus changer une fois créé.</p>
|
||||
<form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-variables" style="display:none;">
|
||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||
<form class="dashCreateBar" id="dashCreate-variables" style="display:none;" onsubmit="return createGlobalVariable(event);">
|
||||
<input type="text" name="name" placeholder="NOM (ex. score_total)" required autocomplete="off">
|
||||
<select name="type">
|
||||
{% for key, t in global_variable_types.items() %}
|
||||
@@ -218,44 +244,26 @@
|
||||
<button type="submit" class="primary">Créer</button>
|
||||
</form>
|
||||
|
||||
{% if global_variables %}
|
||||
{% for v in global_variables %}
|
||||
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.id }}">
|
||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||
</form>
|
||||
{% endfor %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th>Par joueur</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tbody id="globalVariablesBody">
|
||||
{% for v in global_variables %}
|
||||
<tr>
|
||||
<td><code>{{ v.name }}</code></td>
|
||||
<td>
|
||||
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
|
||||
{% for key, t in global_variable_types.items() %}
|
||||
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ t.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" placeholder="Valeur" aria-label="Valeur de « {{ v.name }} »"></td>
|
||||
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non (partagée)' }}</span></td>
|
||||
<td class="fieldsTableActions">
|
||||
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small" title="Enregistrer">💾</button>
|
||||
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
|
||||
onsubmit="return confirm('Supprimer la variable « {{ v.name }} » ? Toute condition/action qui la référence encore cessera de fonctionner.');">
|
||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
||||
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% include "_global_variable_row.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucune variable pour l'instant — crée la première ci-dessus.</p>
|
||||
{% endif %}
|
||||
<p class="hint" id="globalVariablesEmptyHint" {{ 'style=display:none;' if global_variables else '' }}>Aucune variable pour l'instant — crée la première ci-dessus.</p>
|
||||
</div>
|
||||
|
||||
<!-- Onglet "🤖 IA" (voir plan Phase 1) : scopé à CET écran uniquement
|
||||
(pas tout le jeu) — le chat pilote un agent qui n'utilise que les
|
||||
mêmes fonctions que l'éditeur manuel (voir ai/tools.py, Phase 2),
|
||||
pour que tout ce qu'il pose reste relisable/modifiable ici. Rendu
|
||||
par static/js/ia/ia-chat.js dans #iaTabPanelBody. -->
|
||||
<div class="builderTabPanel" id="iaTabPanel" data-tab-panel="ia">
|
||||
<div id="iaTabPanelBody" data-screen-id="{{ screen.id }}"></div>
|
||||
</div>
|
||||
|
||||
{% if game.onboarding_type != 'rpg' %}
|
||||
@@ -646,6 +654,8 @@
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/global-variables.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/triggers/trigger-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ia/ia-chat.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,6 +24,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_")
|
||||
os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db")
|
||||
os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
|
||||
# "Mes assets" (voir auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR) :
|
||||
# même raison que FORGE_USERS_DB_PATH ci-dessus — jamais écrire dans le
|
||||
# vrai user_assets/ du poste pendant les tests.
|
||||
os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets")
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -37,6 +41,17 @@ from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_co
|
||||
import auth
|
||||
import db
|
||||
|
||||
# Onglet "IA" (voir ai/) : jamais un vrai appel réseau depuis les tests,
|
||||
# même si un .env réel existe sur ce poste avec de vraies clés —
|
||||
# load_dotenv() (core.flask_app, importé ci-dessus) les aurait déjà
|
||||
# chargées dans os.environ à ce stade ; on les retire explicitement pour
|
||||
# que CHAQUE test parte de "IA non configurée" par défaut. Un test qui
|
||||
# veut simuler une IA configurée monkeypatche directement
|
||||
# ai.client.get_client/ai.scenario_client.generate_image_url — jamais via
|
||||
# une vraie clé, quel que soit le contenu du .env local.
|
||||
for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRET", "SCENARIO_MODEL_ID"):
|
||||
os.environ.pop(_ai_env_key, None)
|
||||
|
||||
# Un seul compte admin pour toute la session de tests : les ~80 routes
|
||||
# existantes s'attendent (comportement d'avant l'authentification) à un
|
||||
# accès sans restriction — "admin" est le seul rôle illimité (voir
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""ai/chat.py — la boucle tool-use (voir plan Phase 2, §5). Jamais un
|
||||
vrai appel à l'API Claude ici : client.messages.create est monkeypatché
|
||||
par une fausse classe qui rejoue une séquence de réponses programmée."""
|
||||
import ai
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup, name="pytest_ai_chat"):
|
||||
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")
|
||||
conversation_id = screens.create_ia_conversation(slug, screen_id)
|
||||
return slug, screen_id, conversation_id
|
||||
|
||||
|
||||
class _FakeBlock:
|
||||
def __init__(self, type, text=None, id=None, name=None, input=None):
|
||||
self.type = type
|
||||
self.text = text
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input or {}
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content, stop_reason):
|
||||
self.content = content
|
||||
self.stop_reason = stop_reason
|
||||
|
||||
|
||||
class _FakeMessages:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self.call_count = 0
|
||||
self.last_kwargs = None
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.call_count += 1
|
||||
self.last_kwargs = kwargs
|
||||
if len(self._responses) > 1:
|
||||
return self._responses.pop(0)
|
||||
return self._responses[0] # rejoue la dernière indéfiniment (voir test de la borne)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, responses):
|
||||
self.messages = _FakeMessages(responses)
|
||||
|
||||
|
||||
def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="Bonjour, que veux-tu créer ?")], "end_turn")])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
assert reply == "Bonjour, que veux-tu créer ?"
|
||||
assert fake_client.messages.call_count == 1
|
||||
|
||||
|
||||
def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
tool_use_response = _FakeResponse(
|
||||
[_FakeBlock("tool_use", id="t1", name="create_global_variable", input={"name": "score_ia", "var_type": "nombre_entier"})],
|
||||
"tool_use",
|
||||
)
|
||||
final_response = _FakeResponse([_FakeBlock("text", text="Variable créée.")], "end_turn")
|
||||
fake_client = _FakeClient([tool_use_response, final_response])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Crée une variable score_ia")
|
||||
assert reply == "Variable créée."
|
||||
names = [v["name"] for v in db.list_global_variables(slug)]
|
||||
assert "score_ia" in names
|
||||
|
||||
|
||||
def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calling_tools(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
always_tool_use = _FakeResponse(
|
||||
[_FakeBlock("tool_use", id="t1", name="create_global_variable", input={"name": "boucle_infinie"})],
|
||||
"tool_use",
|
||||
)
|
||||
fake_client = _FakeClient([always_tool_use])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Fais quelque chose")
|
||||
assert fake_client.messages.call_count == ai.chat._MAX_TOOL_ITERATIONS
|
||||
assert reply == "(pas de réponse textuelle)"
|
||||
|
||||
|
||||
def test_run_chat_turn_explains_when_cut_short_by_max_tokens(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
"""Bug corrigé : max_tokens=4096 pouvait couper Claude EN PLEINE
|
||||
RÉFLEXION sur une demande riche, avant le moindre appel d'outil —
|
||||
symptôme observé : "(pas de réponse textuelle)" dès le premier tour,
|
||||
aucune progression. Message désormais plus clair pour le créateur."""
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
cut_short = _FakeResponse([], "max_tokens") # aucun bloc texte, coupé en cours de réflexion
|
||||
fake_client = _FakeClient([cut_short])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Fais quelque chose de complexe")
|
||||
assert "interrompue" in reply
|
||||
assert fake_client.messages.call_count == 1 # stop_reason != "tool_use" -> sort dès le premier tour
|
||||
|
||||
|
||||
def test_run_chat_turn_uses_a_generous_max_tokens_not_the_old_lowballed_value(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="ok")], "end_turn")])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
assert fake_client.messages.last_kwargs["max_tokens"] >= 16000
|
||||
|
||||
|
||||
def test_run_chat_turn_raises_when_anthropic_is_not_configured(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
try:
|
||||
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
assert False, "devrait lever AnthropicNotConfiguredError"
|
||||
except ai.AnthropicNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- État de scène ré-injecté à chaque tour (bug corrigé : Ruby
|
||||
# dupliquait des objets faute de voir ce qui existait déjà) ----------
|
||||
|
||||
def test_describe_scene_state_reports_dimensions_and_no_objects(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert "0 à 960" in description and "0 à 540" in description
|
||||
assert "Aucun objet" in description
|
||||
|
||||
|
||||
def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
player_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
screens.set_scene_object_role(slug, player_id, "joueur")
|
||||
pnj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
screens.set_scene_object_collision_rules(slug, pnj_id, screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert f"id={player_id}" in description
|
||||
assert "rôle=joueur" in description
|
||||
assert f"id={pnj_id}" in description
|
||||
assert "rôle=pnj" in description
|
||||
assert "[déclencheur déjà configuré]" in description
|
||||
|
||||
|
||||
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="ok")], "end_turn")])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
system_prompt = fake_client.messages.last_kwargs["system"]
|
||||
assert "0 à 960" in system_prompt and "0 à 540" in system_prompt
|
||||
assert "1 objet(s)" in system_prompt
|
||||
@@ -0,0 +1,19 @@
|
||||
"""ai/client.py — voir plan Phase 2, §2 : une clé absente est une
|
||||
configuration incomplète (AnthropicNotConfiguredError), jamais un crash
|
||||
brut ni une vraie clé nécessaire pour faire tourner ce test (voir
|
||||
tests/conftest.py, qui retire ANTHROPIC_API_KEY avant chaque test)."""
|
||||
import ai
|
||||
|
||||
|
||||
def test_get_client_raises_when_the_api_key_is_missing():
|
||||
try:
|
||||
ai.get_client()
|
||||
assert False, "devrait lever AnthropicNotConfiguredError"
|
||||
except ai.AnthropicNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
def test_get_client_returns_a_client_when_the_api_key_is_set(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-key-for-tests")
|
||||
client = ai.get_client()
|
||||
assert client is not None
|
||||
@@ -0,0 +1,74 @@
|
||||
"""ai/scenario_client.py — voir plan Phase 2, §3 : jamais un vrai appel
|
||||
réseau ici (requests.post/get monkeypatchés), et une configuration
|
||||
incomplète (clé/secret/modèle absents) doit échouer proprement AVANT
|
||||
toute tentative réseau."""
|
||||
import ai
|
||||
from ai.scenario_client import generate_image_url
|
||||
|
||||
|
||||
def test_generate_image_url_raises_when_not_configured():
|
||||
try:
|
||||
generate_image_url("un bureau moderne")
|
||||
assert False, "devrait lever ScenarioNotConfiguredError"
|
||||
except ai.ScenarioNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, json_data):
|
||||
self._json = json_data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
def test_generate_image_url_polls_until_success_then_returns_the_asset_url(monkeypatch):
|
||||
monkeypatch.setenv("SCENARIO_API_KEY", "fake-key")
|
||||
monkeypatch.setenv("SCENARIO_API_SECRET", "fake-secret")
|
||||
monkeypatch.setenv("SCENARIO_MODEL_ID", "fake-model")
|
||||
|
||||
calls = {"jobs": 0}
|
||||
|
||||
def fake_post(url, auth=None, json=None, timeout=None):
|
||||
assert url.endswith("/generate/txt2img")
|
||||
assert json["prompt"] == "un bureau moderne"
|
||||
assert json["modelId"] == "fake-model"
|
||||
return _FakeResponse({"job": {"jobId": "job_1"}})
|
||||
|
||||
def fake_get(url, auth=None, timeout=None):
|
||||
if "/jobs/" in url:
|
||||
calls["jobs"] += 1
|
||||
# Simule un job encore en cours au premier appel, réussi ensuite.
|
||||
status = "running" if calls["jobs"] == 1 else "success"
|
||||
body = {"job": {"status": status}}
|
||||
if status == "success":
|
||||
body["job"]["metadata"] = {"assetIds": ["asset_1"]}
|
||||
return _FakeResponse(body)
|
||||
assert url.endswith("/assets/asset_1")
|
||||
return _FakeResponse({"asset": {"url": "https://scenario.example/final.png"}})
|
||||
|
||||
monkeypatch.setattr("ai.scenario_client.requests.post", fake_post)
|
||||
monkeypatch.setattr("ai.scenario_client.requests.get", fake_get)
|
||||
monkeypatch.setattr("ai.scenario_client.time.sleep", lambda s: None)
|
||||
|
||||
url = generate_image_url("un bureau moderne")
|
||||
assert url == "https://scenario.example/final.png"
|
||||
assert calls["jobs"] == 2
|
||||
|
||||
|
||||
def test_generate_image_url_raises_on_job_failure(monkeypatch):
|
||||
monkeypatch.setenv("SCENARIO_API_KEY", "fake-key")
|
||||
monkeypatch.setenv("SCENARIO_API_SECRET", "fake-secret")
|
||||
monkeypatch.setenv("SCENARIO_MODEL_ID", "fake-model")
|
||||
|
||||
monkeypatch.setattr("ai.scenario_client.requests.post", lambda *a, **k: _FakeResponse({"job": {"jobId": "job_1"}}))
|
||||
monkeypatch.setattr("ai.scenario_client.requests.get", lambda *a, **k: _FakeResponse({"job": {"status": "failure"}}))
|
||||
|
||||
try:
|
||||
generate_image_url("un bureau")
|
||||
assert False, "devrait lever ScenarioGenerationError"
|
||||
except ai.ScenarioGenerationError:
|
||||
pass
|
||||
@@ -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)
|
||||
@@ -372,3 +372,21 @@ def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(c
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
""".env local (voir plan Phase 2, §1, core/flask_app.py::load_dotenv())
|
||||
— propriété de sécurité dont dépend l'isolation des tests
|
||||
(tests/conftest.py pose FORGE_USERS_DB_PATH etc. AVANT cet import) :
|
||||
une variable déjà présente dans os.environ ne doit JAMAIS être écrasée
|
||||
par un .env local, même sur un poste où un vrai .env existe avec de
|
||||
vraies clés IA."""
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import core.flask_app
|
||||
|
||||
|
||||
def test_load_dotenv_never_overrides_an_already_set_variable(monkeypatch):
|
||||
monkeypatch.setenv("FORGE_TEST_DOTENV_VAR", "valeur-deja-posee")
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".env", delete=False) as f:
|
||||
f.write("FORGE_TEST_DOTENV_VAR=valeur-du-fichier-env\n")
|
||||
env_path = f.name
|
||||
try:
|
||||
load_dotenv(env_path)
|
||||
assert os.environ["FORGE_TEST_DOTENV_VAR"] == "valeur-deja-posee"
|
||||
finally:
|
||||
os.remove(env_path)
|
||||
|
||||
|
||||
def test_core_flask_app_does_not_pass_override_true_to_load_dotenv():
|
||||
"""Garde-fou direct sur le code lui-même : si quelqu'un ajoute un
|
||||
jour `override=True`, un .env local avec une VRAIE clé écraserait
|
||||
silencieusement l'isolation des tests (voir ci-dessus)."""
|
||||
source = inspect.getsource(core.flask_app)
|
||||
assert "load_dotenv()" in source
|
||||
assert "override=True" not in source
|
||||
@@ -72,3 +72,50 @@ def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, g
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "score_total" in html
|
||||
assert 'data-tab="variables"' in html
|
||||
|
||||
|
||||
# ---------- Réponse AJAX (voir static/js/scenes/global-variables.js) —
|
||||
# demande explicite : plus de rechargement de page pour créer/modifier/
|
||||
# supprimer une variable. ----------
|
||||
|
||||
def test_create_global_var_ajax_returns_the_row_html_without_redirecting(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/new",
|
||||
data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["ok"] is True
|
||||
assert "score_total" in body["row_html"]
|
||||
assert 'data-var-row="' in body["row_html"]
|
||||
|
||||
|
||||
def test_global_var_edit_ajax_returns_ok_without_redirecting(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/{var_id}/edit",
|
||||
data={"type": "texte", "value": "beaucoup"},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True}
|
||||
assert db.list_global_variables(game)[0]["value"] == "beaucoup"
|
||||
|
||||
|
||||
def test_global_var_delete_ajax_returns_ok_without_redirecting(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/{var_id}/delete",
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True}
|
||||
assert db.list_global_variables(game) == []
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Onglet "Générer avec l'IA" — plusieurs CONVERSATIONS par écran (CRUD,
|
||||
comme claude.ai, voir plan) : routes/ia/ia_chat.py + screens/ia/.
|
||||
ANTHROPIC_API_KEY est toujours absente en test (voir tests/conftest.py) :
|
||||
ces tests exercent donc le chemin de DÉGRADATION GRACIEUSE (message
|
||||
d'erreur clair, jamais une 500) pour l'envoi de message — pas un appel
|
||||
réel, quel que soit le .env local du poste."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_ia_chat"):
|
||||
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_create_and_list_conversations(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations")
|
||||
assert resp.status_code == 200
|
||||
conversation = resp.get_json()
|
||||
assert conversation["title"] == "Nouvelle conversation"
|
||||
|
||||
resp2 = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations")
|
||||
assert resp2.status_code == 200
|
||||
ids = [c["id"] for c in resp2.get_json()]
|
||||
assert conversation["id"] in ids
|
||||
|
||||
|
||||
def test_conversations_are_isolated_per_screen(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id_1 = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screen_id_2 = screens.create_screen(slug, "Scène 2", kind="jeu_2d")
|
||||
client.post(f"/game/{slug}/screens/{screen_id_1}/ia-conversations")
|
||||
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id_2}/ia-conversations")
|
||||
assert resp.get_json() == []
|
||||
|
||||
|
||||
def test_delete_conversation_removes_it_from_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
resp = client.delete(f"/game/{slug}/ia-conversations/{conversation['id']}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
remaining = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
assert all(c["id"] != conversation["id"] for c in remaining)
|
||||
|
||||
|
||||
def test_delete_unknown_conversation_404s(client, game):
|
||||
resp = client.delete(f"/game/{game}/ia-conversations/999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_send_message_persists_it_and_a_graceful_error_reply(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "Génère un dialogue"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["user_message"]["content"] == "Génère un dialogue"
|
||||
assert body["user_message"]["role"] == "user"
|
||||
assert body["assistant_message"]["role"] == "assistant"
|
||||
assert "Configuration IA incomplète" in body["assistant_message"]["content"]
|
||||
|
||||
|
||||
def test_send_message_rejects_an_empty_message(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
resp = client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_send_message_404s_for_an_unknown_conversation(client, game):
|
||||
resp = client.post(f"/game/{game}/ia-conversations/999999/messages", json={"message": "salut"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_messages_history_is_persisted_and_ordered(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "premier"})
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "second"})
|
||||
|
||||
resp = client.get(f"/game/{slug}/ia-conversations/{conversation['id']}/messages")
|
||||
assert resp.status_code == 200
|
||||
messages = resp.get_json()
|
||||
user_contents = [m["content"] for m in messages if m["role"] == "user"]
|
||||
assert user_contents == ["premier", "second"]
|
||||
|
||||
|
||||
def test_messages_are_isolated_per_conversation(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation_1 = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
conversation_2 = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation_1['id']}/messages", json={"message": "pour la 1ère"})
|
||||
|
||||
resp = client.get(f"/game/{slug}/ia-conversations/{conversation_2['id']}/messages")
|
||||
assert resp.get_json() == []
|
||||
|
||||
|
||||
def test_first_user_message_renames_the_conversation(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "Crée un fond de bureau"})
|
||||
|
||||
conversations = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
updated = next(c for c in conversations if c["id"] == conversation["id"])
|
||||
assert updated["title"] == "Crée un fond de bureau"
|
||||
|
||||
# Un deuxième message ne renomme plus (titre déjà dérivé du premier).
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "et une variable"})
|
||||
conversations2 = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
updated2 = next(c for c in conversations2 if c["id"] == conversation["id"])
|
||||
assert updated2["title"] == "Crée un fond de bureau"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Poser un objet de scène depuis "Mes assets" (voir plan Phase 1, §2) —
|
||||
routes/scenes/scene_object_add.py::asset_id, isolation stricte par
|
||||
propriétaire comme le reste de "Mes assets" (tests/test_user_assets.py)."""
|
||||
import re
|
||||
|
||||
import auth
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_object_asset"):
|
||||
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_scene_object_add_with_asset_id_poses_the_owners_image(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "abc123.png", original_name="photo.png")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data={"kind": "decor", "asset_id": str(asset_id)},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
obj = screens.get_scene_object(slug, body["id"])
|
||||
assert obj["attributes"]["src"] == f"/assets/{user_id}/abc123.png"
|
||||
|
||||
|
||||
def test_scene_object_add_with_asset_id_403s_for_another_accounts_asset(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:
|
||||
other_user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(other_user_id, "not_mine.png")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data={"kind": "decor", "asset_id": str(asset_id)},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,80 @@
|
||||
""""Mes assets" (voir plan Phase 1, §2) — bibliothèque d'images PAR
|
||||
COMPTE, distincte du dossier d'un jeu (routes/uploads/upload_file.py) :
|
||||
réutilisable d'un projet à l'autre du MÊME compte, jamais visible par un
|
||||
autre."""
|
||||
import io
|
||||
|
||||
import auth
|
||||
|
||||
|
||||
def test_create_and_list_user_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "abc123.png", original_name="photo.png", source="upload")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["id"] == asset_id and a["filename"] == "abc123.png" for a in assets)
|
||||
|
||||
|
||||
def test_list_user_assets_is_isolated_per_account(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
auth.create_user_asset(admin_id, "admin_only.png")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert all(a["filename"] != "admin_only.png" for a in assets)
|
||||
|
||||
|
||||
def test_get_user_asset_returns_none_for_unknown_id():
|
||||
assert auth.get_user_asset(999999) is None
|
||||
|
||||
|
||||
def test_delete_user_asset_refuses_when_not_the_owner(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(admin_id, "admin_only.png")
|
||||
assert auth.delete_user_asset(asset_id, user_id) is False
|
||||
assert auth.get_user_asset(asset_id) is not None
|
||||
|
||||
|
||||
def test_upload_user_asset_route_stores_the_file_and_returns_a_url(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["id"]
|
||||
assert "/assets/" in body["url"]
|
||||
|
||||
dl = client.get(body["url"])
|
||||
assert dl.status_code == 200
|
||||
assert dl.data == b"fake-image-bytes"
|
||||
|
||||
|
||||
def test_upload_user_asset_route_requires_a_file(client):
|
||||
resp = client.post("/assets/upload", data={}, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_serve_user_asset_route_403s_for_another_account(client, user_client):
|
||||
# Un compte "user" fraîchement inscrit (sans projet) est redirigé vers
|
||||
# l'onboarding pour toute route hors _REACHABLE_WITHOUT_PROJECT (voir
|
||||
# core/auth_guard.py) — lui assigner un projet ici isole ce test sur
|
||||
# le SEUL comportement qu'on veut vérifier : l'isolation par
|
||||
# propriétaire de "Mes assets", pas le parcours d'onboarding.
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
auth.set_project_slug(other_user_id, "pytest_dummy_project")
|
||||
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
url = resp.get_json()["url"]
|
||||
resp2 = user_client.get(url)
|
||||
assert resp2.status_code == 403
|
||||
Reference in New Issue
Block a user