diff --git a/.env.example b/.env.example index 398b04c1..6579f415 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 9d4d5502..b8d44de5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 00000000..d6541c65 --- /dev/null +++ b/ai/__init__.py @@ -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", +] diff --git a/ai/chat.py b/ai/chat.py new file mode 100644 index 00000000..2879d1fc --- /dev/null +++ b/ai/chat.py @@ -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)" diff --git a/ai/client.py b/ai/client.py new file mode 100644 index 00000000..16df7252 --- /dev/null +++ b/ai/client.py @@ -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) diff --git a/ai/scenario_client.py b/ai/scenario_client.py new file mode 100644 index 00000000..17d905ac --- /dev/null +++ b/ai/scenario_client.py @@ -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.") diff --git a/ai/tools.py b/ai/tools.py new file mode 100644 index 00000000..407ffaa3 --- /dev/null +++ b/ai/tools.py @@ -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\": , \"text\": }, " + "SOIT une QUESTION DE QUIZ {\"type\":\"question\", \"text\", \"choices\": " + "[2 à 4 réponses], \"correct_index\": , " + f"\"reward_type\": {list(QUESTION_REWARD_TYPES)!r}, \"reward_amount\": }}. " + "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) diff --git a/auth/__init__.py b/auth/__init__.py index c869ba36..5998cd40 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -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", ] diff --git a/auth/create_user_asset.py b/auth/create_user_asset.py new file mode 100644 index 00000000..98cfb208 --- /dev/null +++ b/auth/create_user_asset.py @@ -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 diff --git a/auth/delete_user_asset.py b/auth/delete_user_asset.py new file mode 100644 index 00000000..22fc8bb4 --- /dev/null +++ b/auth/delete_user_asset.py @@ -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 diff --git a/auth/ensure_user_assets_schema.py b/auth/ensure_user_assets_schema.py new file mode 100644 index 00000000..b3bf759d --- /dev/null +++ b/auth/ensure_user_assets_schema.py @@ -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() diff --git a/auth/get_user_asset.py b/auth/get_user_asset.py new file mode 100644 index 00000000..cffad34e --- /dev/null +++ b/auth/get_user_asset.py @@ -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 diff --git a/auth/list_user_assets.py b/auth/list_user_assets.py new file mode 100644 index 00000000..af243d6e --- /dev/null +++ b/auth/list_user_assets.py @@ -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] diff --git a/auth/save_user_asset.py b/auth/save_user_asset.py new file mode 100644 index 00000000..9157687c --- /dev/null +++ b/auth/save_user_asset.py @@ -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 diff --git a/auth/user_assets_dir.py b/auth/user_assets_dir.py new file mode 100644 index 00000000..2d907d63 --- /dev/null +++ b/auth/user_assets_dir.py @@ -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)) diff --git a/core/flask_app.py b/core/flask_app.py index effee6e5..72285248 100644 --- a/core/flask_app.py +++ b/core/flask_app.py @@ -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") diff --git a/db/constants.py b/db/constants.py index 5eead8a7..405d6e9e 100644 --- a/db/constants.py +++ b/db/constants.py @@ -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///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"}, diff --git a/requirements.txt b/requirements.txt index 66d01c43..40a623ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/routes/__init__.py b/routes/__init__.py index f71ff2b9..7e986f29 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/assets/__init__.py b/routes/assets/__init__.py new file mode 100644 index 00000000..60745d2e --- /dev/null +++ b/routes/assets/__init__.py @@ -0,0 +1 @@ +from . import upload_user_asset, serve_user_asset diff --git a/routes/assets/serve_user_asset.py b/routes/assets/serve_user_asset.py new file mode 100644 index 00000000..159dba00 --- /dev/null +++ b/routes/assets/serve_user_asset.py @@ -0,0 +1,15 @@ +from flask import g, send_from_directory, abort + +import auth + +from core.flask_app import app + + +@app.route("/assets//") +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) diff --git a/routes/assets/upload_user_asset.py b/routes/assets/upload_user_asset.py new file mode 100644 index 00000000..28b86d7f --- /dev/null +++ b/routes/assets/upload_user_asset.py @@ -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), + }) diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py index 146d8b10..32ab1608 100644 --- a/routes/global_vars/create_global_var.py +++ b/routes/global_vars/create_global_var.py @@ -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)) diff --git a/routes/global_vars/global_var_delete.py b/routes/global_vars/global_var_delete.py index 46e4a056..944bfab7 100644 --- a/routes/global_vars/global_var_delete.py +++ b/routes/global_vars/global_var_delete.py @@ -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//variables//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)) diff --git a/routes/global_vars/global_var_edit.py b/routes/global_vars/global_var_edit.py index 98733b6f..db718017 100644 --- a/routes/global_vars/global_var_edit.py +++ b/routes/global_vars/global_var_edit.py @@ -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//variables//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)) diff --git a/routes/ia/__init__.py b/routes/ia/__init__.py new file mode 100644 index 00000000..9e842a9a --- /dev/null +++ b/routes/ia/__init__.py @@ -0,0 +1 @@ +from . import ia_chat diff --git a/routes/ia/ia_chat.py b/routes/ia/ia_chat.py new file mode 100644 index 00000000..068d55a7 --- /dev/null +++ b/routes/ia/ia_chat.py @@ -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//screens//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//screens//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//ia-conversations/", 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//ia-conversations//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//ia-conversations//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}, + }) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 2ce0bdea..a54433e1 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -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), diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py index 86ec1a48..e00a1de2 100644 --- a/routes/scenes/scene_object_add.py +++ b/routes/scenes/scene_object_add.py @@ -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) diff --git a/routes/triggers/triggers.py b/routes/triggers/triggers.py index d6f89647..3a751486 100644 --- a/routes/triggers/triggers.py +++ b/routes/triggers/triggers.py @@ -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}) diff --git a/screens/__init__.py b/screens/__init__.py index 6c283f73..2505cc9c 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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 diff --git a/screens/ia/add_ia_chat_message.py b/screens/ia/add_ia_chat_message.py new file mode 100644 index 00000000..882b96c8 --- /dev/null +++ b/screens/ia/add_ia_chat_message.py @@ -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 diff --git a/screens/ia/create_ia_conversation.py b/screens/ia/create_ia_conversation.py new file mode 100644 index 00000000..6370a7ef --- /dev/null +++ b/screens/ia/create_ia_conversation.py @@ -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 diff --git a/screens/ia/delete_ia_conversation.py b/screens/ia/delete_ia_conversation.py new file mode 100644 index 00000000..5ba538f5 --- /dev/null +++ b/screens/ia/delete_ia_conversation.py @@ -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() diff --git a/screens/ia/ensure_ia_chat_schema.py b/screens/ia/ensure_ia_chat_schema.py new file mode 100644 index 00000000..b871a0ca --- /dev/null +++ b/screens/ia/ensure_ia_chat_schema.py @@ -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() diff --git a/screens/ia/get_ia_conversation.py b/screens/ia/get_ia_conversation.py new file mode 100644 index 00000000..144690e0 --- /dev/null +++ b/screens/ia/get_ia_conversation.py @@ -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 diff --git a/screens/ia/list_ia_chat_messages.py b/screens/ia/list_ia_chat_messages.py new file mode 100644 index 00000000..73f15e1e --- /dev/null +++ b/screens/ia/list_ia_chat_messages.py @@ -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] diff --git a/screens/ia/list_ia_conversations.py b/screens/ia/list_ia_conversations.py new file mode 100644 index 00000000..f8d94be9 --- /dev/null +++ b/screens/ia/list_ia_conversations.py @@ -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] diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index 03555bf9..873f42ac 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -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) diff --git a/screens/scenes/append_action_to_trigger.py b/screens/scenes/append_action_to_trigger.py new file mode 100644 index 00000000..fe5cc66a --- /dev/null +++ b/screens/scenes/append_action_to_trigger.py @@ -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 diff --git a/static/branding/ruby-logo.svg b/static/branding/ruby-logo.svg new file mode 100644 index 00000000..ae4c13a2 --- /dev/null +++ b/static/branding/ruby-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/static/js/ia/ia-chat.js b/static/js/ia/ia-chat.js new file mode 100644 index 00000000..cf404641 --- /dev/null +++ b/static/js/ia/ia-chat.js @@ -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 ( + '
' + + '' + + 'Décris ce que tu veux à Ruby' + + 'Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.' + + '
' + ); +} + +// #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 ' 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 = ''; + 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 ( + '' + ); +} + +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
. +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 = + '
' + + '
' + + '
' + + '' + + 'Ruby' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
'; + + 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(); diff --git a/static/js/play/__tests__/conditions.test.js b/static/js/play/__tests__/conditions.test.js index aa4be5fc..69716a38 100644 --- a/static/js/play/__tests__/conditions.test.js +++ b/static/js/play/__tests__/conditions.test.js @@ -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'); }); diff --git a/static/js/play/conditions.js b/static/js/play/conditions.js index c33ed97c..790cac41 100644 --- a/static/js/play/conditions.js +++ b/static/js/play/conditions.js @@ -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; diff --git a/static/js/scenes/global-variables.js b/static/js/scenes/global-variables.js new file mode 100644 index 00000000..2d5db2f3 --- /dev/null +++ b/static/js/scenes/global-variables.js @@ -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; +} diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index 96fa1f72..d38a2584 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -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 diff --git a/static/js/screen_edit/tree-panels.js b/static/js/screen_edit/tree-panels.js index b0df3276..a77f1ab6 100644 --- a/static/js/screen_edit/tree-panels.js +++ b/static/js/screen_edit/tree-panels.js @@ -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 diff --git a/static/pjax.js b/static/pjax.js index 7ed7377b..3744c6ca 100644 --- a/static/pjax.js +++ b/static/pjax.js @@ -31,6 +31,20 @@ } function runScriptsIn(container) { + // Bug corrigé : un + + {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 534d34f3..1b991959 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_ai_chat.py b/tests/test_ai_chat.py new file mode 100644 index 00000000..3dd807cb --- /dev/null +++ b/tests/test_ai_chat.py @@ -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 diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py new file mode 100644 index 00000000..dac844cd --- /dev/null +++ b/tests/test_ai_client.py @@ -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 diff --git a/tests/test_ai_scenario_client.py b/tests/test_ai_scenario_client.py new file mode 100644 index 00000000..158e4ec1 --- /dev/null +++ b/tests/test_ai_scenario_client.py @@ -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 diff --git a/tests/test_ai_tools.py b/tests/test_ai_tools.py new file mode 100644 index 00000000..6d469b21 --- /dev/null +++ b/tests/test_ai_tools.py @@ -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