diff --git a/.gitignore b/.gitignore index 2f5abfd0e..e43b54478 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ src/commit.h +mcp/ Game.exe Editor.exe lib/* diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md new file mode 100644 index 000000000..6dfb0ce8a --- /dev/null +++ b/docs/OPEN_QUESTIONS.md @@ -0,0 +1,100 @@ +# Questions ouvertes — à valider avant d'écrire du code moteur (Phase 3) + +Statut : **Phases 0 à 4 terminées et compilées (Debug + Release, 0/0).** Cf. `docs/PHASE3-4-IMPLEMENTATION.md`. +Questions #1–#3 tranchées par l'utilisateur le 2026-09-06, #1 implémentée : +- **#1 → Option 1**, implémentée. Câblage réel = **3 fichiers de build existants** (un de plus que prévu) + `Engine.cpp`, tout sous `#if VG_ENABLE_MCPBRIDGE`, gate runtime par variable d'env `VG_MCP_BRIDGE` : + - `sharpmake/vg.solution.sharpmake.cs` : `conf.AddProject(target);` (1 ligne) + - `sharpmake/vg.engine.sharpmake.cs` : `conf.Defines.Add("VG_ENABLE_MCPBRIDGE");` (1 ligne) + - `sharpmake/vg.data.sharpmake.cs` : `SourceFilesExcludeRegex.Add(@".*\\mcpbridge(\.*)?");` dans le projet `Version` (1 ligne — sinon le projet utilitaire `version` ramasse les `.cpp` du nouveau module, comme pour tous les autres modules déjà listés là) + - `src/engine/Engine.cpp` : include gardé + pointeur statique + create/Init + Tick + Deinit (~25 lignes, 5 emplacements, tous `#if VG_ENABLE_MCPBRIDGE`, aucune ligne existante modifiée) +- **#2 → contrat quaternion** confirmé. +- **#3 → `spawn_object` inclus en V1** — fait. + +Historique des questions ci-dessous. + +--- + +## #1 — Point d'insertion du bridge dans la boucle moteur (BLOQUANT) + +### Constat (cf. `engine-analysis.md` §4, §5, §7, §9) + +Il n'existe **aucun point d'extension propre** pour brancher du code externe sans toucher au cœur : +- pas de scripting runtime ; +- pas de système de commandes console enregistrables ; +- pas de scan d'un dossier de plugins — chaque DLL est chargée par un appel explicite `Plugin::create("nom")` codé en dur (`src/engine/Engine.cpp`) ; +- l'auto-registration de classes (`AutoRegisterClassInfo`) ne se déclenche que si la DLL qui contient ces classes est chargée. + +Un module bridge additif (`src/mcpbridge/…` + `sharpmake/vg.mcpbridge.sharpmake.cs`) est **entièrement du code neuf**, mais pour qu'il tourne il faut au minimum : + +| # | Modif | Fichier | Ampleur | Statut règle d'or | +|---|---|---|---|---| +| a | `conf.AddProject(target);` | `sharpmake/vg.solution.sharpmake.cs` (existant) | 1 ligne | ⚠️ ajout dans un fichier existant | +| b | Chargement de la DLL + tick chaque frame | 1 point dans `src/engine/Engine.cpp` **ou** `src/application/…` (existant) | ~3–5 lignes | ⚠️ ajout dans un fichier existant | + +> Le fichier `vg.mcpbridge.sharpmake.cs` lui-même est auto-inclus (`sharpmake/main.sharpmake.cs:5`) → aucune modif de build pour être *compilé*, seulement pour être *lié à la solution et chargé*. + +### Options proposées (à trancher par l'utilisateur) + +- **Option 1 — Assumer les 2 ajouts (a) + (b).** + Diff minimal, purement additif (aucune ligne existante supprimée/modifiée). C'est la voie recommandée. + Diff exact proposé pour (b), à valider : + ```cpp + // src/engine/Engine.cpp, dans Engine::init() après la création des autres plugins + #if VG_ENABLE_MCPBRIDGE + m_mcpBridge = Plugin::create("mcpbridge"); // no-op si DLL absente + #endif + // ... dans RunOneFrame(), dans le bloc ToolUpdate déjà existant (Engine.cpp:979-993) : + #if VG_ENABLE_MCPBRIDGE + if (m_mcpBridge) m_mcpBridge->Tick(); + #endif + ``` + Gardé derrière `#if VG_ENABLE_MCPBRIDGE` (défini seulement par le projet bridge) → **zéro impact** sur le build normal quand le bridge est désactivé (checklist Phase 6). + +- **Option 2 — Héberger le bridge dans le projet `game` existant.** + `projects/game/` est déjà chargé par l'éditeur (`Engine.cpp:424`) et a un `ToolUpdate`. Mais cela modifie `projects/game/src/Game.cpp` (fichier existant) → **plus** invasif que l'option 1, et couple le bridge au jeu. Non recommandé. + +- **Option 3 — Process externe uniquement, pas de code moteur.** + Le serveur MCP lit/écrit directement les fichiers `.scene` XML sur disque. **Rejeté** : ne fonctionne pas sur une scène chargée en mémoire (pas de rechargement à chaud), donc pas de feedback visuel live — contredit le cas d'usage. + +### Décision attendue + +➡️ **Quelle option ?** (défaut recommandé : Option 1) +➡️ Si Option 1 : le point de chargement va dans `engine` ou dans `application` ? + +--- + +## #2 — Contrat de rotation (résolu, à confirmer) + +Le moteur stocke le transform en `float4x4` (pas de quaternion). Le plan (Phase 2) recommande de garder le **quaternion** dans le contrat MCP et de convertir côté bridge. + +➡️ **Confirmation** : contrat MCP en quaternion `{x,y,z,w}`, conversion dans le module C++ via hlslpp + `TRSToFloat4x4` (`src/core/Math/Math.h:129`). OK ? + +*(Aucune action requise si d'accord — c'est l'hypothèse retenue dans `docs/data-contract.md`.)* + +--- + +## #3 — Point bloquant §2 du plan : pool vs création dynamique (résolu) + +`engine-analysis.md` §8 : `IObject::Instanciate()` existe et est déjà utilisé par le copier-coller éditeur. +→ La **création dynamique (a)** est possible. V1 peut inclure `spawn_object`. + +➡️ **Confirmation de périmètre V1** : inclut-on `spawn_object` (clone du prefab `Box1X1M`) dès V1, ou on s'en tient strictement à `list/get/set_transform` sur objets existants pour la première itération ? + +*(Le plan Phase 5 penche pour inclure le spawn si (a) est vrai. Hypothèse retenue : `spawn_object` inclus en V1, mais implémenté après validation des 3 fonctions de base.)* + +--- + +## #4 — Cube de référence + convention de rotation — **RÉSOLU (2026-09-06)** + +✅ **Primitive de référence = `data/Prefabs/Box_Base.prefab`** (et non `Box1X1M`), tranché par l'utilisateur. +- **Pivot au centre de la base** du mesh → `position.z` = niveau du sol, `scale.z` = hauteur totale, aucun offset de demi-hauteur. +- **Monde Z-up** : X/Y = plan du sol, Z = hauteur (confirmé par la gravité `(0,0,-9.81)` et l'étendue des 1435 transforms d'`Aiguelongue.scene` : X 170 / Y 237 / **Z 38**). Le `target_axes.up = POSITIVE_Y` de l'importeur FBX ne concerne que la conversion à l'import — ne pas s'y fier. +- Dimensions : **mesurées à l'exécution** plutôt que devinées — le bridge renvoie `bounds_local` et `size_world` via `IGameObject::TryGetAABB()`. + +✅ **Convention de rotation validée.** Le module décompose/recompose le transform lui-même (`SceneBridge.cpp`, base I/J/K **en lignes** = axes locaux ; quaternion via Shepperd) sans passer par `TRSToFloat4x4(T, quaternion, S)` (`Math.h:129`), dont la surcharge quaternion s'est révélée incohérente avec le reste du moteur (translation placée en 4ᵉ **colonne** au lieu de 4ᵉ ligne). + +Deux vérifications faites en Phase 5, dans cet ordre — la première ne suffisait pas : + +1. **Round-trip** (set puis get). A d'abord échoué : envoyé `z=+0.7071`, relu `z=−0.7071`. Cause : `decompose` lisait la matrice **transposée** de ce que `recompose` écrivait, ce qui négate l'axe. Corrigé en prenant les termes antisymétriques comme `(r_ij − r_ji)` au lieu de `(r_ji − r_ij)` ; les termes symétriques sont inchangés. + ⚠️ Un test à **90° ne peut pas révéler une erreur de signe** (une boîte est symétrique) — le test doit se faire à 45°. +2. **Signe vs le moteur.** Le round-trip seul ne prouve rien : il est cohérent avec lui-même. Source de vérité indépendante utilisée : l'Inspector de l'éditeur, qui affiche les angles d'Euler via `Float4x4ToTRS` — **du code moteur, pas celui du bridge**. Yaw envoyé `+45°` → Inspector affiche **`+45`**. Conventions alignées. diff --git a/docs/PHASE3-4-IMPLEMENTATION.md b/docs/PHASE3-4-IMPLEMENTATION.md new file mode 100644 index 000000000..96c3e528c --- /dev/null +++ b/docs/PHASE3-4-IMPLEMENTATION.md @@ -0,0 +1,129 @@ +# Phases 3-4 — Implémentation (module moteur + serveur MCP) + +État : **compilé Debug + Release, 0 erreur / 0 warning** (solution complète) et **validé contre le moteur réel** +via le protocole MCP complet sur la scène `Aiguelongue` (7832 objets) — 2026-09-06. +Reste à jouer : le scénario « plan de ville » depuis Claude Code (§ Phase 5). + +--- + +## Fichiers ajoutés (100 % additifs) + +### Module moteur — `src/mcpbridge/` +| Fichier | Rôle | +|---|---| +| `IMCPBridge.h` | Interface plugin (`core::IPlugin`) : `Init(IEngine*, Singletons&)`, `Deinit()`, `Tick()`, `IsEnabled()` | +| `MCPBridge.{h,cpp}` | Plugin + `core::Singleton`. Transport fichier : poll `mcp/commands.jsonl` (mtime), écrit `mcp/state.json` (atomique via `MoveFileEx`). Gate runtime : variable d'env `VG_MCP_BRIDGE` | +| `SceneBridge.{h,cpp}` | Accès scène : `list_objects`, `get_transform`, `set_transform`, `spawn_object`, `save_world`. Résolution d'id via `core::IFactory::FindByUID`. Décomposition/recomposition matricielle maison (position + scale exacts) | +| `Json.{h,cpp}` | Mini-JSON autonome (parse + serialize), sous-ensemble du contrat | +| `Precomp.{h,cpp}`, `mcpbridge.def` | Boilerplate plugin (mirroir de `src/physics`, `src/audio`) | + +### Build +| Fichier | Rôle | +|---|---| +| `sharpmake/vg.mcpbridge.sharpmake.cs` | **Nouveau** projet `MCPBridge` (`Type.DynamicLibrary`, dépend de `Core`). Auto-inclus par `main.sharpmake.cs` | + +### Serveur MCP — `mcp-server/` (Node + TypeScript) +| Fichier | Rôle | +|---|---| +| `src/bridgeClient.ts` | Transport fichier côté serveur : écrit `commands.jsonl`, poll `state.json`, compacte le fichier de commandes, gère `ENGINE_NOT_RUNNING` / `TIMEOUT` | +| `src/index.ts` | Serveur `@modelcontextprotocol/sdk` (stdio). 6 tools : `engine_status`, `list_objects`, `get_transform`, `set_transform`, `spawn_object`, `save_world` | +| `package.json`, `tsconfig.json`, `README.md` | Setup | + +## Fichiers existants modifiés (additif uniquement — aucune ligne existante supprimée/altérée) + +| Fichier | Diff | +|---|---| +| `sharpmake/vg.solution.sharpmake.cs` | +1 ligne : `conf.AddProject(target);` | +| `sharpmake/vg.engine.sharpmake.cs` | +1 ligne : `conf.Defines.Add("VG_ENABLE_MCPBRIDGE");` | +| `sharpmake/vg.data.sharpmake.cs` | +1 ligne : exclusion `mcpbridge` du projet utilitaire `Version` (comme tous les autres modules) | +| `src/engine/Engine.cpp` | +34 lignes, 5 blocs, **tous `#if VG_ENABLE_MCPBRIDGE`** : include, pointeur statique, create+Init (si `getenv("VG_MCP_BRIDGE")`), `Tick()` dans `RunOneFrame`, `Deinit()` | +| `.gitignore` | +1 : `mcp/` | + +Retirer la ligne de `vg.engine.sharpmake.cs` + celle de `vg.solution.sharpmake.cs` et régénérer ⇒ le moteur est identique à l'état d'origine (le `#if` neutralise tout le code dans `Engine.cpp`). + +--- + +## Boucle d'intégration + +`Engine::RunOneFrame()` → (après les updates, avant `m_editor->RunOneFrame()`) → `g_mcpBridge->Tick()`. +Appelé **chaque frame en mode éditeur, y compris hors Play**. `Tick()` ne fait rien tant que +`mcp/commands.jsonl` n'a pas changé de date de modification (coût quasi nul). + +## Protocole (rappel, détail dans `docs/data-contract.md`) + +- `mcp/commands.jsonl` : append-only, une commande JSON/ligne `{id, tool, args}`. Écrit par le serveur, lu par le moteur. +- `mcp/state.json` : `{engine:{running,playing}, lastProcessedId, results:[{id,tool,ok,data|error}]}`. Écrit par le moteur (atomique), lu par le serveur. +- Le serveur compacte `commands.jsonl` (supprime les lignes `id <= lastProcessedId`) avant chaque envoi. +- Testé de bout en bout avec un faux moteur (script Node) : list / set / propagation d'erreur ✅. + +--- + +## Piège connu — l'éditeur crashe au démarrage (GTAO) + +**Symptôme** : au lancement de `editor.exe` / `vgframework_win64_msvc_dx12_*.exe`, deux assertions puis crash : +`Texture resource "ScreenSpaceAmbient - Editor 0" does not exist in FrameGraph` (`FrameGraph.cpp:164`) +puis `RWTexture "ScreenSpaceAmbient - Editor 0" does not exist in FrameGraph` (`UserPass.hpp:230`). + +**Cause** : fonctionnalité amont **en chantier** (commits `9c8c688a SSAO WIP`, `9715a2b1 GTAO`), activée par +défaut dans l'état committé. `LitView::RegisterFrameGraph` (`LitView.hpp:142-144`) n'ajoute la passe qui +**crée** la texture que si `m_lightingMode == Deferred` **et** `GetScreenSpaceAmbient() != None` ; quand la +ressource manque au `Render` alors que la condition est vraie, le `Setup` de la passe SSA n'a pas tourné. + +**Sans rapport avec le bridge MCP** : sans la variable d'environnement `VG_MCP_BRIDGE`, `g_mcpBridge` reste +`nullptr` et les trois points d'appel dans `Engine.cpp` sont des branches mortes. + +**Contournement appliqué** : `Editor.xml:35` `m_screenSpaceAmbient` **`GTAO` → `None`** (vérifié : l'éditeur +se lance normalement). Pour revenir en arrière : `git checkout -- Editor.xml`. + +--- + +## Phase 5 — mode d'emploi + +```powershell +# 1. serveur MCP (une fois) +cd D:\GitHUB_Repo\mcp-server ; npm install ; npm run build + +# 2. enregistrer le serveur dans Claude Code (une fois) +# -e et non --env ; le VG_MCP_DIR doit pointer le MÊME dossier que celui du moteur +claude mcp add vgframework -e VG_MCP_DIR=D:/GitHUB_Repo/mcp -- node D:/GitHUB_Repo/mcp-server/dist/index.js +# puis REDÉMARRER Claude Code : une session déjà lancée ne voit pas un serveur ajouté après coup + +# 3. lancer l'éditeur AVEC le bridge (à chaque session) +cd D:\GitHUB_Repo ; $env:VG_MCP_BRIDGE = "1" ; .\editor.exe +# log attendu : [MCPBridge] enabled - watching "…/mcp/commands.jsonl" +# le dossier mcp/ doit se créer avec un state.json contenant "running": true +``` + +4. Poser **à la main** un `data/Prefabs/Box_Base.prefab` dans la scène : `spawn_object` **clone un objet déjà + présent**, il ne sait pas instancier un prefab depuis le disque (hors périmètre V1). +5. Dans Claude Code, en langage naturel : « vérifie que le moteur répond », « liste les objets », + « cherche Box_Base », puis le scénario grille. + +**Rien n'est écrit sur disque tant que `save_world` n'est pas appelé** — recharger la scène annule tout. +Attention : il n'y a **pas de `delete_object`** en V1, le ménage se fait à la main dans l'éditeur. + +### Checklist Phase 6 + +- [x] `docs/engine-analysis.md` complet avec citations +- [x] `docs/architecture.md` (ADR) cohérent avec Phase 0 +- [x] `docs/OPEN_QUESTIONS.md` à jour — #1 à #4 tous résolus +- [x] Module bridge compilé sans modifier de fichier existant *(hors 4 câblages additifs validés, tous `#if`-gardés)* +- [x] Serveur MCP fonctionnel, 6 tools **testés contre le moteur réel** via le protocole MCP complet +- [x] Convention de rotation vérifiée contre l'Inspector du moteur (`OPEN_QUESTIONS.md` #4) +- [x] Build Debug **et** Release OK, 0/0, avec et sans `VG_MCP_BRIDGE` défini à l'exécution +- [ ] Scénario « plan de ville » 5×5 joué de bout en bout depuis Claude Code *(nécessite un `Box_Base` posé à la main dans la scène)* + +--- + +## Bugs trouvés pendant la Phase 5 + +Le passage sur une vraie scène (`Aiguelongue`, 7832 objets) a révélé quatre défauts. **Trois étaient les miens** — aucun n'aurait été vu sur une scène jouet. + +| # | Défaut | Où | Correction | +|---|---|---|---| +| 1 | Le heartbeat réécrivait `results` **vide** : tout client plus lent que ~2 s perdait sa réponse | `MCPBridge::writeState` | `results` est toujours republié ; `m_recentResults` stocke des `Json` au lieu de chaînes re-parsées | +| 2 | `list_objects` renvoyait **9,7 Mo** de JSON — inutilisable via MCP | `SceneBridge::listObjects` | modes *browse* (profondeur 1 par défaut) / *search* (`name_contains`), + `parent_id`, `max_depth`, `limit`, `child_count`. **9,7 Mo → 10 Ko** | +| 3 | Signe de rotation inversé : `decompose` lisait la **transposée** de ce qu'écrivait `recompose` | `SceneBridge.cpp` | termes antisymétriques pris en `(r_ij − r_ji)`. ⚠️ Un test à 90° ne peut **pas** détecter ce bug (boîte symétrique) — tester à 45° | +| 4 | `World::AddScene` teste `nullptr == m_activeScene` sur un **tableau** (`World.h:95`) → condition toujours fausse, aucune scène n'est jamais marquée active au chargement d'un monde | `src/engine/World/World.cpp:171` — **amont** | non corrigé (règle d'or). Contourné : le bridge énumère `GetSceneCount`/`GetScene`. Correctif amont si souhaité : `m_activeScene[typeIndex]` | + +Le bug 4 passait inaperçu parce que seule la vue Prefab appelle `SetActiveScene` explicitement (`ImGuiPrefabView.hpp:193`). diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 000000000..1277aa16d --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,183 @@ +# Quickstart — construire un quartier depuis Claude Code + +Scénario de bout en bout : lancer le moteur, connecter Claude, poser un petit quartier. +Durée : ~10 min la première fois, ~1 min les fois suivantes. + +> **Rien n'est écrit sur disque tant que `save_world` n'est pas appelé.** Recharger la scène +> dans l'éditeur annule tout ce qui suit. C'est le filet de sécurité de ce scénario. + +--- + +## A. Installation (une seule fois) + +```powershell +# 1. compiler le serveur MCP +cd D:\GitHUB_Repo\mcp-server +npm install +npm run build + +# 2. l'enregistrer auprès de Claude Code +# -e et non --env. VG_MCP_DIR doit designer le MEME dossier que celui du moteur. +claude mcp add vgframework -e VG_MCP_DIR=D:/GitHUB_Repo/mcp -- node D:/GitHUB_Repo/mcp-server/dist/index.js +``` + +Vérification : + +```powershell +claude mcp list # attendu : vgframework: ... - ✔ Connected +``` + +⚠️ **Redémarre Claude Code après cet enregistrement.** Une session déjà lancée ne voit pas +un serveur MCP ajouté après son démarrage. `/exit` puis `claude`, ensuite `/mcp` doit lister +`vgframework` avec ses 10 tools (`engine_status`, `list_objects`, `get_transform`, +`set_transform`, `spawn_object`, `spawn_objects`, `create_group`, `create_scene`, +`delete_object`, `save_world`). Idem après un `npm run build` du serveur : la session en cours garde +l'ancienne version chargée. + +--- + +## B. Lancer le moteur (à chaque session) + +La variable d'environnement doit être posée **dans le terminal qui lance l'exe**, et le +répertoire courant doit être la racine du repo (le bridge crée `mcp/` à côté). + +```powershell +cd D:\GitHUB_Repo +$env:VG_MCP_BRIDGE = "1" +.\editor.exe +``` + +Deux signes que le bridge est actif : + +1. dans la console de l'éditeur : `[MCPBridge] enabled - watching ".../mcp/commands.jsonl"` +2. le fichier `D:\GitHUB_Repo\mcp\state.json` existe et contient `"running": true` + +Sans la variable, le plugin n'est pas chargé du tout et tous les tools répondront +`ENGINE_NOT_RUNNING`. + +--- + +## C. Vérifier la liaison + +Dans Claude Code : + +> Vérifie que le moteur répond, puis liste les objets de la scène. + +Attendu sur la scène de démarrage `Aiguelongue` : + +``` +browse in scene(s) Aiguelongue — 8 of 8 match(es): + 114612410 Aiguelongue/Cameras (3 children) + 1177627180 Aiguelongue/UI (6 children) + 3646148306 Aiguelongue/Environment (2 children) + 1093025436 Aiguelongue/Level (7 children) + ... +``` + +`list_objects` ne descend que d'un niveau par défaut — un listing récursif complet de cette +scène fait 9,7 Mo de JSON. Pour explorer : « montre-moi ce qu'il y a sous Level » +(`parent_id`) ou « cherche les objets dont le nom contient X » (`name_contains`). + +--- + +## D. Poser la graine — seule action manuelle + +`spawn_object` **clone un objet déjà présent dans la scène**. Il ne sait pas instancier un +prefab depuis le disque (hors périmètre V1). Il faut donc un premier `Box_Base` à la main. + +Dans l'éditeur : glisse `data/Prefabs/Box_Base.prefab` dans la hiérarchie, sous `Level` par +exemple, et pose-le dans une zone dégagée. Note approximativement où il est. + +Puis, dans Claude Code : + +> Trouve l'objet Box_Base et donne-moi son id, sa position et ses dimensions. + +Tu obtiendras son `object_id` (l'UID, à réutiliser ensuite) et son `bounds_local`, qui +révèle la taille réelle du mesh et la position du pivot. + +--- + +## E. Un premier essai minuscule (recommandé) + +Avant les 9 bâtiments, valide la mécanique sur trois boîtes : + +> À partir du Box_Base, crée 3 immeubles alignés sur l'axe X, espacés de 12 m, +> de 8×8 m d'emprise et de hauteurs 10, 18 et 6 m. Pose-les au niveau du sol. + +Regarde le résultat dans l'éditeur. Si les proportions sont bonnes, enchaîne. + +--- + +## F. Le quartier + +> Construis un petit quartier à partir du Box_Base. +> Grille 3×3 de blocs, chaque bloc fait 8×8 m d'emprise, avec 4 m de rue entre les blocs +> (donc un pas de 12 m). Le coin du quartier part de la position actuelle du Box_Base. +> Donne à chaque bâtiment une hauteur différente entre 6 et 25 m pour casser la monotonie. +> Tous les bâtiments posés au niveau du sol. +> Nomme-les Bat_00 à Bat_08. + +### Ce que Claude doit appliquer (conventions du moteur) + +Ces règles sont déjà dans les descriptions des tools, mais autant les connaître pour +relire ce qu'il fait : + +| Règle | Conséquence | +|---|---| +| Le monde est **Z-up** | X et Y = plan du sol, **Z = hauteur** | +| Pivot de `Box_Base` **au centre de sa base** | `position.z = 0` pose au sol ; `scale.z` = hauteur totale, **sans offset de demi-hauteur** | +| Unités en mètres | un bâtiment 8×8×20 = `scale {x:8, y:8, z:20}` | +| Cap (yaw) d'angle `a` | `rotation {x:0, y:0, z:sin(a/2), w:cos(a/2)}` | + +Un bâtiment de 20 m se fait donc avec `scale {x:8, y:8, z:20}` à `position {z:0}` — et non +`z:10`, réflexe naturel avec un pivot centré, mais faux ici. + +### Rangement et débit (depuis le 2026-09-12) + +Pour un quartier, Claude doit d'abord créer un groupe (`create_group`) puis poser les +bâtiments en **un seul** `spawn_objects` avec ce groupe en `parent_id` — 150 clones en +0,23 s, et la scène garde un seul nœud au lieu de 150 objets à plat. Si ce n'est pas ce +qu'il fait, dis-le explicitement : + +> Range tout ça dans un groupe « Quartier_Nord » et crée les bâtiments en un seul lot. + +### Variantes à tester + +> Fais varier l'orientation : donne à un bâtiment sur trois un cap de 90°. + +> Élargis les rues à 6 m et refais la grille. + +> Liste les bâtiments que tu viens de créer avec leurs hauteurs. + +--- + +## G. Garder ou jeter + +- **Jeter** : recharge la scène dans l'éditeur. Rien n'a touché le disque. +- **Garder** : « Sauvegarde le monde. » (tool `save_world`, qui écrit les `.scene` **et** le + `.world`). Round-trip vérifié le 2026-09-12 : après redémarrage de l'éditeur, objets, + hiérarchie, transforms et **UID** sont identiques. + +⚠️ À connaître **avant** de sauvegarder : + +- `save_world` **écrase les `.scene` sur disque** — sur une scène qui compte, commite ou + sauvegarde une copie avant ; +- un objet créé n'obtient un `object_id` stable qu'**après** la sauvegarde. Avant, son id + change à chaque rechargement de scène ; +- `delete_object` existe depuis le 2026-09-12, mais **sans undo** : pas de Ctrl-Z dans + l'éditeur après une suppression par le bridge (supprimer un groupe emporte son contenu). + +--- + +## Dépannage + +| Symptôme | Cause probable | +|---|---| +| Les tools n'apparaissent pas dans `/mcp` | session Claude Code pas redémarrée après `claude mcp add` | +| `ENGINE_NOT_RUNNING` | éditeur pas lancé, ou lancé sans `VG_MCP_BRIDGE`, ou `VG_MCP_DIR` ≠ dossier du moteur | +| `NO_ACTIVE_SCENE` | aucun monde chargé, ou commande envoyée pendant le démarrage | +| `OBJECT_NOT_FOUND` | id périmé : l'éditeur a rechargé la scène. Refais un `list_objects` | +| L'éditeur crashe sur `ScreenSpaceAmbient - Editor 0` | bug GTAO amont — voir `PHASE3-4-IMPLEMENTATION.md` § « Piège connu » | +| Les objets ne bougent pas visuellement | vérifie que l'éditeur a bien le focus sur une vue toolmode | + +Tout retirer : `claude mcp remove vgframework`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..98bf85b54 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,98 @@ +# ADR 001 — Couche de communication du bridge MCP + +Statut : **Accepté et implémenté** (Phases 3-4, cf. `docs/PHASE3-4-IMPLEMENTATION.md`) +Date : 2026-09-06 +Basé sur : `docs/engine-analysis.md` (Phase 0) + +> Écart d'implémentation vs. cette ADR : la conversion de rotation ne passe pas par +> `TRSToFloat4x4(T, quaternion, S)` (`Math.h:129`) — cette surcharge s'est révélée +> incohérente avec le reste du moteur (translation en 4ᵉ colonne). Le module fait sa +> propre décomposition/recomposition matricielle (base I/J/K en lignes). Position et +> scale sont exacts ; la rotation reste à valider visuellement (`OPEN_QUESTIONS.md` #4). + +--- + +## Contexte + +Piloter la scène active du moteur vgframework depuis Claude Code via un serveur MCP, pour du level design (poser/redimensionner des cubes de référence). Périmètre V1 : `list_objects`, `get_transform`, `set_transform` (+ `spawn_object` candidat, cf. OQ #3). + +Contraintes issues de la Phase 0 : +- **Aucun fichier moteur existant modifié** (sauf câblage minimal validé, OQ #1). +- Pas de scripting runtime, pas de console de commandes, **pas d'IPC existant** → tout canal est à créer. +- Le moteur tourne en boucle éditeur vivante ; `ToolUpdate` est appelé chaque frame hors Play (`src/engine/Engine.cpp:983-991`) → bon endroit pour pomper un canal. +- Objets adressables par `UID u32` via `IFactory::FindByUID()` (`src/core/IFactory.h:69`). +- Transform = `float4x4` ; conversions TRS/quaternion dispo (`src/core/Math/Math.h:123-132`). + +--- + +## Décision + +**Adopter l'option B (fichier de commandes JSON) pour V1**, implémentée dans un **module bridge C++ additif** (`src/mcpbridge/` + `sharpmake/vg.mcpbridge.sharpmake.cs`), pompé depuis le hook `ToolUpdate` de la boucle moteur. + +Le serveur MCP (Node/TypeScript, process séparé) écrit `mcp/commands.jsonl` et lit `mcp/state.json`. Le module moteur fait l'inverse. + +Migration vers l'option C (socket TCP loopback) prévue et non bloquante (voir *Conséquences*). + +### Schéma + +``` +Claude Code ──stdio──> serveur MCP (Node) ──écrit──> /mcp/commands.jsonl + (process séparé) <──lit── /mcp/state.json + ▲ │ + fichiers sur disque │ + │ ▼ + moteur (process éditeur) : module mcpbridge.dll ──lit commands, applique, écrit state── + └─ tick appelé depuis Engine::RunOneFrame() bloc ToolUpdate (câblage OQ #1) +``` + +### Emplacement des fichiers d'échange + +`/mcp/` — à côté du projet de jeu chargé, hors `data/` versionné lourd. +Fallback : `/mcp/` (racine repo). Répertoire créé par le module s'il n'existe pas. Ajouté au `.gitignore`. + +### Format + +- `commands.jsonl` : une commande JSON par ligne (append-only), champ `id` monotone. Le module traite les lignes non encore vues (curseur persistant en mémoire + `lastProcessedId` écrit dans `state.json`), puis **tronque** le fichier quand toutes les commandes sont traitées et que le serveur MCP a accusé réception (`ack` dans un `commands.ack` ou simplement : le module tronque après lecture, le serveur n'attend pas de persistance). +- `state.json` : snapshot complet écrit à chaque frame où (a) une commande a été appliquée, ou (b) toutes les N frames si `list_objects` a été demandé. Écriture atomique (`state.json.tmp` + rename). + +--- + +## Options considérées + +| Option | Latence | Code moteur | Robustesse | Verdict | +|---|---|---|---|---| +| **A — scripting existant** | — | nul | — | ❌ impossible : pas de scripting (analysis §4) | +| **B — fichier de commandes** | ~1 frame + I/O disque (10–30 ms typiques, borné par le poll) | module additif + hook `ToolUpdate` | élevée (pas de socket, survit aux redémarrages, débuggable à la main) | ✅ **retenu V1** | +| **C — socket TCP loopback** | <1 ms | module additif + hook + **thread réseau** (`WSAStartup`, accept, buffers) | moyenne (gestion déco, ports, pare-feu Windows) | ⏭️ V1.1 si la latence de B gêne l'usage interactif | +| **D — canal debug existant** | — | — | — | ❌ impossible : aucun IPC existant (analysis §6) | + +### Pourquoi B plutôt que C d'emblée + +1. **Moins de surface de code moteur** : pas de thread, pas de gestion de socket → le module bridge reste ~200 lignes, entièrement mono-thread dans le tick `ToolUpdate` (pas de synchro avec la scène à gérer). +2. **Débuggabilité** : on peut inspecter/éditer `commands.jsonl` et `state.json` à la main pendant le développement. +3. **Le cas d'usage tolère la latence** : le level design par lots (« pose 25 cubes en grille ») n'est pas sensible à 30 ms ; c'est un aller-retour, pas du drag temps réel. +4. **Chemin de migration propre** : l'interface interne du module (`applyCommand(Command&) → Result`, `buildState() → json`) est identique pour B et C ; seul le transport change. Passer à C = ajouter un thread qui appelle les mêmes fonctions. + +### Pourquoi un module C++ et pas un process 100 % externe + +Un process externe qui patche les `.scene` XML sur disque ne serait pas répercuté sur la scène **déjà chargée en mémoire** (pas de rechargement à chaud — analysis §7), donc aucun retour visuel. Le pilotage live impose du code dans le process moteur. + +--- + +## Conséquences + +### Positives +- Aucune dépendance réseau ; fonctionne même si le moteur est lancé/relancé plusieurs fois. +- Le module est désactivable par `#if VG_ENABLE_MCPBRIDGE` → build normal intact (checklist Phase 6). +- `set_transform` / `list` fonctionnent **en mode éditeur sans Play** (hook `ToolUpdate`). + +### Négatives / risques +- **Câblage minimal inévitable** dans 2 fichiers existants (OQ #1) — à faire valider avant commit. +- Latence bornée par la fréquence de poll (1 frame) + I/O disque ; acceptable V1, à surveiller. +- `commands.jsonl` append-only : prévoir la troncature pour éviter la croissance illimitée. +- Écriture de `state.json` chaque frame si mal borné → n'écrire que sur changement ou sur demande explicite. +- UID non stable pour objets créés non sauvegardés (analysis §3) → `spawn_object` doit renvoyer l'UID attribué, et on recommande `SaveWorld` après une session de placement. + +### Suivi +- ADR 002 (à écrire si migration) : passage au transport socket loopback. +- Le choix du langage/SDK du serveur MCP est traité en Phase 4 (Node + `@modelcontextprotocol/sdk`, vérifier la doc courante avant d'coder). diff --git a/docs/data-contract.md b/docs/data-contract.md new file mode 100644 index 000000000..953f08b88 --- /dev/null +++ b/docs/data-contract.md @@ -0,0 +1,210 @@ +# Phase 2 — Contrat de données (schéma partagé bridge ↔ serveur MCP) + +Basé sur `docs/engine-analysis.md`. Format d'échange : JSON. +Le moteur stocke le transform en `float4x4` ; **le contrat reste en TRS + quaternion**, la conversion se fait dans le module C++ (`src/core/Math/Math.h:123-132` + hlslpp). + +--- + +## Objet + +```jsonc +{ + "object_id": "1945741270", // string décimale d'un UID u32 (IObject::GetUID). Stable dans la session. + "name": "Box1X1M_03", + "path": "Root/Blocks/Box1X1M_03", // chemin lisible (noms de GameObject), pour debug — NON garanti unique + "primitive_type": "cube", // "cube" si le mesh est le prefab de référence, sinon "unknown" + "enabled": true, // InstanceFlags::Enabled + "static": false, // InstanceFlags::Static + "transform": { + "position": { "x": 0.0, "y": 0.0, "z": 0.0 }, // translation locale (m[3].xyz), unités moteur = mètres + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, // quaternion local (identité = 0,0,0,1) + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } // échelle locale par axe + } +} +``` + +- `transform` est **local** (relatif au parent). Le champ `space` optionnel (`"local"` | `"world"`) pourra être ajouté en V1.1 ; V1 = local uniquement. +- **Convention d'axes : le monde est Z-UP.** X et Y = plan du sol, **Z = hauteur**, unités en mètres (preuves : `engine-analysis.md` §11). Un cap (yaw) d'angle `a` = quaternion `{x:0, y:0, z:sin(a/2), w:cos(a/2)}`. Le serveur MCP ne réinterprète pas les axes. +- **Primitive de référence : `data/Prefabs/Box_Base.prefab`**, dont le **pivot est au centre de sa base** → `position.z` = niveau du sol et `scale.z` = hauteur totale, sans offset de demi-hauteur. + +Deux champs supplémentaires sont retournés par `list_objects` / `get_transform` pour ne rien avoir à deviner : + +```jsonc +"bounds_local": { // bornes du mesh dans l'espace local, NON scalées + "min": { "x": 0, "y": 0, "z": 0 }, // -> révèle la position du pivot dans le mesh + "max": { "x": 1, "y": 1, "z": 1 }, + "size": { "x": 1, "y": 1, "z": 1 } +}, +"size_world": { "x": 8, "y": 8, "z": 20 } // bounds_local.size * scale = emprise réelle +``` + +--- + +## Tools MCP → commandes bridge (1:1) + +### `list_objects` + +**Toujours borné.** Un listing récursif complet est inutilisable : mesuré sur `Aiguelongue.scene` (7832 objets), il produisait un `state.json` de **9,7 Mo**. Deux modes, tous deux plafonnés : + +| Mode | Déclencheur | Comportement | +|---|---|---| +| **browse** (défaut) | pas de `name_contains` | descend jusqu'à `max_depth` (défaut **1** = enfants directs) | +| **search** | `name_contains` non vide | parcourt **tout** le sous-arbre, ne retourne que les noms contenant la sous-chaîne (insensible à la casse) | + +Entrée — tout est optionnel : +```jsonc +{ + "name_contains": "box", // active le mode search + "parent_id": "1093025436", // part de cet objet au lieu des racines de scène + "max_depth": 1, // mode browse uniquement ; 0 = illimité + "limit": 200, // plafond d'objets retournés + "detail": "compact" // "compact" (défaut) | "full" +} +``` +Sortie : +```jsonc +{ + // absent quand parent_id est fourni. root_id ajouté le 2026-09-12 : la racine n'est + // jamais listée comme un objet, et c'est le SEUL moyen de viser une scène vide (qui + // n'a rien à lister) — à passer en parent_id de create_group / spawn_objects. + "scenes": [{ "name": "City", "root_id": "3000000002", "child_count": 0 }], + "objects": [ /* Objet[] ci-dessus, + "path" et "child_count" */ ], + "returned": 8, // taille de objects + "matched": 8, // total des correspondances, même au-delà de limit + "truncated": false, + "mode": "browse" +} +``` +`child_count` permet de savoir s'il faut descendre. Résultat mesuré en mode browse sur la même scène : **10 Ko** au lieu de 9,7 Mo. + +**`detail` (ajouté 2026-09-12).** En `compact` (défaut) un objet ne porte que `object_id`, `name`, `path`, `child_count` ; côté serveur MCP la réponse est rendue **une ligne par objet, sans dump JSON** — c'est ce qui faisait exploser la limite de tokens de l'outil dès ~170 objets. `detail: "full"` rajoute `enabled`, `transform`, `bounds_local` et `size_world` (~40 lignes de JSON par objet) : à réserver à une poignée d'objets, sinon utiliser `get_transform`, qui est toujours complet. + +**Énumère toutes les scènes du monde principal** (`IWorld::GetSceneCount` / `GetScene`) et **non** `GetActiveScene` : `World::AddScene` teste `nullptr == m_activeScene` sur un **tableau** (`src/engine/World/World.cpp:171`, membre déclaré `World.h:95`), condition toujours fausse — aucune scène n'est donc jamais marquée active au chargement d'un monde. Bug amont non corrigé ici (règle d'or) ; l'énumération le contourne et gère au passage les mondes multi-scènes. + +### `get_transform` +Entrée : `{ "object_id": "1945741270" }` +Sortie : l'Objet complet, ou erreur (voir plus bas). +Résolution : `Kernel::getFactory()->FindByUID(id)` (`src/core/IFactory.h:69`). + +### `set_transform` +Entrée — **tous les sous-champs indépendamment optionnels** : +```jsonc +{ + "object_id": "1945741270", + "position": { "x": 4.0, "z": 2.0 }, // y absent => y inchangé ; composantes idem + "rotation": { "x": 0, "y": 0.7071, "z": 0, "w": 0.7071 }, + "scale": { "x": 8.0, "y": 20.0, "z": 8.0 } +} +``` +Sémantique : +- champ absent (`position`/`rotation`/`scale`) → cette partie du transform est conservée ; +- sous-champ absent (`position.y`) → composante conservée ; +- le module lit la matrice locale, la décompose (`Float4x4ToTRS`), applique les deltas fournis, recompose (`TRSToFloat4x4(T, quat, S)`), `SetLocalMatrix`, puis **`OnLocalMatrixChanged(false, true)`** (`src/core/IInstance.h:37`). +Sortie : `{ "object_id": "...", "transform": { ...état final... } }` + +### `spawn_object` *(candidat V1 — cf. OQ #3)* +Entrée : +```jsonc +{ + "source_id": "1945741270", // UID d'un objet à cloner (défaut : le prefab Box1X1M de la scène) + "name": "Box1X1M_07", // optionnel + "parent_id": "114612410", // optionnel, défaut = parent de la source + "transform": { "position": {...}, "rotation": {...}, "scale": {...} } +} +``` +Sortie : `{ "object_id": "", ...Objet }` +Impl : `FindByUID(source_id)->Instanciate()` → `AddChild` → `SetLocalMatrix` (`src/core/Object/Object.cpp:302`, `src/engine/Selection/Selection.cpp:335`). + +### `spawn_objects` *(lot — ajouté 2026-09-12)* +Même clonage que `spawn_object`, mais **N copies en une commande** : un aller-retour au lieu de N. Mesuré : **150 clones en 0,23 s**, là où 150 `spawn_object` en parallèle déclenchaient des timeouts client à 15 s (le moteur traitait quand même la commande → doublons). +```jsonc +{ + "source_id": "1284883740", + "parent_id": "3518068110", // optionnel, défaut = parent de la source ; typiquement un create_group + "items": [ // 1 entrée = 1 clone ; placement relatif au parent + { "name": "Bat_00", "position": {...}, "rotation": {...}, "scale": {...} } + ] +} +``` +Sortie : `{ "parent_id", "count", "spawned": [{ object_id, name }], "failed"? }`. Un échec sur un item n'interrompt pas le lot : il est reporté dans `failed`. + +### `create_group` *(ajouté 2026-09-12)* +Crée un `GameObject` vide servant de dossier, pour ne pas laisser des centaines d'objets à plat sous la racine de scène. +```jsonc +{ "name": "Quartier_Nord", "parent_id": "...", "position": {...} } // parent_id défaut = racine de la 1re scène +``` +Impl : `IFactory::CreateObject("GameObject", name, parent)` → `RegisterUID()` → `parent->AddChild()` → `Release()` (même séquence que l'éditeur, `ImGuiGameObjectSceneEditorMenu.hpp:629`). Groupe posé à l'identité : les coordonnées des enfants restent égales aux coordonnées monde. Groupes imbriquables. + +### `delete_object` *(ajouté 2026-09-12)* +```jsonc +{ "object_id": "278952507" } // ou { "object_ids": ["...", "..."] } +``` +Détache l'objet de son parent (`IGameObject::RemoveChild`) : la dernière référence tombe et **tout le sous-arbre** part avec lui (supprimer un groupe supprime son contenu). Sortie `{ "deleted": [{object_id, name}], "count", "failed"? }` — un id invalide ou une racine de scène est refusé sans interrompre le lot. + +Deux écarts assumés par rapport au `Delete` de l'éditeur : +- **aucune entrée undo/redo** n'est créée → pas de Ctrl-Z après une suppression par le bridge (l'éditeur, lui, garde l'objet vivant dans sa pile d'undo, `Editor.cpp:796`) ; +- l'objet est retiré de la sélection avant destruction (`ISelection::Remove`) pour ne pas laisser un pointeur mort dans l'éditeur. + +### `create_scene` *(ajouté 2026-09-12)* +```jsonc +{ "name": "City", "folder": "data/Scenes" } // folder optionnel +``` +Équivalent de **SceneList > New Scene** de l'éditeur : `IWorldResource::CreateSceneResource(file, BaseSceneType::Scene)` (`ImGuiSceneList.hpp:675`), qui écrit un `.scene` contenant un unique `Root`, enregistre la ressource et déclenche son chargement. + +Deux conséquences à connaître : +- **le chargement est asynchrone** → la scène n'est pas encore dans le monde au retour de l'appel ; enchaîner sur `list_objects` pour lire son `root_id` ; +- **le monde ne mémorise la nouvelle scène qu'après `save_world`** (sinon le `.scene` existe sur disque mais `Game.world` ne le référence pas). + +Garde-fous (`CreateSceneResource` écrase sans prévenir) : refus si le fichier existe déjà (`ALREADY_EXISTS`), si une scène du même nom est déjà chargée, ou si le nom contient un séparateur de chemin / joker (`INVALID_VALUE`). + +Sortie : `{ "name", "file", "loading": true, "note" }`. + +### `save_world` +Sauvegarde **chaque scène puis le fichier monde**, dans l'ordre du bouton « Save All » de l'éditeur (`ImGuiEditorView.hpp:85-95`). + +> **Piège corrigé le 2026-09-12.** La V1 n'appelait que `IEngine::SaveWorld()`, qui ne réécrit que `data/Worlds/*.world` — un fichier de **références de scènes, sans aucun GameObject**. Le tool renvoyait `saved: true` alors que rien du travail de level design n'atteignait le disque. Le contenu vit dans les `.scene`, écrits uniquement par `IEngine::SaveScene(sceneRes)`, énumérables via `IWorldResource::GetSceneResourceCount/GetSceneResource` (`src/engine/IWorldResource.h:28-29`). + +Sortie : `{ "saved", "world_saved", "scenes_saved", "scenes": [{ file, saved }] }`. + +**Round-trip validé le 2026-09-12** (scène `Aiguelongue`) : groupe + 5 boîtes créés par le bridge → `save_world` → redémarrage de l'éditeur → objets présents, **UID identiques**, transforms exacts. Vérifié aussi : après suppression des objets de test et resauvegarde, le `.scene` est **bit à bit identique** à sa version d'avant-test (le sérialiseur ne produit pas de bruit de diff). + +--- + +## Erreurs (jamais de crash silencieux — plan Phase 3) + +```jsonc +{ "error": { "code": "OBJECT_NOT_FOUND", "message": "No object with UID 123", "object_id": "123" } } +``` + +| code | quand | +|---|---| +| `OBJECT_NOT_FOUND` | `FindByUID` renvoie `nullptr` | +| `NOT_A_GAMEOBJECT` | l'UID désigne un objet non `IGameObject` (Component, Resource…) | +| `INVALID_VALUE` | NaN/Inf, quaternion non normalisable, scale nul | +| `NO_ACTIVE_SCENE` | `GetActiveScene(Scene)` == null | +| `ENGINE_BUSY` | moteur en cours de (dé)chargement de monde | +| `NOT_SUPPORTED` | commande inconnue / hors périmètre V1 | + +Côté serveur MCP : si `state.json` ne bouge pas après N secondes (moteur non lancé) → renvoyer une erreur explicite `ENGINE_NOT_RUNNING`, pas un timeout muet (plan Phase 4). + +--- + +## Enveloppe de transport (option B — fichier) + +`commands.jsonl` (une ligne par commande) : +```jsonc +{ "id": 42, "tool": "set_transform", "args": { "object_id": "1945741270", "scale": { "x": 8, "y": 20, "z": 8 } } } +``` + +`state.json` : +```jsonc +{ + "engine": { "running": true, "playing": false, "loading": false }, + "lastProcessedId": 42, + "results": [ + { "id": 42, "ok": true, "data": { "object_id": "1945741270", "transform": { } } } + ], + "scene": { "world": "Game", "scene": "Aiguelongue", "objects": [ ] } +} +``` +`results` = réponses aux commandes depuis le dernier snapshot (borné, ex. 100 dernières). `scene.objects` rempli seulement si un `list_objects` récent l'a demandé (évite d'écrire toute la scène chaque frame). diff --git a/docs/engine-analysis.md b/docs/engine-analysis.md new file mode 100644 index 000000000..d93b97ef1 --- /dev/null +++ b/docs/engine-analysis.md @@ -0,0 +1,233 @@ +# Phase 0 — Analyse du moteur (vgframework) + +> Reconnaissance en **lecture seule**. Aucun fichier moteur modifié. +> Toutes les réponses sont sourcées `fichier:ligne` (chemins relatifs à la racine du repo). +> Vérifié le 2026-09-06 sur la branche `master` (commit `08ef0d8c`). + +Le moteur est **VG Framework** : C++ ~94 %, HLSL ~5 %, C# (Sharpmake uniquement), Python (exporters DCC uniquement). +Architecture modulaire : chaque sous-système est une **DLL plugin** (`core`, `gfx`, `renderer`, `engine`, `physics`, `audio`, `editor`, `game`) exposant un `extern "C" CreateNew()`. + +--- + +## 1. Représentation des objets de scène + +**Scene graph hiérarchique** (pas d'ECS). Hiérarchie : + +``` +World (core::IWorld) src/core/IWorld.h:33 + └─ Scene / Prefab (core::IBaseScene) src/core/IBaseScene.h:23 + └─ GameObject "Root" (core::IGameObject) src/core/IBaseScene.h:28 (GetRoot) + ├─ GameObject enfant … src/core/IGameObject.h:24 (GetChildren) + └─ Component[] src/core/IGameObject.h:35 (GetComponents) +``` + +- `World` = conteneur de scènes ; scène active par type : `IWorld::GetActiveScene(BaseSceneType)` — `src/core/IWorld.h:49`. +- `IBaseScene::GetRoot()` → `IGameObject *` racine — `src/core/IBaseScene.h:29`. +- `GameObject` : `class GameObject : public IGameObject` — `src/core/GameObject/GameObject.h:18` ; enfants `m_children`, composants `m_components` — `src/core/GameObject/GameObject.h:142-143`. +- Implémentation concrète : `src/core/GameObject/GameObject.cpp`. + +### Transform + +Il n'existe **pas** de classe `Transform` / `Position` / `Rotation` / `Scale` séparée. +Le transform est porté par la classe de base `core::Instance` (parent de `IGameObject`) sous forme de **deux matrices 4×4** : + +```cpp +float4x4 m_local = float4x4::identity(); // src/core/Instance/Instance.h:74 +float4x4 m_global = float4x4::identity(); // src/core/Instance/Instance.h:75 +``` + +API publique (`src/core/IInstance.h:31-37`) : + +| Méthode | Rôle | +|---|---| +| `SetLocalMatrix(const float4x4 &)` | écrit le transform local | +| `GetLocalMatrix() const` | lit le transform local | +| `SetGlobalMatrix(const float4x4 &)` | écrit en espace monde (converti en local en interne) | +| `GetGlobalMatrix() const` | lit en espace monde | +| `OnLocalMatrixChanged(bool recomputeParents, bool recomputeChildren)` | **à appeler après toute écriture** pour propager aux enfants + notifier le renderer/physique | + +`GameObject` redéfinit `OnLocalMatrixChanged` — `src/core/GameObject/GameObject.h:118` / `.cpp`. + +`float4x4` provient de **hlslpp** (`extern/hlslpp`), convention **row-major, vecteurs-ligne** : la translation est la **4ᵉ ligne** `m[3].xyz` (confirmé `src/engine/Selection/Selection.cpp:197` `T.xyz += m[3].xyz`). + +--- + +## 2. Format de la rotation + +**Matrice** (`float4x4`), pas de quaternion ni d'angles d'Euler stockés. +Sérialisation scène : les 16 flottants bruts (`Ix..Iw Jx..Jw Kx..Kw Tx..Tw`), ex. `data/Scenes/BLAStest.scene:8`. + +Helpers de conversion disponibles dans `src/core/Math/Math.h` (implémentation `Math.inl` / `Math.cpp`) : + +| Fonction | Signature | `Math.h` | +|---|---|---| +| `Float4x4ToTRS` | `(const float4x4&, float3& T, float3& R_euler, float3& S)` | `:123` | +| `TRSToFloat4x4` | `(const float3& T, const float3& R_euler, const float3& S)` | `:126` | +| `TRSToFloat4x4` | `(const float3& T, quaternion R, float3 S)` | `:129` | +| `extractRotation` | `(const float4x4&) → float3x3` | `:132` | +| `clearScale` / `clearRotation` / `clearTranslation` | décomposition partielle | `:50-96` | + +`quaternion` existe comme type hlslpp (`src/core/Math/Math.h:346`, `slerpShortestPath` `:165`) mais **n'est pas** le format de stockage du transform. + +> **Conséquence pour le bridge** : le contrat MCP peut rester en quaternion ; la conversion quaternion↔matrice se fait **côté moteur** via `TRSToFloat4x4(T, quaternion, S)` et, en lecture, via `Float4x4ToTRS` (Euler) puis Euler→quaternion, ou une extraction quaternion directe à écrire dans le module bridge (hlslpp fournit `float4x4 → quaternion`). + +--- + +## 3. Identifiant d'objet stable + +**Oui — UID `u32` stable et résolvable globalement.** + +- Type : `using UID = core::u32;` — `src/core/IObject.h:41`. +- Tout `IObject` porte : `GetUID()`, `SetUID()`, `RegisterUID()`, `HasValidUID()`, plus `GetOriginalUID()` (traçabilité prefab) — `src/core/IObject.h:65-74`. +- **Registre global** dans la Factory (singleton `Kernel::getFactory()`) : + - `IObject * IFactory::FindByUID(UID) const` — `src/core/IFactory.h:69` ← **résolution id → objet pour le bridge** + - `const UIDObjectHash & GetUIDObjects() const` (`unordered_map`) — `src/core/IFactory.h:64,68` + - `UID RegisterUID(IObject*)` — `src/core/IFactory.h:66` +- Implémentation : `src/core/Object/Factory.{h,cpp}` (`m_uidObjectHash` — `Factory.h:89`). + +### Persistance de l'UID + +- Format de scène **actuel** : l'UID est sérialisé par objet — `data/Scenes/Aiguelongue.scene` contient 3379 `m_uid` (un par objet). +- `m_uid value="0"` = « non assigné » → un nouvel UID est généré au chargement (`getNewUID` — `src/core/Object/Factory.h:74`). C'est le cas des prefabs neufs (`data/Prefabs/Box1X1M.prefab:7`) et de l'ancien format (`data/Scenes/BLAStest.scene` n'a aucun `m_uid`). + +> **Conséquence** : un UID est stable **au sein d'une session** et **entre chargements si la scène a été sauvegardée** après attribution. Un objet créé à l'exécution (non sauvegardé) reçoit un nouvel UID au prochain chargement. Pour le scénario Phase 5, **sauvegarder la scène** après placement fige les UID. + +--- + +## 4. Couche de scripting existante + +**Aucune couche de scripting runtime.** + +- Le C# du repo sert **uniquement** à Sharpmake (génération de projets) — `sharpmake/*.sharpmake.cs`. +- Le Python du repo sert **uniquement** aux exporters DCC — `data/Scripts/3dsMAX/`, `data/Scripts/Blender/`. +- La logique de jeu s'écrit en **C++** via des `Behaviour` / `Component` compilés — `src/core/Component/Behaviour/Behaviour.h`, projet exemple `projects/game/src/`. +- Enregistrement des classes : auto-registration statique `AutoRegisterClassInfo::registerClasses(factory)` déclenchée au chargement de chaque DLL — `projects/game/src/Game.cpp:66`. + +> **Conséquence** : l'option « brancher le bridge depuis un script existant » (Phase 1 option A) est **écartée** : il n'y a pas de script. + +--- + +## 5. Console de commandes / debug commands runtime + +**Quasi inexistant.** `src/editor/ImGui/Window/Console/ImGuiConsole.{h,cpp}` est une **console de log** ImGui. +`ImGuiConsole::execute()` — `ImGuiConsole.cpp:422` — ne reconnaît que `CLEAR`, `HELP`, `HISTORY` en dur (`:439-452`). Pas de table de commandes enregistrables, pas d'accès scène. + +> **Conséquence** : pas de canal « commande console » exploitable. + +--- + +## 6. Réseau / IPC existant + +**Aucun.** Recherche `socket`, `WSAStartup`, `listen(`, `recv(`, `httplib`, `WebSocket`, pipe nommé → **0 résultat** dans `src/`. +Le seul `LoadLibrary` hors plugins est le compilateur de shaders DXC (`src/gfx/Shader/dxc/ShaderCompiler_dxc.hpp:33`). +Pas de serveur de remote-debug, live-reload distant ni profiling réseau (Optick est local). + +> **Conséquence** : rien à réutiliser (Phase 1 option D écartée). Tout canal réseau serait à créer. + +--- + +## 7. Boucle de vie / hot-connect + +- Exécutables à la racine : `vgframework_win64_msvc_dx12_{debug,release}.exe` ; `editor.exe` et `game.exe` sont des **copies** post-build (cf. mémoire *build-toolchain-constraints*). +- Boucle : `Engine::RunOneFrame()` — `src/engine/Engine.cpp:838` — appelée en continu par l'application. Elle tourne **en mode éditeur** que l'on soit en Play ou non. +- Ordre par frame (`Engine.cpp:919-996`) : `FixedUpdate` → `physics->Update` → `game->Update` → `world->update` → `LateUpdate` → **`ToolUpdate` (si une vue toolmode est visible, `Engine.cpp:979` / `anyToolmodeViewVisible()` `:1021`)** → `editor->RunOneFrame` → `renderer->RunOneFrame`. +- **`game->ToolUpdate(dt)` et `world->toolUpdate()` sont appelés chaque frame dans l'éditeur, hors Play** — `Engine.cpp:983-991`. C'est le point d'ancrage idéal pour un bridge qui doit fonctionner sans lancer le jeu. +- **Pas de hot-connect** : aucun IPC (cf. §6). Le process tourne en boucle vivante mais on ne peut s'y attacher que par du code chargé **dans** le process. Un changement de code moteur impose un rebuild + relance ; un changement de *données* (scène, `commands.json`) non. + +--- + +## 8. Duplication / instanciation de nœud par code + +**Oui — fonction interne de clonage existante et déjà exposée.** + +- `IObject::Instanciate(InstanciateFlags = 0)` — déclaré `src/core/IObject.h:84`, défini `src/core/Object/Object.cpp:302`. + Implémentation : `Kernel::getFactory()->Instanciate(this, nullptr, flags)` — copie profonde de propriétés + **nouvel UID** — `src/core/Object/Object.cpp:309`. +- `IFactory::Instanciate(const IObject*, IObject* parent, CopyPropertyFlags)` — `src/core/IFactory.h:60`. +- Utilisé par le copier-coller de l'éditeur : `ISelection::DuplicateGameObjects()` — `src/core/ISelection.h` / `src/engine/Selection/Selection.cpp:321`, qui appelle `go->Instanciate()` puis `parentGameObject->AddChild(newGO, index+1)` — `Selection.cpp:335,396`. +- Rattachement : `IGameObject::AddChild(IGameObject*, uint index = -1)` — `src/core/IGameObject.h:21`. +- Création par nom de classe : `IFactory::CreateObject(className, name, parent)` — `src/core/IFactory.h:44` ; `IGameObject::AddComponent(const char* className, name)` — `src/core/IGameObject.h:33`. + +> **Conséquence** : le point bloquant §2 du plan se résout **en faveur de (a)**. V1 peut inclure un `spawn_object(source_uid, transform)` = `FindByUID(source_uid)->Instanciate()` + `AddChild` + `SetLocalMatrix`. Le « cube de référence » est le prefab `data/Prefabs/Box1X1M.prefab` (déjà présent dans le repo). + +--- + +## 9. Système de build + +- **Sharpmake** génère une **solution Visual Studio 2022** (`vgframework_vs2022.sln`). +- Génération : `sharpmake/generate_projects_Windows_msvc.bat` (le variant sans `_msvc` échoue, pas de LLVM — cf. mémoire *build-toolchain-constraints*). +- Build : `MSBuild.exe vgframework_vs2022.sln /p:Configuration=Debug /p:Platform="Win64 MSVC DX12" /m`. +- Un projet = un fichier `sharpmake/vg..sharpmake.cs`. **Tous les `vg.*.sharpmake.cs` sont auto-inclus** : `[module: Sharpmake.Include("vg.*.sharpmake.cs")]` — `sharpmake/main.sharpmake.cs:5`. +- **MAIS** chaque projet doit être ajouté explicitement dans `Solution.ConfigureAll` : `conf.AddProject(target)` — `sharpmake/vg.solution.sharpmake.cs:44-72` (commentaire `:43` : « All projects must be explicitly added here »). +- Modèle de plugin minimal : `sharpmake/vg.game.sharpmake.cs` (18 lignes) + `projects/game/src/Game.{h,cpp,def}` avec `Game.def` exportant `CreateNew`. +- Chargement runtime : `Plugin::createInternal()` — `src/core/Plugin/Plugin.cpp:62` — `LoadLibraryExA` + `GetProcAddress("CreateNew")`. Cherche d'abord `build/bin// [ dx12]/.dll` puis `bin/…`. +- **Il n'y a pas de scan de dossier de plugins** : chaque DLL est chargée par un appel explicite `Plugin::create("nom")` (ex. `src/engine/Engine.cpp:386,413,420,429`). Le nom du plugin `game` vient de `EngineOptions::GetProjectPath()` (`src/engine/EngineOptions.cpp:224`), les autres sont en dur. + +> **Conséquence** : voir `docs/OPEN_QUESTIONS.md` #1 — un nouveau module bridge implique **1 ligne** dans `vg.solution.sharpmake.cs` (fichier existant) + **1 point de chargement** runtime. Ni l'un ni l'autre n'est un « point d'extension » propre : ce sont les câblages minimaux à faire valider. + +--- + +## 10. Sérialisation de scène + +- Format : **XML** maison via `tinyxml2` (`extern/tinyxml2`), API `IFactory::LoadFromXML` / `SaveToXML` / `SerializeFromXML` / `SerializeToXML` — `src/core/IFactory.h:46-50`. +- Fichiers : `data/Worlds/*.world`, `data/Scenes/*.scene`, `data/Prefabs/*.prefab` — tous du XML ``. +- Un `GameObject` sérialise : `m_name`, `m_uid`, `m_flags`, `m_color`, **`m_local` (Float4x4, 16 floats)**, `m_tags`, `m_components`, `m_children` — cf. `data/Prefabs/Box1X1M.prefab`, `data/Scenes/Aiguelongue.scene`. +- Sauvegarde depuis l'API moteur : `IEngine::SaveWorld()` / `SaveWorldAs()` / `SaveScene(IResource*)` — `src/engine/IEngine.h:100-105`. +- `ObjectRuntimeFlags::NotSerialized` (`src/core/IObject.h:25`) : un objet instancié à l'exécution avec ce flag n'est **pas** sauvegardé → à ne PAS mettre si l'on veut que le placement MCP persiste. + +> **Conséquence** : les modifications faites via le bridge (matrice locale) sont persistées telles quelles si l'on appelle `SaveWorld`/`SaveScene`. Le contrat de test Phase 5 (« vérifier après sauvegarde/rechargement ») est réalisable. + +--- + +## 11. Conventions du monde (axes, unités, primitive de référence) + +*(ajouté après validation utilisateur + vérification dans les données, 2026-09-06)* + +### Le monde est **Z-up** + +| Preuve | Source | +|---|---| +| Gravité par défaut `float3(0, 0, -9.81)` | `src/physics/Options/PhysicsOptions.h:44`, `Physics.xml:14` | +| Sur les 1435 transforms de la scène ville `Aiguelongue.scene` : étendue X = 170,5 / Y = 237,0 / **Z = 38,5** → X et Y forment le plan du sol, Z est la verticale | `data/Scenes/Aiguelongue.scene` | + +- **X, Y = plan du sol ; Z = hauteur.** Unités = mètres. +- ⚠️ Piège : l'importeur FBX force `opts.target_axes.up = UFBX_COORDINATE_AXIS_POSITIVE_Y` (`src/renderer/Importer/FBX/UFBXImporter/UFBXImporter.cpp:38`). Ce réglage concerne la conversion d'axes **à l'import du fichier** et ne reflète pas la convention du monde. Ne pas s'y fier : **le monde runtime est Z-up**. +- Conséquence pour la rotation : un cap (yaw) d'angle `a` autour de la verticale est le quaternion `{x:0, y:0, z:sin(a/2), w:cos(a/2)}`. + +### Primitive de référence : `data/Prefabs/Box_Base.prefab` + +- Structure : `Root` (GameObject vide) → enfant `Box_Base` portant un `MeshComponent` → `data/Meshes/Box/BOX_Base/BOX_Base.fbx`. +- **Pivot au centre de la base du mesh** (et non au centre du volume). Donc : + - `position.z` = niveau du sol, directement ; + - `scale.z` = hauteur totale ; **pas d'offset de demi-hauteur à appliquer**. + - Un immeuble 8 × 8 × 20 m ⇒ `scale {x:8, y:8, z:20}` à `position {z:0}`. +- `Box1X1M` (créé le 2026-09-06) est remplacé par `Box_Base` dans ce rôle. + +### Dimensions : mesurées à l'exécution, pas devinées + +Le cache cuit (`cache/data/Meshes/.../*.bin`) n'est pas exploitable de façon fiable pour retrouver l'AABB. Le bridge expose donc les bornes réelles via `IGameObject::TryGetAABB()` (`src/core/GameObject/GameObject.cpp:822`, AABB dans l'espace local **non scalé**, agrégée composants + enfants) : + +- `bounds_local` `{min, max, size}` → montre où tombe le pivot dans le mesh et la taille de référence ; +- `size_world` = `bounds_local.size × scale` → l'emprise réelle. + +--- + +## Synthèse pour les phases suivantes + +| Question plan | Verdict | +|---|---| +| ECS / scene graph | Scene graph hiérarchique `World → Scene → GameObject → Component` | +| Format rotation | `float4x4` (matrice) ; helpers TRS/quaternion dispo côté moteur | +| ID stable | **Oui**, `UID u32` + `IFactory::FindByUID()` ; persisté si scène sauvegardée | +| Scripting | **Aucun** runtime → option A écartée | +| Console commandes | Non exploitable | +| IPC existant | **Aucun** → option D écartée, à créer | +| Boucle / hot-connect | Boucle éditeur vivante (`ToolUpdate` chaque frame hors Play) ; pas de hot-connect | +| Duplication par code | **Oui**, `IObject::Instanciate()` + `AddChild()` → point bloquant §2 résolu en (a) | +| Build | Sharpmake→VS2022 ; nouveau projet = nouveau `vg.*.sharpmake.cs` (auto-inclus) + 1 ligne solution + 1 point de chargement | +| Sérialisation | XML tinyxml2 ; `m_local` sérialisé ; persistance OK via `SaveWorld`/`SaveScene` | + +**Options de communication retenues comme réalistes (détaillées Phase 1) :** +- **B — fichier de commandes** (`commands.json` / `state.json`) poll dans `ToolUpdate`. Le moins invasif fonctionnellement. +- **C — socket TCP loopback** dans un thread du module bridge. Latence faible, un peu plus de code. + +Dans les deux cas le **point d'ancrage dans la boucle** est le sujet de `OPEN_QUESTIONS.md` #1. diff --git a/launch_editor_mcp.cmd b/launch_editor_mcp.cmd new file mode 100644 index 000000000..e5e262eec --- /dev/null +++ b/launch_editor_mcp.cmd @@ -0,0 +1,12 @@ +@echo off +REM Lance l'editeur vgframework avec le bridge MCP actif. +REM La variable DOIT etre posee dans le process qui lance l'exe. + +cd /d "%~dp0" +set "VG_MCP_BRIDGE=1" + +echo [launch_editor_mcp] VG_MCP_BRIDGE=%VG_MCP_BRIDGE% +echo [launch_editor_mcp] cwd = %CD% +echo [launch_editor_mcp] demarrage de editor.exe ... + +start "" "%~dp0editor.exe" diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 000000000..080a038fd --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +_smoke/ +_e2e/ diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 000000000..8281310e2 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,89 @@ +# vgframework MCP server — level design + +MCP server that lets Claude Code drive the **active scene** of the vgframework editor +for level-design: list objects, read/write transforms, clone the reference box. + +See `../docs/architecture.md` for the design and `../docs/data-contract.md` for the schema. + +## How it works + +``` +Claude Code ──stdio──> this server ──writes──> /commands.jsonl + <──reads── /state.json + ▲ │ + the mcpbridge plugin, in-process in the editor +``` + +`` defaults to `mcp/` under the process working directory; override with `VG_MCP_DIR`. +Both sides must point at the **same** folder. + +## Setup + +```bash +cd mcp-server +npm install +npm run build +``` + +### 1. Enable the bridge in the engine + +The `mcpbridge` plugin is compiled into every build but stays completely inert unless +the environment variable `VG_MCP_BRIDGE` is set when the editor starts: + +```bat +set VG_MCP_BRIDGE=1 +vgframework_win64_msvc_dx12_release.exe :: (or editor.exe) +``` + +On startup the log shows: `[MCPBridge] enabled - watching "…/mcp/commands.jsonl"`. + +### 2. Register the server with Claude Code + +```bash +claude mcp add vgframework-leveldesign -- node /absolute/path/to/mcp-server/dist/index.js +``` + +Set `VG_MCP_DIR` in the command if the editor's working directory is not the repo root: + +```bash +claude mcp add vgframework-leveldesign --env VG_MCP_DIR=D:/GitHUB_Repo/mcp -- node .../dist/index.js +``` + +## Tools + +| Tool | Params | Purpose | +|---|---|---| +| `engine_status` | — | Is the editor running and reachable? | +| `list_objects` | `name_contains?`, `parent_id?`, `max_depth?`, `limit?` | Browse one level at a time, or search the whole tree by name | +| `get_transform` | `object_id` | One object's local transform | +| `set_transform` | `object_id`, `position?`, `rotation?`, `scale?` | Move / rotate / resize an existing object | +| `spawn_object` | `source_id`, `name?`, `parent_id?`, `position?`, `rotation?`, `scale?` | Clone an object (e.g. the 1×1×1 box) | +| `save_world` | — | Persist the world (freezes ids, keeps edits across reload) | + +`position` / `scale` are `{x?,y?,z?}` — missing components are left unchanged. +`rotation` is a unit quaternion `{x,y,z,w}`; omit it for axis-aligned boxes. + +## World conventions (important) + +- The engine world is **Z-UP**: X and Y span the ground plane, **Z is height**. Units are metres. + (The FBX importer's `target_axes.up = POSITIVE_Y` is an import-time conversion setting — it does + *not* describe the runtime world. Gravity is `(0, 0, -9.81)`.) +- Reference primitive: **`data/Prefabs/Box_Base.prefab`**, whose **pivot is at the centre of its base**. + So `position.z` is the ground level and `scale.z` is the full height — no half-height offset. + A 8 × 8 × 20 m building is `scale {x:8, y:8, z:20}` at `position {z:0}`. +- Yaw of angle `a` around the vertical axis: `rotation {x:0, y:0, z:sin(a/2), w:cos(a/2)}`. +- `list_objects` / `get_transform` also return `bounds_local` (un-scaled mesh bounds — shows where the + pivot sits) and `size_world` (`bounds × scale`, the real footprint). **Read them before laying out** + rather than assuming dimensions. + +## Notes / limitations (V1) + +- Transform is **local** (relative to parent). +- Newly `spawn_object`-ed objects only get a stable `object_id` after `save_world`. +- `list_objects` is always bounded (see the table above): a full recursive listing of a real + scene is ~9.7 MB of JSON. Default is one level deep; use `name_contains` to search. +- Rotation is validated: a +45° yaw sent through the bridge reads back as +45° in the editor's + own Inspector (which decomposes via the engine's `Float4x4ToTRS`). +- If the editor asserts on `ScreenSpaceAmbient - Editor 0` at startup, that is an upstream WIP + GTAO issue, not the bridge — see `../docs/PHASE3-4-IMPLEMENTATION.md` § "Piège connu". +- Latency is one engine frame + file I/O (fine for batch placement, not for dragging). diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 000000000..0ecae6468 --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1237 @@ +{ + "name": "vgframework-mcp-leveldesign", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vgframework-mcp-leveldesign", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + }, + "bin": { + "vgframework-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 000000000..d0cdf0768 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,22 @@ +{ + "name": "vgframework-mcp-leveldesign", + "version": "0.1.0", + "description": "MCP server bridging Claude Code to the vgframework editor for level-design (see ../docs/architecture.md)", + "type": "module", + "bin": { + "vgframework-mcp": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } +} diff --git a/mcp-server/src/bridgeClient.ts b/mcp-server/src/bridgeClient.ts new file mode 100644 index 000000000..668568075 --- /dev/null +++ b/mcp-server/src/bridgeClient.ts @@ -0,0 +1,172 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; + +// One command line written to commands.jsonl +interface Command { + id: number; + tool: string; + args: Record; +} + +// One entry in state.json "results" +interface CommandResult { + id: number; + tool: string; + ok: boolean; + data?: unknown; + error?: { code: string; message: string; object_id?: string }; +} + +interface EngineState { + engine?: { running?: boolean; playing?: boolean }; + lastProcessedId?: number; + results?: CommandResult[]; +} + +export class BridgeError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + this.name = "BridgeError"; + } +} + +/** + * File-based transport to the in-engine mcpbridge plugin (docs/architecture.md, option B). + * Writes /commands.jsonl (append-only) and polls /state.json for results. + */ +export class BridgeClient { + private readonly dir: string; + private readonly commandsPath: string; + private readonly statePath: string; + private nextId = 1; + private idReady: Promise; + + constructor(dir?: string) { + this.dir = dir ?? process.env.VG_MCP_DIR ?? path.resolve(process.cwd(), "mcp"); + this.commandsPath = path.join(this.dir, "commands.jsonl"); + this.statePath = path.join(this.dir, "state.json"); + this.idReady = this.initId(); + } + + get directory(): string { + return this.dir; + } + + private async initId(): Promise { + // Resume id numbering above whatever is already in the command file so a + // restarted server never reuses an id the engine may have processed. + try { + const text = await fs.readFile(this.commandsPath, "utf8"); + let max = 0; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + try { + const parsed = JSON.parse(t) as Command; + if (typeof parsed.id === "number" && parsed.id > max) max = parsed.id; + } catch { + /* ignore malformed line */ + } + } + this.nextId = max + 1; + } catch { + this.nextId = 1; + } + } + + private async readState(): Promise { + try { + const text = await fs.readFile(this.statePath, "utf8"); + return JSON.parse(text) as EngineState; + } catch { + return null; + } + } + + /** Trim commands.jsonl to only lines the engine has not acknowledged yet. */ + private async compactCommandFile(lastProcessedId: number): Promise { + if (lastProcessedId <= 0) return; + let text: string; + try { + text = await fs.readFile(this.commandsPath, "utf8"); + } catch { + return; + } + const kept: string[] = []; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + try { + const parsed = JSON.parse(t) as Command; + if (typeof parsed.id === "number" && parsed.id > lastProcessedId) kept.push(t); + } catch { + /* drop malformed */ + } + } + const next = kept.length ? kept.join("\n") + "\n" : ""; + await fs.writeFile(this.commandsPath, next, "utf8"); + } + + /** + * Send a command and wait for the engine to report its result. + * @throws BridgeError on engine errors, engine-not-running, or timeout. + */ + async call( + tool: string, + args: Record, + timeoutMs = 15000, + ): Promise { + await this.idReady; + await fs.mkdir(this.dir, { recursive: true }); + + const pre = await this.readState(); + if (pre?.lastProcessedId) { + await this.compactCommandFile(pre.lastProcessedId).catch(() => {}); + } + + const id = this.nextId++; + const line = JSON.stringify({ id, tool, args } satisfies Command) + "\n"; + await fs.appendFile(this.commandsPath, line, "utf8"); + + const deadline = Date.now() + timeoutMs; + let sawState = pre !== null; + + while (Date.now() < deadline) { + await delay(120); + const state = await this.readState(); + if (!state) continue; + sawState = true; + + const hit = state.results?.find((r) => r.id === id); + if (hit) { + if (hit.ok) return hit.data ?? null; + const e = hit.error; + throw new BridgeError(e?.code ?? "UNKNOWN", e?.message ?? "command failed"); + } + } + + if (!sawState) { + throw new BridgeError( + "ENGINE_NOT_RUNNING", + `No response from the engine. Is the vgframework editor running with VG_MCP_BRIDGE set, ` + + `and writing to "${this.statePath}"?`, + ); + } + throw new BridgeError("TIMEOUT", `Engine did not answer command #${id} (${tool}) within ${timeoutMs}ms`); + } + + async status(): Promise<{ running: boolean; playing: boolean; dir: string }> { + const state = await this.readState(); + return { + running: state?.engine?.running === true, + playing: state?.engine?.playing === true, + dir: this.dir, + }; + } +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 000000000..2abfbcfb3 --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,767 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { BridgeClient, BridgeError } from "./bridgeClient.js"; + +const client = new BridgeClient(); + +const vec3 = z + .object({ x: z.number().optional(), y: z.number().optional(), z: z.number().optional() }) + .describe("Any missing component is left unchanged."); + +const quat = z + .object({ x: z.number(), y: z.number(), z: z.number(), w: z.number() }) + .describe( + "Unit quaternion. Identity is {x:0,y:0,z:0,w:1}. The world is Z-up, so a heading (yaw) of " + + "angle a radians around the vertical axis is {x:0, y:0, z:sin(a/2), w:cos(a/2)}. " + + "For axis-aligned boxes, leave it out.", + ); + +/** Shared world-convention preamble injected into the placement tool descriptions. */ +const WORLD_CONVENTIONS = + "WORLD CONVENTIONS: the engine is Z-UP — X and Y span the ground plane, Z is height. " + + "Units are metres. The reference primitive is the prefab data/Prefabs/Box_Base.prefab, " + + "whose PIVOT IS AT THE CENTRE OF ITS BASE: set position.z to the ground level and scale.z to " + + "the full height (no half-height offset). A 8x8x20 m building is scale {x:8,y:8,z:20} at " + + "position {z:0}. list_objects/get_transform also return bounds_local (un-scaled mesh bounds, " + + "which show where the pivot sits) and size_world (bounds x scale, the real footprint) — read " + + "them once before laying anything out instead of assuming dimensions."; + +type ToolResult = { content: Array<{ type: "text"; text: string }>; isError?: boolean }; + +function ok(payload: unknown, note?: string): ToolResult { + const body = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); + return { content: [{ type: "text", text: note ? `${note}\n\n${body}` : body }] }; +} + +function fail(err: unknown): ToolResult { + const msg = err instanceof BridgeError ? `[${err.code}] ${err.message}` : String(err); + return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; +} + +function buildTransform(a: { + position?: Record; + rotation?: Record; + scale?: Record; +}): Record | undefined { + const t: Record = {}; + if (a.position) t.position = a.position; + if (a.rotation) t.rotation = a.rotation; + if (a.scale) t.scale = a.scale; + return Object.keys(t).length ? t : undefined; +} + +const server = new McpServer({ + name: "vgframework-leveldesign", + version: "0.1.0", +}); + +server.registerTool( + "engine_status", + { + title: "Engine status", + description: + "Check whether the vgframework editor is running and reachable through the MCP bridge. " + + "Call this first if other tools time out.", + inputSchema: {}, + }, + async () => { + const s = await client.status(); + return ok( + s, + s.running + ? "Engine is running and answering." + : `Engine not detected. Start the vgframework editor with the VG_MCP_BRIDGE environment variable set. Exchange dir: ${s.dir}`, + ); + }, +); + +server.registerTool( + "list_objects", + { + title: "List scene objects", + description: + "Browse or search the scene graph. Returns each GameObject's stable object_id (UID), name, path, " + + "child_count, local transform, bounds_local and size_world.\n" + + "ALWAYS BOUNDED — a real scene holds thousands of objects, so this never dumps the whole tree:\n" + + " • BROWSE (default): returns only the direct children of the scene roots. Pass parent_id to step " + + "into a node, or max_depth to go deeper. Follow child_count to know where to descend.\n" + + " • SEARCH: pass name_contains to walk the entire tree and return only matching names.\n" + + "The header line lists every scene of the world with its ROOT_ID — pass that as parent_id " + + "(to list) or as the parent of a create_group to work in a specific scene, which is the only " + + "way to reach an empty scene since it has nothing to list.\n" + + "Start with a plain call to see the top-level groups, then drill down or search. " + + WORLD_CONVENTIONS, + inputSchema: { + name_contains: z + .string() + .optional() + .describe("Case-insensitive substring on the object name. Switches to search mode (whole tree)."), + parent_id: z + .string() + .optional() + .describe("List below this object instead of the scene roots."), + max_depth: z + .number() + .int() + .optional() + .describe("Browse mode only. 1 = direct children (default), 0 = unlimited."), + limit: z.number().int().optional().describe("Max objects returned (default 200)."), + detail: z + .enum(["compact", "full"]) + .optional() + .describe( + "compact (default): id, name, path, child_count — one line per object. " + + "full: adds transform, bounds_local and size_world, which is ~40 lines of JSON per " + + "object, so only ask for it on a handful of objects (or use get_transform).", + ), + }, + }, + async (args) => { + try { + const req: Record = {}; + if (args.name_contains) req.name_contains = args.name_contains; + if (args.parent_id) req.parent_id = args.parent_id; + if (args.max_depth !== undefined) req.max_depth = args.max_depth; + if (args.limit !== undefined) req.limit = args.limit; + if (args.detail) req.detail = args.detail; + + const data = (await client.call("list_objects", req)) as { + scenes?: Array<{ name: string; root_id?: string; child_count?: number }>; + returned?: number; + matched?: number; + truncated?: boolean; + mode?: string; + detail?: string; + objects?: Array<{ object_id: string; name: string; path?: string; child_count?: number }>; + }; + + const lines = (data.objects ?? []) + .map( + (o) => + ` ${o.object_id.padStart(10)} ${o.path ?? o.name}` + + (o.child_count ? ` (${o.child_count} children)` : ""), + ) + .join("\n"); + + const where = data.scenes?.length + ? `scene(s) ${data.scenes + .map((s) => `${s.name} [root ${s.root_id ?? "?"}, ${s.child_count ?? 0} children]`) + .join(", ")}` + : "subtree"; + const head = + `${data.mode ?? "browse"} in ${where} — ${data.returned ?? 0} of ${data.matched ?? 0} match(es)` + + (data.truncated ? " (truncated, raise limit or narrow the search)" : ""); + + // In compact mode the one-line-per-object rendering IS the answer: dumping the raw + // JSON as well is what used to blow the tool's token budget past ~170 objects. + if (data.detail !== "full") return ok(`${head}:\n${lines}`); + + return ok(data, `${head}:\n${lines}`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "get_transform", + { + title: "Get object transform", + description: + "Return the local transform of one object (position, rotation quaternion, scale) plus its bounds_local " + + "and size_world. " + WORLD_CONVENTIONS, + inputSchema: { object_id: z.string().describe("Stable object id (UID) from list_objects") }, + }, + async ({ object_id }) => { + try { + return ok(await client.call("get_transform", { object_id })); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "set_transform", + { + title: "Set object transform", + description: + "Update the local transform of one existing object. position / rotation / scale are independently optional; " + + "omitted parts (and omitted x/y/z components) keep their current value. Non-uniform scale on the reference " + + "box produces any rectangular volume. " + WORLD_CONVENTIONS, + inputSchema: { + object_id: z.string().describe("Stable object id (UID) from list_objects"), + position: vec3.optional(), + rotation: quat.optional(), + scale: vec3.optional(), + }, + }, + async ({ object_id, position, rotation, scale }) => { + try { + const args: Record = { object_id }; + if (position) args.position = position; + if (rotation) args.rotation = rotation; + if (scale) args.scale = scale; + return ok(await client.call("set_transform", args)); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "spawn_object", + { + title: "Spawn (clone) an object", + description: + "Clone an existing object (typically a Box_Base instance already in the scene) and place the copy. " + + "Returns the new object_id. By default the clone is parented next to the source; pass parent_id (a " + + "create_group id) to file it under a group. Use spawn_objects instead as soon as you need more than " + + "one or two copies. Newly spawned objects only get a persistent id once the world is saved " + + "(see save_world). " + WORLD_CONVENTIONS, + inputSchema: { + source_id: z.string().describe("object_id of the object to clone (e.g. the 1x1x1 reference box)"), + name: z.string().optional().describe("Name for the new object"), + parent_id: z.string().optional().describe("object_id of the parent (defaults to the source's parent)"), + position: vec3.optional(), + rotation: quat.optional(), + scale: vec3.optional(), + }, + }, + async ({ source_id, name, parent_id, position, rotation, scale }) => { + try { + const args: Record = { source_id }; + if (name) args.name = name; + if (parent_id) args.parent_id = parent_id; + const t = buildTransform({ position, rotation, scale }); + if (t) args.transform = t; + return ok(await client.call("spawn_object", args)); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "spawn_objects", + { + title: "Spawn many clones at once", + description: + "Batch version of spawn_object: one call clones the same source N times, each with its own name and " + + "placement. ALWAYS PREFER THIS over repeated spawn_object calls when laying out more than a couple of " + + "boxes — it is one engine round trip instead of N, and avoids the timeouts that parallel single spawns " + + "hit. Pair it with create_group + parent_id so the result lands under one node instead of hundreds of " + + "siblings at the scene root. Positions are relative to the parent. " + + WORLD_CONVENTIONS, + inputSchema: { + source_id: z.string().describe("object_id of the object to clone (e.g. the 1x1x1 reference box)"), + parent_id: z + .string() + .optional() + .describe("object_id of the parent for every clone (defaults to the source's parent). Use a create_group id."), + items: z + .array( + z.object({ + name: z.string().optional().describe("Name for this clone"), + position: vec3.optional(), + rotation: quat.optional(), + scale: vec3.optional(), + }), + ) + .min(1) + .describe("One entry per clone to create."), + }, + }, + async ({ source_id, parent_id, items }) => { + try { + const args: Record = { source_id, items }; + if (parent_id) args.parent_id = parent_id; + + const data = (await client.call("spawn_objects", args, 60000)) as { + count?: number; + parent_id?: string; + spawned?: Array<{ object_id: string; name: string }>; + failed?: Array<{ name?: string; error?: { code: string; message: string } }>; + }; + + const lines = (data.spawned ?? []) + .map((o) => ` ${o.object_id.padStart(10)} ${o.name}`) + .join("\n"); + const head = `spawned ${data.count ?? 0} object(s) under parent ${data.parent_id ?? "?"}`; + const failures = data.failed?.length + ? `\n${data.failed.length} failed: ${data.failed + .map((f) => `${f.name ?? "?"} (${f.error?.code ?? "?"})`) + .join(", ")}` + : ""; + + return ok(`${head}:\n${lines}${failures}`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "create_group", + { + title: "Create an empty group node", + description: + "Create an empty GameObject to be used as a folder, then pass its object_id as parent_id to " + + "spawn_object / spawn_objects so generated content is grouped by category (one node per district, " + + "building, layer...) instead of hundreds of objects flat at the scene root. Children's positions are " + + "relative to the group, so a group left at the origin keeps child coordinates equal to world " + + "coordinates. Groups can be nested by passing another group's id as parent_id. " + + WORLD_CONVENTIONS, + inputSchema: { + name: z.string().describe("Name of the group node"), + parent_id: z + .string() + .optional() + .describe("object_id of the parent (defaults to the root of the first scene)"), + position: vec3.optional(), + rotation: quat.optional(), + scale: vec3.optional(), + }, + }, + async ({ name, parent_id, position, rotation, scale }) => { + try { + const args: Record = { name }; + if (parent_id) args.parent_id = parent_id; + if (position) args.position = position; + if (rotation) args.rotation = rotation; + if (scale) args.scale = scale; + + const data = (await client.call("create_group", args)) as { object_id?: string; name?: string }; + return ok(`group "${data.name}" created — object_id ${data.object_id} (use it as parent_id)`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "create_scene", + { + title: "Create a new scene", + description: + "Add a new empty scene to the world — the editor's SceneList > New Scene. Use it to build " + + "something in its own scene instead of dropping it into the level the user is working on. " + + "Writes data/Scenes/.scene (refuses to overwrite an existing file or a scene name " + + "already loaded) and the scene appears in the editor's scene list.\n" + + "TWO FOLLOW-UPS MATTER: the scene loads asynchronously, so call list_objects right after to " + + "read its root_id from the scenes header, and use that id as parent_id for everything you " + + "put in it; and the world only remembers the new scene after save_world.", + inputSchema: { + name: z.string().describe("Scene name, also the file name (no path, no extension). E.g. \"City\""), + folder: z + .string() + .optional() + .describe("Destination folder, relative to the project (default \"data/Scenes\")"), + }, + }, + async ({ name, folder }) => { + try { + const args: Record = { name }; + if (folder) args.folder = folder; + const data = (await client.call("create_scene", args)) as { name?: string; file?: string }; + return ok( + `scene "${data.name}" created (${data.file}). It loads asynchronously: call list_objects to ` + + `read its root_id, then save_world to record it in the world file.`, + ); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "create_world", + { + title: "Create a new world", + description: + "Create a new empty .world file and open it in the editor — the editor's File > World > New. " + + "Writes data/Worlds/.world (refuses to overwrite an existing file).\n" + + "THIS REPLACES THE WORLD THE EDITOR CURRENTLY HAS OPEN: the engine holds a single world at a " + + "time, so unsaved changes to the current world are lost and getting back to it means File > " + + "World > Open in the editor. Ask the user before calling it. The previous .world file on disk " + + "is not modified, and neither is the world the standalone game loads at startup (Engine.xml's " + + "Start World), so the game keeps working as before.\n" + + "The new world loads asynchronously and starts with no scene: call create_scene right after, " + + "retrying while it answers WORLD_NOT_READY, then save_world.", + inputSchema: { + name: z.string().describe("World name, also the file name (no path, no extension). E.g. \"City\""), + folder: z + .string() + .optional() + .describe("Destination folder, relative to the project (default \"data/Worlds\")"), + }, + }, + async ({ name, folder }) => { + try { + const args: Record = { name }; + if (folder) args.folder = folder; + const data = (await client.call("create_world", args)) as { + name?: string; + file?: string; + previous_world?: string; + }; + return ok( + `world "${data.name}" created (${data.file}) and opened in the editor, replacing ` + + `"${data.previous_world}". It is empty and loads asynchronously: call create_scene ` + + `(retry while it answers WORLD_NOT_READY), then save_world.`, + ); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "delete_object", + { + title: "Delete objects", + description: + "Remove one or several objects from the scene, with their whole subtree — deleting a group deletes " + + "everything under it. Give object_id for one, or object_ids for a batch. " + + "THIS CANNOT BE UNDONE from the editor (no Ctrl-Z entry is created) and the change only reaches disk " + + "on the next save_world. Scene root objects cannot be deleted.", + inputSchema: { + object_id: z.string().optional().describe("Single object to delete"), + object_ids: z.array(z.string()).optional().describe("Batch of objects to delete"), + }, + }, + async ({ object_id, object_ids }) => { + try { + const args: Record = {}; + if (object_ids?.length) args.object_ids = object_ids; + else if (object_id) args.object_id = object_id; + else return fail(new BridgeError("INVALID_VALUE", "give object_id or object_ids")); + + const data = (await client.call("delete_object", args, 60000)) as { + count?: number; + deleted?: Array<{ object_id: string; name: string }>; + failed?: Array<{ code?: string; message?: string; object_id?: string }>; + }; + + const lines = (data.deleted ?? []).map((o) => ` ${o.object_id.padStart(10)} ${o.name}`).join("\n"); + const failures = data.failed?.length + ? `\n${data.failed.length} failed: ${data.failed + .map((f) => `${f.object_id ?? "?"} (${f.code ?? "?"}: ${f.message ?? ""})`) + .join(", ")}` + : ""; + + return ok(`deleted ${data.count ?? 0} object(s):\n${lines}${failures}`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "save_world", + { + title: "Save the world and its scenes", + description: + "Persist every scene of the world and the world file itself to disk — the editor's 'Save All'. " + + "Nothing the bridge creates or edits survives a reload until this is called, and newly spawned " + + "objects only get a frozen id here. Overwrites the .scene files on disk, so ask before calling it " + + "on a scene the user cares about.", + inputSchema: {}, + }, + async () => { + try { + const data = (await client.call("save_world", {}, 60000)) as { + saved?: boolean; + world_saved?: boolean; + scenes_saved?: number; + scenes?: Array<{ file: string; saved: boolean }>; + }; + const files = (data.scenes ?? []).map((s) => ` ${s.saved ? "ok " : "FAIL"} ${s.file}`).join("\n"); + return ok( + `${data.saved ? "saved" : "PARTIAL SAVE"} — world file ${data.world_saved ? "ok" : "FAILED"}, ` + + `${data.scenes_saved ?? 0} scene(s):\n${files}`, + ); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "set_material", + { + title: "Assign a material", + description: + "Point the material slots of an object's MeshComponent at a .mat file. Without this every clone keeps " + + "the material of whatever it was cloned from, so anything built out of one reference box comes out " + + "monochrome — use it to vary roofs, walls, props.\n" + + "The MeshComponent is looked up in the CHILDREN too, which is where it sits on a prefab instance. " + + "Omit `slot` to paint every slot of the mesh, or give it to target one material ID (a mesh with 2 IDs " + + "has slots 0 and 1 — list_objects/create_prefabs tell you how many). Takes object_ids for a batch.\n" + + "Nothing reaches disk until save_world.", + inputSchema: { + object_id: z.string().optional().describe("Object to repaint"), + object_ids: z.array(z.string()).optional().describe("Batch of objects to repaint"), + material: z + .string() + .describe("Path to the .mat, relative to the project. E.g. \"data/Materials/Grass/Grass_Plastic.mat\""), + slot: z + .number() + .int() + .min(0) + .optional() + .describe("Material slot (material ID) to set. Omit to set them all."), + }, + }, + async ({ object_id, object_ids, material, slot }) => { + try { + const args: Record = { material }; + if (object_ids?.length) args.object_ids = object_ids; + else if (object_id) args.object_id = object_id; + else return fail(new BridgeError("INVALID_VALUE", "give object_id or object_ids")); + if (slot !== undefined) args.slot = slot; + + const data = (await client.call("set_material", args, 60000)) as { count?: number }; + return ok(data, `${data.count ?? 0} object(s) now use ${material}.`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "set_enabled", + { + title: "Show or hide objects", + description: + "Enable or disable an object and its whole subtree — the editor's checkbox. Every scene of a world " + + "shares one coordinate space and they are drawn together, so disabling the scene root of a level you " + + "are not working on is the way to stop it sitting on top of what you build (pass its root_id, which " + + "list_objects reports in the scenes header).\n" + + "A child of a disabled parent stays invisible, so the result reports enabled_in_hierarchy as well. " + + "Nothing reaches disk until save_world.", + inputSchema: { + object_id: z.string().optional().describe("Object to show or hide"), + object_ids: z.array(z.string()).optional().describe("Batch of objects"), + enabled: z.boolean().describe("true to show, false to hide"), + }, + }, + async ({ object_id, object_ids, enabled }) => { + try { + const args: Record = { enabled }; + if (object_ids?.length) args.object_ids = object_ids; + else if (object_id) args.object_id = object_id; + else return fail(new BridgeError("INVALID_VALUE", "give object_id or object_ids")); + + const data = (await client.call("set_enabled", args, 60000)) as { count?: number }; + return ok(data, `${data.count ?? 0} object(s) ${enabled ? "enabled" : "disabled"}.`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "select_object", + { + title: "Select objects in the editor", + description: + "Put objects in the editor's selection, so what the bridge just built can be framed with the editor's " + + "own focus shortcut and inspected in the Inspector. Use it to show the user the result of a batch of " + + "edits instead of describing where to look.\n" + + "This is the reachable half of \"point the camera at it\": the viewport camera is private editor state " + + "re-fed to the view every frame, so the bridge cannot move it without modifying the editor.\n" + + "Pass add:true to extend the current selection, or clear:true with no id to deselect everything.", + inputSchema: { + object_id: z.string().optional().describe("Object to select"), + object_ids: z.array(z.string()).optional().describe("Batch of objects to select"), + add: z.boolean().optional().describe("Add to the current selection instead of replacing it"), + clear: z.boolean().optional().describe("With no id: clear the selection"), + }, + }, + async ({ object_id, object_ids, add, clear }) => { + try { + const args: Record = {}; + if (object_ids?.length) args.object_ids = object_ids; + else if (object_id) args.object_id = object_id; + else if (clear) args.clear = true; + else return fail(new BridgeError("INVALID_VALUE", "give object_id, object_ids, or clear:true")); + if (add) args.add = true; + + const data = (await client.call("select_object", args, 60000)) as { count?: number; cleared?: boolean }; + return ok(data, data.cleared ? "selection cleared." : `${data.count ?? 0} object(s) selected.`); + } catch (e) { + return fail(e); + } + }, +); + +server.registerTool( + "instantiate_prefab", + { + title: "Place a prefab in the scene", + description: + "Put a .prefab file into the scene — the editor's Add Prefab. This is what spawn_object CANNOT do: " + + "spawn_object only clones an object already present in the world, so a prefab with no existing instance " + + "was unreachable. Use this to lay out a prefab library (or anything create_prefabs produced).\n" + + "Give `items` to place several at once in a single engine round trip, or a single position/rotation/scale. " + + "Positions are relative to the parent, so pair it with create_group (or pass a scene root_id as parent_id) " + + "to keep the result tidy.\n" + + "The prefab CONTENT loads asynchronously: the object and its transform exist on return, its children appear " + + "a moment later — call list_objects again to see them. Nothing reaches disk until save_world.\n" + + "WORLD CONVENTIONS: Z-UP (X and Y span the ground, Z is height), metres.", + inputSchema: { + prefab: z + .string() + .describe("Path to the .prefab, relative to the project. E.g. \"data/Prefabs/Giraphon/Giraphon.prefab\""), + parent_id: z + .string() + .optional() + .describe("object_id of the parent (defaults to the first scene root — pass a scene root_id to target another scene)"), + name: z.string().optional().describe("Name of the instance (defaults to the prefab file name)"), + position: vec3.optional(), + rotation: quat.optional(), + scale: vec3.optional(), + items: z + .array( + z.object({ + name: z.string().optional(), + position: vec3.optional(), + rotation: quat.optional(), + scale: vec3.optional(), + }), + ) + .optional() + .describe("Batch: one entry per instance. Replaces the single placement above."), + }, + }, + async ({ prefab, parent_id, name, position, rotation, scale, items }) => { + try { + const args: Record = { prefab }; + if (parent_id) args.parent_id = parent_id; + if (items?.length) args.items = items; + else { + if (name) args.name = name; + if (position) args.position = position; + if (rotation) args.rotation = rotation; + if (scale) args.scale = scale; + } + + const data = (await client.call("instantiate_prefab", args, 60000)) as { + count?: number; + created?: Array<{ object_id: string; name: string }>; + parent_id?: string; + }; + + return ok( + data, + `${data.count ?? 0} instance(s) of ${prefab} placed. Content loads asynchronously — ` + + `call list_objects again to see the children, and save_world to persist.`, + ); + } catch (e) { + return fail(e); + } + }, +); + +type CreatePrefabsResult = { + staged?: number; + pending?: number; + created?: Array<{ + mesh?: string; + prefab?: string; + written?: boolean; + materials?: Array<{ name?: string; file?: string; albedo?: string; normal?: string; pbr?: string }>; + }>; + warnings?: string[]; +}; + +server.registerTool( + "create_prefabs", + { + title: "Create prefabs from FBX files", + description: + "Turn every .fbx found in a folder into a ready-to-use prefab: data/Prefabs//.prefab " + + "holding a MeshComponent, plus one data/Materials//.mat per material ID of the FBX. " + + "Accepts either a single mesh folder (data/Meshes/Giraphon) or a folder holding one folder per mesh.\n" + + "Material names come from the FBX itself, and textures are picked up NEXT TO THE FBX by suffix: " + + "_BaseColor/_Color/_Albedo -> Albedo, _Normal -> Normal, _OcclusionRoughnessMetallic/_ORM/_PBR -> PBR. " + + "A material whose name matches no texture is still written, with a warning - rename the FBX material " + + "or the textures so they agree. Each .mat uses the Default shader, Opaque, cull Back, UV0, tiling 1, offset 0.\n" + + "NOTHING IS OVERWRITTEN by default: an existing file is written as -01, -02... so the result can be " + + "compared with a hand-made asset. Pass overwrite:true to replace in place instead.\n" + + "Importing an FBX is asynchronous, so this polls the engine until every model has resolved.", + inputSchema: { + folder: z + .string() + .describe("Folder to scan, relative to the project. E.g. \"data/Meshes/Giraphon\""), + prefab_folder: z + .string() + .optional() + .describe("Where prefabs are written (default \"data/Prefabs\")"), + material_folder: z + .string() + .optional() + .describe("Where materials are written (default \"data/Materials\")"), + overwrite: z + .boolean() + .optional() + .describe("Replace existing files instead of adding a -01 suffix (default false)"), + }, + }, + async ({ folder, prefab_folder, material_folder, overwrite }) => { + try { + const args: Record = { folder }; + if (prefab_folder) args.prefab_folder = prefab_folder; + if (material_folder) args.material_folder = material_folder; + if (overwrite) args.overwrite = true; + + const first = (await client.call("create_prefabs", args, 60000)) as CreatePrefabsResult; + const staged = first.staged ?? 0; + + // The first call only registers the meshes; importing happens on engine frames. + const created: NonNullable = []; + const warnings: string[] = [...(first.warnings ?? [])]; + let pending = first.pending ?? 0; + + const deadlineMs = Date.now() + 120000; + while (pending > 0 && Date.now() < deadlineMs) { + await new Promise((r) => setTimeout(r, 1000)); + const next = (await client.call("create_prefabs", args, 60000)) as CreatePrefabsResult; + created.push(...(next.created ?? [])); + warnings.push(...(next.warnings ?? [])); + pending = next.pending ?? 0; + } + + if (pending > 0) + return fail( + new BridgeError( + "ENGINE_BUSY", + `${pending} mesh(es) still importing after 120 s — call create_prefabs again to finish them`, + ), + ); + + const note = + `${created.length} prefab(s) written from ${staged} FBX` + + (warnings.length ? `, ${warnings.length} warning(s)` : ""); + + return ok({ created, warnings }, note); + } catch (e) { + return fail(e); + } + }, +); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); + // stderr is safe for logs; stdout is the MCP channel. + console.error(`[vgframework-mcp] ready. Exchange dir: ${client.directory}`); +} + +main().catch((err) => { + console.error("[vgframework-mcp] fatal:", err); + process.exit(1); +}); diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 000000000..8ab49ceb9 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/plan-mcp-leveldesign.md b/plan-mcp-leveldesign.md new file mode 100644 index 000000000..1decacdbb --- /dev/null +++ b/plan-mcp-leveldesign.md @@ -0,0 +1,196 @@ +# Plan — MCP Bridge pour moteur 3D custom (assistance Level Design) + +> **Destinataire de ce document : Claude Code.** +> Ce fichier est un brief d'implémentation. Exécute les phases dans l'ordre. Ne saute pas la Phase 0 : elle conditionne toutes les décisions techniques qui suivent. Si une information manque pour continuer une phase, **arrête-toi et documente la question dans `docs/OPEN_QUESTIONS.md`** plutôt que de deviner ou d'improviser une solution. + +--- + +## 0. Contexte et objectif + +Le moteur 3D est un moteur custom (C++ ~94%, HLSL ~5%, C# <1%, Python <1%) développé par l'auteur. L'objectif est de pouvoir piloter le moteur depuis Claude Code via un serveur MCP (Model Context Protocol), pour accélérer la construction de niveaux (Level Design / LD) — par exemple poser un plan de ville en plaçant des primitives (cubes, etc.) selon une grille de rues/blocks. + +**Portée V1 (ce document) :** lire et modifier la **position / rotation / échelle** d'objets déjà présents dans une scène active. Rien d'autre. + +## 1. Règle d'or — non négociable + +**Aucun fichier existant du moteur ne doit être modifié.** Toute intégration se fait via du code additif : +- nouveaux fichiers, nouveau dossier/module isolé, nouveau projet dans la solution/CMake ; +- si un point d'extension existant (plugin API, hook de scripting, système d'entités déjà extensible) permet de brancher le bridge sans toucher au cœur, l'utiliser en priorité ; +- si aucun point d'extension n'existe et qu'un minimum de câblage est réellement inévitable (ex: enregistrer un nouveau système dans la boucle de frame), le signaler explicitement dans `docs/OPEN_QUESTIONS.md` avec le diff proposé, et attendre validation avant de committer. + +Avant et après chaque phase impliquant du code moteur : `git status` / `git diff` pour prouver qu'aucun fichier existant n'a été touché (uniquement des ajouts). + +## 2. Point bloquant identifié dès maintenant — à vérifier en priorité en Phase 0 + +Le cas d'usage cible ("construire un plan de ville") implique de poser un nombre arbitraire de volumes, pas seulement de repositionner un petit nombre d'objets déjà placés à la main. + +**Mise à jour : un cube de référence 1m × 1m × 1m (mesh unitaire) résout la question « qu'est-ce qu'une primitive ».** Un scale non-uniforme (X/Y/Z indépendants) sur ce cube unique suffit à produire n'importe quel volume rectangulaire — bâtiment, mur, séparateur de rue — sans avoir besoin que le moteur sache générer plusieurs types de primitives (sphère, cylindre, etc.). V1 peut donc reposer sur un seul mesh de référence. + +Ce qui reste à trancher est donc reformulé : **le moteur peut-il dupliquer/instancier un nœud de scène existant par du code externe** (plutôt que « créer une géométrie à partir de rien ») ? C'est une question généralement plus facile à résoudre : un copier-coller d'objet dans l'éditeur repose presque toujours sur une fonction interne de duplication de nœud, qui est un candidat naturel à exposer sans toucher à la structure du moteur. + +- **(a)** Le moteur expose une fonction de duplication/instanciation de nœud (scripting, commande console, API de scène). → V1 peut inclure `spawn_object(source_object_id, transform)` qui clone le cube de référence et applique un nouveau transform. +- **(b)** Aucun moyen de dupliquer sans passer par l'éditeur/UI. → V1 se limite à repositionner un **pool de cubes de référence pré-placés manuellement** (le même cube 1×1×1, dupliqué à la main N fois dans l'éditeur), que le MCP redimensionne/repositionne/masque un par un. Limitation acceptable pour un premier prototype, à communiquer clairement à l'utilisateur. + +**Ne pas supposer (a) par défaut.** La Phase 0 doit trancher factuellement, avec preuve à l'appui (fichier + ligne de code). + +## 3. Hors-scope V1 (explicitement exclu, ne pas implémenter) + +- Création/suppression de mesh custom (hors primitives basiques) +- Matériaux, textures, éclairage, physique, gameplay/scripting de logique +- Undo/redo, historique de modifications +- Édition multi-utilisateur/concurrente +- Tout ce qui touche au build/packaging du jeu final + +--- + +## Phase 0 — Reconnaissance du moteur (lecture seule, aucun code écrit) + +Objectif : cartographier le moteur sans y toucher, pour que les phases suivantes reposent sur des faits vérifiés et non des suppositions. + +Produire `docs/engine-analysis.md` répondant, **avec citation du fichier source et des lignes concernées** pour chaque réponse : + +1. **Représentation des objets de scène** : ECS ? scene graph ? liste de nodes ? Où sont définies les classes/structures `Transform`, `Position`, `Rotation`, `Scale` ? +2. **Format de la rotation** : quaternion, angles d'Euler, ou matrice ? (critique pour éviter des bugs de conversion silencieux) +3. **Identifiant d'objet stable** : chaque objet a-t-il un ID/GUID stable, ou seulement un pointeur/index qui peut changer d'une frame à l'autre ou d'un chargement à l'autre ? Si non → à signaler, un ID stable est un prérequis pour un bridge fiable. +4. **Couche de scripting existante** : le C# et/ou Python présents dans le repo exposent-ils déjà une API de la scène (lister/modifier des objets) ? Si oui, c'est le chemin d'intégration le plus rapide et le moins invasif. +5. **Console de commandes / debug commands runtime** : existe-t-il un système de commandes exécutables au runtime (souvent présent dans les moteurs custom pour le debug) ? +6. **Réseau / IPC existant** : y a-t-il déjà un serveur (RPC, HTTP, socket) pour du remote debugging, du live-reload de shaders, du profiling distant, etc. ? Réutilisable ou non ? +7. **Boucle de vie** : le moteur tourne-t-il en mode éditeur avec une boucle vivante interrogeable en continu, ou uniquement en exécutable compilé/jeu lancé ? Peut-on se connecter à un process déjà lancé (hot-connect), ou faut-il relancer à chaque changement ? +8. **Duplication/instanciation de nœud par code** : existe-t-il une fonction interne du type `DuplicateNode()`, `CloneObject()`, `Instantiate(sourceId, transform)` utilisée par le copier-coller de l'éditeur ou par du contenu de démo ? C'est la fonction clé à trouver — plus généralement disponible qu'une génération de primitive from-scratch (voir point bloquant §2). +9. **Système de build** : CMake, solution Visual Studio, autre ? Comment ajouter un nouveau module/projet sans modifier les fichiers de build existants (nouvelle cible liée en plus, pas de modification des cibles actuelles) ? +10. **Sérialisation de scène** : format de sauvegarde (JSON, binaire propriétaire, autre) ? Utile pour vérifier que les modifications faites via le bridge persistent bien après sauvegarde/rechargement. + +--- + +## Phase 1 — Décision d'architecture : couche de communication + +À documenter dans `docs/architecture.md` (format ADR), **après** la Phase 0, en fonction de ce qui a été découvert. + +### Options (invasivité croissante) + +| Option | Description | Quand la choisir | +|---|---|---| +| **A. Bridge via scripting existant** | Si le moteur expose déjà du C#/Python scriptable, exposer les 3 fonctions cibles directement depuis un script, zéro nouveau code moteur en C++ | Si Phase 0 confirme l'existence d'une couche de scripting fonctionnelle | +| **B. Fichier de commandes (polling)** | Le moteur lit un `commands.json` (watcher ou poll à chaque frame) et écrit un `state.json` en retour | Le plus simple à ajouter sans toucher au réseau, latence plus élevée, bon choix de prototype si aucune option plus légère n'existe | +| **C. Pipe nommé / socket TCP loopback** | Petit thread serveur ajouté dans un module additif, protocole JSON simple | Si le moteur tourne en process persistant (éditeur) et qu'on veut une latence faible | +| **D. Réutilisation d'un canal réseau/debug existant** | Si Phase 0 a trouvé un serveur RPC/debug déjà présent | Le moins de code à écrire, mais dépend fortement de ce qui existe | + +**Recommandation par défaut si rien de spécifique n'est trouvé en Phase 0 : Option B (fichier de commandes)** pour le prototype initial — le moins invasif, le plus rapide à valider de bout en bout, migration vers C possible ensuite si la latence gêne l'usage interactif. + +Documenter le choix retenu, les alternatives écartées et pourquoi (format ADR classique : Contexte / Décision / Options considérées / Conséquences). + +--- + +## Phase 2 — Contrat de données (schéma partagé) + +Schéma JSON minimal, à ajuster selon les réponses de la Phase 0 (notamment le format de rotation) : + +```json +{ + "object_id": "string — identifiant stable, pas un pointeur volatile", + "name": "string", + "primitive_type": "cube | sphere | cylinder | plane | unknown", + "transform": { + "position": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } + } +} +``` + +- Si le moteur utilise des angles d'Euler en interne, garder le quaternion comme contrat MCP (plus robuste) et faire la conversion côté bridge moteur, pas côté serveur MCP. +- Toutes les valeurs de `set_transform` doivent être **optionnelles indépendamment** (on peut vouloir changer seulement `scale` sans toucher à `position`). + +--- + +## Phase 3 — Module bridge côté moteur (code additif C++) + +- Nouveau dossier isolé, nom proposé : `Engine/Modules/MCPBridge/` (chemin exact à adapter à l'arborescence réelle découverte en Phase 0). +- 3 fonctions exposées pour V1 : + - `list_objects()` → liste des objets de la scène active avec leur transform (schéma ci-dessus) + - `get_transform(object_id)` + - `set_transform(object_id, { position?, rotation?, scale? })` +- Intégration dans la boucle moteur : uniquement via un point d'extension existant si Phase 0 en a trouvé un (ex: système enregistrable, subscriber d'events). Sinon, documenter précisément le point d'insertion minimal nécessaire dans `docs/OPEN_QUESTIONS.md` avant de l'appliquer. +- Gestion d'erreurs : objet introuvable, ID invalide, valeurs hors plage → retour d'erreur structuré, jamais un crash silencieux. + +**Signaler immédiatement si :** +- Pas d'identifiant stable par objet (bloquant, voir Phase 0 point 3) +- Pas de moyen d'itérer la liste des objets de la scène depuis l'extérieur du moteur + +--- + +## Phase 4 — Serveur MCP + +- Process séparé, aucune modification du moteur ici (c'est un client externe qui parle au bridge de la Phase 3). +- Stack recommandée : Node.js + TypeScript avec le SDK officiel MCP (`@modelcontextprotocol/sdk`). **Vérifier la documentation officielle actuelle sur modelcontextprotocol.io avant d'écrire le code** : l'API du SDK évolue, ne pas se fier à un exemple mémorisé qui pourrait être obsolète. +- 3 tools MCP exposés, correspondant 1:1 aux fonctions du bridge : + - `list_objects` — pas de paramètre + - `get_transform` — paramètre `object_id` + - `set_transform` — paramètres `object_id`, `position?`, `rotation?`, `scale?` +- Chaque tool doit avoir une description claire et des schémas de paramètres explicites (types, obligatoire/optionnel) — c'est ce que Claude Code lira pour savoir quand et comment les appeler. +- Timeout et gestion de la perte de connexion au moteur (le moteur peut ne pas être lancé) → message d'erreur clair plutôt qu'un timeout muet. + +--- + +## Phase 5 — Cas de test V1 : plan de ville simple + +Scénario de validation de bout en bout : +1. Scène de test avec un pool de cubes de référence 1×1×1 déjà placés (nombre à définir selon la résolution du point bloquant §2). Chaque bâtiment de la ville est obtenu en scalant ce même cube non-uniformément (ex: 8×8×20 pour un immeuble), pas en changeant de mesh. +2. Demander à Claude Code, via le chat Claude Code (pas ce document) : *« Dispose les cubes disponibles en grille 5×5 espacée de 2 unités pour former un plan de blocks de ville, garde une rue de large 1 unité entre chaque ligne. »* +3. Vérifier visuellement dans le moteur que le résultat correspond. +4. Vérifier qu'aucun fichier existant du moteur n'a été modifié (`git status`). + +Si le point bloquant §2 est résolu en faveur de (a) (création de primitives possible), ce scénario peut être étendu à un nombre d'objets arbitraire plutôt qu'un pool fixe — à traiter en V1.1, pas en V1. + +--- + +## Phase 6 — Validation finale + +- Checklist avant de considérer V1 terminé : + - [ ] `docs/engine-analysis.md` complet avec citations + - [ ] `docs/architecture.md` (ADR) rédigé et cohérent avec Phase 0 + - [ ] `docs/OPEN_QUESTIONS.md` à jour, vide si tout est résolu + - [ ] Module bridge compilé sans modifier de fichier existant (diff vérifié) + - [ ] Serveur MCP fonctionnel, testé avec les 3 tools depuis Claude Code + - [ ] Scénario de la Phase 5 validé visuellement dans le moteur + - [ ] Aucune régression sur le build/run normal du moteur sans le bridge activé + +--- + +## Roadmap V1.1+ (hors scope de ce document, à ne pas anticiper maintenant) + +- `create_primitive(type, transform)` si Phase 0 le permet +- `delete_object(object_id)` +- Opérations batch (déplacer/créer N objets en un seul appel, pour limiter les allers-retours) +- Snap-to-grid côté serveur MCP (utilitaire, pas dans le moteur) +- Sauvegarde de scène déclenchable depuis MCP + +--- + +## Arborescence de fichiers attendue en fin de V1 + +``` +docs/ + engine-analysis.md + architecture.md + OPEN_QUESTIONS.md +Engine/Modules/MCPBridge/ (nom/chemin à adapter, voir Phase 3) + ... +mcp-server/ + package.json + src/ + index.ts + tools/ + list_objects.ts + get_transform.ts + set_transform.ts +``` + +--- + +## Questions ouvertes à remonter à l'utilisateur (ne pas trancher seul) + +1. Le point bloquant §2 : primitives pré-placées (pool) vs. création dynamique — dépend de ce que la Phase 0 trouve. +2. Format de rotation interne du moteur (Euler vs quaternion) — impacte le schéma de données. +3. Existence ou non d'un point d'extension propre pour brancher le bridge sans toucher à la boucle de frame existante. +4. Si aucune option de communication n'est vraiment non-invasive (Phase 1), présenter le diff minimal proposé avant de l'appliquer. diff --git a/sharpmake/vg.data.sharpmake.cs b/sharpmake/vg.data.sharpmake.cs index 267d03395..f57513284 100644 --- a/sharpmake/vg.data.sharpmake.cs +++ b/sharpmake/vg.data.sharpmake.cs @@ -147,6 +147,7 @@ public Version() : SourceFilesExcludeRegex.Add(@".*\\editor(\.*)?"); SourceFilesExcludeRegex.Add(@".*\\engine(\.*)?"); SourceFilesExcludeRegex.Add(@".*\\gfx(\.*)?"); + SourceFilesExcludeRegex.Add(@".*\\mcpbridge(\.*)?"); SourceFilesExcludeRegex.Add(@".*\\physics(\.*)?"); SourceFilesExcludeRegex.Add(@".*\\renderer(\.*)?"); } diff --git a/sharpmake/vg.engine.sharpmake.cs b/sharpmake/vg.engine.sharpmake.cs index d9660e947..7bc37b978 100644 --- a/sharpmake/vg.engine.sharpmake.cs +++ b/sharpmake/vg.engine.sharpmake.cs @@ -15,6 +15,10 @@ public override void ConfigureAll(Configuration conf, Target target) base.ConfigureAll(conf, target); conf.AddPrivateDependency(target); conf.LibraryFiles.Add("dinput8.lib", "dxguid.lib"); + + // Compile the additive MCP bridge hook (src/mcpbridge, loaded at runtime only + // when the VG_MCP_BRIDGE env var is set). Remove this line to strip it entirely. + conf.Defines.Add("VG_ENABLE_MCPBRIDGE"); } } } \ No newline at end of file diff --git a/sharpmake/vg.mcpbridge.sharpmake.cs b/sharpmake/vg.mcpbridge.sharpmake.cs new file mode 100644 index 000000000..adfe216d1 --- /dev/null +++ b/sharpmake/vg.mcpbridge.sharpmake.cs @@ -0,0 +1,22 @@ +using Sharpmake; + +namespace vg +{ + // Additive module — MCP bridge for level-design tooling (see docs/architecture.md). + // Auto-included by main.sharpmake.cs ("vg.*.sharpmake.cs"); must also be added + // explicitly in vg.solution.sharpmake.cs (ConfigureAll). + [Sharpmake.Generate] + public class MCPBridge : Project + { + public MCPBridge() : base("mcpbridge", Type.DynamicLibrary) + { + + } + + public override void ConfigureAll(Configuration conf, Target target) + { + base.ConfigureAll(conf, target); + conf.AddPrivateDependency(target); + } + } +} diff --git a/sharpmake/vg.solution.sharpmake.cs b/sharpmake/vg.solution.sharpmake.cs index 7efd7b08c..ea5a92ebe 100644 --- a/sharpmake/vg.solution.sharpmake.cs +++ b/sharpmake/vg.solution.sharpmake.cs @@ -53,6 +53,10 @@ public void ConfigureAll(Configuration conf, Target target) conf.AddProject(target); //conf.AddProject(target, false, "tests"); + // Additive level-design bridge (see docs/architecture.md). Purely optional: + // the engine only loads it when the VG_MCP_BRIDGE environment variable is set. + conf.AddProject(target); + conf.AddProject