sbox-mcp-server 2.0.0 → 2.1.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/dist/index.js CHANGED
@@ -63,6 +63,15 @@ import { registerAuditTools } from "./tools/audit.js";
63
63
  import { registerBatchTools } from "./tools/batch.js";
64
64
  import { registerWorkflowTools } from "./tools/workflow.js";
65
65
  import { registerVehicleTools } from "./tools/vehicles.js";
66
+ import { registerEconomySaveTools } from "./tools/economysave.js";
67
+ import { registerStatsAchievementsTools } from "./tools/statsachievements.js";
68
+ import { registerRoundUiTools } from "./tools/roundui.js";
69
+ import { registerWorldRenderTools } from "./tools/worldrender.js";
70
+ import { registerAiSystemsTools } from "./tools/aisystems.js";
71
+ import { registerGameplayRecorderTools } from "./tools/gameplayrecorder.js";
72
+ import { registerDialogueFxTools } from "./tools/dialoguefx.js";
73
+ import { registerMovieAuthoringTools } from "./tools/movieauthoring.js";
74
+ import { registerCinematicRecordingTools } from "./tools/cinematicrecording.js";
66
75
  // ── CLI flags ──────────────────────────────────────────────────────
67
76
  const args = process.argv.slice(2);
68
77
  /** Read the package version from package.json, or return "unknown" on failure. */
@@ -246,6 +255,15 @@ registerAuditTools(server, bridge);
246
255
  registerBatchTools(server, bridge);
247
256
  registerWorkflowTools(server, bridge);
248
257
  registerVehicleTools(server, bridge);
258
+ registerEconomySaveTools(server, bridge);
259
+ registerStatsAchievementsTools(server, bridge);
260
+ registerRoundUiTools(server, bridge);
261
+ registerWorldRenderTools(server, bridge);
262
+ registerAiSystemsTools(server, bridge);
263
+ registerGameplayRecorderTools(server, bridge);
264
+ registerDialogueFxTools(server, bridge);
265
+ registerMovieAuthoringTools(server, bridge);
266
+ registerCinematicRecordingTools(server, bridge);
249
267
  /** Start the MCP server on stdio and attempt initial Bridge connection. */
