sbox-mcp-server 1.5.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/index.js +6 -0
- package/dist/tools/characters.js +51 -0
- package/dist/tools/components.js +2 -2
- package/dist/tools/diagnostics.js +162 -2
- package/dist/tools/gameplay.d.ts +4 -0
- package/dist/tools/gameplay.js +227 -0
- package/dist/tools/npc.d.ts +4 -0
- package/dist/tools/npc.js +195 -0
- package/dist/tools/playmode.js +4 -2
- package/dist/tools/selftest.d.ts +4 -0
- package/dist/tools/selftest.js +121 -0
- package/dist/tools/status.js +28 -7
- package/dist/tools/templates.js +17 -5
- package/dist/tools/world.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sbox-mcp-server
|
|
2
2
|
|
|
3
|
-
MCP Server for the s&box game engine. Lets Claude Code build s&box games through conversation — **
|
|
3
|
+
MCP Server for the s&box game engine. Lets Claude Code build s&box games through conversation — **150+ tools** for scenes, scripts, GameObjects, components, assets, materials, audio, physics, UI, networking, publishing, world-gen, lighting & atmosphere, characters, scene layout, navmesh & spatial queries, particles, self-diagnosis, console/C# execution, live docs search, and type discovery.
|
|
4
4
|
|
|
5
5
|
## Fastest install — the Claude Code plugin
|
|
6
6
|
|
|
@@ -44,7 +44,7 @@ Open your project. The bridge starts automatically. Verify with:
|
|
|
44
44
|
Check the bridge status.
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
You should see `connected: true
|
|
47
|
+
You should see `connected: true` with a healthy `handlerCount`. (That's the editor-side handler count; the server exposes a few more tools total — a handful run MCP-server-side and need no editor handler.)
|
|
48
48
|
|
|
49
49
|
## How it works
|
|
50
50
|
|
|
@@ -54,9 +54,9 @@ Claude Code → (stdio) → sbox-mcp-server → (file IPC) → bridge addon →
|
|
|
54
54
|
|
|
55
55
|
Communication uses file-based IPC through `%TEMP%/sbox-bridge-ipc/`. The MCP server writes request JSON files, the bridge addon (running inside s&box) polls and processes on the main editor thread, then writes response files back. WebSocket is not used — s&box's sandboxed C# environment blocks `System.Net`.
|
|
56
56
|
|
|
57
|
-
## Tools (
|
|
57
|
+
## Tools (v1.5.2)
|
|
58
58
|
|
|
59
|
-
`get_bridge_status` reports `handlerCount
|
|
59
|
+
`get_bridge_status` reports the `handlerCount` — that's the C# handlers compiled inside the editor. Six tools run **MCP-server-side** and need no editor handler: `read_log`, `get_compile_errors`, `execute_csharp`, `search_docs`, `get_doc_page`, `list_doc_categories`. They read the log / hotload-eval / fetch docs directly, so they keep working even when the editor has crashed or stalled.
|
|
60
60
|
|
|
61
61
|
| Category | Tools |
|
|
62
62
|
|----------|-------|
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,9 @@ import { registerObjectTools } from "./tools/objecttools.js";
|
|
|
40
40
|
import { registerDiagnosticTools } from "./tools/diagnostics.js";
|
|
41
41
|
import { registerDocsTools } from "./tools/docs.js";
|
|
42
42
|
import { registerNavigationTools } from "./tools/navigation.js";
|
|
43
|
+
import { registerSelfTestTools } from "./tools/selftest.js";
|
|
44
|
+
import { registerGameplayTools } from "./tools/gameplay.js";
|
|
45
|
+
import { registerNpcTools } from "./tools/npc.js";
|
|
43
46
|
// ── CLI flags ──────────────────────────────────────────────────────
|
|
44
47
|
const args = process.argv.slice(2);
|
|
45
48
|
/** Read the package version from package.json, or return "unknown" on failure. */
|
|
@@ -183,6 +186,9 @@ registerObjectTools(server, bridge);
|
|
|
183
186
|
registerDiagnosticTools(server, bridge);
|
|
184
187
|
registerDocsTools(server, bridge);
|
|
185
188
|
registerNavigationTools(server, bridge);
|
|
189
|
+
registerSelfTestTools(server, bridge);
|
|
190
|
+
registerGameplayTools(server, bridge);
|
|
191
|
+
registerNpcTools(server, bridge);
|
|
186
192
|
/** Start the MCP server on stdio and attempt initial Bridge connection. */
|
|
187
193
|
async function main() {
|
|
188
194
|
const transport = new StdioServerTransport();
|
package/dist/tools/characters.js
CHANGED
|
@@ -205,5 +205,56 @@ export function registerCharacterTools(server, bridge) {
|
|
|
205
205
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
206
206
|
};
|
|
207
207
|
});
|
|
208
|
+
// ── list_animations ───────────────────────────────────────────────── (Batch 33)
|
|
209
|
+
server.tool("list_animations", "List the animation sequences available on a GameObject's SkinnedModelRenderer (a spawned Citizen or animated model), plus whether it's driven by an AnimationGraph. Call this before play_animation or set_animgraph_param to see valid names.", {
|
|
210
|
+
id: z.string().describe("GUID of the GameObject with a SkinnedModelRenderer"),
|
|
211
|
+
}, async (params) => {
|
|
212
|
+
const res = await bridge.send("list_animations", params);
|
|
213
|
+
if (!res.success) {
|
|
214
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
// ── play_animation ─────────────────────────────────────────────────
|
|
221
|
+
server.tool("play_animation", "Play a named animation sequence on a GameObject's SkinnedModelRenderer (sets the Sequence). Best for models with raw sequences; for AnimationGraph characters (Citizen) prefer set_animgraph_param. The renderer needs PlayAnimationsInEditorScene = true to animate in-editor — then screenshot to verify. Use list_animations for valid names.", {
|
|
222
|
+
id: z.string().describe("GUID of the GameObject with a SkinnedModelRenderer"),
|
|
223
|
+
animation: z.string().describe("Sequence name to play (see list_animations)"),
|
|
224
|
+
looping: z.boolean().optional().describe("Loop the sequence (default: the model's setting)"),
|
|
225
|
+
speed: z.number().optional().describe("Playback rate multiplier (default 1)"),
|
|
226
|
+
}, async (params) => {
|
|
227
|
+
const res = await bridge.send("play_animation", params);
|
|
228
|
+
if (!res.success) {
|
|
229
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
// ── set_animgraph_param ────────────────────────────────────────────
|
|
236
|
+
server.tool("set_animgraph_param", "Set an AnimationGraph parameter on a GameObject's SkinnedModelRenderer (calls Set). This drives Citizen/animgraph motion — e.g. 'move_x'/'move_y' (float), 'b_grounded'/'b_ducked' (bool), or a Vector3. Pose previews in-editor when PlayAnimationsInEditorScene is on; screenshot to verify. Param names are defined by the model's animation graph.", {
|
|
237
|
+
id: z.string().describe("GUID of the GameObject with a SkinnedModelRenderer"),
|
|
238
|
+
param: z.string().describe("Animgraph parameter name, e.g. 'move_x', 'b_grounded'"),
|
|
239
|
+
value: z
|
|
240
|
+
.union([
|
|
241
|
+
z.number(),
|
|
242
|
+
z.boolean(),
|
|
243
|
+
z.object({ x: z.number(), y: z.number(), z: z.number() }),
|
|
244
|
+
])
|
|
245
|
+
.describe("Value: number (float), boolean, or {x,y,z} vector"),
|
|
246
|
+
type: z
|
|
247
|
+
.enum(["float", "int", "bool", "vector"])
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("Force the parameter type (default: inferred from value)"),
|
|
250
|
+
}, async (params) => {
|
|
251
|
+
const res = await bridge.send("set_animgraph_param", params);
|
|
252
|
+
if (!res.success) {
|
|
253
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
257
|
+
};
|
|
258
|
+
});
|
|
208
259
|
}
|
|
209
260
|
//# sourceMappingURL=characters.js.map
|
package/dist/tools/components.js
CHANGED
|
@@ -56,7 +56,7 @@ export function registerComponentTools(server, bridge) {
|
|
|
56
56
|
};
|
|
57
57
|
});
|
|
58
58
|
// ── add_component_with_properties ────────────────────────────────
|
|
59
|
-
server.tool("add_component_with_properties", "Add a component to a GameObject and configure its properties in one call. Use list_available_components to find valid types", {
|
|
59
|
+
server.tool("add_component_with_properties", "Add a component to a GameObject and configure its properties in one call (properties PERSIST through save+reload). Use list_available_components to find valid types. Returns appliedProperties + failedProperties so you can see exactly what stuck", {
|
|
60
60
|
id: z.string().describe("GUID of the GameObject"),
|
|
61
61
|
component: z
|
|
62
62
|
.string()
|
|
@@ -64,7 +64,7 @@ export function registerComponentTools(server, bridge) {
|
|
|
64
64
|
properties: z
|
|
65
65
|
.record(z.unknown())
|
|
66
66
|
.optional()
|
|
67
|
-
.describe("Key-value map of property names to values
|
|
67
|
+
.describe("Key-value map of property names to values, each auto-converted to the property's real type. Primitives '5'/true; Color/Vector3 as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('Model':'models/dev/box.vmdl', 'MaterialOverride':'materials/x.vmat'); GameObject/Component refs as a target GUID. Best-effort per key — failures are reported in failedProperties, not silently dropped"),
|
|
68
68
|
}, async (params) => {
|
|
69
69
|
const res = await bridge.send("add_component_with_properties", params);
|
|
70
70
|
if (!res.success) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
4
|
/**
|
|
5
5
|
* Diagnostic tools (Batch 24 — "let Claude see its own errors"): read s&box's
|
|
6
6
|
* editor log so Claude can check compile errors, exceptions, and Log.Info
|
|
@@ -60,6 +60,36 @@ function locateSboxLog() {
|
|
|
60
60
|
}
|
|
61
61
|
return { path: null, tried };
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Derive the editor's screenshots folder from the located log path
|
|
65
|
+
* (<sbox>/logs/sbox-dev.log → <sbox>/screenshots). SBOX_SCREENSHOTS_DIR overrides.
|
|
66
|
+
*/
|
|
67
|
+
function locateScreenshotsDir() {
|
|
68
|
+
if (process.env.SBOX_SCREENSHOTS_DIR)
|
|
69
|
+
return process.env.SBOX_SCREENSHOTS_DIR;
|
|
70
|
+
const { path } = locateSboxLog();
|
|
71
|
+
if (!path)
|
|
72
|
+
return null;
|
|
73
|
+
return join(dirname(dirname(path)), "screenshots");
|
|
74
|
+
}
|
|
75
|
+
/** Newest .png in a dir with mtime strictly greater than afterMs, or null. */
|
|
76
|
+
function newestPng(dir, afterMs) {
|
|
77
|
+
try {
|
|
78
|
+
let best = null;
|
|
79
|
+
for (const f of readdirSync(dir)) {
|
|
80
|
+
if (!f.toLowerCase().endsWith(".png"))
|
|
81
|
+
continue;
|
|
82
|
+
const fp = join(dir, f);
|
|
83
|
+
const m = statSync(fp).mtimeMs;
|
|
84
|
+
if (m > afterMs && (!best || m > best.mtimeMs))
|
|
85
|
+
best = { path: fp, mtimeMs: m };
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
function tailLines(text, n) {
|
|
64
94
|
const lines = text.split(/\r?\n/);
|
|
65
95
|
return lines.slice(Math.max(0, lines.length - n));
|
|
@@ -321,5 +351,135 @@ export function registerDiagnosticTools(server, bridge) {
|
|
|
321
351
|
],
|
|
322
352
|
};
|
|
323
353
|
});
|
|
354
|
+
// ── get_bounds ─────────────────────────────────────────────────────── (bridge, Batch 33)
|
|
355
|
+
server.tool("get_bounds", "Get a GameObject's world-space bounding box — center, size, extents, mins/maxs, and a radius. Useful for placing/framing objects and sizing camera moves. Reads GameObject.GetBounds(); objects with no renderer report empty:true with their world position.", {
|
|
356
|
+
id: z.string().describe("GUID of the GameObject to measure"),
|
|
357
|
+
}, async (params) => {
|
|
358
|
+
const res = await bridge.send("get_bounds", params);
|
|
359
|
+
if (!res.success) {
|
|
360
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
361
|
+
}
|
|
362
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
363
|
+
});
|
|
364
|
+
// ── screenshot_orbit ──────────────────────────────────────────────── (orchestrated, Batch 33)
|
|
365
|
+
server.tool("screenshot_orbit", "Capture a GameObject from several angles in ONE call — orbits the scene's main camera around the object and screenshots each angle, so Claude can verify 3D work from multiple sides instead of guessing from one. Drives get_bounds (framing) + screenshot_from per angle (each its own frame, the reliable capture path). Returns the saved PNG paths in order — READ them to inspect.", {
|
|
366
|
+
id: z.string().describe("GUID of the GameObject to orbit"),
|
|
367
|
+
shots: z
|
|
368
|
+
.number()
|
|
369
|
+
.int()
|
|
370
|
+
.optional()
|
|
371
|
+
.describe("Number of angles around the object (default 4, clamped 2-8)"),
|
|
372
|
+
elevation: z
|
|
373
|
+
.number()
|
|
374
|
+
.optional()
|
|
375
|
+
.describe("Camera height factor: 0 = level, 1 = high (default 0.4)"),
|
|
376
|
+
distance: z
|
|
377
|
+
.number()
|
|
378
|
+
.optional()
|
|
379
|
+
.describe("Camera distance in units (default: auto from bounds)"),
|
|
380
|
+
width: z.number().int().optional().describe("Screenshot width (default 1280)"),
|
|
381
|
+
height: z.number().int().optional().describe("Screenshot height (default 720)"),
|
|
382
|
+
}, async (params) => {
|
|
383
|
+
const b = await bridge.send("get_bounds", { id: params.id });
|
|
384
|
+
if (!b.success) {
|
|
385
|
+
return { content: [{ type: "text", text: `Error (get_bounds): ${b.error}` }] };
|
|
386
|
+
}
|
|
387
|
+
const data = b.data;
|
|
388
|
+
const c = data.center;
|
|
389
|
+
const sizeLen = Math.hypot(data.size.x, data.size.y, data.size.z);
|
|
390
|
+
const dist = params.distance ?? Math.max(sizeLen * 1.6, 150);
|
|
391
|
+
let shots = params.shots ?? 4;
|
|
392
|
+
shots = Math.max(2, Math.min(8, shots));
|
|
393
|
+
const elev = params.elevation ?? 0.4;
|
|
394
|
+
const w = params.width ?? 1280;
|
|
395
|
+
const h = params.height ?? 720;
|
|
396
|
+
const ssDir = locateScreenshotsDir();
|
|
397
|
+
let lastMtime = 0;
|
|
398
|
+
if (ssDir) {
|
|
399
|
+
const n = newestPng(ssDir, 0);
|
|
400
|
+
if (n)
|
|
401
|
+
lastMtime = n.mtimeMs;
|
|
402
|
+
}
|
|
403
|
+
const results = [];
|
|
404
|
+
for (let i = 0; i < shots; i++) {
|
|
405
|
+
// s&box names screenshots at 1-second granularity, so two shots in the
|
|
406
|
+
// same wall-clock second overwrite each other. Space them out.
|
|
407
|
+
if (i > 0)
|
|
408
|
+
await new Promise((res) => setTimeout(res, 1100));
|
|
409
|
+
const ang = (2 * Math.PI * i) / shots;
|
|
410
|
+
const dx = Math.cos(ang);
|
|
411
|
+
const dy = Math.sin(ang);
|
|
412
|
+
const len = Math.hypot(dx, dy, elev) || 1;
|
|
413
|
+
const camPos = {
|
|
414
|
+
x: c.x + (dx / len) * dist,
|
|
415
|
+
y: c.y + (dy / len) * dist,
|
|
416
|
+
z: c.z + (elev / len) * dist,
|
|
417
|
+
};
|
|
418
|
+
const deg = Math.round((ang * 180) / Math.PI);
|
|
419
|
+
const r = await bridge.send("screenshot_from", {
|
|
420
|
+
position: camPos,
|
|
421
|
+
lookAt: c,
|
|
422
|
+
width: w,
|
|
423
|
+
height: h,
|
|
424
|
+
});
|
|
425
|
+
if (!r.success) {
|
|
426
|
+
results.push({ angle: deg, error: r.error });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
let file = null;
|
|
430
|
+
if (ssDir) {
|
|
431
|
+
const started = Date.now();
|
|
432
|
+
while (Date.now() - started < 4000) {
|
|
433
|
+
const n = newestPng(ssDir, lastMtime);
|
|
434
|
+
if (n) {
|
|
435
|
+
file = n.path;
|
|
436
|
+
lastMtime = n.mtimeMs;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
await new Promise((res) => setTimeout(res, 200));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
results.push({ angle: deg, position: camPos, file });
|
|
443
|
+
}
|
|
444
|
+
const files = results
|
|
445
|
+
.filter((s) => typeof s.file === "string")
|
|
446
|
+
.map((s) => s.file);
|
|
447
|
+
const summary = {
|
|
448
|
+
orbited: data.name ?? params.id,
|
|
449
|
+
center: c,
|
|
450
|
+
distance: Math.round(dist),
|
|
451
|
+
shots: results,
|
|
452
|
+
note: ssDir
|
|
453
|
+
? `Captured ${files.length}/${shots} angle(s). READ these PNGs to inspect the object from each side:\n${files.join("\n")}`
|
|
454
|
+
: `Captured ${shots} angle(s), but couldn't locate the screenshots folder (set SBOX_LOG_PATH or SBOX_SCREENSHOTS_DIR). Read the newest PNGs in <sbox>/screenshots/.`,
|
|
455
|
+
};
|
|
456
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
457
|
+
});
|
|
458
|
+
// ── capture_view ──────────────────────────────────────────────────── (bridge, Batch 34)
|
|
459
|
+
server.tool("capture_view", "Capture a PNG of the scene from a camera — and crucially this WORKS IN PLAY MODE, capturing the RUNNING game (via CameraComponent.RenderToBitmap, unlike take_screenshot/screenshot_from which are edit-only). With no args it renders the live main camera = the player's POV (incl. HUD). Pass position {x,y,z} (+ lookAt or rotation) or id (a GameObject to frame) to capture from a temporary camera that never disturbs the game's own camera. Returns the saved PNG's absolute 'path' — READ it to see the result.", {
|
|
460
|
+
id: z.string().optional().describe("GUID of a GameObject to frame (uses a temp camera)"),
|
|
461
|
+
position: z
|
|
462
|
+
.object({ x: z.number(), y: z.number(), z: z.number() })
|
|
463
|
+
.optional()
|
|
464
|
+
.describe("Camera world position (temp camera; use instead of id)"),
|
|
465
|
+
lookAt: z
|
|
466
|
+
.object({ x: z.number(), y: z.number(), z: z.number() })
|
|
467
|
+
.optional()
|
|
468
|
+
.describe("World point to look at (pair with position)"),
|
|
469
|
+
rotation: z
|
|
470
|
+
.object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
|
|
471
|
+
.optional()
|
|
472
|
+
.describe("Explicit camera rotation (pair with position)"),
|
|
473
|
+
fov: z.number().optional().describe("Field of view for the temp camera"),
|
|
474
|
+
renderUI: z.boolean().optional().describe("Include UI/HUD (default true). Renders world + world-space UI but NOT fullscreen screen-space panels (lobby/title overlays) — so capture_view sees 'through' menus; use take_screenshot for screen-space UI."),
|
|
475
|
+
width: z.number().int().optional().describe("Width (default 1280)"),
|
|
476
|
+
height: z.number().int().optional().describe("Height (default 720)"),
|
|
477
|
+
}, async (params) => {
|
|
478
|
+
const res = await bridge.send("capture_view", params);
|
|
479
|
+
if (!res.success) {
|
|
480
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
481
|
+
}
|
|
482
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
483
|
+
});
|
|
324
484
|
}
|
|
325
485
|
//# sourceMappingURL=diagnostics.js.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Gameplay scaffold tools — Phase 1 of "playable game in one ask".
|
|
4
|
+
*
|
|
5
|
+
* Two low-level capability gap-fillers:
|
|
6
|
+
* - set_component_reference wire a component property to a LIVE scene object
|
|
7
|
+
* - add_component_to_new_object atomic create-GO + add-component + props
|
|
8
|
+
*
|
|
9
|
+
* Three system scaffolds (generate a clean, self-contained .cs, optionally place it):
|
|
10
|
+
* - create_objective_system the win/lose primitive (ObjectiveManager)
|
|
11
|
+
* - create_health_system Health/damage component
|
|
12
|
+
* - create_pickup trigger-based collectible
|
|
13
|
+
*
|
|
14
|
+
* The `sbox-scaffold-game` skill orchestrates these into a playable starter.
|
|
15
|
+
* All scene/file-mutating; refused during play mode by the bridge dispatch.
|
|
16
|
+
*/
|
|
17
|
+
const Vector3Schema = z
|
|
18
|
+
.object({
|
|
19
|
+
x: z.number().describe("X coordinate"),
|
|
20
|
+
y: z.number().describe("Y coordinate"),
|
|
21
|
+
z: z.number().describe("Z coordinate"),
|
|
22
|
+
})
|
|
23
|
+
.describe("3D vector with x, y, z components");
|
|
24
|
+
const RotationSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
pitch: z.number().describe("Pitch angle in degrees"),
|
|
27
|
+
yaw: z.number().describe("Yaw angle in degrees"),
|
|
28
|
+
roll: z.number().describe("Roll angle in degrees"),
|
|
29
|
+
})
|
|
30
|
+
.describe("Euler rotation with pitch, yaw, roll in degrees");
|
|
31
|
+
export function registerGameplayTools(server, bridge) {
|
|
32
|
+
// ── set_component_reference ───────────────────────────────────────
|
|
33
|
+
// Assign one scene GameObject (or a component on it) to a component property.
|
|
34
|
+
// set_property can now also set a GameObject/Component ref from a GUID, but this
|
|
35
|
+
// tool is the ergonomic choice for refs: it can pull a specific component type off
|
|
36
|
+
// the target (targetComponent) and validates the wiring. set_prefab_ref is for
|
|
37
|
+
// PREFAB assets. Wires live scene objects: Spawner.SpawnPoint = thatEmpty,
|
|
38
|
+
// Camera follows thatPlayer, Door.Hinge = thatPivot.
|
|
39
|
+
server.tool("set_component_reference", "Wire a component's GameObject/Component-typed property to ANOTHER live object in the scene by GUID (e.g. ObjectiveManager.Player = the player, a camera's follow target, a door's hinge). Preferred for object/component refs (can pick a specific component type off the target via targetComponent, and validates). set_property also accepts a GUID for ref props; set_prefab_ref is for prefab assets. Set clear:true to null the reference", {
|
|
40
|
+
id: z
|
|
41
|
+
.string()
|
|
42
|
+
.describe("GUID of the GameObject that HOLDS the component you're writing into"),
|
|
43
|
+
component: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe("Component type name on that object (e.g. 'ObjectiveManager', 'CameraComponent')"),
|
|
46
|
+
property: z
|
|
47
|
+
.string()
|
|
48
|
+
.describe("The property to set (must be a GameObject- or Component-typed property)"),
|
|
49
|
+
targetId: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("GUID of the GameObject to reference. Required unless clear:true"),
|
|
53
|
+
targetComponent: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("If the property is a Component subtype, the specific component type to pull off the target object. Omit to auto-match by the property's type"),
|
|
57
|
+
clear: z
|
|
58
|
+
.boolean()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("If true, set the reference to null instead of assigning a target"),
|
|
61
|
+
}, async (params) => {
|
|
62
|
+
const res = await bridge.send("set_component_reference", params);
|
|
63
|
+
if (!res.success) {
|
|
64
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
// ── add_component_to_new_object ───────────────────────────────────
|
|
71
|
+
server.tool("add_component_to_new_object", "Create a new GameObject, add a component, set its properties, and optionally parent/position/tag it — all in one atomic call. Collapses the create_gameobject → add_component_with_properties → set_parent sequence. NOTE: a freshly GENERATED component type only resolves after a trigger_hotload; generate the script, hotload, THEN call this", {
|
|
72
|
+
name: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Display name for the new GameObject. Defaults to the component type name"),
|
|
76
|
+
component: z
|
|
77
|
+
.string()
|
|
78
|
+
.describe("Component type name to add (e.g. 'CameraComponent', 'ObjectiveManager'). Use list_available_components to find valid types"),
|
|
79
|
+
properties: z
|
|
80
|
+
.record(z.unknown())
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Key-value map of property names to values, auto-converted to the right type (same convention as add_component_with_properties)"),
|
|
83
|
+
position: Vector3Schema.optional().describe("World position"),
|
|
84
|
+
rotation: RotationSchema.optional().describe("World rotation"),
|
|
85
|
+
scale: Vector3Schema.optional().describe("World scale (per-axis)"),
|
|
86
|
+
parentId: z
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("GUID of a parent GameObject. Omit for scene root"),
|
|
90
|
+
tags: z
|
|
91
|
+
.array(z.string())
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Tags to add to the new GameObject (e.g. ['player'])"),
|
|
94
|
+
}, async (params) => {
|
|
95
|
+
const res = await bridge.send("add_component_to_new_object", params);
|
|
96
|
+
if (!res.success) {
|
|
97
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
// ── create_objective_system ───────────────────────────────────────
|
|
104
|
+
// The win/lose primitive — turns "objects in a scene" into "a game with a goal".
|
|
105
|
+
server.tool("create_objective_system", "Generate an ObjectiveManager component — the win/lose brain of a game. Tracks an objective (collect_all / reach_goal / survive_time / eliminate_all), fires a win, and handles a lose condition (fall below kill-Z / timer / out of lives). Self-contained C#; other systems call ObjectiveManager.Instance. Optionally placed as a scene singleton", {
|
|
106
|
+
name: z
|
|
107
|
+
.string()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe("Class name. Defaults to 'ObjectiveManager'"),
|
|
110
|
+
directory: z
|
|
111
|
+
.string()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Subdirectory for the .cs file. Defaults to 'Code'"),
|
|
114
|
+
objective: z
|
|
115
|
+
.enum(["collect_all", "reach_goal", "survive_time", "eliminate_all"])
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Win condition. Defaults to 'reach_goal'"),
|
|
118
|
+
targetCount: z
|
|
119
|
+
.number()
|
|
120
|
+
.int()
|
|
121
|
+
.optional()
|
|
122
|
+
.describe("How many to collect/eliminate (for collect_all / eliminate_all). Defaults to 3"),
|
|
123
|
+
timeLimit: z
|
|
124
|
+
.number()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Seconds — survive this long to win (survive_time) or before losing (loseOn=timer). Defaults to 60"),
|
|
127
|
+
loseOn: z
|
|
128
|
+
.enum(["fall", "timer", "lives", "none"])
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Lose condition. 'fall' = player drops below killZ. Defaults to 'fall'"),
|
|
131
|
+
killZ: z
|
|
132
|
+
.number()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("World Z below which the player is considered fallen out of the world. Defaults to -1000"),
|
|
135
|
+
lives: z
|
|
136
|
+
.number()
|
|
137
|
+
.int()
|
|
138
|
+
.optional()
|
|
139
|
+
.describe("Lives before game over (loseOn=lives). Defaults to 1"),
|
|
140
|
+
placeInScene: z
|
|
141
|
+
.boolean()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Place the manager as a scene singleton. Defaults to true. (Only attaches if the type is already loaded — generate, hotload, then it places; otherwise add it after hotload.)"),
|
|
144
|
+
}, async (params) => {
|
|
145
|
+
const res = await bridge.send("create_objective_system", params);
|
|
146
|
+
if (!res.success) {
|
|
147
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
// ── create_health_system ──────────────────────────────────────────
|
|
154
|
+
server.tool("create_health_system", "Generate a Health component: MaxHealth, [Sync] CurrentHealth, TakeDamage/Heal, an OnDeath event, optional regen and respawn. Host-authoritative damage when networked, single-player safe. Optionally attached to an existing GameObject by GUID", {
|
|
155
|
+
name: z.string().optional().describe("Class name. Defaults to 'Health'"),
|
|
156
|
+
directory: z
|
|
157
|
+
.string()
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("Subdirectory for the .cs file. Defaults to 'Code'"),
|
|
160
|
+
maxHealth: z
|
|
161
|
+
.number()
|
|
162
|
+
.optional()
|
|
163
|
+
.describe("Starting/maximum health. Defaults to 100"),
|
|
164
|
+
regen: z
|
|
165
|
+
.boolean()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("Include passive health regeneration after a delay. Defaults to false"),
|
|
168
|
+
respawn: z
|
|
169
|
+
.boolean()
|
|
170
|
+
.optional()
|
|
171
|
+
.describe("On death, respawn at a RespawnPoint (wire it with set_component_reference) instead of disabling. Defaults to false"),
|
|
172
|
+
targetId: z
|
|
173
|
+
.string()
|
|
174
|
+
.optional()
|
|
175
|
+
.describe("GUID of an existing GameObject to attach the Health component to (only attaches if the type is already loaded — hotload first)"),
|
|
176
|
+
}, async (params) => {
|
|
177
|
+
const res = await bridge.send("create_health_system", params);
|
|
178
|
+
if (!res.success) {
|
|
179
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
// ── create_pickup ─────────────────────────────────────────────────
|
|
186
|
+
server.tool("create_pickup", "Generate a trigger-based collectible component. On enter by a tagged object it raises OnCollected (wire it to your objective/score system) and despawns. Optionally builds a visible pickup GameObject with a trigger SphereCollider (+ a model) in one call", {
|
|
187
|
+
name: z.string().optional().describe("Class name. Defaults to 'Pickup'"),
|
|
188
|
+
directory: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("Subdirectory for the .cs file. Defaults to 'Code'"),
|
|
192
|
+
action: z
|
|
193
|
+
.enum(["score", "heal", "item", "custom"])
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("Effect flavour (all self-contained; the heal/item branches show the typed call to a companion system in comments). Defaults to 'score'"),
|
|
196
|
+
amount: z
|
|
197
|
+
.number()
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Magnitude of the effect (score points, heal amount). Defaults to 1"),
|
|
200
|
+
filterTag: z
|
|
201
|
+
.string()
|
|
202
|
+
.optional()
|
|
203
|
+
.describe("Only collect for objects with this tag. Defaults to 'player'"),
|
|
204
|
+
placeInScene: z
|
|
205
|
+
.boolean()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe("Also build a pickup GameObject (trigger SphereCollider + optional model). Defaults to false"),
|
|
208
|
+
position: Vector3Schema.optional().describe("World position when placeInScene is true"),
|
|
209
|
+
radius: z
|
|
210
|
+
.number()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("Trigger sphere radius when placed. Defaults to 24"),
|
|
213
|
+
model: z
|
|
214
|
+
.string()
|
|
215
|
+
.optional()
|
|
216
|
+
.describe("Optional model path for a visible pickup (e.g. 'models/dev/box.vmdl'). Cloud assets must be installed first"),
|
|
217
|
+
}, async (params) => {
|
|
218
|
+
const res = await bridge.send("create_pickup", params);
|
|
219
|
+
if (!res.success) {
|
|
220
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=gameplay.js.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* NPC Brains (Feature Wave #3) — give bridge-built NPCs actual behavior.
|
|
4
|
+
*
|
|
5
|
+
* create_npc_brain generates a finite-state-machine Component (idle/patrol/
|
|
6
|
+
* wander/chase/search/flee/ambush) driven by occlusion-aware perception (FOV
|
|
7
|
+
* cone + range + line-of-sight trace + hearing) with last-known-position
|
|
8
|
+
* memory — the decision layer on top of the existing movement substrate
|
|
9
|
+
* (bake_navmesh / get_navmesh_path / create_npc_controller). place_patrol_route
|
|
10
|
+
* + assign_patrol_route make a patrol authorable end-to-end; create_npc_spawner
|
|
11
|
+
* is the swarm/wave backbone. simulate_npc_perception is the keystone verifier:
|
|
12
|
+
* it runs the EXACT line-of-sight check the brain uses, in EDIT mode, without
|
|
13
|
+
* play — so "does the tree block the NPC's view" is checkable structurally
|
|
14
|
+
* (the bridge can't see a real chase in a single static screenshot).
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the templates.ts / navigation.ts module shape: zod params, one
|
|
17
|
+
* bridge.send per tool, JSON.stringify(res.data) on success.
|
|
18
|
+
*/
|
|
19
|
+
const Vec3 = z
|
|
20
|
+
.object({
|
|
21
|
+
x: z.number().describe("X"),
|
|
22
|
+
y: z.number().describe("Y"),
|
|
23
|
+
z: z.number().describe("Z"),
|
|
24
|
+
})
|
|
25
|
+
.describe("A world-space Vector3");
|
|
26
|
+
export function registerNpcTools(server, bridge) {
|
|
27
|
+
// ── create_npc_brain ──────────────────────────────────────────────
|
|
28
|
+
server.tool("create_npc_brain", "Generate an NpcBrain Component: a behavior state machine (Idle/Patrol/Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception — FOV cone + sight range + a line-of-sight trace (respects walls/trees) + proximity hearing — with last-known-position memory (lose-LOS -> search -> give up -> resume). This is the decision layer on top of bake_navmesh / NavMeshAgent movement. Pick a behavior preset, then tune via the generated [Property] fields with set_property. After generating: trigger_hotload + get_compile_errors, place a route with place_patrol_route + assign_patrol_route, bake_navmesh, and verify perception in EDIT mode with simulate_npc_perception (chase/search behavior needs play mode). The component is added to a GameObject like any other; it auto-adds a NavMeshAgent in OnStart.", {
|
|
29
|
+
name: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Class/file name. Defaults to 'NpcBrain'. Sanitized to a valid C# identifier."),
|
|
33
|
+
directory: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
|
|
37
|
+
behavior: z
|
|
38
|
+
.enum(["patrol", "guard", "hunter", "swarm", "skittish"])
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Preset (sets StartState + flee toggle): 'patrol' (walk waypoints), 'guard' (Ambush near spawn until a target enters range), 'hunter' (patrol->chase->search, the Sasquatch), 'swarm' (wander/idle->chase nearest, RUN mobs), 'skittish' (chase but flee on low health). The generated file is the same shape; the preset just changes defaults. Defaults to 'hunter'."),
|
|
41
|
+
targetTag: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Tag the NPC hunts (its candidates are GameObjects with this tag). Defaults to 'player'."),
|
|
45
|
+
moveSpeed: z.number().optional().describe("Patrol/wander speed (NavMeshAgent MaxSpeed). Default 130."),
|
|
46
|
+
chaseSpeed: z.number().optional().describe("Chase/flee speed. Default 200."),
|
|
47
|
+
sightRange: z.number().optional().describe("Max sight distance. Default 1500."),
|
|
48
|
+
fovDegrees: z
|
|
49
|
+
.number()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Full field-of-view cone angle in degrees. Default 110. (Baked into a cosine threshold for cheap, trig-free checks.)"),
|
|
52
|
+
eyeHeight: z.number().optional().describe("Trace origin height above the NPC's feet. Default 64."),
|
|
53
|
+
hearingRadius: z
|
|
54
|
+
.number()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Proximity-hearing radius — a target within it is investigated (sets last-known-pos) but NOT instantly aggroed. Default 600."),
|
|
57
|
+
giveUpTime: z
|
|
58
|
+
.number()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("Seconds to search after losing line-of-sight before giving up and resuming the start state. Default 6."),
|
|
61
|
+
searchRadius: z.number().optional().describe("Wander radius around the last-known position while searching. Default 400."),
|
|
62
|
+
waypointStopDistance: z
|
|
63
|
+
.number()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("How close the NPC must get to a waypoint/target before it counts as reached. Default 80."),
|
|
66
|
+
canFlee: z.boolean().optional().describe("Enable the Flee state (else the NPC never flees). Defaults from the preset."),
|
|
67
|
+
fleeHealthFrac: z
|
|
68
|
+
.number()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Flee when CurrentHealthFrac drops to/below this (the game sets CurrentHealthFrac 0..1). Default 0.25."),
|
|
71
|
+
networked: z
|
|
72
|
+
.boolean()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("When true (default), emit a host-authoritative brain: 'if (IsProxy) return;' + [Sync] CurrentState. NOTE: a no-session solo playtest makes everything a proxy, so a networked brain won't think until a host session exists — pass false to iterate solo in the edit scene."),
|
|
75
|
+
}, async (params) => {
|
|
76
|
+
const res = await bridge.send("create_npc_brain", params);
|
|
77
|
+
if (!res.success) {
|
|
78
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
// ── place_patrol_route ────────────────────────────────────────────
|
|
85
|
+
server.tool("place_patrol_route", "Place a set of waypoint GameObjects (tagged empties) for a patrol route and group them under a parent route object — authorable in one call. Optionally snaps each point to the ground (raycast down) so waypoints sit on the navmesh, not floating. Returns the route parent GUID + ordered waypoint GUIDs to feed into assign_patrol_route. Validate connectivity afterward with get_navmesh_path between consecutive waypoints (catches a 'point in a wall').", {
|
|
86
|
+
points: z
|
|
87
|
+
.array(Vec3)
|
|
88
|
+
.min(2)
|
|
89
|
+
.describe("Ordered world positions for the route (at least 2)."),
|
|
90
|
+
name: z.string().optional().describe("Route name. Defaults to 'PatrolRoute'. Waypoints are named <route>_WP0, _WP1, ..."),
|
|
91
|
+
tag: z.string().optional().describe("Tag applied to each waypoint. Defaults to 'waypoint'."),
|
|
92
|
+
snapToGround: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Drop each point onto the surface below via a downward raycast. Default true."),
|
|
96
|
+
parentId: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe("Existing parent GameObject GUID to nest the waypoints under; otherwise a new route empty is created at the points' centroid."),
|
|
100
|
+
}, async (params) => {
|
|
101
|
+
const res = await bridge.send("place_patrol_route", params);
|
|
102
|
+
if (!res.success) {
|
|
103
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
// ── assign_patrol_route ───────────────────────────────────────────
|
|
110
|
+
server.tool("assign_patrol_route", "Wire a placed route (or an arbitrary ordered GUID list) into an NpcBrain's Waypoints list on a target NPC. This is the list-of-GameObject-references case that plain set_property can't express. Pass either waypointIds (explicit order) or routeId (a route parent whose children become the waypoints in hierarchy order). The list count is returned; List<GameObject> refs may read back as handles/GUIDs via get_property, so trust the count or confirm patrol in play mode.", {
|
|
111
|
+
npcId: z
|
|
112
|
+
.string()
|
|
113
|
+
.describe("GUID of the GameObject holding the NpcBrain (or any component with a List<GameObject> waypoint property)."),
|
|
114
|
+
waypointIds: z
|
|
115
|
+
.array(z.string())
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Ordered waypoint GameObject GUIDs (e.g. from place_patrol_route). Takes precedence over routeId."),
|
|
118
|
+
routeId: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("A route parent GUID whose children (in hierarchy order) become the waypoints."),
|
|
122
|
+
property: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("The List<GameObject> property name to set. Defaults to 'Waypoints'. (Use 'SpawnPoints' to wire spawn points on a spawner.)"),
|
|
126
|
+
}, async (params) => {
|
|
127
|
+
const res = await bridge.send("assign_patrol_route", params);
|
|
128
|
+
if (!res.success) {
|
|
129
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
// ── create_npc_spawner ────────────────────────────────────────────
|
|
136
|
+
server.tool("create_npc_spawner", "Generate a spawner Component that instantiates an NPC prefab over time / in escalating waves at spawn points, capped by maxAlive. RUN's swarm backbone and Sasquatched's round-start spawn. After generating: set NpcPrefab via set_prefab_ref, set SpawnPoints (reuse place_patrol_route to make a set of empties, then assign_patrol_route with property='SpawnPoints'), trigger_hotload + get_compile_errors. Verify by watching the GameObject count over time in play mode. Networked spawns use NetworkSpawn() and are host-only.", {
|
|
137
|
+
name: z.string().optional().describe("Class/file name. Defaults to 'NpcSpawner'."),
|
|
138
|
+
directory: z.string().optional().describe("Subdirectory under the project root. Defaults to 'Code'."),
|
|
139
|
+
mode: z
|
|
140
|
+
.enum(["continuous", "waves", "burst"])
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("'continuous' (one every interval), 'waves' (a batch every interval, waveCount times), 'burst' (one batch then stop). Default 'waves'."),
|
|
143
|
+
count: z.number().optional().describe("NPCs per wave (waves) or per batch (burst/continuous batch). Default 5."),
|
|
144
|
+
interval: z.number().optional().describe("Seconds between spawns (continuous) or between waves (waves). Default 8."),
|
|
145
|
+
waveCount: z.number().optional().describe("Number of waves (waves mode). Default 3."),
|
|
146
|
+
waveGrowth: z
|
|
147
|
+
.number()
|
|
148
|
+
.optional()
|
|
149
|
+
.describe("Multiply count each wave (>1 = escalating). Default 1.0."),
|
|
150
|
+
radius: z.number().optional().describe("Random scatter radius around a spawn point. Default 200."),
|
|
151
|
+
maxAlive: z
|
|
152
|
+
.number()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe("Cap on concurrent live NPCs (important so swarms don't melt the frame rate). Default 12."),
|
|
155
|
+
networked: z
|
|
156
|
+
.boolean()
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("When true (default), spawn via NetworkSpawn() (host-only, try/catch solo-safe) so clients see the NPCs; false = a plain local Clone for solo/edit testing."),
|
|
159
|
+
}, async (params) => {
|
|
160
|
+
const res = await bridge.send("create_npc_spawner", params);
|
|
161
|
+
if (!res.success) {
|
|
162
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
// ── simulate_npc_perception ───────────────────────────────────────
|
|
169
|
+
server.tool("simulate_npc_perception", "READ-ONLY edit-mode verifier: evaluate the NPC's perception math RIGHT NOW without entering play mode. Given an NPC (reads its NpcBrain SightRange/FovDegrees/EyeHeight/TargetTag + transform) and either a targetId or a point, it runs the SAME line-of-sight check the brain uses — FOV cone (dot vs the baked cosine), sight-range gate, and an occlusion trace from the eye to the target — and reports the result AND why. This is the keystone verifier: it makes the perception layer checkable in edit mode (no flaky screenshot timing) — e.g. place the Sasquatch, place a camper behind a tree, and confirm the tree blocks LOS. Call params override the brain's values, so it also works before/without an NpcBrain (uses defaults). Safe in play mode too (read-only, like raycast).", {
|
|
170
|
+
npcId: z
|
|
171
|
+
.string()
|
|
172
|
+
.describe("GUID of the NPC GameObject (ideally with an NpcBrain; its perception [Property] values are read)."),
|
|
173
|
+
targetId: z
|
|
174
|
+
.string()
|
|
175
|
+
.optional()
|
|
176
|
+
.describe("GUID of the target GameObject to test visibility to (e.g. a player). Provide this OR point."),
|
|
177
|
+
point: Vec3.optional().describe("A raw world point to test visibility to. Provide this OR targetId."),
|
|
178
|
+
sightRange: z.number().optional().describe("Override the sight range for this check (else read from the NpcBrain / default 1500)."),
|
|
179
|
+
fovDegrees: z.number().optional().describe("Override the FOV cone angle for this check (else read from the NpcBrain / default 110)."),
|
|
180
|
+
eyeHeight: z.number().optional().describe("Override the eye height for this check (else read from the NpcBrain / default 64)."),
|
|
181
|
+
targetTag: z
|
|
182
|
+
.string()
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("Override the target tag (canSee also requires the target to carry this tag; else read from the NpcBrain / default 'player')."),
|
|
185
|
+
}, async (params) => {
|
|
186
|
+
const res = await bridge.send("simulate_npc_perception", params);
|
|
187
|
+
if (!res.success) {
|
|
188
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=npc.js.map
|
package/dist/tools/playmode.js
CHANGED
|
@@ -76,11 +76,13 @@ export function registerPlayModeTools(server, bridge) {
|
|
|
76
76
|
};
|
|
77
77
|
});
|
|
78
78
|
// ── set_property ─────────────────────────────────────────────────
|
|
79
|
-
server.tool("set_property", "Set a property value on a component (editor mode).
|
|
79
|
+
server.tool("set_property", "Set a property value on a component (editor mode), and PERSIST it (survives save+reload). Handles primitives, enums, value types (Color/Vector3 as comma strings), AND references: pass an asset PATH for Model/Material/Texture/SoundEvent props, or a GameObject GUID for GameObject/Component-typed props (resolved like set_component_reference). Returns success=false with a clear error if a path/GUID can't be resolved (no more silent null). For wiring object refs prefer set_component_reference; for prefab refs use set_prefab_ref", {
|
|
80
80
|
id: z.string().describe("GUID of the GameObject"),
|
|
81
81
|
component: z.string().describe("Component type name"),
|
|
82
82
|
property: z.string().describe("Property name to set"),
|
|
83
|
-
value: z
|
|
83
|
+
value: z
|
|
84
|
+
.unknown()
|
|
85
|
+
.describe("New value as a string. Primitive: '5', 'true'. Color/Vector3: '1,0,0,1' / '0,0,200'. Enum: the member name. Asset ref (Model/Material/...): the asset path e.g. 'models/dev/box.vmdl'. GameObject/Component ref: the target GameObject's GUID. Empty/'null' clears the property"),
|
|
84
86
|
}, async (params) => {
|
|
85
87
|
const res = await bridge.send("set_property", params);
|
|
86
88
|
if (!res.success) {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
function report(checks, override, runId) {
|
|
4
|
+
const pass = checks.filter((c) => c.ok).length;
|
|
5
|
+
const total = checks.length;
|
|
6
|
+
let verdict = override;
|
|
7
|
+
if (!verdict) {
|
|
8
|
+
verdict =
|
|
9
|
+
pass === total
|
|
10
|
+
? `HEALTHY — ${pass}/${total} checks passed`
|
|
11
|
+
: pass >= total - 1
|
|
12
|
+
? `DEGRADED — ${pass}/${total} passed`
|
|
13
|
+
: `BROKEN — only ${pass}/${total} passed`;
|
|
14
|
+
}
|
|
15
|
+
const lines = checks
|
|
16
|
+
.map((c) => ` ${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? " — " + c.detail : ""}`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
const json = JSON.stringify({ verdict, pass, total, runId: runId ?? null, checks }, null, 2);
|
|
19
|
+
return { content: [{ type: "text", text: `run_self_test: ${verdict}\n\n${lines}\n\n${json}` }] };
|
|
20
|
+
}
|
|
21
|
+
export function registerSelfTestTools(server, bridge) {
|
|
22
|
+
server.tool("run_self_test", "Run an end-to-end health check of the bridge: create a temp object, add a component, assign + measure a model, capture a screenshot, recompile a temp asset, remove the component — then clean it all up, reporting pass/fail per subsystem. Use it to confirm the install works or to catch regressions before a release. Safe and self-cleaning; refuses to run in play mode.", {}, async () => {
|
|
23
|
+
const runId = Date.now().toString(36);
|
|
24
|
+
const checks = [];
|
|
25
|
+
const add = (name, ok, detail = "") => checks.push({ name, ok, detail });
|
|
26
|
+
const send = (cmd, p = {}) => bridge.send(cmd, p, 15000);
|
|
27
|
+
let createdId = null;
|
|
28
|
+
let projectRoot = null;
|
|
29
|
+
let tempVmatAbs = null;
|
|
30
|
+
const tempVmatRel = `materials/__selftest_${runId}.vmat`;
|
|
31
|
+
try {
|
|
32
|
+
// 0. Connectivity
|
|
33
|
+
const pi = await send("get_project_info");
|
|
34
|
+
if (!pi.success) {
|
|
35
|
+
add("connectivity", false, pi.error ?? "no response");
|
|
36
|
+
return report(checks, "BROKEN — bridge not responding. Is s&box running with the Claude Bridge addon?");
|
|
37
|
+
}
|
|
38
|
+
projectRoot = pi.data?.path ?? null;
|
|
39
|
+
add("connectivity", true, "get_project_info round-trip OK");
|
|
40
|
+
// Pre-flight: refuse in play mode (the battery mutates the scene)
|
|
41
|
+
const ip = await send("is_playing");
|
|
42
|
+
if (ip.success && ip.data?.isPlaying) {
|
|
43
|
+
add("play-mode guard", false, "editor is in play mode");
|
|
44
|
+
return report(checks, "ABORTED — stop play mode first; the self-test mutates the scene.");
|
|
45
|
+
}
|
|
46
|
+
// 1. Create a temp object
|
|
47
|
+
const cg = await send("create_gameobject", { name: `__selftest_${runId}` });
|
|
48
|
+
const cgd = cg.data;
|
|
49
|
+
createdId = cgd?.id ?? cgd?.gameObject?.id ?? cgd?.guid ?? null;
|
|
50
|
+
if (!cg.success || !createdId) {
|
|
51
|
+
add("create_gameobject", false, cg.error ?? "no id returned");
|
|
52
|
+
return report(checks, "BROKEN — couldn't create a GameObject.");
|
|
53
|
+
}
|
|
54
|
+
add("create_gameobject", true, `id ${createdId}`);
|
|
55
|
+
// 2. Add a component
|
|
56
|
+
const ac = await send("add_component_with_properties", { id: createdId, component: "ModelRenderer" });
|
|
57
|
+
add("add_component", ac.success, ac.success ? "ModelRenderer added" : ac.error ?? "fail");
|
|
58
|
+
// 3. Assign a model
|
|
59
|
+
const am = await send("assign_model", { id: createdId, model: "models/dev/box.vmdl" });
|
|
60
|
+
add("assign_model", am.success, am.success ? "box.vmdl" : am.error ?? "fail");
|
|
61
|
+
// 4. Bounds round-trip — non-empty bounds prove the model write took effect
|
|
62
|
+
const gb = await send("get_bounds", { id: createdId });
|
|
63
|
+
const sz = gb.data?.size;
|
|
64
|
+
const nonEmpty = !!sz && Math.abs(sz.x ?? 0) + Math.abs(sz.y ?? 0) + Math.abs(sz.z ?? 0) > 0.001;
|
|
65
|
+
add("get_bounds", gb.success && nonEmpty, gb.success ? (nonEmpty ? "non-empty bounds" : "empty bounds (model not applied?)") : gb.error ?? "fail");
|
|
66
|
+
// 5. Capture (the new RenderToBitmap path)
|
|
67
|
+
const cv = await send("capture_view", { width: 640, height: 360 });
|
|
68
|
+
const cvPath = cv.data?.path;
|
|
69
|
+
const capOk = cv.success && !!cvPath && existsSync(cvPath);
|
|
70
|
+
add("capture_view", capOk, capOk ? "PNG written" : cv.error ?? "no PNG produced");
|
|
71
|
+
if (cvPath && existsSync(cvPath)) {
|
|
72
|
+
try {
|
|
73
|
+
unlinkSync(cvPath);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* best effort */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 6. Recompile a temp asset
|
|
80
|
+
const wf = await send("write_file", {
|
|
81
|
+
path: tempVmatRel,
|
|
82
|
+
content: `// selftest\n"Layer0"\n{\n\tshader "shaders/complex.shader"\n}\n`,
|
|
83
|
+
});
|
|
84
|
+
if (wf.success) {
|
|
85
|
+
if (projectRoot)
|
|
86
|
+
tempVmatAbs = join(projectRoot, tempVmatRel.replace(/\//g, "\\"));
|
|
87
|
+
const rc = await send("recompile_asset", { path: tempVmatRel });
|
|
88
|
+
add("recompile_asset", rc.success, rc.success ? "compiled temp .vmat" : rc.error ?? "fail");
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
add("recompile_asset", false, `write_file failed: ${wf.error ?? "?"}`);
|
|
92
|
+
}
|
|
93
|
+
// 7. Remove the component (symmetric with step 2)
|
|
94
|
+
const rm = await send("remove_component", { id: createdId, component: "ModelRenderer" });
|
|
95
|
+
add("remove_component", rm.success, rm.success ? "removed" : rm.error ?? "fail");
|
|
96
|
+
return report(checks, null, runId);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
// Cleanup — runs on success, throw, or early-return.
|
|
100
|
+
if (createdId) {
|
|
101
|
+
try {
|
|
102
|
+
await send("delete_gameobject", { id: createdId });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* best effort */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const f of [tempVmatAbs, tempVmatAbs ? tempVmatAbs + "_c" : null]) {
|
|
109
|
+
if (f && existsSync(f)) {
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(f);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* best effort */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=selftest.js.map
|
package/dist/tools/status.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
// The running MCP server's own version (from package.json) — compared against the
|
|
6
|
+
// addon's reported BridgeVersion to catch a stale server vs. a newer addon (or vice
|
|
7
|
+
// versa), the #1 source of "the new tools aren't showing up" confusion.
|
|
8
|
+
let SERVER_VERSION = "unknown";
|
|
9
|
+
try {
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf-8"));
|
|
11
|
+
SERVER_VERSION = pkg.version ?? "unknown";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* best-effort */
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* Diagnostic and health-check tool (get_bridge_status).
|
|
4
18
|
* Reports connection state, latency, host/port, and editor version.
|
|
@@ -10,34 +24,41 @@ export function registerStatusTools(server, bridge) {
|
|
|
10
24
|
const ipcDir = bridge.getIpcDir();
|
|
11
25
|
const heartbeatAgeMs = bridge.getHeartbeatAgeMs();
|
|
12
26
|
let latencyMs = null;
|
|
13
|
-
let
|
|
27
|
+
let bridgeVersion = null;
|
|
28
|
+
let handlerCount = null;
|
|
14
29
|
let roundTripOk = false;
|
|
15
30
|
if (connected) {
|
|
16
31
|
latencyMs = await bridge.ping();
|
|
17
|
-
// A real round-trip
|
|
18
|
-
// heartbeat is fresh but whose request loop is
|
|
32
|
+
// A real round-trip via the addon's get_bridge_status command — distinguishes
|
|
33
|
+
// a live editor from one whose heartbeat is fresh but whose request loop is
|
|
34
|
+
// stalled, and surfaces the bridge version + handler count.
|
|
19
35
|
try {
|
|
20
|
-
const res = await bridge.send("
|
|
36
|
+
const res = await bridge.send("get_bridge_status", {}, 5000);
|
|
21
37
|
roundTripOk = res.success;
|
|
22
38
|
if (res.success && res.data) {
|
|
23
39
|
const data = res.data;
|
|
24
|
-
|
|
40
|
+
bridgeVersion = data.version ?? null;
|
|
41
|
+
handlerCount = data.handlerCount ?? null;
|
|
25
42
|
}
|
|
26
43
|
}
|
|
27
44
|
catch {
|
|
28
45
|
// Non-fatal
|
|
29
46
|
}
|
|
30
47
|
}
|
|
48
|
+
const versionsAligned = bridgeVersion == null || bridgeVersion === SERVER_VERSION;
|
|
31
49
|
const status = {
|
|
32
50
|
connected,
|
|
33
51
|
ipcDir,
|
|
34
52
|
heartbeatAgeMs,
|
|
35
53
|
roundTripOk,
|
|
54
|
+
bridgeVersion,
|
|
55
|
+
mcpServerVersion: SERVER_VERSION,
|
|
56
|
+
versionsAligned,
|
|
57
|
+
handlerCount,
|
|
36
58
|
latencyMs: connected ? latencyMs : null,
|
|
37
59
|
lastPong: connected
|
|
38
60
|
? new Date(bridge.getLastPongTime()).toISOString()
|
|
39
61
|
: null,
|
|
40
|
-
editorVersion,
|
|
41
62
|
// legacy/cosmetic — there is no socket; transport is file IPC
|
|
42
63
|
host: bridge.getHost(),
|
|
43
64
|
port: bridge.getPort(),
|
|
@@ -47,7 +68,7 @@ export function registerStatusTools(server, bridge) {
|
|
|
47
68
|
text = `Bridge NOT connected — no recent heartbeat in ${ipcDir}. Is s&box running with the Claude Bridge addon?`;
|
|
48
69
|
}
|
|
49
70
|
else if (roundTripOk) {
|
|
50
|
-
text = `Bridge connected and responding (IPC: ${ipcDir}, heartbeat ${heartbeatAgeMs ?? "?"}ms ago)
|
|
71
|
+
text = `Bridge connected and responding — addon v${bridgeVersion ?? "?"} / server v${SERVER_VERSION}, ${handlerCount ?? "?"} handlers (IPC: ${ipcDir}, heartbeat ${heartbeatAgeMs ?? "?"}ms ago).${versionsAligned ? "" : ` ⚠️ Version mismatch — restart Claude Code (and/or republish the addon) so the MCP server and addon match.`}`;
|
|
51
72
|
}
|
|
52
73
|
else {
|
|
53
74
|
text = `Bridge heartbeat is live but a test round-trip FAILED — the editor isn't draining requests. IPC: ${ipcDir}. Check the s&box editor console for [SboxBridge] lines.`;
|
package/dist/tools/templates.js
CHANGED
|
@@ -8,7 +8,7 @@ import { z } from "zod";
|
|
|
8
8
|
*/
|
|
9
9
|
export function registerTemplateTools(server, bridge) {
|
|
10
10
|
// ── create_player_controller ──────────────────────────────────────
|
|
11
|
-
server.tool("create_player_controller", "Generate a player controller script with WASD movement, mouse look, jumping, and sprint. Supports first-person
|
|
11
|
+
server.tool("create_player_controller", "Generate a player controller script with WASD movement, mouse look, jumping, and sprint. Supports first-person, third-person, and top-down movement modes. Optionally places a player rig (GameObject + CharacterController + Camera) in the scene — note the generated component is attached AFTER a trigger_hotload (it isn't in the TypeLibrary until a recompile)", {
|
|
12
12
|
name: z
|
|
13
13
|
.string()
|
|
14
14
|
.optional()
|
|
@@ -18,9 +18,9 @@ export function registerTemplateTools(server, bridge) {
|
|
|
18
18
|
.optional()
|
|
19
19
|
.describe("Subdirectory under code/ for the file"),
|
|
20
20
|
type: z
|
|
21
|
-
.enum(["first_person", "third_person"])
|
|
21
|
+
.enum(["first_person", "third_person", "top_down"])
|
|
22
22
|
.optional()
|
|
23
|
-
.describe("
|
|
23
|
+
.describe("Movement mode: 'first_person' (mouse-look body+camera, WASD relative to facing), 'third_person' (mouse yaw, WASD relative to facing, boom camera), or 'top_down' (screen-relative WASD, fixed overhead camera, no jump). Defaults to 'first_person'"),
|
|
24
24
|
moveSpeed: z
|
|
25
25
|
.number()
|
|
26
26
|
.optional()
|
|
@@ -28,11 +28,23 @@ export function registerTemplateTools(server, bridge) {
|
|
|
28
28
|
jumpForce: z
|
|
29
29
|
.number()
|
|
30
30
|
.optional()
|
|
31
|
-
.describe("Jump force. Defaults to 350"),
|
|
31
|
+
.describe("Jump force (ignored for top_down). Defaults to 350"),
|
|
32
32
|
sprintMultiplier: z
|
|
33
33
|
.number()
|
|
34
34
|
.optional()
|
|
35
|
-
.describe("Sprint speed multiplier. Defaults to 1.5"),
|
|
35
|
+
.describe("Sprint speed multiplier (held 'run' action). Defaults to 1.5"),
|
|
36
|
+
placeInScene: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("If true, build a player rig in the scene: a GameObject (tagged 'player') with a CharacterController and (unless createCamera=false) a Camera. The generated controller component is NOT attached in this call — trigger_hotload then add_component_with_properties on the returned GameObject. Defaults to false (file-only)."),
|
|
40
|
+
createCamera: z
|
|
41
|
+
.boolean()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("When placeInScene is true, also create a Camera (FP/TP: child at eye/boom offset; top_down: fixed overhead). Defaults to true."),
|
|
44
|
+
spawnPosition: z
|
|
45
|
+
.object({ x: z.number(), y: z.number(), z: z.number() })
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("When placeInScene is true, the world position to spawn the player rig at. Defaults to the origin."),
|
|
36
48
|
}, async (params) => {
|
|
37
49
|
const res = await bridge.send("create_player_controller", params);
|
|
38
50
|
if (!res.success) {
|
package/dist/tools/world.js
CHANGED
|
@@ -17,13 +17,13 @@ import { z } from "zod";
|
|
|
17
17
|
*/
|
|
18
18
|
export function registerWorldTools(server, bridge) {
|
|
19
19
|
// ── invoke_button ────────────────────────────────────────────────
|
|
20
|
-
server.tool("invoke_button", "
|
|
20
|
+
server.tool("invoke_button", "Call a no-argument public method on a component. Matching is tried in order: (1) a [Button] attribute label, (2) the exact method NAME, (3) case-insensitive name with spaces stripped. So this calls ANY parameterless public method, not only [Button]-attributed ones (e.g. 'StartGame' works even without a [Button]). This is the general 'call a component method to drive game state' tool. LIMITATION: parameterless methods only — methods that take arguments are skipped. (list_component_buttons only lists [Button] methods, so a plain method may be invokable yet not appear there.)", {
|
|
21
21
|
component: z
|
|
22
22
|
.string()
|
|
23
|
-
.describe("Component type name (e.g. 'MapBuilder', '
|
|
23
|
+
.describe("Component type name (e.g. 'MapBuilder', 'SasquatchedGame')"),
|
|
24
24
|
button: z
|
|
25
25
|
.string()
|
|
26
|
-
.describe("Button label
|
|
26
|
+
.describe("A [Button] label OR a public no-arg method name (e.g. 'Build Terrain', 'StartGame'); case- and space-insensitive"),
|
|
27
27
|
id: z
|
|
28
28
|
.string()
|
|
29
29
|
.optional()
|
|
@@ -35,7 +35,7 @@ export function registerWorldTools(server, bridge) {
|
|
|
35
35
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
36
36
|
});
|
|
37
37
|
// ── list_component_buttons ───────────────────────────────────────
|
|
38
|
-
server.tool("list_component_buttons", "List
|
|
38
|
+
server.tool("list_component_buttons", "List the [Button]-attributed methods on a component. NOTE: this only finds methods decorated with [Button]; invoke_button can ALSO call any plain public no-arg method by name, so a method missing here may still be invokable. Use describe_type / get_method_signature to find non-button methods.", {
|
|
39
39
|
component: z.string().describe("Component type name"),
|
|
40
40
|
id: z.string().optional().describe("Optional GameObject GUID"),
|
|
41
41
|
}, async (params) => {
|