Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Build and deploy / test-python (push) Successful in 12m24s
Build and deploy / test-js (push) Successful in 1m9s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-09-09 19:40:31 +02:00
co-authored by Claude Sonnet 5
parent ab6635eee0
commit b07b231a61
62 changed files with 2928 additions and 62 deletions
+26 -4
View File
@@ -1,7 +1,13 @@
# Ce fichier ne configure PAS l'application (elle ne lit aucune variable
# d'environnement) — il configure uniquement docker-compose.prod.yml sur le
# serveur de production. Copier en ".env" à côté de docker-compose.prod.yml
# sur le serveur et adapter les valeurs ; ne jamais committer ce ".env".
# Deux usages distincts pour ce fichier, jamais mélangés :
# - En LOCAL (poste de dev) : copier en ".env" à la racine du dépôt et
# renseigner les clés IA ci-dessous — core/flask_app.py les charge lui-
# même (python-dotenv), rien d'autre à faire. Jamais committer ce ".env"
# (déjà dans .gitignore).
# - En PRODUCTION : ce fichier configure docker-compose.prod.yml (image,
# port) — copier en ".env" à côté de docker-compose.prod.yml sur le
# serveur. L'app elle-même n'y lit AUCUN fichier .env en production
# (aucun n'y est déployé) : les variables y sont posées directement sur
# l'hôte/le conteneur, docker-compose.prod.yml les lui transmettant.
# Adresse complète de l'image dans le registre Gitea, ex :
# gitea.exemple.com/mon-compte/forge-engine
@@ -23,3 +29,19 @@ SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Onglet "IA" (voir ai/client.py) — clé du compte Anthropic Console
# (console.anthropic.com), PAS l'abonnement claude.ai Entreprise (deux
# systèmes de facturation séparés). Laisser vide désactive le chat IA
# (message d'erreur clair affiché au créateur, jamais une 500).
ANTHROPIC_API_KEY=
# Génération d'images (voir ai/scenario_client.py) — compte Scenario
# (scenario.com), clé + secret (authentification Basic), et l'id du
# modèle entraîné sur le style graphique unique de Forge Engine (voir
# l'étude préalable : un seul style pour tout le moteur, pas par projet).
# Laisser vide désactive juste la génération d'images (le reste du chat
# IA continue de fonctionner).
SCENARIO_API_KEY=
SCENARIO_API_SECRET=
SCENARIO_MODEL_ID=
+11
View File
@@ -18,6 +18,10 @@ regles/
# Jeux créés par les utilisateurs (données runtime, pas du code)
projects/
# "Mes assets" — images par compte (données runtime, voir
# auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR)
user_assets/
# Comptes utilisateurs (base SQLite + clé de session) — données runtime,
# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
data/
@@ -38,6 +42,13 @@ Thumbs.db
.vscode/
.idea/
# Journaux de diagnostic ad-hoc (redirection stdout/stderr d'un serveur
# de dev lancé pour déboguer) — jamais du code.
_srv_out.txt
_srv_err.txt
_diag_out.txt
_diag_err.txt
# Documents internes/business (cadrage produit...) et état local de
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
.claude/
+15
View File
@@ -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
View File
@@ -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)"
+23
View File
@@ -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)
+68
View File
@@ -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
View File
@@ -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)
+9
View File
@@ -30,6 +30,13 @@ from .update_profile import update_profile
from .count_admins import count_admins
from .delete_user import delete_user
from .update_email import update_email, EmailUpdateError
from .ensure_user_assets_schema import ensure_user_assets_schema
from .user_assets_dir import user_assets_dir
from .create_user_asset import create_user_asset
from .list_user_assets import list_user_assets
from .get_user_asset import get_user_asset
from .delete_user_asset import delete_user_asset
from .save_user_asset import save_user_asset
__all__ = [
"users_db_path", "ensure_users_schema", "is_first_user",
@@ -46,4 +53,6 @@ __all__ = [
"send_password_reset_email", "EmailNotConfiguredError",
"update_profile", "count_admins", "delete_user",
"update_email", "EmailUpdateError",
"ensure_user_assets_schema", "user_assets_dir", "create_user_asset",
"list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset",
]
+20
View File
@@ -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
+27
View File
@@ -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
+23
View File
@@ -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()
+17
View File
@@ -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
+17
View File
@@ -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]
+20
View File
@@ -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
+10
View File
@@ -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))
+12
View File
@@ -1,8 +1,20 @@
import os
import secrets
from dotenv import load_dotenv
from flask import Flask
# Charge .env AVANT tout le reste (voir .env.example) — ce module est le
# tout premier import interne de app.py, donc les variables sont posées
# avant qu'un `import db`/`auth` ne les résolve à l'import (ex.
# db/constants.py::PROJECTS_DIR). Ne remplace JAMAIS une variable déjà
# présente dans os.environ (comportement par défaut de load_dotenv) —
# tests/conftest.py, qui pose ses propres variables en Python avant tout
# import, reste donc isolé d'un .env local même s'il en existe un sur le
# poste. Aucun effet en production (pas de fichier .env sur le serveur,
# les variables y sont posées directement sur l'hôte/le conteneur).
load_dotenv()
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
+9
View File
@@ -10,6 +10,15 @@ PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
)
# "Mes assets" (voir auth/user_assets/) : stockage PAR UTILISATEUR, pas
# par jeu — distinct de PROJECTS_DIR/<owner>/<projet>/uploads (voir
# routes/uploads/upload_file.py), pour qu'une image reste réutilisable
# d'un projet à l'autre du même compte. Même schéma de surcharge que
# PROJECTS_DIR ci-dessus.
USER_ASSETS_DIR = os.environ.get("FORGE_USER_ASSETS_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "user_assets"
)
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
FIELD_TYPES = {
"texte": {"label": "Texte court", "sql": "TEXT"},
+8
View File
@@ -1,3 +1,11 @@
Flask==3.0.3
pyotp==2.10.0
qrcode==8.2
# Onglet "IA" (voir ai/) : anthropic pour la boucle tool-use (ai/chat.py),
# requests pour le client Scenario (ai/scenario_client.py, seule
# dépendance HTTP du projet), python-dotenv pour charger .env en local
# (core/flask_app.py) — jamais utilisé en production, où les variables
# sont déjà posées sur l'hôte/le conteneur (voir .env.example).
anthropic==1.4.0
requests==2.34.2
python-dotenv==1.2.3
+1 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from . import upload_user_asset, serve_user_asset
+15
View File
@@ -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)
+27
View File
@@ -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),
})
+13 -2
View File
@@ -1,4 +1,4 @@
from flask import request, redirect, url_for
from flask import request, redirect, render_template, url_for, jsonify
import db
@@ -9,7 +9,10 @@ from core.flask_app import app
def create_global_var(slug):
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
— idempotent par nom (voir db.create_global_variable), donc sans
risque en cas de double soumission."""
risque en cas de double soumission. Réponse AJAX (voir
static/js/scenes/global-variables.js) : renvoie le HTML de la LIGNE
créée (templates/_global_variable_row.html, partagé avec le rendu
initial de la page — jamais dupliqué), que le JS insère sans recharger."""
name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or ""
@@ -21,6 +24,14 @@ def create_global_var(slug):
per_player = request.form.get("per_player", "1") == "1"
if name:
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
if request.headers.get("X-Requested-With") == "fetch":
row_html = ""
if name:
variable = db.get_global_variable(slug, name)
row_html = render_template(
"_global_variable_row.html", v=variable, global_variable_types=db.GLOBAL_VARIABLE_TYPES,
)
return jsonify({"ok": True, "row_html": row_html})
if screen_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
return redirect(url_for("game_dashboard", slug=slug))
+6 -1
View File
@@ -1,4 +1,4 @@
from flask import request, redirect, url_for
from flask import request, redirect, url_for, jsonify
import db
@@ -7,8 +7,13 @@ from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
def global_var_delete(slug, var_id):
"""Suppression en AJAX (voir static/js/scenes/global-variables.js) —
même raison que global_var_edit.py. Redirection classique conservée
en secours pour tout appelant non-JS."""
screen_id = request.form.get("screen_id", type=int)
db.delete_global_variable_by_id(slug, var_id)
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True})
if screen_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
return redirect(url_for("game_dashboard", slug=slug))
+7 -1
View File
@@ -1,4 +1,4 @@
from flask import request, redirect, url_for
from flask import request, redirect, url_for, jsonify
import db
@@ -7,10 +7,16 @@ from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
def global_var_edit(slug, var_id):
"""Enregistrement en AJAX (voir static/js/scenes/global-variables.js) —
demande explicite : modifier/supprimer une variable ne doit plus
recharger toute la page. Redirection classique conservée en secours
pour tout appelant non-JS (même patron que scene_object_add.py)."""
var_type = request.form.get("type") or "texte"
value = request.form.get("value") or ""
screen_id = request.form.get("screen_id", type=int)
db.update_global_variable(slug, var_id, var_type, value)
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True})
if screen_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
return redirect(url_for("game_dashboard", slug=slug))
+1
View File
@@ -0,0 +1 @@
from . import ia_chat
+85
View File
@@ -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},
})
+6
View File
@@ -2,6 +2,7 @@ import json
from flask import render_template, request, abort, g
import auth
import db
import screens
@@ -81,6 +82,10 @@ def render_scene_edit(slug, screen_id, game):
# Images de fond CraftPix (screens.BACKGROUND_LIBRARY) : même règle
# d'accès que les sprites animaux — réservées au rôle admin.
background_library = screens.BACKGROUND_LIBRARY if g.current_user["role"] == "admin" else {}
# "Mes assets" (voir auth/user_assets_dir.py) : par COMPTE, jamais
# filtré par rôle contrairement à sprite_library/background_library
# ci-dessus (rien à voir avec le catalogue Forge réservé à l'admin).
user_assets = auth.list_user_assets(g.current_user["id"])
return render_template(
"scene_edit.html",
@@ -96,6 +101,7 @@ def render_scene_edit(slug, screen_id, game):
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
background_library=background_library,
user_assets=user_assets,
element_animations_map_json=json.dumps(element_animations_map),
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
+14 -2
View File
@@ -1,5 +1,6 @@
from flask import request, redirect, url_for, jsonify
from flask import g, request, redirect, url_for, jsonify, abort
import auth
import screens
from core.flask_app import app
@@ -27,12 +28,23 @@ def scene_object_add(slug, screen_id):
kind = request.form.get("kind", "personnage")
forge_character = request.form.get("forge_character")
background_slug = request.form.get("background_slug")
asset_id = request.form.get("asset_id", type=int)
if forge_character:
forbid_admin_only_character(forge_character)
if background_slug:
forbid_admin_only_background(background_slug)
image_url = None
if asset_id:
# "Mes assets" (voir auth/user_assets_dir.py) : isolation stricte
# par propriétaire, même esprit que les projets (core/auth_guard.py)
# — jamais poser l'asset d'un autre compte, même en devinant son id.
asset = auth.get_user_asset(asset_id)
if not asset or asset["user_id"] != g.current_user["id"]:
abort(403)
image_url = url_for("serve_user_asset", user_id=asset["user_id"], filename=asset["filename"])
new_id = screens.add_scene_object(
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
slug, screen_id, kind=kind, forge_character=forge_character,
background_slug=background_slug, image_url=image_url,
)
if request.headers.get("X-Requested-With") == "fetch":
obj = screens.get_scene_object(slug, new_id)
+4 -6
View File
@@ -96,17 +96,15 @@ def trigger_append_action(slug, object_id):
DERNIÈRE feuille d'une chaîne. 404 si `after_id` introuvable ou si
cette feuille a déjà un "then" (jamais écrasé par cette route — un
nouvel appel doit d'abord recharger la chaîne à jour)."""
obj = screens.get_scene_object(slug, object_id)
if obj is None:
if screens.get_scene_object(slug, object_id) is None:
return jsonify({"error": "objet introuvable"}), 404
body = request.get_json(silent=True) or {}
after_id = body.get("after_id")
if not isinstance(after_id, str) or not after_id.strip():
return jsonify({"error": "after_id invalide"}), 400
rules = screens.resolve_collision_rules(obj)
if not screens.set_then_action_in_rules(rules, after_id, body.get("action")):
# Chemin d'écriture PARTAGÉ avec l'agent IA (voir ai/tools.py) —
# ni cette route ni l'agent n'écrivent _collision_rules directement.
if not screens.append_action_to_trigger(slug, object_id, after_id, body.get("action")):
return jsonify({"error": "action précédente introuvable ou déjà chaînée"}), 404
sanitized = screens.sanitize_collision_rules(rules)
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
return jsonify({"ok": True})
+9
View File
@@ -87,6 +87,7 @@ from .scenes.set_scene_object_name import set_scene_object_name
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
from .scenes.render_scene_object import render_scene_object
from .scenes.build_collision_rule_objects import build_collision_rule_objects
from .scenes.append_action_to_trigger import append_action_to_trigger
from .rendering.trigger_graph import (
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
list_completable_dialogue_ids,
@@ -128,3 +129,11 @@ from .custom_events.list_custom_event_usages import list_custom_event_usages
from .custom_events.delete_custom_event import delete_custom_event
from .payload.full_game_payload import full_game_payload
from .ia.ensure_ia_chat_schema import ensure_ia_chat_schema
from .ia.list_ia_chat_messages import list_ia_chat_messages
from .ia.add_ia_chat_message import add_ia_chat_message
from .ia.create_ia_conversation import create_ia_conversation
from .ia.list_ia_conversations import list_ia_conversations
from .ia.get_ia_conversation import get_ia_conversation
from .ia.delete_ia_conversation import delete_ia_conversation
+33
View File
@@ -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
+16
View File
@@ -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
+13
View File
@@ -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()
+38
View File
@@ -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()
+16
View File
@@ -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
+16
View File
@@ -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]
+16
View File
@@ -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]
+11 -2
View File
@@ -15,7 +15,7 @@ _QUIZ_BOX_DEFAULT_SIZE = (340, 240)
_SCORE_WIDGET_DEFAULT_SIZE = (140, 50)
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None, image_url=None):
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
@@ -28,7 +28,14 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
static/style.css .sceneBackground) et à la taille RÉELLE de l'image
choisie (voir screens/labels/background_library.py) par défaut — une
image plus grande que la scène devient alors le "monde" que la caméra
suit en défilant (voir static/js/play/personnage-controller.js)."""
suit en défilant (voir static/js/play/personnage-controller.js).
image_url (kind "fond"/"decor" uniquement) : une image ARBITRAIRE
(voir "Mes assets", auth/user_assets/) plutôt qu'une clé de
BACKGROUND_LIBRARY — prioritaire sur background_slug si les deux sont
fournis. Pas de largeur/hauteur connues d'avance ici (contrairement à
BACKGROUND_LIBRARY qui les documente) : retombe sur les colonnes par
défaut de _scene_objects, exactement comme un "decor" ordinaire."""
ensure_scene_schema(slug)
kind = kind if kind in _KINDS else "personnage"
attributes = {}
@@ -52,6 +59,8 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
# et disparaîtrait à tort de "🧩 Collision" (voir scene_edit_view.py,
# qui exclut toujours le joueur).
attributes["_personnage_role"] = "pnj"
elif kind in ("fond", "decor") and image_url:
attributes["src"] = image_url
elif kind == "fond" and background_slug:
from ..labels.background_library import BACKGROUND_LIBRARY
background = BACKGROUND_LIBRARY.get(background_slug)
@@ -0,0 +1,25 @@
from .get_scene_object import get_scene_object
from .set_scene_object_collision_rules import set_scene_object_collision_rules
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, set_then_action_in_rules
def append_action_to_trigger(slug, object_id, after_id, raw_action):
""""+ Ajouter une action" en bout de chaîne — extrait de
routes/triggers/triggers.py::trigger_append_action pour être
l'UNIQUE chemin d'écriture, partagé par la route HTTP ET par l'agent
IA (voir ai/tools.py) : ni l'un ni l'autre n'écrit `_collision_rules`
directement, tous deux passent par ici. `after_id` : dialogue.id
(feuille "dialogue") ou id (feuille "variable") de la DERNIÈRE
feuille d'une chaîne. Renvoie False si l'objet, `after_id`, ou
`raw_action` sont invalides, ou si la feuille visée a déjà un "then"
(jamais écrasé par cette fonction — set_then_action_in_rules ne fait
qu'ajouter en bout de chaîne réelle)."""
obj = get_scene_object(slug, object_id)
if obj is None:
return False
rules = resolve_collision_rules(obj)
if not set_then_action_in_rules(rules, after_id, raw_action):
return False
sanitized = sanitize_collision_rules(rules)
set_scene_object_collision_rules(slug, object_id, sanitized)
return True
+14
View File
@@ -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