250
268
  async function main() {
251
269
  const transport = new StdioServerTransport();
@@ -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 registerAiSystemsTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=aisystems.d.ts.map
@@ -0,0 +1,236 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * AI & Systems pack — five scaffold generators:
4
+ *
5
+ * - create_needs_system sim/tycoon needs engine (decay -> Happiness + events)
6
+ * - create_utility_ai scored-action brain (abstract Action base + brain + 2 examples)
7
+ * - create_npc_schedule_brain daily-routine NPC bound to the day-night clock contract
8
+ * - create_event_bus typed LOCAL pub/sub static class (not a Component)
9
+ * - add_tts_voice TTS speaker over Sandbox.Speech.Synthesizer
10
+ *
11
+ * All write a .cs file into the project (scene/file-mutating; refused during play
12
+ * mode by the bridge dispatch). Every generated template was live-compile-verified
13
+ * (hotload + TypeLibrary-load) against the 2026-07 SDK on 2026-07-12.
14
+ *
15
+ * Mirrors the gamefeel.ts module shape: zod params, one bridge.send per tool,
16
+ * JSON.stringify(res.data) on success.
17
+ */
18
+ // A world-space Vector3 accepted as EITHER an object {x,y,z} OR a comma string
19
+ // "x,y,z", passed through unchanged. The C# handler parses both forms.
20
+ const Vec3 = z
21
+ .union([
22
+ z.object({
23
+ x: z.number().describe("X"),
24
+ y: z.number().describe("Y"),
25
+ z: z.number().describe("Z"),
26
+ }),
27
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
28
+ ])
29
+ .describe('A world-space Vector3 — object {x,y,z} OR comma string "x,y,z"');
30
+ export function registerAiSystemsTools(server, bridge) {
31
+ // ── create_needs_system ───────────────────────────────────────────
32
+ server.tool("create_needs_system", "Generate a sim/tycoon needs engine component: a [Property] list of need definitions (name, decay rate/s, critical threshold, weight) with per-need 0..100 values that decay over Time.Delta, Satisfy(name, amount) to restore, an aggregate Happiness (weighted mean, [Sync(FromHost)] when networked), and static OnNeedCritical (edge-triggered: fires once crossing below threshold, re-arms above) + OnHappinessChanged (>0.25-point moves) events. Returns {created, path, className, needs[], propertyNames[], note}. Next: trigger_hotload, get_compile_errors, then attach via targetId re-call or add_component_with_properties; drive from game code (e.g. a create_interactable that calls Satisfy). Limits: per-need values live on the simulating machine only (host) — sync per-need UI yourself via RPCs; events fire on the simulating machine only; networked default true means a no-session solo playtest won't tick (everything is a proxy) — pass networked:false to iterate solo. Refused during play mode; refuses to overwrite an existing file.", {
33
+ name: z
34
+ .string()
35
+ .optional()
36
+ .describe("Class/file name. Defaults to 'NeedsSystem'. Sanitized to a valid C# identifier."),
37
+ directory: z
38
+ .string()
39
+ .optional()
40
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
41
+ needs: z
42
+ .array(z.object({
43
+ name: z.string().describe("Need name, e.g. 'Hunger' (the key for Satisfy/GetNeed)."),
44
+ decayPerSecond: z.number().optional().describe("Points lost per second on the 0..100 scale. Defaults to 0.5."),
45
+ criticalThreshold: z.number().optional().describe("OnNeedCritical fires when the value falls below this. Defaults to 20."),
46
+ weight: z.number().optional().describe("Contribution to the Happiness weighted mean. Defaults to 1."),
47
+ }))
48
+ .optional()
49
+ .describe("Need definitions baked as inspector-editable defaults. Defaults to the classic sim trio: Hunger(0.8/s), Energy(0.5/s), Fun(0.3/s)."),
50
+ networked: z
51
+ .boolean()
52
+ .optional()
53
+ .describe("true (default): host-authoritative (IsProxy guard) + [Sync(FromHost)] Happiness — needs a host session. false: local build that ticks in a solo playtest."),
54
+ targetId: z
55
+ .string()
56
+ .optional()
57
+ .describe("GUID of a GameObject to attach the component to (only attaches if the type is already in the TypeLibrary — hotload first, then re-call or use add_component_with_properties)."),
58
+ }, async (params) => {
59
+ const res = await bridge.send("create_needs_system", params);
60
+ if (!res.success) {
61
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
62
+ }
63
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
64
+ });
65
+ // ── create_utility_ai ─────────────────────────────────────────────
66
+ server.tool("create_utility_ai", "Generate a utility-AI (scored-action) brain: one file with an abstract {name}Action : Component base (Score() 0..1 + Begin/Tick/End lifecycle), a sealed {name}Brain that every EvaluateInterval picks the highest-scoring sibling action (score × ScoreWeight, current action gets +HysteresisBonus so near-ties don't flip-flop), and two example actions — {name}IdleAction (constant fallback score) and {name}WanderAction (desire builds while idle, walks to random points by direct transform movement, no navmesh). How it differs from create_npc_brain: the FSM has a FIXED transition table; here behavior EMERGES from per-frame scores — add behaviors by subclassing the base on the same GameObject, no transition wiring. Returns {created, path, classNames[4], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach the brain AND example actions to one GameObject (targetId attaches only the brain), verify in play mode via get_runtime_property CurrentActionName. Limits: networked default true = host-authoritative (won't tick in a no-session solo playtest — use networked:false); actions Tick on the simulating machine only. Refused during play mode; refuses to overwrite an existing file.", {
67
+ name: z
68
+ .string()
69
+ .optional()
70
+ .describe("System prefix — generates {name}Action / {name}Brain / {name}IdleAction / {name}WanderAction in {name}Ai.cs. Defaults to 'Utility'. Sanitized to a valid C# identifier."),
71
+ directory: z
72
+ .string()
73
+ .optional()
74
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
75
+ evaluateInterval: z
76
+ .number()
77
+ .optional()
78
+ .describe("Seconds between score evaluations (the active action still Ticks every frame). Defaults to 0.25."),
79
+ hysteresisBonus: z
80
+ .number()
81
+ .optional()
82
+ .describe("Score bonus the current action gets during evaluation — stickiness that prevents flip-flopping between near-tied actions. Defaults to 0.15."),
83
+ moveSpeed: z
84
+ .number()
85
+ .optional()
86
+ .describe("Example WanderAction walk speed in world units/s. Defaults to 80."),
87
+ wanderRadius: z
88
+ .number()
89
+ .optional()
90
+ .describe("Example WanderAction roam radius around its start position. Defaults to 300."),
91
+ networked: z
92
+ .boolean()
93
+ .optional()
94
+ .describe("true (default): host-authoritative brain (IsProxy guard) + [Sync(FromHost)] CurrentActionName. false: local build for solo iteration."),
95
+ targetId: z
96
+ .string()
97
+ .optional()
98
+ .describe("GUID of a GameObject to attach the BRAIN to (actions must be added separately; only attaches if the type is already in the TypeLibrary — hotload first)."),
99
+ }, async (params) => {
100
+ const res = await bridge.send("create_utility_ai", params);
101
+ if (!res.success) {
102
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
103
+ }
104
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
105
+ });
106
+ // ── create_npc_schedule_brain ─────────────────────────────────────
107
+ server.tool("create_npc_schedule_brain", "Generate a daily-routine NPC brain: a [Property] list of schedule entries (startHour/endHour 0..24, taskName, target = named scene GameObject or fixed position), the hour read from any create_day_night_clock component (capability match: a float TimeOfDay property, same GameObject first then scene-wide) with an HONEST fallback to its own internal clock when none exists (check the generated UsingClockComponent bool), walking the NPC to the active entry's target and idling outside the schedule, plus a static OnTaskChanged(brain, taskName) event and [Sync(FromHost)] CurrentTask. Entries with endHour < startHour wrap past midnight. Returns {created, path, className, tasks[], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach (targetId or add_component_with_properties), create the named target GameObjects (e.g. 'WorkSpot'), pair with create_day_night_clock for shared time, verify via get_runtime_property CurrentTask in play mode. Limits: default movement is a direct transform walk (walks through walls) — pass useNavMeshAgent:true for pathfinding (then bake_navmesh is REQUIRED); a clock with a different shape (e.g. 0..1 DayProgress) will NOT bind; networked default true won't tick in a no-session solo playtest (networked:false to iterate). Refused during play mode; refuses to overwrite an existing file.", {
108
+ name: z
109
+ .string()
110
+ .optional()
111
+ .describe("Class/file name. Defaults to 'NpcScheduleBrain'. Sanitized to a valid C# identifier."),
112
+ directory: z
113
+ .string()
114
+ .optional()
115
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
116
+ schedule: z
117
+ .array(z.object({
118
+ startHour: z.number().describe("Entry start hour, 0..24 (inclusive)."),
119
+ endHour: z.number().describe("Entry end hour, 0..24 (exclusive). Smaller than startHour = wraps past midnight (e.g. 22 -> 6)."),
120
+ taskName: z.string().describe("Task label, e.g. 'Work' — surfaced via CurrentTask + OnTaskChanged."),
121
+ targetName: z.string().optional().describe("Named scene GameObject to walk to (case-insensitive; wins over targetPosition). Missing name = NPC idles."),
122
+ targetPosition: Vec3.optional().describe("Fixed world position to walk to, used when targetName is empty."),
123
+ }))
124
+ .optional()
125
+ .describe("Schedule entries baked as inspector-editable defaults. Defaults to Work 8-17 @ 'WorkSpot', Relax 17-22 @ 'HomeSpot' (idles/sleeps otherwise)."),
126
+ moveSpeed: z
127
+ .number()
128
+ .optional()
129
+ .describe("Walk speed in world units/s. Defaults to 100."),
130
+ arriveDistance: z
131
+ .number()
132
+ .optional()
133
+ .describe("Distance at which the NPC counts as arrived and idles at the spot. Defaults to 32."),
134
+ useNavMeshAgent: z
135
+ .boolean()
136
+ .optional()
137
+ .describe("true: move via NavMeshAgent.MoveTo (real pathfinding — REQUIRES bake_navmesh or the NPC won't move). Defaults to false (direct transform walk, no navmesh needed, walks through walls)."),
138
+ fallbackDayLengthSeconds: z
139
+ .number()
140
+ .optional()
141
+ .describe("Internal fallback clock only: real seconds per 24 in-game hours when NO TimeOfDay clock component exists. Defaults to 600."),
142
+ fallbackStartHour: z
143
+ .number()
144
+ .optional()
145
+ .describe("Internal fallback clock only: starting hour 0..24. Defaults to 8."),
146
+ networked: z
147
+ .boolean()
148
+ .optional()
149
+ .describe("true (default): host-authoritative (IsProxy guard) + [Sync(FromHost)] CurrentTask. false: local build for solo iteration."),
150
+ targetId: z
151
+ .string()
152
+ .optional()
153
+ .describe("GUID of the NPC GameObject to attach to (only attaches if the type is already in the TypeLibrary — hotload first)."),
154
+ }, async (params) => {
155
+ const res = await bridge.send("create_npc_schedule_brain", params);
156
+ if (!res.success) {
157
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
158
+ }
159
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
160
+ });
161
+ // ── create_event_bus ──────────────────────────────────────────────
162
+ server.tool("create_event_bus", "Generate a typed LOCAL pub/sub event bus: a pure STATIC class (NOT a Component — nothing to place in the scene) with Subscribe<T>(owner, Action<T>), Unsubscribe(owner) (removes all of that owner's handlers across every event type), Publish<T>(evt) (synchronous, exact-type-T subscribers only, snapshot-iterated so handlers may subscribe/unsubscribe mid-publish), Count<T>() and Clear(), keyed by a plain Dictionary<Type, List<(object, Delegate)>> — plus a tiny example event record ({name}Ping). Decouples game systems: the quest system publishes 'EnemyDied', UI and achievements subscribe, neither knows the other. Returns {created, path, className, exampleEvent, api[], note}. Next: trigger_hotload + get_compile_errors, then Subscribe in components' OnStart and — REQUIRED — Unsubscribe(this) in OnDestroy: handler lists hold PLAIN references (no weak refs), so a component that never unsubscribes leaks itself for the scene's life; call Clear() on scene teardown. Limits: LOCAL only — Publish reaches the calling machine's subscribers, NOT other clients; for networked events pair with [Rpc.Broadcast]/[Rpc.Host] methods that Publish on arrival. No base-type dispatch (Publish<Base> won't reach Subscribe<Derived>). Refuses to overwrite an existing file; refused during play mode.", {
163
+ name: z
164
+ .string()
165
+ .optional()
166
+ .describe("Static class/file name. Defaults to 'EventBus'. The example event record is named {name}Ping. Sanitized to a valid C# identifier."),
167
+ directory: z
168
+ .string()
169
+ .optional()
170
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
171
+ }, async (params) => {
172
+ const res = await bridge.send("create_event_bus", params);
173
+ if (!res.success) {
174
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
175
+ }
176
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
177
+ });
178
+ // ── add_tts_voice ─────────────────────────────────────────────────
179
+ server.tool("add_tts_voice", "Generate a text-to-speech speaker component over Sandbox.Speech.Synthesizer (the OS speech engine — dynamic NPC dialog with zero recorded VO): call <class>.Say(\"text\") from game code and it builds a Synthesizer (TrySetVoice by exact VoiceName, else gender/age hint, else OS default) -> WithText -> WithRate -> Play(), returning a tracked SoundHandle — positional 3D parented to the speaker (default) or flat 2D, with stop-previous-on-say interruption, IsSpeaking, StopSpeaking(), and LogVoices() to enumerate installed OS voices. Returns {created, path, className, propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach (targetId or add_component_with_properties), then Say from game code — LOCAL audio only, wrap the Say call in an [Rpc.Broadcast] handler for everyone to hear. Limits & honesty: the editor cannot playtest audio, so RUNTIME speech is UNVERIFIED (the API surface compiles — verify with your ears in play mode); voices are machine/OS-specific and TrySetVoice is best-effort; LIPSYNC IS NOT AUTO-WIRED — s&box's Sandbox.LipSync component consumes a BaseSoundComponent, not the raw SoundHandle TTS produces, and Synthesizer.OnVisemeReached's delegate arg types can't be confirmed via reflection; enableVisemeData:true enables Handle.LipSync.Visemes for your own mouth-drive code (runtime-unverified). Refuses to overwrite an existing file; refused during play mode.", {
180
+ name: z
181
+ .string()
182
+ .optional()
183
+ .describe("Class/file name. Defaults to 'TtsSpeaker'. Sanitized to a valid C# identifier."),
184
+ directory: z
185
+ .string()
186
+ .optional()
187
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
188
+ voiceName: z
189
+ .string()
190
+ .optional()
191
+ .describe("Exact installed OS voice name (machine-specific — the generated LogVoices() lists them at runtime). Empty = use voiceGender/voiceAge, or the OS default."),
192
+ voiceGender: z
193
+ .string()
194
+ .optional()
195
+ .describe("Voice gender hint used only when voiceName is empty (e.g. 'Female', 'Male'). Must be paired with voiceAge. Passed through unvalidated."),
196
+ voiceAge: z
197
+ .string()
198
+ .optional()
199
+ .describe("Voice age hint paired with voiceGender (e.g. 'Adult', 'Child', 'Senior'). Passed through unvalidated."),
200
+ rate: z
201
+ .number()
202
+ .optional()
203
+ .describe("Speaking rate offset (integer): negative = slower, positive = faster. Defaults to 0 (normal)."),
204
+ volume: z
205
+ .number()
206
+ .optional()
207
+ .describe("Playback volume for spoken lines. Defaults to 1."),
208
+ positional: z
209
+ .boolean()
210
+ .optional()
211
+ .describe("true (default): 3D sound parented to the speaker GameObject (follows it). false: flat 2D voice on the listener (narrator/UI style)."),
212
+ stopPreviousOnSay: z
213
+ .boolean()
214
+ .optional()
215
+ .describe("true (default): a new Say() fades out the still-playing previous line. false: lines overlap."),
216
+ stopFadeSeconds: z
217
+ .number()
218
+ .optional()
219
+ .describe("Fade-out duration used when interrupting/stopping a line. Defaults to 0.1."),
220
+ enableVisemeData: z
221
+ .boolean()
222
+ .optional()
223
+ .describe("true: sets Handle.LipSync.Enabled on each played line so custom mouth-drive code can read Handle.LipSync.Visemes. Runtime behavior unverified (editor can't playtest audio). Defaults to false."),
224
+ targetId: z
225
+ .string()
226
+ .optional()
227
+ .describe("GUID of the speaker GameObject to attach to (only attaches if the type is already in the TypeLibrary — hotload first)."),
228
+ }, async (params) => {
229
+ const res = await bridge.send("add_tts_voice", params);
230
+ if (!res.success) {
231
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
232
+ }
233
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
234
+ });
235
+ }
236
+ //# sourceMappingURL=aisystems.js.map
@@ -0,0 +1,31 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ /**
4
+ * Cinematic Recording pair — scripted-run capture + killcam scaffold:
5
+ *
6
+ * - record_playtest one call = scripted playtest + gameplay recording of the
7
+ * SAME run. Composition of the playtest harness and the
8
+ * Batch 60 MovieRecorder stack: automated regression
9
+ * footage — a failing playtest comes with a replayable
10
+ * clip of exactly what happened.
11
+ * - create_killcam codegen scaffold (sealed Component): rolling-buffer
12
+ * recording + on-death replay via MoviePlayer + a
13
+ * chase-camera takeover of Scene.Camera.
14
+ *
15
+ * Live-verified (2026-07-13, Gravehold editor): sandboxed GAME code can
16
+ * construct + drive MovieRecorder/MoviePlayer at runtime, and
17
+ * MovieRecorderOptions.BufferDuration is a true rolling buffer — ~8.7s of
18
+ * recording with a 3s buffer compiled to a clip with Duration exactly 3.00s,
19
+ * re-based to start at 0. The official recording-api doc
20
+ * (Facepunch/sbox-docs docs/movie-maker/recording-api.md) confirms game-code
21
+ * recording is the supported use case and documents
22
+ * MovieRecorderOptions.Default (a static, invisible to describe_type) +
23
+ * record `with` syntax — the whole-scene idiom create_killcam's
24
+ * RecordWholeScene mode generates.
25
+ *
26
+ * record_playtest is NOT scene-mutating (it only exists in play mode — the
27
+ * Batch 60 recording precedent). create_killcam IS scene-mutating (writes a
28
+ * .cs scaffold to disk, like every create_* scaffold).
29
+ */
30
+ export declare function registerCinematicRecordingTools(server: McpServer, bridge: BridgeClient): void;
31
+ //# sourceMappingURL=cinematicrecording.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Cinematic Recording pair — scripted-run capture + killcam scaffold:
4
+ *
5
+ * - record_playtest one call = scripted playtest + gameplay recording of the
6
+ * SAME run. Composition of the playtest harness and the
7
+ * Batch 60 MovieRecorder stack: automated regression
8
+ * footage — a failing playtest comes with a replayable
9
+ * clip of exactly what happened.
10
+ * - create_killcam codegen scaffold (sealed Component): rolling-buffer
11
+ * recording + on-death replay via MoviePlayer + a
12
+ * chase-camera takeover of Scene.Camera.
13
+ *
14
+ * Live-verified (2026-07-13, Gravehold editor): sandboxed GAME code can
15
+ * construct + drive MovieRecorder/MoviePlayer at runtime, and
16
+ * MovieRecorderOptions.BufferDuration is a true rolling buffer — ~8.7s of
17
+ * recording with a 3s buffer compiled to a clip with Duration exactly 3.00s,
18
+ * re-based to start at 0. The official recording-api doc
19
+ * (Facepunch/sbox-docs docs/movie-maker/recording-api.md) confirms game-code
20
+ * recording is the supported use case and documents
21
+ * MovieRecorderOptions.Default (a static, invisible to describe_type) +
22
+ * record `with` syntax — the whole-scene idiom create_killcam's
23
+ * RecordWholeScene mode generates.
24
+ *
25
+ * record_playtest is NOT scene-mutating (it only exists in play mode — the
26
+ * Batch 60 recording precedent). create_killcam IS scene-mutating (writes a
27
+ * .cs scaffold to disk, like every create_* scaffold).
28
+ */
29
+ export function registerCinematicRecordingTools(server, bridge) {
30
+ const reply = (res) => res.success
31
+ ? { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }
32
+ : { content: [{ type: "text", text: `Error: ${res.error}` }] };
33
+ // ── record_playtest ───────────────────────────────────────────────
34
+ server.tool("record_playtest", [
35
+ "Run a scripted playtest AND record the same run to a .movie clip in ONE call — automated regression footage: a failing playtest comes with a replayable clip of exactly what happened. REQUIRES play mode (start_play first).",
36
+ "steps uses the EXACT playtest schema (one verb per step: move / look / lookDelta / action / jump / set / wait / capture / assert — see the playtest tool for the full verb reference). The recording defaults to the playtest's resolved player hierarchy; pass ids to record other objects, or nothing resolvable falls back to whole-scene capture (heavy).",
37
+ "Returns { started, steps, recordingJobId, capture, sampleRate, clipName, folder, recorderCapSeconds, note } immediately; both jobs run ASYNC in the editor frame loop and the clip AUTO-SAVES the moment the playtest finishes (a failing or aborted run still saves its footage; play mode ending early is also saved).",
38
+ "THE POLL CHAIN: 1) playtest_status until finished:true → the per-step pass/fail transcript. 2) gameplay_recording_status → the saved clip summary { saved, assetPath, durationSeconds, trackCount } (if it still says pendingSave, the save is a frame away — poll again; a save error there means name collision: call stop_gameplay_recording yourself with a new name).",
39
+ "Replay the footage with add_movie_player + play_movie. Errors if a playtest or gameplay recording is already active. Only one at a time.",
40
+ ].join("\n"), {
41
+ steps: z
42
+ .array(z.record(z.string(), z.any()))
43
+ .describe("Ordered playtest step objects — identical schema to the playtest tool (move/look/lookDelta/action/jump/set/wait/capture/assert). Runs top-to-bottom in the frame loop."),
44
+ id: z
45
+ .string()
46
+ .optional()
47
+ .describe("GUID of the player/controller GameObject the playtest drives. Omit to auto-resolve the first PlayerController."),
48
+ component: z
49
+ .string()
50
+ .optional()
51
+ .describe("Controller component type to target (e.g. 'PlayerController'). Omit to auto-detect."),
52
+ ids: z
53
+ .array(z.string())
54
+ .optional()
55
+ .describe("GameObject GUIDs to RECORD (from get_scene_hierarchy WHILE PLAYING). Omit to record the playtest's player hierarchy (the default and usually what you want)."),
56
+ sampleRate: z
57
+ .number()
58
+ .int()
59
+ .optional()
60
+ .describe("Recording samples per second (default 30, clamped 1-120)"),
61
+ clipName: z
62
+ .string()
63
+ .optional()
64
+ .describe("Saved .movie asset name without extension (default playtest_<UTC timestamp>; sanitized to [A-Za-z0-9_-])"),
65
+ folder: z
66
+ .string()
67
+ .optional()
68
+ .describe('Assets subfolder to save the clip into (default "recordings")'),
69
+ }, async (params) => reply(await bridge.send("record_playtest", params)));
70
+ // ── create_killcam ────────────────────────────────────────────────
71
+ server.tool("create_killcam", [
72
+ "Generate a sealed killcam Component: a rolling-buffer MovieRecorder keeps ONLY the last MaxBufferSeconds of a target's gameplay (BufferDuration verified live: the compiled clip's Duration equals the buffer, re-based to 0), and TriggerReplay() plays that history back through a MoviePlayer while the main camera chase-follows the target (Scene.Camera takeover in OnPreRender, restored exactly afterwards; static OnReplayFinished event + IsReplaying flag).",
73
+ "Sandbox-safe: live-verified that GAME code can construct and drive MovieRecorder/MoviePlayer at runtime, and killcams/replays are the official recording-api use case — this is the real MovieMaker path, not a transform-history approximation. The replay REWINDS THE LIVE TARGET through its recorded past (classic killcam — the target is dead/inactive when it runs; disable a still-alive controller for the duration). wholeScene:true makes the generated component default to MovieRecorderOptions.Default (all renderers/cameras/sound points/particles — the replay rewinds everything, killer included; heavy in dense scenes), and it stays toggleable per-instance via the RecordWholeScene property.",
74
+ "Returns { created, path, className, bufferSeconds, sampleRate, cameraDistance, cameraHeight, nextSteps }. Then: trigger_hotload → attach to a MANAGER object → set_component_reference Target to the player → arm via WatchOnStart or StartWatching() from spawn code → call TriggerReplay() from death code (pairs with create_health_system). LOCAL/visual-only — wrap in an [Rpc.Broadcast] for all clients. Refuses if the file already exists.",
75
+ ].join("\n"), {
76
+ name: z
77
+ .string()
78
+ .optional()
79
+ .describe('Component class/file name (default "Killcam")'),
80
+ directory: z
81
+ .string()
82
+ .optional()
83
+ .describe('Project folder for the .cs file (default "Code")'),
84
+ bufferSeconds: z
85
+ .number()
86
+ .optional()
87
+ .describe("Rolling-buffer length in seconds — the replay shows at most this much history (default 10, clamped 2-120)"),
88
+ sampleRate: z
89
+ .number()
90
+ .int()
91
+ .optional()
92
+ .describe("Recorder samples per second (default 30, clamped 1-120)"),
93
+ cameraDistance: z
94
+ .number()
95
+ .optional()
96
+ .describe("Replay chase-camera distance behind the target (default 150, clamped 10-2000)"),
97
+ cameraHeight: z
98
+ .number()
99
+ .optional()
100
+ .describe("Replay chase-camera height above the target (default 60, clamped 0-2000)"),
101
+ wholeScene: z
102
+ .boolean()
103
+ .optional()
104
+ .describe("Generated default for RecordWholeScene: true = buffer the WHOLE scene via MovieRecorderOptions.Default (replay rewinds everything; heavy in dense scenes), false = only the Target hierarchy (default)"),
105
+ }, async (params) => reply(await bridge.send("create_killcam", params)));
106
+ }
107
+ //# sourceMappingURL=cinematicrecording.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 registerDialogueFxTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=dialoguefx.d.ts.map
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Dialogue & Camera FX pair — two scaffold generators:
4
+ *
5
+ * - generate_lipsync_dialogue NPCs speak their lines with moving mouths
6
+ * (Synthesizer TTS + viseme-driven morphs + loose
7
+ * create_dialogue_system HUD bind)
8
+ * - create_camera_effects static conveniences over the SDK's built-in
9
+ * CameraComponent.AddShake/AddPunch/AddTilt
10
+ *
11
+ * Both write a .cs file into the project (scene/file-mutating; refused during
12
+ * play mode by the bridge dispatch). Both templates were live-verified on
13
+ * 2026-07-13: default render written into the live project, hotloaded, compile
14
+ * clean, TypeLibrary-load confirmed, then deleted. Key SDK facts verified the
15
+ * same day: Handle.LipSync.Visemes is IReadOnlyList<float> in the engine's
16
+ * 15-viseme order (read live from Sandbox.LipSync.VisemeNames), the viseme->
17
+ * morph mapping is baked into models (Model.GetVisemeMorph — nonzero on
18
+ * Citizen), and the camera built-ins are whitelist-callable from game code.
19
+ *
20
+ * Mirrors the aisystems.ts module shape: zod params, one bridge.send per tool,
21
+ * JSON.stringify(res.data) on success.
22
+ */
23
+ // A world-space Vector3 accepted as EITHER an object {x,y,z} OR a comma string
24
+ // "x,y,z", passed through unchanged. The C# handler parses both forms.
25
+ const Vec3 = z
26
+ .union([
27
+ z.object({
28
+ x: z.number().describe("X"),
29
+ y: z.number().describe("Y"),
30
+ z: z.number().describe("Z"),
31
+ }),
32
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
33
+ ])
34
+ .describe('A world-space Vector3 — object {x,y,z} OR comma string "x,y,z"');
35
+ export function registerDialogueFxTools(server, bridge) {
36
+ // ── generate_lipsync_dialogue ─────────────────────────────────────
37
+ server.tool("generate_lipsync_dialogue", "Generate a lipsync dialogue performer — NPCs SPEAK their lines with MOVING MOUTHS: a sealed Component holding a [Property] line list (speaker GameObject name + text) that, per line, (a) mirrors the line into a generated create_dialogue_system HUD when one exists (loose TypeLibrary capability bind: List<string> Lines + Begin() + bool IsActive — neither system references the other), (b) speaks the text via Sandbox.Speech.Synthesizer positionally AT the speaker (per-speaker voice name/gender/age/rate via the Voices list), (c) drives the speaker's SkinnedModelRenderer mouth morphs from the live viseme stream — Handle.LipSync.Visemes (IReadOnlyList<float> in the engine's 15-viseme order, read live from Sandbox.LipSync.VisemeNames 2026-07-13) multiplied through the model's own baked viseme->morph table (Model.GetVisemeMorph — verified nonzero on Citizen, e.g. viseme_AA -> openjawL/R; NOT a hand-guessed morph map), with MorphScale + smoothing, and (d) advances when the audio handle stops (LineGapSeconds pause, LineTimeoutSeconds safety-skip, Skip() to cut a line short, StopDialogue() to abort). Static events: OnLineStarted(dialogue, lineIndex, speaker) + OnDialogueFinished(dialogue). Returns {created, path, className, lineCount, voiceCount, propertyNames[], note}. Next: trigger_hotload + get_compile_errors, attach (targetId re-call or add_component_with_properties), fill Lines/Voices in the inspector or bake them via params, call Begin() from game code (or autoStart:true; pair with create_interactable). Limits & honesty: the editor cannot playtest audio, so the LIVE viseme stream is RUNTIME-UNVERIFIED — the generated LogVisemes() helper + DebugLogVisemes property confirm it in seconds in play mode (the API surface and mapping data ARE live-verified); models without baked viseme data log a warning and stay audio-only (Citizen has it); voices are machine/OS-specific and TrySetVoice is best-effort; LOCAL-only — call Begin() inside an [Rpc.Broadcast] for everyone; a bound HUD's own advance input stays active (it only ends that HUD's display, not the audio). Refused during play mode; refuses to overwrite an existing file.", {
38
+ name: z
39
+ .string()
40
+ .optional()
41
+ .describe("Class/file name. Defaults to 'LipsyncDialogue'. Sanitized to a valid C# identifier."),
42
+ directory: z
43
+ .string()
44
+ .optional()
45
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
46
+ lines: z
47
+ .array(z.object({
48
+ speaker: z.string().optional().describe("Scene GameObject name to speak from (case-insensitive; drives THAT object's mouth). Empty/omitted = the component's own GameObject."),
49
+ text: z.string().describe("The spoken line."),
50
+ }))
51
+ .optional()
52
+ .describe("Dialogue lines baked as inspector-editable defaults. Defaults to a two-line demo on the own GameObject."),
53
+ voices: z
54
+ .array(z.object({
55
+ speaker: z.string().describe("Speaker name these voice settings apply to (matches lines[].speaker, case-insensitive)."),
56
+ voiceName: z.string().optional().describe("Exact installed OS voice name (machine-specific — the generated component's Voices list is inspector-editable). Empty = use gender/age hints."),
57
+ voiceGender: z.string().optional().describe("Voice gender hint used when voiceName is empty (e.g. 'Female', 'Male'). Pair with voiceAge. Passed through unvalidated."),
58
+ voiceAge: z.string().optional().describe("Voice age hint paired with voiceGender (e.g. 'Adult', 'Child', 'Senior')."),
59
+ rate: z.number().optional().describe("Speaking rate offset (integer): negative = slower, positive = faster. Defaults to 0."),
60
+ }))
61
+ .optional()
62
+ .describe("Per-speaker voice settings baked as defaults. Defaults to empty (every speaker uses the OS default voice)."),
63
+ volume: z.number().optional().describe("Playback volume for spoken lines. Defaults to 1."),
64
+ positional: z
65
+ .boolean()
66
+ .optional()
67
+ .describe("true (default): 3D audio parented to the speaker GameObject. false: flat 2D narrator voice."),
68
+ driveMouth: z
69
+ .boolean()
70
+ .optional()
71
+ .describe("true (default): drive the speaker's SkinnedModelRenderer mouth morphs from the viseme stream. false: audio-only."),
72
+ morphScale: z
73
+ .number()
74
+ .optional()
75
+ .describe("Multiplier on viseme-derived morph weights (same idea as Sandbox.LipSync.MorphScale). Defaults to 1."),
76
+ mouthSmoothSeconds: z
77
+ .number()
78
+ .optional()
79
+ .describe("Seconds of exponential smoothing on mouth morphs (0 = raw viseme weights). Defaults to 0.05."),
80
+ lineGapSeconds: z
81
+ .number()
82
+ .optional()
83
+ .describe("Pause between a line's audio ending and the next line starting. Defaults to 0.2."),
84
+ lineTimeoutSeconds: z
85
+ .number()
86
+ .optional()
87
+ .describe("Safety: a line whose audio never starts (synthesis pending/failed) is skipped after this many seconds. Defaults to 20."),
88
+ bindHud: z
89
+ .boolean()
90
+ .optional()
91
+ .describe("true (default): loosely bind a create_dialogue_system HUD in the scene (TypeLibrary capability match) and mirror each line into it. false: no HUD mirroring."),
92
+ autoStart: z
93
+ .boolean()
94
+ .optional()
95
+ .describe("true: Begin() fires in OnStart. Defaults to false (call Begin() from game code)."),
96
+ debugLogVisemes: z
97
+ .boolean()
98
+ .optional()
99
+ .describe("true: log the live viseme stream ~4x/second while speaking — the fast way to runtime-verify the mouth drive. Defaults to false."),
100
+ targetId: z
101
+ .string()
102
+ .optional()
103
+ .describe("GUID of a GameObject to attach the component to (only attaches if the type is already in the TypeLibrary — hotload first, then re-call or use add_component_with_properties)."),
104
+ }, async (params) => {
105
+ const res = await bridge.send("generate_lipsync_dialogue", params);
106
+ if (!res.success) {
107
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
108
+ }
109
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
110
+ });
111
+ // ── create_camera_effects ─────────────────────────────────────────
112
+ server.tool("create_camera_effects", "Generate static conveniences over the SDK's BUILT-IN camera effects — CameraComponent.AddShake(amplitude, frequency, duration), AddPunch(Vector3 direction, amplitude, frequency, duration, fovAmplitude), AddPunch(Angles, ...) and AddTilt(Angles, duration, easeTime), all fire-and-forget, self-expiring, whitelist-verified in sandboxed game code 2026-07-13: a sealed Component exposing {name}.Shake/ShakeAt/Punch/PunchAngles/Tilt statics that resolve the main camera (Scene.Camera, else IsMainCamera search, else first camera; warn + return null when the scene has none) and return the live Sandbox.CameraEffectSystem.BaseEffect (Stop()/IsDone; ShakeAt sets Epicenter+Radius for distance falloff), plus one-word preset triggers — HitPunch() / ExplosionShake() / ExplosionShakeAt(position, radius) / LandingTilt() — driven by [Property] tunables. Statics work with NO instance placed; place the component only to tune presets in the inspector. RELATIONSHIP: create_camera_shake is the CONTINUOUS trauma model (AddTrauma accumulates and decays); these built-ins are ONE-SHOT engine effects — they compose safely, but don't fire both for the same event or hits feel doubled. Returns {created, path, className, staticApi[], presetTriggers[], propertyNames[], note}. Next: trigger_hotload + get_compile_errors, call the statics from game code (e.g. {name}.Shake(4, 25, 0.8) on explosion) or attach via targetId and trigger presets. Limits & honesty: compile + camera resolution verified; the editor cannot judge FEEL — tune amplitudes in a human playtest; effects are LOCAL visuals (wrap in [Rpc.Broadcast] for everyone). Refused during play mode; refuses to overwrite an existing file.", {
113
+ name: z
114
+ .string()
115
+ .optional()
116
+ .describe("Class/file name. Defaults to 'CameraFx'. Sanitized to a valid C# identifier."),
117
+ directory: z
118
+ .string()
119
+ .optional()
120
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
121
+ hitPunchDirection: Vec3.optional().describe("HitPunch preset: punch direction. Defaults to Vector3.Backward (camera kicks back)."),
122
+ hitPunchAmplitude: z.number().optional().describe("HitPunch preset: positional kick strength. Defaults to 8."),
123
+ hitPunchFrequency: z.number().optional().describe("HitPunch preset: oscillation frequency. Defaults to 20."),
124
+ hitPunchDuration: z.number().optional().describe("HitPunch preset: seconds. Defaults to 0.25."),
125
+ hitPunchFovAmplitude: z.number().optional().describe("HitPunch preset: FOV kick amount (0 = none). Defaults to 3."),
126
+ explosionShakeAmplitude: z.number().optional().describe("ExplosionShake preset: shake strength. Defaults to 5."),
127
+ explosionShakeFrequency: z.number().optional().describe("ExplosionShake preset: oscillation frequency. Defaults to 25."),
128
+ explosionShakeDuration: z.number().optional().describe("ExplosionShake preset: seconds. Defaults to 0.8."),
129
+ landingTiltAngles: Vec3.optional().describe("LandingTilt preset as {x: pitch, y: yaw, z: roll} degrees (or 'p,y,r'). Defaults to 5 pitch / 0 yaw / 2 roll."),
130
+ landingTiltDuration: z.number().optional().describe("LandingTilt preset: seconds the tilt lasts. Defaults to 0.35."),
131
+ landingTiltEase: z.number().optional().describe("LandingTilt preset: ease-in/out time within the duration. Defaults to 0.15."),
132
+ targetId: z
133
+ .string()
134
+ .optional()
135
+ .describe("GUID of a GameObject to attach the component to — only needed to tune presets in the inspector; the statics work with no instance (only attaches if the type is already in the TypeLibrary — hotload first)."),
136
+ }, async (params) => {
137
+ const res = await bridge.send("create_camera_effects", params);
138
+ if (!res.success) {
139
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
140
+ }
141
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
142
+ });
143
+ }
144
+ //# sourceMappingURL=dialoguefx.js.map
@@ -0,0 +1,27 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ /**
4
+ * Economy & Save family — six Tier-2 scaffolds (Track E):
5
+ *
6
+ * - create_currency_account audited host-authoritative ledger: [Sync(FromHost)]
7
+ * balance + Deposit/Withdraw/TryTransfer + a fixed-size
8
+ * transaction ring buffer with GetRecentTransactions()
9
+ * - create_idle_economy geometric bulk-buy purchasing (BaseCost * Growth^Owned,
10
+ * closed-form Buy 1 / N / Max) + income auto-wired to a
11
+ * sibling wallet via TypeLibrary reflection
12
+ * - create_signed_save tamper-evident save: FNV-1a signature over payload+salt,
13
+ * verify-on-load, Sanitize() clamp hook, forced reset on
14
+ * mismatch, versioned
15
+ * - create_meta_progression between-runs roguelite meta: persistent meta-currency +
16
+ * unlock flags, BankRun(int) run-end seam, OnUnlocked event
17
+ * - add_steam_stat_currency currency persisted over Sandbox.Services.Stats
18
+ * (Steam-cloud, per account, per package ident)
19
+ * - create_loot_table_resource GameResource-based loot table assets ([AssetType],
20
+ * .loot files, nested tables, depth cap) + a resolver component
21
+ *
22
+ * All generate clean, self-contained sealed game code (.cs) into the project; file/scene
23
+ * mutating, refused during play mode by the bridge dispatch. Host-authoritative state uses
24
+ * [Sync(SyncFlags.FromHost)] + IsProxy guards throughout.
25
+ */
26
+ export declare function registerEconomySaveTools(server: McpServer, bridge: BridgeClient): void;
27
+ //# sourceMappingURL=economysave.d.ts.map