sbox-mcp-server 1.3.1 → 1.4.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.
@@ -0,0 +1,148 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Scene & level-building tools (Batch 21): snap-to-ground, align, distribute,
4
+ * grid-duplicate, and measure. Transform-level operations for arranging a
5
+ * scene — all verifiable via the editor (screenshot or hierarchy/state).
6
+ */
7
+ const Vector3Schema = z
8
+ .object({ x: z.number(), y: z.number(), z: z.number() })
9
+ .describe("Vector {x,y,z}");
10
+ export function registerLevelTools(server, bridge) {
11
+ // ── snap_to_ground ─────────────────────────────────────────────────
12
+ server.tool("snap_to_ground", "Drop a GameObject straight down onto the surface below it (physics raycast). Works best on collider-less props (an object with its own collider may self-hit). Optional offset lifts it off the surface.", {
13
+ id: z.string().describe("GUID of the GameObject to snap"),
14
+ offset: z.number().optional().describe("Height above the surface to place it (default 0)"),
15
+ startHeight: z.number().optional().describe("How far above the object to start the trace (default 2000)"),
16
+ maxDistance: z.number().optional().describe("Max trace distance downward (default 20000)"),
17
+ }, async (params) => {
18
+ const res = await bridge.send("snap_to_ground", params);
19
+ if (!res.success) {
20
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
21
+ }
22
+ return {
23
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
24
+ };
25
+ });
26
+ // ── align_objects ──────────────────────────────────────────────────
27
+ server.tool("align_objects", "Align several GameObjects on one axis so they share a coordinate. mode = first (match the first object), min, max, or average.", {
28
+ ids: z.array(z.string()).describe("GUIDs of the GameObjects to align (>= 2)"),
29
+ axis: z.enum(["x", "y", "z"]).describe("Axis to align on"),
30
+ mode: z
31
+ .enum(["first", "min", "max", "average"])
32
+ .optional()
33
+ .describe("Target coordinate to align to (default first)"),
34
+ }, async (params) => {
35
+ const res = await bridge.send("align_objects", params);
36
+ if (!res.success) {
37
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
38
+ }
39
+ return {
40
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
41
+ };
42
+ });
43
+ // ── distribute_objects ─────────────────────────────────────────────
44
+ server.tool("distribute_objects", "Evenly space GameObjects along an axis between the lowest and highest (keeps the two ends fixed, spreads the rest evenly).", {
45
+ ids: z.array(z.string()).describe("GUIDs of the GameObjects to distribute (>= 3)"),
46
+ axis: z.enum(["x", "y", "z"]).describe("Axis to distribute along"),
47
+ }, async (params) => {
48
+ const res = await bridge.send("distribute_objects", params);
49
+ if (!res.success) {
50
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
51
+ }
52
+ return {
53
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
54
+ };
55
+ });
56
+ // ── grid_duplicate ─────────────────────────────────────────────────
57
+ server.tool("grid_duplicate", "Clone a GameObject into an X/Y/Z grid with fixed spacing (the original stays in place). Each count is clamped to 50 and total clones to 500. Great for fences, crates, pillars, foliage rows.", {
58
+ id: z.string().describe("GUID of the GameObject to clone"),
59
+ countX: z.number().int().optional().describe("Copies along X (default 1)"),
60
+ countY: z.number().int().optional().describe("Copies along Y (default 1)"),
61
+ countZ: z.number().int().optional().describe("Copies along Z (default 1)"),
62
+ spacing: Vector3Schema.optional().describe("Spacing between copies per axis (default 100,100,100)"),
63
+ }, async (params) => {
64
+ const res = await bridge.send("grid_duplicate", params);
65
+ if (!res.success) {
66
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
67
+ }
68
+ return {
69
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
70
+ };
71
+ });
72
+ // ── measure_distance ───────────────────────────────────────────────
73
+ server.tool("measure_distance", "Measure the distance between two points or two GameObjects. Provide a/b as {x,y,z} or idA/idB as GUIDs. Returns straight-line distance, horizontal (ground) distance, and the delta vector. Read-only (works during play).", {
74
+ a: Vector3Schema.optional().describe("First point {x,y,z}"),
75
+ b: Vector3Schema.optional().describe("Second point {x,y,z}"),
76
+ idA: z.string().optional().describe("First GameObject GUID (overrides a)"),
77
+ idB: z.string().optional().describe("Second GameObject GUID (overrides b)"),
78
+ }, async (params) => {
79
+ const res = await bridge.send("measure_distance", params);
80
+ if (!res.success) {
81
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
82
+ }
83
+ return {
84
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
85
+ };
86
+ });
87
+ // ── scatter_props ──────────────────────────────────────────────────
88
+ server.tool("scatter_props", "Scatter N copies of a model randomly within a radius around a center point — instant foliage, rocks, debris. Each copy gets a random yaw and (by default) is snapped to the ground. Seeded for reproducibility; copies are grouped under one parent by default. Count capped at 300.", {
89
+ model: z.string().describe("Model path to scatter, e.g. 'models/dev/box.vmdl'"),
90
+ center: Vector3Schema.optional().describe("Centre of the scatter area (default origin)"),
91
+ radius: z.number().optional().describe("Scatter radius in units (default 256)"),
92
+ count: z.number().int().optional().describe("How many to place (default 10, max 300)"),
93
+ randomYaw: z.boolean().optional().describe("Randomly rotate each around Z (default true)"),
94
+ snapToGround: z.boolean().optional().describe("Raycast each onto the surface below (default true)"),
95
+ scaleMin: z.number().optional().describe("Min uniform scale (default 1)"),
96
+ scaleMax: z.number().optional().describe("Max uniform scale (default 1; set >min for size variation)"),
97
+ tint: z
98
+ .object({
99
+ r: z.number().min(0),
100
+ g: z.number().min(0),
101
+ b: z.number().min(0),
102
+ a: z.number().min(0).max(1).optional(),
103
+ })
104
+ .optional()
105
+ .describe("Tint applied to every copy"),
106
+ seed: z.number().int().optional().describe("PRNG seed for a reproducible layout (default 1)"),
107
+ group: z.boolean().optional().describe("Parent all copies under one group object (default true)"),
108
+ name: z.string().optional().describe("Base name for the props/group (default 'Prop')"),
109
+ }, async (params) => {
110
+ const res = await bridge.send("scatter_props", params);
111
+ if (!res.success) {
112
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
113
+ }
114
+ return {
115
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
116
+ };
117
+ });
118
+ // ── randomize_transforms ───────────────────────────────────────────
119
+ server.tool("randomize_transforms", "Add natural variation to existing objects: random yaw and/or random uniform scale within a range. Great for breaking up repetition in placed foliage/rocks/crates. Seeded.", {
120
+ ids: z.array(z.string()).describe("GUIDs of the GameObjects to randomize"),
121
+ randomYaw: z.boolean().optional().describe("Randomize Z rotation (default true)"),
122
+ scaleMin: z.number().optional().describe("Min uniform scale (default 1)"),
123
+ scaleMax: z.number().optional().describe("Max uniform scale (default 1; set >min to vary)"),
124
+ seed: z.number().int().optional().describe("PRNG seed (default 1)"),
125
+ }, async (params) => {
126
+ const res = await bridge.send("randomize_transforms", params);
127
+ if (!res.success) {
128
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
129
+ }
130
+ return {
131
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
132
+ };
133
+ });
134
+ // ── group_objects ──────────────────────────────────────────────────
135
+ server.tool("group_objects", "Parent a set of GameObjects under a new empty group object (placed at their centroid) — tidies the hierarchy and lets you move/rotate them together.", {
136
+ ids: z.array(z.string()).describe("GUIDs of the GameObjects to group"),
137
+ name: z.string().optional().describe("Name for the group object (default 'Group')"),
138
+ }, async (params) => {
139
+ const res = await bridge.send("group_objects", params);
140
+ if (!res.success) {
141
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
142
+ }
143
+ return {
144
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
145
+ };
146
+ });
147
+ }
148
+ //# sourceMappingURL=leveltools.js.map
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ export declare function registerObjectTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=objecttools.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Object utility & query tools (Batch 23): find objects in the scene, and
4
+ * bulk-edit tint / model / tags across one or many objects. find_objects is
5
+ * the composable workhorse — query GUIDs, then feed them to align/distribute/
6
+ * set_tint/group/etc.
7
+ */
8
+ const ColorSchema = z
9
+ .object({
10
+ r: z.number().min(0).describe("Red, 0-1"),
11
+ g: z.number().min(0).describe("Green, 0-1"),
12
+ b: z.number().min(0).describe("Blue, 0-1"),
13
+ a: z.number().min(0).max(1).optional().describe("Alpha, 0-1 (default 1)"),
14
+ })
15
+ .describe("RGBA colour as 0-1 floats");
16
+ export function registerObjectTools(server, bridge) {
17
+ // ── find_objects ───────────────────────────────────────────────────
18
+ server.tool("find_objects", "Query the scene for GameObjects by name (case-insensitive substring), component type name, and/or tag — combine filters (AND). Returns {id,name} for matches (limit default 50, max 500). Read-only; works during play. Use it to get GUIDs to feed into align/distribute/set_tint/group/delete/etc.", {
19
+ name: z.string().optional().describe("Name substring (case-insensitive)"),
20
+ component: z
21
+ .string()
22
+ .optional()
23
+ .describe("Component type name, e.g. 'PointLight', 'SkinnedModelRenderer'"),
24
+ tag: z.string().optional().describe("Tag the object must have"),
25
+ limit: z.number().int().optional().describe("Max results (default 50, max 500)"),
26
+ }, async (params) => {
27
+ const res = await bridge.send("find_objects", params);
28
+ if (!res.success) {
29
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
30
+ }
31
+ return {
32
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
33
+ };
34
+ });
35
+ // ── set_tint ───────────────────────────────────────────────────────
36
+ server.tool("set_tint", "Set the renderer tint colour on one object (id) or many (ids) at once. Works on any ModelRenderer/SkinnedModelRenderer.", {
37
+ id: z.string().optional().describe("Single GameObject GUID"),
38
+ ids: z.array(z.string()).optional().describe("Multiple GameObject GUIDs"),
39
+ tint: ColorSchema.describe("Tint colour to apply"),
40
+ }, async (params) => {
41
+ const res = await bridge.send("set_tint", params);
42
+ if (!res.success) {
43
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
44
+ }
45
+ return {
46
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
47
+ };
48
+ });
49
+ // ── replace_model ──────────────────────────────────────────────────
50
+ server.tool("replace_model", "Swap the model on one object (id) or many (ids) — e.g. retheme a row of props in one call.", {
51
+ id: z.string().optional().describe("Single GameObject GUID"),
52
+ ids: z.array(z.string()).optional().describe("Multiple GameObject GUIDs"),
53
+ model: z.string().describe("New model path, e.g. 'models/dev/sphere.vmdl'"),
54
+ }, async (params) => {
55
+ const res = await bridge.send("replace_model", params);
56
+ if (!res.success) {
57
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
58
+ }
59
+ return {
60
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
61
+ };
62
+ });
63
+ // ── set_tags ───────────────────────────────────────────────────────
64
+ server.tool("set_tags", "Add, remove, and/or clear gameplay tags on one object (id) or many (ids). Tags drive collision groups, queries, and triggers.", {
65
+ id: z.string().optional().describe("Single GameObject GUID"),
66
+ ids: z.array(z.string()).optional().describe("Multiple GameObject GUIDs"),
67
+ add: z.array(z.string()).optional().describe("Tags to add"),
68
+ remove: z.array(z.string()).optional().describe("Tags to remove"),
69
+ clear: z.boolean().optional().describe("Remove all existing tags first"),
70
+ }, async (params) => {
71
+ const res = await bridge.send("set_tags", params);
72
+ if (!res.success) {
73
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
74
+ }
75
+ return {
76
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
77
+ };
78
+ });
79
+ }
80
+ //# sourceMappingURL=objecttools.js.map
@@ -6,14 +6,18 @@ export function registerStatusTools(server, bridge) {
6
6
  // ── get_bridge_status ────────────────────────────────────────────
7
7
  server.tool("get_bridge_status", "Check the connection status to the s&box Bridge — whether it's connected, latency, host/port, and editor info. Useful for debugging", {}, async () => {
8
8
  const connected = bridge.isConnected();
9
- let latencyMs = -1;
9
+ const ipcDir = bridge.getIpcDir();
10
+ const heartbeatAgeMs = bridge.getHeartbeatAgeMs();
11
+ let latencyMs = null;
10
12
  let editorVersion = null;
13
+ let roundTripOk = false;
11
14
  if (connected) {
12
- // Measure round-trip ping
13
15
  latencyMs = await bridge.ping();
14
- // Try to get editor version via project info
16
+ // A real round-trip — distinguishes a live editor from one whose
17
+ // heartbeat is fresh but whose request loop is stalled.
15
18
  try {
16
19
  const res = await bridge.send("get_project_info", {}, 5000);
20
+ roundTripOk = res.success;
17
21
  if (res.success && res.data) {
18
22
  const data = res.data;
19
23
  editorVersion = data.editorVersion ?? null;
@@ -25,17 +29,28 @@ export function registerStatusTools(server, bridge) {
25
29
  }
26
30
  const status = {
27
31
  connected,
28
- host: bridge.getHost(),
29
- port: bridge.getPort(),
32
+ ipcDir,
33
+ heartbeatAgeMs,
34
+ roundTripOk,
30
35
  latencyMs: connected ? latencyMs : null,
31
36
  lastPong: connected
32
37
  ? new Date(bridge.getLastPongTime()).toISOString()
33
38
  : null,
34
39
  editorVersion,
40
+ // legacy/cosmetic — there is no socket; transport is file IPC
41
+ host: bridge.getHost(),
42
+ port: bridge.getPort(),
35
43
  };
36
- const text = connected
37
- ? `Bridge connected (${bridge.getHost()}:${bridge.getPort()}, ${latencyMs}ms latency)`
38
- : `Bridge NOT connected (${bridge.getHost()}:${bridge.getPort()}). Is s&box running?`;
44
+ let text;
45
+ if (!connected) {
46
+ text = `Bridge NOT connected — no recent heartbeat in ${ipcDir}. Is s&box running with the Claude Bridge addon?`;
47
+ }
48
+ else if (roundTripOk) {
49
+ text = `Bridge connected and responding (IPC: ${ipcDir}, heartbeat ${heartbeatAgeMs ?? "?"}ms ago).`;
50
+ }
51
+ else {
52
+ 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.`;
53
+ }
39
54
  return {
40
55
  content: [
41
56
  {
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ export declare function registerVisualTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=visuals.d.ts.map
@@ -0,0 +1,259 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Visual & atmosphere tools (Batch 17): lighting, post-processing, fog, sky,
4
+ * reflection probes, and compose-it-all presets.
5
+ *
6
+ * These wrap s&box's visual components with sensible parameters so Claude can
7
+ * author scene mood directly, instead of hand-driving add_component_with_properties
8
+ * (which can't even set a Color). After any change, screenshot the scene and read
9
+ * the result — this layer is where the screenshot loop matters most.
10
+ */
11
+ const ColorSchema = z
12
+ .object({
13
+ r: z.number().min(0).describe("Red, 0-1"),
14
+ g: z.number().min(0).describe("Green, 0-1"),
15
+ b: z.number().min(0).describe("Blue, 0-1"),
16
+ a: z.number().min(0).max(1).optional().describe("Alpha, 0-1 (default 1)"),
17
+ })
18
+ .describe("RGBA colour as 0-1 floats");
19
+ const Vector3Schema = z
20
+ .object({ x: z.number(), y: z.number(), z: z.number() })
21
+ .describe("World position {x,y,z}");
22
+ const RotationSchema = z
23
+ .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
24
+ .describe("Rotation {pitch,yaw,roll} in degrees");
25
+ export function registerVisualTools(server, bridge) {
26
+ // ── add_light ──────────────────────────────────────────────────────
27
+ server.tool("add_light", "Add a light to the active scene. NOTE: s&box lights have no separate brightness field — intensity is the colour magnitude, so 'brightness' scales the colour (use >1 for bright/HDR). Types: directional = sun (aim it with rotation), point = omni-directional (range), spot = cone (range + coneInner/coneOuter degrees), ambient = global fill light.", {
28
+ type: z
29
+ .enum(["directional", "point", "spot", "ambient"])
30
+ .describe("Light type"),
31
+ name: z.string().optional().describe("GameObject name"),
32
+ color: ColorSchema.optional().describe("Light colour (default white)"),
33
+ brightness: z
34
+ .number()
35
+ .optional()
36
+ .describe("Intensity multiplier on the colour (default 1; try 2-10 for point/spot)"),
37
+ range: z
38
+ .number()
39
+ .optional()
40
+ .describe("point/spot only: falloff radius in units (maps to Radius)"),
41
+ coneInner: z
42
+ .number()
43
+ .optional()
44
+ .describe("spot only: inner cone angle in degrees"),
45
+ coneOuter: z
46
+ .number()
47
+ .optional()
48
+ .describe("spot only: outer cone angle in degrees"),
49
+ shadows: z.boolean().optional().describe("Cast shadows (default true)"),
50
+ skyColor: ColorSchema.optional().describe("directional only: ambient sky colour for the upper hemisphere"),
51
+ position: Vector3Schema.optional().describe("World position"),
52
+ rotation: RotationSchema.optional().describe("World rotation — sets the aim direction for directional/spot lights"),
53
+ parentId: z.string().optional().describe("GUID of a parent GameObject"),
54
+ }, async (params) => {
55
+ const res = await bridge.send("add_light", params);
56
+ if (!res.success) {
57
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
58
+ }
59
+ return {
60
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
61
+ };
62
+ });
63
+ // ── set_fog ────────────────────────────────────────────────────────
64
+ server.tool("set_fog", "Add or update distance fog in the active scene. v1 supports gradient fog (atmospheric distance haze — great for mood/horror). Re-running on the same target updates it rather than duplicating.", {
65
+ type: z
66
+ .enum(["gradient"])
67
+ .optional()
68
+ .describe("Fog type (default gradient; cubemap/volumetric coming later)"),
69
+ name: z.string().optional().describe("GameObject name when creating a new fog object"),
70
+ targetId: z
71
+ .string()
72
+ .optional()
73
+ .describe("GUID of an existing GameObject to host the fog (else a new 'Gradient Fog' object is created)"),
74
+ color: ColorSchema.optional().describe("Fog colour"),
75
+ startDistance: z
76
+ .number()
77
+ .optional()
78
+ .describe("Distance (units) where fog begins"),
79
+ endDistance: z
80
+ .number()
81
+ .optional()
82
+ .describe("Distance (units) where fog reaches full density"),
83
+ height: z.number().optional().describe("World height the fog settles around"),
84
+ falloff: z
85
+ .number()
86
+ .optional()
87
+ .describe("Distance falloff exponent (higher = sharper onset)"),
88
+ }, async (params) => {
89
+ const res = await bridge.send("set_fog", params);
90
+ if (!res.success) {
91
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
92
+ }
93
+ return {
94
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
95
+ };
96
+ });
97
+ // ── add_post_process ─────────────────────────────────────────────────
98
+ server.tool("add_post_process", "Add (or update) a post-processing effect on the scene's main camera (auto-enables post-processing). Generic: pass the effect component name + any of its properties. Examples — Bloom {Strength, Threshold, Tint}, Tonemapping, ColorAdjustments {Saturation, Brightness, Contrast}, Vignette {Intensity, Color}, FilmGrain, DepthOfField, ChromaticAberration, MotionBlur, Sharpen, AmbientOcclusion. Call describe_type <Effect> to discover a given effect's properties.", {
99
+ effect: z
100
+ .string()
101
+ .describe("Post-process component type name, e.g. 'Bloom', 'Vignette', 'ColorAdjustments'"),
102
+ properties: z
103
+ .record(z.any())
104
+ .optional()
105
+ .describe("Property name -> value. Floats/ints/bools as numbers/bools, colours as {r,g,b,a}, enums as their string name."),
106
+ cameraId: z
107
+ .string()
108
+ .optional()
109
+ .describe("GUID of a specific camera GameObject (default: the scene's main camera)"),
110
+ }, async (params) => {
111
+ const res = await bridge.send("add_post_process", params);
112
+ if (!res.success) {
113
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
114
+ }
115
+ return {
116
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
117
+ };
118
+ });
119
+ // ── set_skybox ───────────────────────────────────────────────────────
120
+ server.tool("set_skybox", "Set the scene's 2D skybox tint / indirect lighting (re-uses an existing SkyBox2D or creates one). Darken the tint for night/dusk. Optionally point it at a .vmat sky material.", {
121
+ tint: ColorSchema.optional().describe("Sky tint colour"),
122
+ indirectLighting: z
123
+ .boolean()
124
+ .optional()
125
+ .describe("Whether the sky contributes indirect/ambient light"),
126
+ material: z.string().optional().describe("Path to a .vmat sky material (optional)"),
127
+ name: z.string().optional().describe("Name for the sky GameObject if one is created"),
128
+ }, async (params) => {
129
+ const res = await bridge.send("set_skybox", params);
130
+ if (!res.success) {
131
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
132
+ }
133
+ return {
134
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
135
+ };
136
+ });
137
+ // ── apply_atmosphere (preset) ─────────────────────────────────────────
138
+ server.tool("apply_atmosphere", "One-call scene mood: composes ambient + directional light, gradient fog, and a camera post-fx stack (tonemap + colour grade + vignette) tuned for the chosen mood. Idempotent — re-runs update the same 'Atmosphere *' objects.", {
139
+ mood: z
140
+ .enum(["horror-night", "foggy-dawn", "overcast", "warm-interior"])
141
+ .describe("Atmosphere preset"),
142
+ }, async (params) => {
143
+ const res = await bridge.send("apply_atmosphere", params);
144
+ if (!res.success) {
145
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
146
+ }
147
+ return {
148
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
149
+ };
150
+ });
151
+ // ── apply_post_fx_look (preset) ───────────────────────────────────────
152
+ server.tool("apply_post_fx_look", "Apply just a camera post-processing look (no lights/fog): cinematic (tonemap + bloom + soft vignette), filmic-horror (desaturated, high-contrast, heavy vignette, film grain), or clean (tonemap only).", {
153
+ look: z
154
+ .enum(["cinematic", "filmic-horror", "clean"])
155
+ .describe("Post-fx look preset"),
156
+ }, async (params) => {
157
+ const res = await bridge.send("apply_post_fx_look", params);
158
+ if (!res.success) {
159
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
160
+ }
161
+ return {
162
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
163
+ };
164
+ });
165
+ // ── add_envmap_probe ─────────────────────────────────────────────────
166
+ server.tool("add_envmap_probe", "Add an environment reflection/ambient probe (EnvmapProbe) at a position with a cubic influence volume — captures local reflections and indirect light for nearby surfaces.", {
167
+ name: z.string().optional().describe("GameObject name"),
168
+ position: Vector3Schema.optional().describe("World position (centre of the probe)"),
169
+ size: z.number().optional().describe("Cubic influence size in units (default 1024)"),
170
+ tint: ColorSchema.optional().describe("Tint applied to the captured environment"),
171
+ feathering: z
172
+ .number()
173
+ .optional()
174
+ .describe("Edge feathering 0-1 for blending between overlapping probes"),
175
+ }, async (params) => {
176
+ const res = await bridge.send("add_envmap_probe", params);
177
+ if (!res.success) {
178
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
179
+ }
180
+ return {
181
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
182
+ };
183
+ });
184
+ // ── spawn_particle (Batch 18: VFX) ───────────────────────────────────
185
+ server.tool("spawn_particle", "Spawn an additive particle effect (no texture asset needed): kind = fire (rising flame), embers (slow drifting glow), or sparks (a one-shot burst). Renders as tinted glowing dots — great for campfires, torches, and impacts. (smoke needs a soft sprite; not in v1.)", {
186
+ kind: z
187
+ .enum(["fire", "embers", "sparks", "magic", "dust", "blood", "snow"])
188
+ .describe("Particle preset"),
189
+ position: Vector3Schema.optional().describe("World position"),
190
+ color: ColorSchema.optional().describe("Override the particle tint"),
191
+ name: z.string().optional().describe("GameObject name"),
192
+ }, async (params) => {
193
+ const res = await bridge.send("spawn_particle", params);
194
+ if (!res.success) {
195
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
196
+ }
197
+ return {
198
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
199
+ };
200
+ });
201
+ // ── add_trail ────────────────────────────────────────────────────────
202
+ server.tool("add_trail", "Attach a motion trail (TrailRenderer) to an existing GameObject (via targetId) so it leaves a trail as it moves — or create a standalone trail object. Only visible while the object is moving.", {
203
+ targetId: z.string().optional().describe("GUID of the GameObject to attach the trail to (else a new 'Trail' object is made)"),
204
+ position: Vector3Schema.optional().describe("World position when creating a standalone trail"),
205
+ lifetime: z.number().optional().describe("How long (seconds) trail points persist"),
206
+ maxPoints: z.number().optional().describe("Max points in the trail"),
207
+ pointDistance: z.number().optional().describe("Min distance between trail points"),
208
+ name: z.string().optional().describe("GameObject name when creating a new one"),
209
+ }, async (params) => {
210
+ const res = await bridge.send("add_trail", params);
211
+ if (!res.success) {
212
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
213
+ }
214
+ return {
215
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
216
+ };
217
+ });
218
+ // ── add_beam ─────────────────────────────────────────────────────────
219
+ server.tool("add_beam", "Create an energy/laser beam (BeamEffect) from a position to a target point — additive, tintable. Good for lasers, tracers, magic beams.", {
220
+ position: Vector3Schema.optional().describe("Beam start (world position of the beam object)"),
221
+ target: Vector3Schema.optional().describe("Beam end point in world space (default: 128u up)"),
222
+ width: z.number().optional().describe("Beam width/scale (default 4)"),
223
+ color: ColorSchema.optional().describe("Beam colour (default white)"),
224
+ name: z.string().optional().describe("GameObject name"),
225
+ }, async (params) => {
226
+ const res = await bridge.send("add_beam", params);
227
+ if (!res.success) {
228
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
229
+ }
230
+ return {
231
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
232
+ };
233
+ });
234
+ // ── create_particle_effect (generic / raw params) ────────────────────
235
+ server.tool("create_particle_effect", "Build a custom additive particle effect from raw params (ParticleEffect + cone emitter + sprite renderer). Use this when the spawn_particle presets aren't what you want. Texture-free (additive Texture.White glow).", {
236
+ position: Vector3Schema.optional().describe("World position"),
237
+ color: ColorSchema.optional().describe("Particle tint (default white)"),
238
+ rate: z.number().optional().describe("Particles per second when looping (default 30)"),
239
+ burst: z.number().optional().describe("Particle count for a one-shot burst when loop=false (default 30)"),
240
+ loop: z.boolean().optional().describe("Continuous emission (default true) vs a single burst"),
241
+ lifetime: z.number().optional().describe("Particle lifetime in seconds (default 2)"),
242
+ size: z.number().optional().describe("Particle size (default 4)"),
243
+ speed: z.number().optional().describe("Emission speed along the cone (default 100)"),
244
+ coneAngle: z.number().optional().describe("Cone half-angle in degrees; ~85 ≈ hemisphere (default 40)"),
245
+ gravity: z.number().optional().describe("Downward force (default 0 = none)"),
246
+ additive: z.boolean().optional().describe("Additive (glow) blending (default true)"),
247
+ maxParticles: z.number().optional().describe("Max live particles (default 500)"),
248
+ name: z.string().optional().describe("GameObject name"),
249
+ }, async (params) => {
250
+ const res = await bridge.send("create_particle_effect", params);
251
+ if (!res.success) {
252
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
253
+ }
254
+ return {
255
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
256
+ };
257
+ });
258
+ }
259
+ //# sourceMappingURL=visuals.js.map
@@ -1,11 +1,40 @@
1
1
  /**
2
- * File-based IPC transport for communicating with the s&box Bridge Addon.
2
+ * Max age of the editor's status heartbeat before we consider the bridge dead.
3
+ * The addon refreshes the heartbeat roughly once per second from its frame
4
+ * loop, so this gives generous margin for GC pauses / frame hitches while still
5
+ * catching a closed, crashed, or frame-stalled editor within a few seconds.
6
+ */
7
+ export declare const STATUS_STALE_MS = 5000;
8
+ /** Resolve the IPC directory, honoring an explicit override. */
9
+ export declare function resolveIpcDir(): string;
10
+ /** Result of inspecting the editor's status.json heartbeat. */
11
+ export interface StatusClassification {
12
+ /** The editor reported `running: true`. */
13
+ running: boolean;
14
+ /** The heartbeat is recent enough to trust (or the addon predates heartbeats). */
15
+ fresh: boolean;
16
+ /** Age of the heartbeat in ms, or null if the addon doesn't emit one. */
17
+ heartbeatMs: number | null;
18
+ }
19
+ /**
20
+ * Decide whether a parsed status.json means the bridge is live.
3
21
  *
4
- * Instead of WebSocket, this uses a shared temp directory where:
5
- * - MCP server writes request files (req_*.json)
6
- * - s&box addon polls for them, processes, and writes response files (res_*.json)
7
- * - MCP server polls for response files
22
+ * A recent heartbeat fresh. A stale heartbeat not fresh (editor closed,
23
+ * crashed, or frame loop stalled). No heartbeat field at all → treated as fresh
24
+ * for backward compatibility with addons built before v1.3.2 (so upgrading the
25
+ * MCP server alone never regresses a working setup to "disconnected").
8
26
  */
27
+ export declare function classifyStatus(status: unknown, nowMs: number, staleMs: number): StatusClassification;
28
+ /**
29
+ * Build a timeout error that names WHICH side of the IPC broke, so a 30s hang
30
+ * is actionable instead of opaque.
31
+ */
32
+ export declare function describeTimeout(opts: {
33
+ reqConsumed: boolean;
34
+ ipcDir: string;
35
+ timeoutMs: number;
36
+ command: string;
37
+ }): string;
9
38
  /** A single command request sent to the s&box Bridge. */
10
39
  export interface BridgeRequest {
11
40
  id: string;
@@ -32,8 +61,16 @@ export declare class BridgeClient {
32
61
  static readonly POLL_INTERVAL_MS = 50;
33
62
  static readonly STATUS_CHECK_INTERVAL_MS = 5000;
34
63
  constructor(host?: string, port?: number);
64
+ /** The directory this client reads/writes IPC files in. */
65
+ getIpcDir(): string;
66
+ private statusPath;
67
+ /** Read + classify the editor's status heartbeat. Never throws. */
68
+ readStatus(): StatusClassification;
69
+ /** Age of the editor's last heartbeat in ms, or null if unavailable. */
70
+ getHeartbeatAgeMs(): number | null;
35
71
  /**
36
- * Check if the s&box Bridge is running by looking for the status file.
72
+ * Verify the s&box Bridge is live (recent heartbeat), throwing a specific
73
+ * error if it is missing or stale.
37
74
  */
38
75
  connect(): Promise<void>;
39
76
  /**
@@ -48,7 +85,7 @@ export declare class BridgeClient {
48
85
  params?: Record<string, unknown>;
49
86
  }>, timeoutMs?: number): Promise<BridgeResponse>;
50
87
  /**
51
- * Check if bridge is alive by looking for status file.
88
+ * Liveness check. Returns elapsed ms if the heartbeat is recent, else -1.
52
89
  */
53
90
  ping(): Promise<number>;
54
91
  isConnected(): boolean;