+334
View File
@@ -0,0 +1,334 @@
// ---------- Onglet "Générer avec l'IA" — chat scopé à L'ÉCRAN OUVERT ----------
// Persiste la conversation (routes/ia/ia_chat.py) et pilote l'agent
// Ruby (Claude Sonnet 5) via des tools qui appellent les mêmes fonctions
// que l'éditeur manuel (voir ai/tools.py) — ce que l'IA pose doit donc
// apparaître ICI sans recharger la page, sinon on croit que ça ne marche
// pas (demande explicite) : voir iaChatRefreshAfterTurn() plus bas.
//
// Plusieurs CONVERSATIONS par écran (demande explicite : gérer/créer/
// supprimer des fils, comme claude.ai) — _iaActiveConversationId retient
// laquelle est affichée dans le fil de droite.
var _iaActiveConversationId = null;
function iaChatEmptyStateHtml() {
return (
'<div class="iaChatEmpty">' +
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar iaChatAvatar-large">' +
'<strong>Décris ce que tu veux à Ruby</strong>' +
'<span>Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.</span>' +
'</div>'
);
}
// #iaChatLog défile lui-même (voir static/style.css — maquette validée :
// header/body/footer par colonne, seul le BODY de chaque colonne défile,
// jamais l'onglet entier).
function iaChatScrollToBottom() {
var log = document.getElementById('iaChatLog');
if (log) log.scrollTop = log.scrollHeight;
}
// "Ruby" — identité visuelle de l'IA (demande explicite) : même mark que
// Forge (static/branding/forge-logo.svg), recoloré rouge/rose pâle (voir
// static/branding/ruby-logo.svg), jamais un logo différent.
function iaChatSenderHtml(isUser) {
if (isUser) return 'Toi';
return '<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar"> Ruby';
}
function iaChatRowHtml(message) {
var isUser = message.role === 'user';
var row = document.createElement('div');
row.className = 'iaChatRow ' + (isUser ? 'iaChatRow-user' : 'iaChatRow-assistant');
var sender = document.createElement('div');
sender.className = 'iaChatSender';
sender.innerHTML = iaChatSenderHtml(isUser);
var bubble = document.createElement('div');
bubble.className = 'iaChatBubble';
bubble.textContent = message.content;
row.appendChild(sender);
row.appendChild(bubble);
return row;
}
// Affichée à la place de la réponse le temps que Ruby/les outils
// travaillent (peut prendre plusieurs secondes, un écran complet enchaîne
// plusieurs appels d'outils) — sans ça, un créateur qui ne voit rien
// bouger clique à nouveau, pensant que ça n'a pas marché.
function iaChatLoaderRowHtml() {
var row = document.createElement('div');
row.className = 'iaChatRow iaChatRow-assistant';
var sender = document.createElement('div');
sender.className = 'iaChatSender';
sender.innerHTML = iaChatSenderHtml(false);
var bubble = document.createElement('div');
bubble.className = 'iaChatBubble iaChatTyping';
bubble.innerHTML = '<span></span><span></span><span></span>';
row.appendChild(sender);
row.appendChild(bubble);
return row;
}
function iaChatAutoGrow(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
// Un tour peut poser des objets/variables/déclencheurs (voir ai/tools.py)
// — jamais su à l'avance lesquels depuis la seule réponse texte, donc on
// rafraîchit systématiquement les 3 zones que l'IA peut modifier :
// Déclencheurs (déjà un composant JS, triggerLoadAll() suffit), Variables
// et la scène (tous deux rendus côté serveur — on re-télécharge CETTE
// MÊME page et on ne récupère que ces deux fragments, jamais un
// rechargement visible/perte de l'onglet IA en cours).
function iaChatRefreshAfterTurn() {
if (typeof triggerLoadAll === 'function') triggerLoadAll();
fetch(window.location.href)
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var newCanvasFrame = doc.querySelector('.canvasFrame');
var oldCanvasFrame = document.querySelector('.canvasFrame');
if (newCanvasFrame && oldCanvasFrame) {
oldCanvasFrame.innerHTML = newCanvasFrame.innerHTML;
// Bug corrigé ("plus moyen de rien poser hors du champ de la
// caméra") : SCENE_WIDTH/SCENE_HEIGHT (posées une seule fois à
// l'ouverture de la page, voir templates/scene_edit.html) ne sont
// jamais rejouées par ce rafraîchissement partiel — un fond
// agrandi par l'IA (le "monde" devient plus grand que la caméra
// nominale) laissait ces globales à leur ANCIENNE valeur, et le
// glisser-déposer (scene-editor.js) continuait de tout borner à
// l'ancien cadre. #canvas vient d'être remplacé avec sa VRAIE
// taille à jour (voir world_width/world_height,
// routes/scenes/scene_edit_view.py) — on la relit directement
// dessus plutôt que de rejouer un script inline.
var canvas = document.getElementById('canvas');
if (canvas) {
var newWidth = parseFloat(canvas.style.width);
var newHeight = parseFloat(canvas.style.height);
if (!isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
if (!isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
}
}
var newVariablesPanel = doc.getElementById('variablesTabPanel');
var oldVariablesPanel = document.getElementById('variablesTabPanel');
if (newVariablesPanel && oldVariablesPanel) oldVariablesPanel.innerHTML = newVariablesPanel.innerHTML;
// Relance les aperçus animés (voir personnage-preview.js) — un
// personnage tout juste posé par l'IA reste sur sa frame statique
// (ELEMENT_ANIMATIONS_MAP n'est mis à jour qu'au vrai rechargement
// de page), léger mais sans conséquence fonctionnelle.
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
});
}
function iaChatSend() {
var input = document.getElementById('iaChatInput');
var sendBtn = document.getElementById('iaChatSendBtn');
if (sendBtn.disabled) return; // déjà une requête en cours — jamais spammer l'IA
var message = (input.value || '').trim();
if (!message || !_iaActiveConversationId) return;
var log = document.getElementById('iaChatLog');
var emptyState = log.querySelector('.iaChatEmpty');
if (emptyState) emptyState.remove();
// Le message du créateur s'affiche TOUT DE SUITE (avant même la
// requête) — sinon, tant que l'IA travaille, l'écran semble ignorer ce
// qu'on vient de taper.
log.appendChild(iaChatRowHtml({ role: 'user', content: message }));
var loaderRow = iaChatLoaderRowHtml();
log.appendChild(loaderRow);
iaChatScrollToBottom();
input.value = '';
iaChatAutoGrow(input);
input.disabled = true;
sendBtn.disabled = true;
var conversationId = _iaActiveConversationId;
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message }),
})
.then(function (r) { return r.json(); })
.then(function (json) {
loaderRow.remove();
if (json.assistant_message) log.appendChild(iaChatRowHtml(json.assistant_message));
iaChatScrollToBottom();
iaChatRefreshAfterTurn();
// Le titre de la conversation vient de se dériver de ce premier
// message (voir screens/ia/add_ia_chat_message.py) — recharge la
// colonne pour l'afficher sans attendre un changement d'onglet.
iaChatLoadConversationsList(conversationId);
})
.catch(function () {
loaderRow.remove();
log.appendChild(iaChatRowHtml({ role: 'assistant', content: 'Erreur réseau — réessaie dans un instant.' }));
iaChatScrollToBottom();
})
.finally(function () {
input.disabled = false;
sendBtn.disabled = false;
input.focus();
});
}
function iaChatInputKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
iaChatSend();
}
}
// ---------- Colonne "Conversations" (CRUD, comme claude.ai) ----------
function iaChatConversationItemHtml(conversation) {
var active = conversation.id === _iaActiveConversationId;
return (
'<button type="button" class="iaConversationItem' + (active ? ' active' : '') + '" ' +
'data-conversation-id="' + conversation.id + '" onclick="iaChatSelectConversation(' + conversation.id + ')">' +
'<span class="iaConversationTitle">' + _iaEscapeHtml(conversation.title) + '</span>' +
'<span class="iaConversationDelete" title="Supprimer" ' +
'onclick="iaChatDeleteConversation(event, ' + conversation.id + ')">🗑️</span>' +
'</button>'
);
}
function _iaEscapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function iaChatRenderConversationsList(conversations) {
var list = document.getElementById('iaConversationsList');
if (!list) return;
list.innerHTML = conversations.map(iaChatConversationItemHtml).join('');
}
function iaChatLoadConversationsList(activeIdOverride) {
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
.then(function (r) { return r.json(); })
.then(function (conversations) {
if (activeIdOverride) _iaActiveConversationId = activeIdOverride;
iaChatRenderConversationsList(conversations);
});
}
function iaChatLoadMessages(conversationId) {
var log = document.getElementById('iaChatLog');
log.innerHTML = '';
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages')
.then(function (r) { return r.json(); })
.then(function (messages) {
if (!messages || !messages.length) {
log.innerHTML = iaChatEmptyStateHtml();
return;
}
messages.forEach(function (message) { log.appendChild(iaChatRowHtml(message)); });
iaChatScrollToBottom();
});
}
function iaChatSelectConversation(conversationId) {
_iaActiveConversationId = conversationId;
document.querySelectorAll('.iaConversationItem').forEach(function (el) { el.classList.remove('active'); });
var item = document.querySelector('.iaConversationItem[data-conversation-id="' + conversationId + '"]');
if (item) item.classList.add('active');
iaChatLoadMessages(conversationId);
}
function iaChatCreateConversation() {
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (conversation) {
_iaActiveConversationId = conversation.id;
iaChatLoadConversationsList(conversation.id);
var log = document.getElementById('iaChatLog');
log.innerHTML = iaChatEmptyStateHtml();
document.getElementById('iaChatInput').focus();
});
}
function iaChatDeleteConversation(event, conversationId) {
event.stopPropagation(); // jamais sélectionner la conversation qu'on supprime
if (!confirm('Supprimer cette conversation ? Cette action est irréversible.')) return;
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId, { method: 'DELETE' })
.then(function () { return fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations'); })
.then(function (r) { return r.json(); })
.then(function (conversations) {
if (conversationId === _iaActiveConversationId) {
if (conversations.length) {
_iaActiveConversationId = conversations[0].id;
iaChatLoadMessages(_iaActiveConversationId);
iaChatRenderConversationsList(conversations);
} else {
// Plus aucune conversation — en recrée une tout de suite (le
// fil de droite doit toujours pouvoir recevoir un message).
iaChatCreateConversation();
}
} else {
iaChatRenderConversationsList(conversations);
}
});
}
// Même patron que initTriggersTab() (trigger-editor.js) : appelé
// directement, pas de DOMContentLoaded — rejoué par pjax.js à chaque
// remplacement de <main>.
function initIaTab() {
var body = document.getElementById('iaTabPanelBody');
if (!body) return;
// Maquette validée (Artifact "Maquette Ruby") : une div pleine
// hauteur/largeur de l'onglet, 2 colonnes (gauche 40%, droite 60%),
// chacune header/body/footer. Les deux zones non précisées (footer
// gauche, header droite) restent vides pour l'instant.
body.innerHTML =
'<div class="iaTabLayout">' +
'<div class="iaCol iaCol-left">' +
'<div class="iaColHeader">' +
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaHeaderLogo">' +
'<span class="iaHeaderName">Ruby</span>' +
'</div>' +
'<div class="iaColBody">' +
'<button type="button" class="button is-small primary iaNewConvBtn" onclick="iaChatCreateConversation()">+ Nouvelle conversation</button>' +
'<div class="iaConversationsList" id="iaConversationsList"></div>' +
'</div>' +
'<div class="iaColFooter"></div>' +
'</div>' +
'<div class="iaCol iaCol-right">' +
'<div class="iaColHeader"></div>' +
'<div class="iaColBody">' +
'<div class="iaChatLog" id="iaChatLog"></div>' +
'</div>' +
'<div class="iaColFooter">' +
'<div class="iaChatInputBar">' +
'<textarea id="iaChatInput" rows="1" placeholder="Décris ce que tu veux à Ruby…" ' +
'oninput="iaChatAutoGrow(this)" onkeydown="iaChatInputKeydown(event)"></textarea>' +
'<button type="button" class="iaChatSendBtn" id="iaChatSendBtn" onclick="iaChatSend()" title="Envoyer" aria-label="Envoyer">➤</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
.then(function (r) { return r.json(); })
.then(function (conversations) {
if (!conversations.length) {
// Premier passage sur cet écran : aucune conversation encore —
// en crée une tout de suite, jamais un onglet vide sans rien à
// cliquer.
iaChatCreateConversation();
return;
}
_iaActiveConversationId = conversations[0].id; // la plus récente
iaChatRenderConversationsList(conversations);
iaChatLoadMessages(_iaActiveConversationId);
});
}
initIaTab();
@@ -41,12 +41,29 @@ test('compareValues — repli sur comparaison de chaînes si non numérique', ()
assert.equal(compareValues('abc', 'different', 'xyz'), true);
});
test('compareValues — "egal"/"different" sur du texte, insensible à la casse (bug corrigé)', () => {
assert.equal(compareValues('Vrai', 'egal', 'vrai'), true);
assert.equal(compareValues('VRAI', 'egal', 'vrai'), true);
assert.equal(compareValues('Vrai', 'different', 'vrai'), false);
assert.equal(compareValues('Vrai', 'egal', 'faux'), false);
});
test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => {
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
assert.equal(compareValues(true, 'different', 'non', 'booleen'), true);
});
test('compareValues — champ booléen, la valeur RÉELLE stockée en texte "faux" est bien fausse (bug corrigé)', () => {
// Une variable booléenne est stockée en texte ("vrai"/"faux") — la
// chaîne "faux" est non vide, donc VRAIE au sens JS naïf : sans
// normalisation des DEUX côtés, "faux" == "Vrai" ressortait vrai à tort.
assert.equal(compareValues('faux', 'egal', 'Vrai', 'booleen'), false);
assert.equal(compareValues('faux', 'different', 'Vrai', 'booleen'), true);
assert.equal(compareValues('vrai', 'egal', 'Vrai', 'booleen'), true);
assert.equal(compareValues('non', 'egal', 'oui', 'booleen'), false);
});
test('resolveVariablePath — chemin vide renvoie la valeur brute', () => {
assert.equal(resolveVariablePath('42', ''), '42');
});
+24 -9
View File
@@ -25,14 +25,22 @@ function readFieldValue(definitionId, rowId, fieldName) {
function compareValues(actual, operator, expected, fieldType) {
if (fieldType === 'booleen') {
const a = actual ? 1 : 0;
// Bug corrigé : une variable booléenne est stockée en TEXTE
// ("vrai"/"faux", voir gameData.variables) — `actual ? 1 : 0` prenait
// n'importe quelle chaîne non vide pour vraie, y compris la chaîne
// "faux" elle-même ! Les deux côtés doivent passer par la MÊME
// normalisation texte->booléen, pas seulement le côté "expected".
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
// partout ailleurs pour un champ booléen — une valeur de comparaison
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
// seulement "1"/"true" (et insensible à la casse, aligné avec
// _compare() côté Python, voir filter_repeater_rows.py).
const expectedStr = String(expected).trim().toLowerCase();
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
// partout ailleurs pour un champ booléen — une valeur tapée "Oui"
// doit donc être reconnue vraie ici aussi, pas seulement "1"/"true"
// (et insensible à la casse, aligné avec _compare() côté Python).
const normalizeBool = function (v) {
if (v === true) return 1;
if (v === false || v === null || v === undefined) return 0;
return ['1', 'true', 'vrai', 'oui'].includes(String(v).trim().toLowerCase()) ? 1 : 0;
};
const a = normalizeBool(actual);
const e = normalizeBool(expected);
return operator === 'different' ? a !== e : a === e;
}
const an = parseFloat(actual), en = parseFloat(expected);
@@ -49,9 +57,16 @@ function compareValues(actual, operator, expected, fieldType) {
}
const as = (actual === undefined || actual === null) ? '' : String(actual);
const es = (expected === undefined || expected === null) ? '' : String(expected);
// "egal"/"different" insensibles à la casse (bug corrigé : une valeur de
// comparaison texte tapée avec une casse différente de la valeur réelle
// — ex. "Vrai" vs "vrai" — était jugée différente à tort, alors que le
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
// superieur/inferieur restent une comparaison lexicographique brute
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
const asLower = as.toLowerCase(), esLower = es.toLowerCase();
switch (operator) {
case 'egal': return as === es;
case 'different': return as !== es;
case 'egal': return asLower === esLower;
case 'different': return asLower !== esLower;
case 'superieur': return as > es;
case 'inferieur': return as < es;
case 'superieur_egal': return as >= es;
+67
View File
@@ -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;
}
+19 -1
View File
@@ -16,11 +16,14 @@
// automatiquement sélectionné/ouvert dans le panneau de propriétés (voir
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
// posé sur le canevas, cliquable ensuite comme n'importe quel autre.
function addSceneObject(kind, forgeCharacter, backgroundSlug) {
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
var body = new FormData();
body.append('kind', kind);
if (forgeCharacter) body.append('forge_character', forgeCharacter);
if (backgroundSlug) body.append('background_slug', backgroundSlug);
// "Mes assets" (voir auth/user_assets_dir.py) : une image importée par
// le créateur ou générée par l'IA, distincte de BACKGROUND_LIBRARY.
if (assetId) body.append('asset_id', assetId);
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add", {
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
})
@@ -62,6 +65,21 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
.catch(function () { window.location.reload(); });
}
// "Mes assets" (voir routes/assets/upload_user_asset.py, panneau "Mes
// assets" de scene_edit.html) : un simple rechargement après l'upload —
// la galerie est rendue côté serveur (user_assets), pas la peine de la
// reconstruire en JS pour un flux qui n'arrive pas souvent.
function uploadUserAsset(inputEl) {
var file = inputEl.files && inputEl.files[0];
if (!file) return;
var body = new FormData();
body.append('file', file);
fetch('/assets/upload', { method: 'POST', body: body })
.then(function (r) { return r.json(); })
.then(function () { window.location.reload(); })
.catch(function () { window.location.reload(); });
}
// AJAX (voir routes/scenes/scene_object_delete.py) : bouton 🗑️ du panneau
// de propriétés (toujours l'objet SÉLECTIONNÉ, seul cas où ce bouton est
// affiché) — supprime l'objet SANS recharger la page ni même redemander
+7
View File
@@ -556,6 +556,13 @@ function refreshBuilderPanel(selectedId) {
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
// document, où cette fonction n'existe simplement pas.
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
// Bug corrigé : #builder3 contient AUSSI les onglets "Déclencheurs"
// et "Générer avec l'IA" (composants JS, pas de simple HTML statique)
// — curBuilder.innerHTML = ... ne réexécute aucun script, et sans cet
// appel explicite leur contenu restait vide (jusqu'à un vrai
// rechargement de page) dès qu'on sélectionnait un objet de la scène.
if (typeof initTriggersTab === "function") initTriggersTab();
if (typeof initIaTab === "function") initIaTab();
})
.catch(function () {
// Échec réseau : on retombe sur une vraie navigation plutôt que de
+15
View File
@@ -31,6 +31,20 @@
}
function runScriptsIn(container) {
// Bug corrigé : un <script src="..."> créé via document.createElement
// est ASYNC PAR DÉFAUT (contrairement à un <script> du HTML source,
// exécuté dans l'ordre du document) — plusieurs scripts de page
// (variables globales GAME_SLUG/SCREEN_ID posées en inline, puis
// scene-editor.js/trigger-editor.js/ia-chat.js qui en dépendent
// immédiatement) pouvaient donc s'exécuter dans le désordre après une
// navigation pjax, selon l'ordre d'arrivée réseau — jamais après un
// vrai rechargement de page (où l'ordre du document est respecté).
// Symptôme observé : l'onglet "Générer avec l'IA" se retrouvait vide
// après le moindre changement ailleurs (variable, déclencheur), un
// rechargement complet le "réparant" — en réalité ia-chat.js avait dû
// s'exécuter avant que GAME_SLUG/SCREEN_ID existent. `async = false`
// restaure l'exécution dans l'ordre du document pour CHAQUE script
// externe, exactement comme à l'analyse initiale du HTML.
var scripts = container.querySelectorAll("script");
scripts.forEach(function (old) {
var fresh = document.createElement("script");
@@ -38,6 +52,7 @@
var attr = old.attributes[i];
fresh.setAttribute(attr.name, attr.value);
}
if (fresh.src) fresh.async = false;
fresh.textContent = old.textContent;
old.replaceWith(fresh);
});
+105
View File
@@ -1110,6 +1110,111 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
}
.questTriggerAddChainedActionBtn:hover{ border-color:var(--accent); color:var(--accent); }
/* Onglet "Générer avec l'IA" (voir static/js/ia/ia-chat.js) — maquette
validée (Artifact "Maquette Ruby") : une div occupe TOUTE la hauteur/
largeur de l'onglet, coupée en 2 colonnes (gauche 40%, droite 60%),
chacune header/body/footer. #iaTabPanelBody remplit exactement
#iaTabPanel (déjà display:flex/min-height:0 quand actif, voir
.builderTabPanel plus haut) pour que CET onglet précis n'ait jamais
rien à faire défiler lui-même — seuls les .iaColBody de chaque colonne
défilent, indépendamment l'un de l'autre. */
#iaTabPanelBody{ display:flex; flex-direction:column; flex:1 1 auto; min-height:0; }
.iaTabLayout{
display:flex; width:100%; flex:1 1 auto; min-height:0;
border:1px solid var(--border); border-radius:10px; overflow:hidden;
}
.iaCol{ display:flex; flex-direction:column; min-width:0; min-height:0; }
.iaCol-left{ flex:0 0 40%; border-right:1px solid var(--border); }
.iaCol-right{ flex:1 1 60%; }
.iaColHeader{
flex:0 0 auto; min-height:52px; display:flex; align-items:center; gap:10px;
padding:0 16px; border-bottom:1px solid var(--border);
}
.iaColBody{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; }
.iaColFooter{ flex:0 0 auto; border-top:1px solid var(--border); }
/* Header gauche : logo + nom (identité "Ruby") */
.iaHeaderLogo{ width:26px; height:26px; border-radius:6px; }
.iaHeaderName{ font-size:15px; font-weight:700; }
/* Body gauche : bouton "+ Nouvelle conversation" (fixe) puis la liste,
seule à défiler (CRUD des conversations, comme claude.ai). */
.iaCol-left .iaColBody{ padding:14px; gap:10px; }
.iaNewConvBtn{ flex:0 0 auto; }
.iaConversationsList{ flex:1 1 auto; min-height:0; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
.iaConversationItem{
display:flex; align-items:center; justify-content:space-between; gap:6px; width:100%;
padding:8px 10px; border-radius:8px; border:1px solid transparent; background:transparent;
color:var(--text-dim); text-align:left; cursor:pointer; font-size:13px;
}
.iaConversationItem:hover{ background:var(--panel2); }
.iaConversationItem.active{ background:var(--panel2); border-color:var(--accent); color:var(--text); }
.iaConversationTitle{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
.iaConversationDelete{ opacity:0; flex-shrink:0; }
.iaConversationItem:hover .iaConversationDelete, .iaConversationItem.active .iaConversationDelete{ opacity:1; }
/* Body droite : le fil de dialogue, seul à défiler ici. */
.iaChatLog{
flex:1 1 auto; min-height:0; overflow-y:auto;
display:flex; flex-direction:column; gap:16px; padding:18px;
}
.iaChatEmpty{
margin:auto; text-align:center; color:var(--text-dim); max-width:360px;
display:flex; flex-direction:column; align-items:center; gap:8px;
}
.iaChatEmpty strong{ color:var(--text); font-size:15px; }
.iaChatAvatar{ width:16px; height:16px; border-radius:4px; vertical-align:-3px; margin-right:4px; }
.iaChatAvatar-large{ width:40px; height:40px; border-radius:10px; margin:0; }
.iaChatRow{ display:flex; flex-direction:column; max-width:78%; }
.iaChatRow-user{ align-self:flex-end; align-items:flex-end; }
.iaChatRow-assistant{ align-self:flex-start; align-items:flex-start; }
.iaChatSender{
font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em;
color:var(--text-dim); margin:0 4px 4px; display:flex; align-items:center;
}
.iaChatBubble{
padding:10px 14px; border-radius:14px; white-space:pre-wrap; line-height:1.5; font-size:14px;
}
.iaChatRow-user .iaChatBubble{ background:var(--accent); color:#fff; border-bottom-right-radius:4px; }
.iaChatRow-assistant .iaChatBubble{ background:var(--panel2); color:var(--text); border:1px solid var(--border); border-bottom-left-radius:4px; }
/* Footer droite : le champ de saisie prend tout le footer. */
.iaCol-right .iaColFooter{ padding:12px 16px; }
.iaChatInputBar{
display:flex; align-items:flex-end; gap:8px; background:var(--panel);
border:1px solid var(--border); border-radius:16px; padding:8px 8px 8px 16px;
width:100%;
}
.iaChatInputBar textarea{
flex:1; background:transparent; border:none; color:var(--text); resize:none;
min-height:24px; max-height:200px; padding:8px 0; font:inherit; line-height:1.5;
}
.iaChatInputBar textarea:focus{ outline:none; }
.iaChatSendBtn{
flex-shrink:0; width:36px; height:36px; border-radius:50%; border:none; cursor:pointer;
background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center;
font-size:16px; line-height:1;
}
.iaChatSendBtn:disabled{ opacity:.5; cursor:default; }
.iaChatSendBtn:hover:not(:disabled){ background:var(--accent-2); }
/* Loader "IA en train d'écrire" (demande explicite : sans ça, un
créateur qui ne voit rien bouger pendant qu'un écran complet s'enchaîne
sur plusieurs appels d'outils reclique, pensant que ça n'a pas marché). */
.iaChatTyping{ display:flex; gap:4px; padding:14px; align-items:center; }
.iaChatTyping span{
width:6px; height:6px; border-radius:50%; background:var(--text-dim);
animation:iaChatTypingBounce 1.2s infinite ease-in-out;
}
.iaChatTyping span:nth-child(2){ animation-delay:.15s; }
.iaChatTyping span:nth-child(3){ animation-delay:.3s; }
@keyframes iaChatTypingBounce{
0%, 60%, 100%{ opacity:.3; transform:translateY(0); }
30%{ opacity:1; transform:translateY(-3px); }
}
@media (prefers-reduced-motion: reduce){
.iaChatTyping span{ animation:none; opacity:.6; }
}
/* Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
"il faut tout mettre verticalement", le losange lui-même ne change
pas) : le losange reste petit et centré, la branche qui MÈNE à cette
+22
View File
@@ -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
View File
@@ -65,11 +65,37 @@
sélectionnable en cliquant directement dessus sur le canevas
(voir onSceneObjectMouseDown, static/js/scenes/scene-editor.js). -->
<!-- "Mes assets" (voir auth/user_assets_dir.py) : bibliothèque PAR
COMPTE (pas par jeu) — upload manuel ou généré par l'IA (onglet
"IA", Phase 2), réutilisable d'un projet à l'autre du même
compte. Posée comme un "decor"/"fond" ordinaire (voir
screens/scenes/add_scene_object.py::image_url). PREMIER menu du
panneau (demande explicite) : le point d'entrée le plus direct
pour un créateur qui vient d'importer/générer une image. -->
<details class="propGroup" open>
<summary>Mes assets</summary>
<div class="propGroupBody">
<div class="iconGallery personnageGallery">
{% for asset in user_assets %}
<button type="button" class="iconGalleryTile" title="{{ asset.original_name or 'Asset' }}"
onclick="addSceneObject('decor', null, null, {{ asset.id }})">
<img src="{{ url_for('serve_user_asset', user_id=asset.user_id, filename=asset.filename) }}"
alt="{{ asset.original_name or 'Asset' }}" class="personnageThumb">
</button>
{% endfor %}
</div>
<label class="button is-small is-fullwidth mt-2">
+ Importer une image
<input type="file" accept="image/*" style="display:none" onchange="uploadUserAsset(this)">
</label>
<p class="help">Réutilisable dans tous tes projets — jamais visible par les autres comptes.</p>
</div>
</details>
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
tuile pose un personnage déjà configuré, animé immédiatement (voir
personnage-preview.js, réutilisé tel quel : ne dépend que de
data-personnage, pas de la table d'origine). -->
<details class="propGroup" open>
<details class="propGroup">
<summary>Personnages</summary>
<div class="propGroupBody">
<div class="iconGallery personnageGallery">
@@ -126,6 +152,7 @@
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">Scène</button>
<button type="button" class="builderTabBtn" data-tab="triggers" onclick="switchBuilderTab('triggers')">Déclencheurs</button>
<button type="button" class="builderTabBtn" data-tab="variables" onclick="switchBuilderTab('variables')">Variables</button>
<button type="button" class="builderTabBtn" data-tab="ia" onclick="switchBuilderTab('ia')">Générer avec l'IA</button>
{% if game.onboarding_type != 'rpg' %}
<!-- Événements personnalisés (voir db.list_custom_events) : aucun
moyen de les DÉCLENCHER depuis un jeu RPG pour l'instant — le
@@ -203,8 +230,7 @@
<button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</button>
</div>
<p class="hint">Accessibles depuis n'importe quelle scène (conditions, actions "Modifier une variable"). Le nom ne peut plus changer une fois créé.</p>
<form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-variables" style="display:none;">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<form class="dashCreateBar" id="dashCreate-variables" style="display:none;" onsubmit="return createGlobalVariable(event);">
<input type="text" name="name" placeholder="NOM (ex. score_total)" required autocomplete="off">
<select name="type">
{% for key, t in global_variable_types.items() %}
@@ -218,44 +244,26 @@
<button type="submit" class="primary">Créer</button>
</form>
{% if global_variables %}
{% for v in global_variables %}
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.id }}">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
</form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th>Par joueur</th><th></th></tr></thead>
<tbody>
<tbody id="globalVariablesBody">
{% for v in global_variables %}
<tr>
<td><code>{{ v.name }}</code></td>
<td>
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
{% for key, t in global_variable_types.items() %}
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ t.label }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" placeholder="Valeur" aria-label="Valeur de « {{ v.name }} »"></td>
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non (partagée)' }}</span></td>
<td class="fieldsTableActions">
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small" title="Enregistrer">💾</button>
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
onsubmit="return confirm('Supprimer la variable « {{ v.name }} » ? Toute condition/action qui la référence encore cessera de fonctionner.');">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</td>
</tr>
{% include "_global_variable_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucune variable pour l'instant — crée la première ci-dessus.</p>
{% endif %}
<p class="hint" id="globalVariablesEmptyHint" {{ 'style=display:none;' if global_variables else '' }}>Aucune variable pour l'instant — crée la première ci-dessus.</p>
</div>
<!-- Onglet "🤖 IA" (voir plan Phase 1) : scopé à CET écran uniquement
(pas tout le jeu) — le chat pilote un agent qui n'utilise que les
mêmes fonctions que l'éditeur manuel (voir ai/tools.py, Phase 2),
pour que tout ce qu'il pose reste relisable/modifiable ici. Rendu
par static/js/ia/ia-chat.js dans #iaTabPanelBody. -->
<div class="builderTabPanel" id="iaTabPanel" data-tab-panel="ia">
<div id="iaTabPanelBody" data-screen-id="{{ screen.id }}"></div>
</div>
{% if game.onboarding_type != 'rpg' %}
@@ -646,6 +654,8 @@
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/scenes/global-variables.js') }}"></script>
<script src="{{ url_for('static', filename='js/triggers/trigger-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/ia/ia-chat.js') }}"></script>
{% endblock %}
+15
View File
@@ -24,6 +24,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_")
os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db")
os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
# "Mes assets" (voir auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR) :
# même raison que FORGE_USERS_DB_PATH ci-dessus — jamais écrire dans le
# vrai user_assets/ du poste pendant les tests.
os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets")
import pytest
@@ -37,6 +41,17 @@ from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_co
import auth
import db
# Onglet "IA" (voir ai/) : jamais un vrai appel réseau depuis les tests,
# même si un .env réel existe sur ce poste avec de vraies clés —
# load_dotenv() (core.flask_app, importé ci-dessus) les aurait déjà
# chargées dans os.environ à ce stade ; on les retire explicitement pour
# que CHAQUE test parte de "IA non configurée" par défaut. Un test qui
# veut simuler une IA configurée monkeypatche directement
# ai.client.get_client/ai.scenario_client.generate_image_url — jamais via
# une vraie clé, quel que soit le contenu du .env local.
for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRET", "SCENARIO_MODEL_ID"):
os.environ.pop(_ai_env_key, None)
# Un seul compte admin pour toute la session de tests : les ~80 routes
# existantes s'attendent (comportement d'avant l'authentification) à un
# accès sans restriction — "admin" est le seul rôle illimité (voir
+160
View File
@@ -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
+19
View File
@@ -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
+74
View File
@@ -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
+315
View File
@@ -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)
+18
View File
@@ -372,3 +372,21 @@ def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(c
html = client.get(f"/game/{slug}/play").data.decode()
assert 'src="/static/js/play/offline/compute-operation.js"' in html
assert 'src="/static/js/play/offline/apply-actions.js"' in html
def test_add_scene_object_with_image_url_sets_src_without_background_library(client, tmp_game_slug_cleanup):
""""Mes assets"/IA (voir plan Phase 1, §1) : une image ARBITRAIRE,
jamais une clé de BACKGROUND_LIBRARY."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png")
obj = screens.get_scene_object(slug, object_id)
assert obj["attributes"]["src"] == "/assets/1/abc.png"
def test_add_scene_object_image_url_takes_priority_over_background_slug(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = screens.add_scene_object(
slug, screen_id, kind="fond", background_slug="ne-existe-pas", image_url="/assets/1/abc.png",
)
obj = screens.get_scene_object(slug, object_id)
assert obj["attributes"]["src"] == "/assets/1/abc.png"
+34
View File
@@ -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
+47
View File
@@ -72,3 +72,50 @@ def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, g
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "score_total" in html
assert 'data-tab="variables"' in html
# ---------- Réponse AJAX (voir static/js/scenes/global-variables.js) —
# demande explicite : plus de rechargement de page pour créer/modifier/
# supprimer une variable. ----------
def test_create_global_var_ajax_returns_the_row_html_without_redirecting(client, game):
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/variables/new",
data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id},
headers={"X-Requested-With": "fetch"},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["ok"] is True
assert "score_total" in body["row_html"]
assert 'data-var-row="' in body["row_html"]
def test_global_var_edit_ajax_returns_ok_without_redirecting(client, game):
screen_id = _create_screen(client, game)
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id})
var_id = db.list_global_variables(game)[0]["id"]
resp = client.post(
f"/game/{game}/variables/{var_id}/edit",
data={"type": "texte", "value": "beaucoup"},
headers={"X-Requested-With": "fetch"},
)
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}
assert db.list_global_variables(game)[0]["value"] == "beaucoup"
def test_global_var_delete_ajax_returns_ok_without_redirecting(client, game):
screen_id = _create_screen(client, game)
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
var_id = db.list_global_variables(game)[0]["id"]
resp = client.post(
f"/game/{game}/variables/{var_id}/delete",
headers={"X-Requested-With": "fetch"},
)
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}
assert db.list_global_variables(game) == []
+121
View File
@@ -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"
+45
View File
@@ -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
+80
View File
@@ -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