Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ab6635eee0
commit
b07b231a61
+26
-4
@@ -1,7 +1,13 @@
|
|||||||
# Ce fichier ne configure PAS l'application (elle ne lit aucune variable
|
# Deux usages distincts pour ce fichier, jamais mélangés :
|
||||||
# d'environnement) — il configure uniquement docker-compose.prod.yml sur le
|
# - En LOCAL (poste de dev) : copier en ".env" à la racine du dépôt et
|
||||||
# serveur de production. Copier en ".env" à côté de docker-compose.prod.yml
|
# renseigner les clés IA ci-dessous — core/flask_app.py les charge lui-
|
||||||
# sur le serveur et adapter les valeurs ; ne jamais committer ce ".env".
|
# 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 :
|
# Adresse complète de l'image dans le registre Gitea, ex :
|
||||||
# gitea.exemple.com/mon-compte/forge-engine
|
# gitea.exemple.com/mon-compte/forge-engine
|
||||||
@@ -23,3 +29,19 @@ SMTP_PORT=587
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
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)
|
# Jeux créés par les utilisateurs (données runtime, pas du code)
|
||||||
projects/
|
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,
|
# Comptes utilisateurs (base SQLite + clé de session) — données runtime,
|
||||||
# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
|
# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
|
||||||
data/
|
data/
|
||||||
@@ -38,6 +42,13 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.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
|
# Documents internes/business (cadrage produit...) et état local de
|
||||||
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
|
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
|
||||||
.claude/
|
.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 .count_admins import count_admins
|
||||||
from .delete_user import delete_user
|
from .delete_user import delete_user
|
||||||
from .update_email import update_email, EmailUpdateError
|
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__ = [
|
__all__ = [
|
||||||
"users_db_path", "ensure_users_schema", "is_first_user",
|
"users_db_path", "ensure_users_schema", "is_first_user",
|
||||||
@@ -46,4 +53,6 @@ __all__ = [
|
|||||||
"send_password_reset_email", "EmailNotConfiguredError",
|
"send_password_reset_email", "EmailNotConfiguredError",
|
||||||
"update_profile", "count_admins", "delete_user",
|
"update_profile", "count_admins", "delete_user",
|
||||||
"update_email", "EmailUpdateError",
|
"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 os
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from flask import Flask
|
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__)))
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
_TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
|
_TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
|
||||||
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
|
_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"
|
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.
|
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
|
||||||
FIELD_TYPES = {
|
FIELD_TYPES = {
|
||||||
"texte": {"label": "Texte court", "sql": "TEXT"},
|
"texte": {"label": "Texte court", "sql": "TEXT"},
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
Flask==3.0.3
|
Flask==3.0.3
|
||||||
pyotp==2.10.0
|
pyotp==2.10.0
|
||||||
qrcode==8.2
|
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
|
import db
|
||||||
|
|
||||||
@@ -9,7 +9,10 @@ from core.flask_app import app
|
|||||||
def create_global_var(slug):
|
def create_global_var(slug):
|
||||||
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
|
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
|
||||||
— idempotent par nom (voir db.create_global_variable), donc sans
|
— 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()
|
name = (request.form.get("name") or "").strip()
|
||||||
var_type = request.form.get("type") or "texte"
|
var_type = request.form.get("type") or "texte"
|
||||||
default_value = request.form.get("default_value") or ""
|
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"
|
per_player = request.form.get("per_player", "1") == "1"
|
||||||
if name:
|
if name:
|
||||||
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
|
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:
|
if screen_id:
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||||
return redirect(url_for("game_dashboard", slug=slug))
|
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
|
import db
|
||||||
|
|
||||||
@@ -7,8 +7,13 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
||||||
def global_var_delete(slug, var_id):
|
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)
|
screen_id = request.form.get("screen_id", type=int)
|
||||||
db.delete_global_variable_by_id(slug, var_id)
|
db.delete_global_variable_by_id(slug, var_id)
|
||||||
|
if request.headers.get("X-Requested-With") == "fetch":
|
||||||
|
return jsonify({"ok": True})
|
||||||
if screen_id:
|
if screen_id:
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||||
return redirect(url_for("game_dashboard", slug=slug))
|
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
|
import db
|
||||||
|
|
||||||
@@ -7,10 +7,16 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
|
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
|
||||||
def global_var_edit(slug, var_id):
|
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"
|
var_type = request.form.get("type") or "texte"
|
||||||
value = request.form.get("value") or ""
|
value = request.form.get("value") or ""
|
||||||
screen_id = request.form.get("screen_id", type=int)
|
screen_id = request.form.get("screen_id", type=int)
|
||||||
db.update_global_variable(slug, var_id, var_type, value)
|
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:
|
if screen_id:
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||||
return redirect(url_for("game_dashboard", slug=slug))
|
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
|
from flask import render_template, request, abort, g
|
||||||
|
|
||||||
|
import auth
|
||||||
import db
|
import db
|
||||||
import screens
|
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
|
# 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.
|
# 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 {}
|
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(
|
return render_template(
|
||||||
"scene_edit.html",
|
"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=sprite_library, sprite_library_families=sprite_library_families,
|
||||||
sprite_library_json=json.dumps(sprite_library),
|
sprite_library_json=json.dumps(sprite_library),
|
||||||
background_library=background_library,
|
background_library=background_library,
|
||||||
|
user_assets=user_assets,
|
||||||
element_animations_map_json=json.dumps(element_animations_map),
|
element_animations_map_json=json.dumps(element_animations_map),
|
||||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
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
|
import screens
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
@@ -27,12 +28,23 @@ def scene_object_add(slug, screen_id):
|
|||||||
kind = request.form.get("kind", "personnage")
|
kind = request.form.get("kind", "personnage")
|
||||||
forge_character = request.form.get("forge_character")
|
forge_character = request.form.get("forge_character")
|
||||||
background_slug = request.form.get("background_slug")
|
background_slug = request.form.get("background_slug")
|
||||||
|
asset_id = request.form.get("asset_id", type=int)
|
||||||
if forge_character:
|
if forge_character:
|
||||||
forbid_admin_only_character(forge_character)
|
forbid_admin_only_character(forge_character)
|
||||||
if background_slug:
|
if background_slug:
|
||||||
forbid_admin_only_background(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(
|
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":
|
if request.headers.get("X-Requested-With") == "fetch":
|
||||||
obj = screens.get_scene_object(slug, new_id)
|
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
|
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
|
cette feuille a déjà un "then" (jamais écrasé par cette route — un
|
||||||
nouvel appel doit d'abord recharger la chaîne à jour)."""
|
nouvel appel doit d'abord recharger la chaîne à jour)."""
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
if screens.get_scene_object(slug, object_id) is None:
|
||||||
if obj is None:
|
|
||||||
return jsonify({"error": "objet introuvable"}), 404
|
return jsonify({"error": "objet introuvable"}), 404
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
after_id = body.get("after_id")
|
after_id = body.get("after_id")
|
||||||
if not isinstance(after_id, str) or not after_id.strip():
|
if not isinstance(after_id, str) or not after_id.strip():
|
||||||
return jsonify({"error": "after_id invalide"}), 400
|
return jsonify({"error": "after_id invalide"}), 400
|
||||||
|
|
||||||
rules = screens.resolve_collision_rules(obj)
|
# Chemin d'écriture PARTAGÉ avec l'agent IA (voir ai/tools.py) —
|
||||||
if not screens.set_then_action_in_rules(rules, after_id, body.get("action")):
|
# 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
|
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})
|
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.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||||
from .scenes.render_scene_object import render_scene_object
|
from .scenes.render_scene_object import render_scene_object
|
||||||
from .scenes.build_collision_rule_objects import build_collision_rule_objects
|
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 (
|
from .rendering.trigger_graph import (
|
||||||
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
|
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
|
||||||
list_completable_dialogue_ids,
|
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 .custom_events.delete_custom_event import delete_custom_event
|
||||||
|
|
||||||
from .payload.full_game_payload import full_game_payload
|
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)
|
_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).
|
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
|
||||||
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
|
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
|
||||||
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
|
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
|
static/style.css .sceneBackground) et à la taille RÉELLE de l'image
|
||||||
choisie (voir screens/labels/background_library.py) par défaut — une
|
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
|
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)
|
ensure_scene_schema(slug)
|
||||||
kind = kind if kind in _KINDS else "personnage"
|
kind = kind if kind in _KINDS else "personnage"
|
||||||
attributes = {}
|
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,
|
# et disparaîtrait à tort de "🧩 Collision" (voir scene_edit_view.py,
|
||||||
# qui exclut toujours le joueur).
|
# qui exclut toujours le joueur).
|
||||||
attributes["_personnage_role"] = "pnj"
|
attributes["_personnage_role"] = "pnj"
|
||||||
|
elif kind in ("fond", "decor") and image_url:
|
||||||
|
attributes["src"] = image_url
|
||||||
elif kind == "fond" and background_slug:
|
elif kind == "fond" and background_slug:
|
||||||
from ..labels.background_library import BACKGROUND_LIBRARY
|
from ..labels.background_library import BACKGROUND_LIBRARY
|
||||||
background = BACKGROUND_LIBRARY.get(background_slug)
|
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);
|
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', () => {
|
test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => {
|
||||||
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
|
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
|
||||||
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
|
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
|
||||||
assert.equal(compareValues(true, 'different', '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', () => {
|
test('resolveVariablePath — chemin vide renvoie la valeur brute', () => {
|
||||||
assert.equal(resolveVariablePath('42', ''), '42');
|
assert.equal(resolveVariablePath('42', ''), '42');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,14 +25,22 @@ function readFieldValue(definitionId, rowId, fieldName) {
|
|||||||
|
|
||||||
function compareValues(actual, operator, expected, fieldType) {
|
function compareValues(actual, operator, expected, fieldType) {
|
||||||
if (fieldType === 'booleen') {
|
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é
|
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
||||||
// partout ailleurs pour un champ booléen — une valeur de comparaison
|
// partout ailleurs pour un champ booléen — une valeur tapée "Oui"
|
||||||
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
|
// doit donc être reconnue vraie ici aussi, pas seulement "1"/"true"
|
||||||
// seulement "1"/"true" (et insensible à la casse, aligné avec
|
// (et insensible à la casse, aligné avec _compare() côté Python).
|
||||||
// _compare() côté Python, voir filter_repeater_rows.py).
|
const normalizeBool = function (v) {
|
||||||
const expectedStr = String(expected).trim().toLowerCase();
|
if (v === true) return 1;
|
||||||
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
|
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;
|
return operator === 'different' ? a !== e : a === e;
|
||||||
}
|
}
|
||||||
const an = parseFloat(actual), en = parseFloat(expected);
|
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 as = (actual === undefined || actual === null) ? '' : String(actual);
|
||||||
const es = (expected === undefined || expected === null) ? '' : String(expected);
|
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) {
|
switch (operator) {
|
||||||
case 'egal': return as === es;
|
case 'egal': return asLower === esLower;
|
||||||
case 'different': return as !== es;
|
case 'different': return asLower !== esLower;
|
||||||
case 'superieur': return as > es;
|
case 'superieur': return as > es;
|
||||||
case 'inferieur': return as < es;
|
case 'inferieur': return as < es;
|
||||||
case 'superieur_egal': 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
|
// automatiquement sélectionné/ouvert dans le panneau de propriétés (voir
|
||||||
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
|
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
|
||||||
// posé sur le canevas, cliquable ensuite comme n'importe quel autre.
|
// 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();
|
var body = new FormData();
|
||||||
body.append('kind', kind);
|
body.append('kind', kind);
|
||||||
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
||||||
if (backgroundSlug) body.append('background_slug', backgroundSlug);
|
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", {
|
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add", {
|
||||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||||
})
|
})
|
||||||
@@ -62,6 +65,21 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
|
|||||||
.catch(function () { window.location.reload(); });
|
.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
|
// 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
|
// 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
|
// 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
|
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
|
||||||
// document, où cette fonction n'existe simplement pas.
|
// document, où cette fonction n'existe simplement pas.
|
||||||
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
|
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 () {
|
.catch(function () {
|
||||||
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
||||||
|
|||||||
@@ -31,6 +31,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runScriptsIn(container) {
|
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");
|
var scripts = container.querySelectorAll("script");
|
||||||
scripts.forEach(function (old) {
|
scripts.forEach(function (old) {
|
||||||
var fresh = document.createElement("script");
|
var fresh = document.createElement("script");
|
||||||
@@ -38,6 +52,7 @@
|
|||||||
var attr = old.attributes[i];
|
var attr = old.attributes[i];
|
||||||
fresh.setAttribute(attr.name, attr.value);
|
fresh.setAttribute(attr.name, attr.value);
|
||||||
}
|
}
|
||||||
|
if (fresh.src) fresh.async = false;
|
||||||
fresh.textContent = old.textContent;
|
fresh.textContent = old.textContent;
|
||||||
old.replaceWith(fresh);
|
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); }
|
.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 :
|
/* Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
|
||||||
"il faut tout mettre verticalement", le losange lui-même ne change
|
"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
|
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
|
sélectionnable en cliquant directement dessus sur le canevas
|
||||||
(voir onSceneObjectMouseDown, static/js/scenes/scene-editor.js). -->
|
(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
|
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
|
||||||
tuile pose un personnage déjà configuré, animé immédiatement (voir
|
tuile pose un personnage déjà configuré, animé immédiatement (voir
|
||||||
personnage-preview.js, réutilisé tel quel : ne dépend que de
|
personnage-preview.js, réutilisé tel quel : ne dépend que de
|
||||||
data-personnage, pas de la table d'origine). -->
|
data-personnage, pas de la table d'origine). -->
|
||||||
<details class="propGroup" open>
|
<details class="propGroup">
|
||||||
<summary>Personnages</summary>
|
<summary>Personnages</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
<div class="iconGallery personnageGallery">
|
<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 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="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="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' %}
|
{% if game.onboarding_type != 'rpg' %}
|
||||||
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
|
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
|
||||||
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le
|
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>
|
<button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</button>
|
||||||
</div>
|
</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>
|
<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;">
|
<form class="dashCreateBar" id="dashCreate-variables" style="display:none;" onsubmit="return createGlobalVariable(event);">
|
||||||
<input type="hidden" name="screen_id" value="{{ screen.id }}">
|
|
||||||
<input type="text" name="name" placeholder="NOM (ex. score_total)" required autocomplete="off">
|
<input type="text" name="name" placeholder="NOM (ex. score_total)" required autocomplete="off">
|
||||||
<select name="type">
|
<select name="type">
|
||||||
{% for key, t in global_variable_types.items() %}
|
{% for key, t in global_variable_types.items() %}
|
||||||
@@ -218,44 +244,26 @@
|
|||||||
<button type="submit" class="primary">Créer</button>
|
<button type="submit" class="primary">Créer</button>
|
||||||
</form>
|
</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">
|
<div class="fieldsTableWrap">
|
||||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
<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>
|
<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 %}
|
{% for v in global_variables %}
|
||||||
<tr>
|
{% include "_global_variable_row.html" %}
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
<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>
|
||||||
<p class="hint">Aucune variable pour l'instant — crée la première ci-dessus.</p>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
{% if game.onboarding_type != 'rpg' %}
|
{% 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/builder-tabs.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.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/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/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/scenes/collision-rules-editor.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/ia/ia-chat.js') }}"></script>
|
||||||
{% endblock %}
|
{% 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_")
|
_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_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")
|
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
|
import pytest
|
||||||
|
|
||||||
@@ -37,6 +41,17 @@ from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_co
|
|||||||
import auth
|
import auth
|
||||||
import db
|
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
|
# Un seul compte admin pour toute la session de tests : les ~80 routes
|
||||||
# existantes s'attendent (comportement d'avant l'authentification) à un
|
# existantes s'attendent (comportement d'avant l'authentification) à un
|
||||||
# accès sans restriction — "admin" est le seul rôle illimité (voir
|
# 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()
|
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/compute-operation.js"' in html
|
||||||
assert 'src="/static/js/play/offline/apply-actions.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)
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||||
assert "score_total" in html
|
assert "score_total" in html
|
||||||
assert 'data-tab="variables"' 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