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 +18 -0
- package/dist/tools/aisystems.d.ts +4 -0
- package/dist/tools/aisystems.js +236 -0
- package/dist/tools/cinematicrecording.d.ts +31 -0
- package/dist/tools/cinematicrecording.js +107 -0
- package/dist/tools/dialoguefx.d.ts +4 -0
- package/dist/tools/dialoguefx.js +144 -0
- package/dist/tools/economysave.d.ts +27 -0
- package/dist/tools/economysave.js +231 -0
- package/dist/tools/gameplayrecorder.d.ts +18 -0
- package/dist/tools/gameplayrecorder.js +73 -0
- package/dist/tools/movieauthoring.d.ts +31 -0
- package/dist/tools/movieauthoring.js +84 -0
- package/dist/tools/multiplayertest.d.ts +23 -0
- package/dist/tools/multiplayertest.js +62 -0
- package/dist/tools/roundui.d.ts +22 -0
- package/dist/tools/roundui.js +88 -0
- package/dist/tools/statsachievements.d.ts +4 -0
- package/dist/tools/statsachievements.js +243 -0
- package/dist/tools/worldrender.d.ts +4 -0
- package/dist/tools/worldrender.js +183 -0
- package/package.json +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Economy & Save family — six Tier-2 scaffolds (Track E):
|
|
4
|
+
*
|
|
5
|
+
* - create_currency_account audited host-authoritative ledger: [Sync(FromHost)]
|
|
6
|
+
* balance + Deposit/Withdraw/TryTransfer + a fixed-size
|
|
7
|
+
* transaction ring buffer with GetRecentTransactions()
|
|
8
|
+
* - create_idle_economy geometric bulk-buy purchasing (BaseCost * Growth^Owned,
|
|
9
|
+
* closed-form Buy 1 / N / Max) + income auto-wired to a
|
|
10
|
+
* sibling wallet via TypeLibrary reflection
|
|
11
|
+
* - create_signed_save tamper-evident save: FNV-1a signature over payload+salt,
|
|
12
|
+
* verify-on-load, Sanitize() clamp hook, forced reset on
|
|
13
|
+
* mismatch, versioned
|
|
14
|
+
* - create_meta_progression between-runs roguelite meta: persistent meta-currency +
|
|
15
|
+
* unlock flags, BankRun(int) run-end seam, OnUnlocked event
|
|
16
|
+
* - add_steam_stat_currency currency persisted over Sandbox.Services.Stats
|
|
17
|
+
* (Steam-cloud, per account, per package ident)
|
|
18
|
+
* - create_loot_table_resource GameResource-based loot table assets ([AssetType],
|
|
19
|
+
* .loot files, nested tables, depth cap) + a resolver component
|
|
20
|
+
*
|
|
21
|
+
* All generate clean, self-contained sealed game code (.cs) into the project; file/scene
|
|
22
|
+
* mutating, refused during play mode by the bridge dispatch. Host-authoritative state uses
|
|
23
|
+
* [Sync(SyncFlags.FromHost)] + IsProxy guards throughout.
|
|
24
|
+
*/
|
|
25
|
+
export function registerEconomySaveTools(server, bridge) {
|
|
26
|
+
// ── create_currency_account ───────────────────────────────────────
|
|
27
|
+
server.tool("create_currency_account", "Generate a host-authoritative currency ACCOUNT component (sealed) — the audited sibling of create_economy_wallet (wallet = simple money, account = money + a ledger). Balance is [Sync(SyncFlags.FromHost)] so clients can't author their own money; host-guarded Deposit(amount, reason), Withdraw(amount, reason) -> bool, and TryTransfer(otherAccount, amount, reason) -> bool each record a Transaction { Time (Time.Now), signed Amount, Reason, BalanceAfter } into a fixed-size ring buffer (historySize, default 32; oldest entries overwritten SILENTLY). GetRecentTransactions(max) returns them NEWEST FIRST — the ledger is HOST-SIDE ONLY and does not replicate (Balance does); proxies get an empty list. Bind the instance OnBalanceChanged(long) for HUD labels. Single-player safe. Returns { created, path, className, startingBalance, historySize, placedOn, note, nextSteps }. Next: trigger_hotload, then attach via targetId re-run or add_component_to_new_object. Refuses if the file already exists; refused during play mode. Use create_economy_wallet when you don't need the audit trail; pair with create_idle_economy (it auto-wires this account's Money/TrySpend).", {
|
|
28
|
+
name: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Class name for the generated component. Defaults to 'CurrencyAccount'"),
|
|
32
|
+
directory: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Subdirectory for the generated .cs file. Defaults to 'Code'"),
|
|
36
|
+
startingBalance: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Balance the account opens with (host seeds it in OnStart). Defaults to 0"),
|
|
41
|
+
historySize: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Transaction ring-buffer capacity (clamped 1..4096); fixed once the first transaction is recorded, oldest overwritten silently after that. Defaults to 32"),
|
|
46
|
+
targetId: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("GUID of a per-player/bank GameObject to attach to (only attaches if the type is already loaded — hotload first)"),
|
|
50
|
+
}, async (params) => {
|
|
51
|
+
const res = await bridge.send("create_currency_account", params);
|
|
52
|
+
if (!res.success) {
|
|
53
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
54
|
+
}
|
|
55
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
56
|
+
});
|
|
57
|
+
// ── create_idle_economy ───────────────────────────────────────────
|
|
58
|
+
server.tool("create_idle_economy", "Generate a geometric idle-economy component (sealed): generators on the classic BaseCost * Growth^Owned cost curve with Buy 1 / Buy N / Buy Max — CostOf(index, count), MaxAffordable(index), TryBuy(index, count) and BuyMax(index) all use the CLOSED-FORM geometric series (cost = c0*(g^n-1)/(g-1), buyMax = floor(log_g(funds*(g-1)/c0+1))) — no per-copy loops, Buy 1000 is the same math as Buy 1. Wallet wiring is TypeLibrary reflection with NO compile-time wallet dependency (the shipped create_idle_income pattern): each income tick invokes AddMoney(long|int) on the first sibling component that has one, purchases invoke TrySpend(long|int), Buy Max reads the sibling's Money (or Balance) property — works out of the box next to create_economy_wallet or create_currency_account; with NO wallet sibling, purchases are refused with a Log.Warning (never silent) while TotalEarned still accumulates. Host-authoritative: mutations IsProxy-guarded; owned counts are HOST-SIDE state (not replicated); TotalEarned is [Sync(FromHost)]. Static events OnPurchased(index, count, cost) and OnIncomeTick(amount, total). BuyMax steps down once past a whole-currency rounding edge rather than failing. Returns { created, path, className, generators, tickSeconds, placedOn, note, nextSteps }. Next: trigger_hotload, place it NEXT TO a wallet on the same GameObject, tune the parallel GeneratorNames/BaseCosts/Growths/IncomesPerSecond lists with set_property. Refused during play mode. Pair with create_offline_progress for away-time earnings; use create_idle_income for a bare income ticker with no purchasing.", {
|
|
59
|
+
name: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Class name for the generated component. Defaults to 'IdleEconomy'"),
|
|
63
|
+
directory: z
|
|
64
|
+
.string()
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("Subdirectory for the generated .cs file. Defaults to 'Code'"),
|
|
67
|
+
tickSeconds: z
|
|
68
|
+
.number()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Seconds between income grants (floored at 0.1). Defaults to 1"),
|
|
71
|
+
generators: z
|
|
72
|
+
.array(z.object({
|
|
73
|
+
name: z.string().optional().describe("Generator display name"),
|
|
74
|
+
baseCost: z.number().optional().describe("Cost of the first copy. Defaults to 15"),
|
|
75
|
+
growth: z.number().optional().describe("Per-copy cost multiplier, min 1 (1.15 = classic curve). Defaults to 1.15"),
|
|
76
|
+
incomePerSecond: z.number().optional().describe("Income each owned copy produces per second. Defaults to 0.5"),
|
|
77
|
+
}))
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Baked-in generator defaults (inspector-tunable after generation). Omit for a starter trio: Cursor 15/1.15/0.5, Farm 200/1.15/4, Factory 3000/1.12/30"),
|
|
80
|
+
targetId: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("GUID of the GameObject to attach to — put it on the SAME GameObject as the wallet so the reflection wiring finds it (only attaches if the type is already loaded — hotload first)"),
|
|
84
|
+
}, async (params) => {
|
|
85
|
+
const res = await bridge.send("create_idle_economy", params);
|
|
86
|
+
if (!res.success) {
|
|
87
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
88
|
+
}
|
|
89
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
90
|
+
});
|
|
91
|
+
// ── create_signed_save ────────────────────────────────────────────
|
|
92
|
+
server.tool("create_signed_save", "Generate a tamper-evident, versioned save-system component (sealed, owner-only). The SaveData payload POCO is serialized to JSON (Sandbox.Json), FNV-1a-64 hashed over payload + version + salt, and written as a signed envelope { Version, Payload, Signature } to FileSystem.Data. Load() re-verifies: a signature mismatch (hand-edited/corrupt file) triggers a FORCED RESET — the save file is DELETED, defaults are used, and the static OnTampered(reason) event fires (destructive and deliberate; tell the player). A version mismatch starts fresh without the tamper event (add migrations in Load). Loaded values pass a Sanitize() clamp hook so even a re-signed save can't smuggle absurd values. Dirty-flag autosave (autosaveSeconds, default 10; MarkDirty() to arm) + a final save in OnDestroy. HONEST LIMIT: the salt ships inside the game assembly, so this is tamper-EVIDENT (stops notepad edits), NOT cryptographically secure. If you omit salt, a unique random one is baked into the generated file — changing it later invalidates existing saves. Returns { created, path, className, fileName, version, autosaveSeconds, placedOn, note, nextSteps }. Next: trigger_hotload, attach, add your fields to SaveData + clamps to Sanitize(), bump version on shape changes. Refused during play mode. Use create_save_system for a plain unsigned save, create_save_slots for multi-slot UI flows, create_meta_progression for roguelite meta-state.", {
|
|
93
|
+
name: z
|
|
94
|
+
.string()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("Class name for the generated component. Defaults to 'SignedSave'"),
|
|
97
|
+
directory: z
|
|
98
|
+
.string()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("Subdirectory for the generated .cs file. Defaults to 'Code'"),
|
|
101
|
+
fileName: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("FileSystem.Data path the signed envelope is written to. Defaults to 'save_signed.json'"),
|
|
105
|
+
version: z
|
|
106
|
+
.number()
|
|
107
|
+
.int()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe("Save-shape version baked into the file and the signature; mismatched files start fresh. Defaults to 1"),
|
|
110
|
+
salt: z
|
|
111
|
+
.string()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Signing salt baked into the generated code. Omit to bake a unique random salt (recommended); changing it later invalidates existing saves"),
|
|
114
|
+
autosaveSeconds: z
|
|
115
|
+
.number()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Dirty-flag autosave cadence in seconds; 0 disables the heartbeat (OnDestroy still saves). Defaults to 10"),
|
|
118
|
+
targetId: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("GUID of a save-manager GameObject to attach to (only attaches if the type is already loaded — hotload first)"),
|
|
122
|
+
}, async (params) => {
|
|
123
|
+
const res = await bridge.send("create_signed_save", params);
|
|
124
|
+
if (!res.success) {
|
|
125
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
126
|
+
}
|
|
127
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
128
|
+
});
|
|
129
|
+
// ── create_meta_progression ───────────────────────────────────────
|
|
130
|
+
server.tool("create_meta_progression", "Generate a between-runs roguelite meta-progression component (sealed, owner-only): persistent meta-currency + an unlock-flag dictionary saved to FileSystem.Data JSON (dirty-flag autosave + OnDestroy, the create_save_system shape). API: Grant(long), TrySpend(long) -> bool, Unlock(key) (idempotent — the static OnUnlocked(key) event fires only on the FIRST unlock, and unlocks write through to disk immediately), IsUnlocked(key) -> bool, and the run-end seam BankRun(int earned) which converts a finished run's earnings into meta-currency, bumps RunsBanked, and saves immediately — call it from your round machine's end-of-run transition (create_round_state_machine / create_round_phase_machine). Instance OnCurrencyChanged(long) drives meta-shop balance labels. Versioned payload: old-version files start fresh. IsProxy-guarded — in multiplayer each machine banks only its own local meta file (this is per-machine persistence, not a server economy). Returns { created, path, className, fileName, version, placedOn, note, nextSteps }. Next: trigger_hotload, attach to a persistent hub/menu-scene manager GameObject, gate content with IsUnlocked when building the player. Refused during play mode. Pair with create_currency_account (in-run money) and create_signed_save (if the meta file needs tamper evidence — this one is unsigned).", {
|
|
131
|
+
name: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Class name for the generated component. Defaults to 'MetaProgression'"),
|
|
135
|
+
directory: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Subdirectory for the generated .cs file. Defaults to 'Code'"),
|
|
139
|
+
fileName: z
|
|
140
|
+
.string()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("FileSystem.Data path the meta state is written to. Defaults to 'meta.json'"),
|
|
143
|
+
version: z
|
|
144
|
+
.number()
|
|
145
|
+
.int()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe("Payload version; mismatched files start fresh. Defaults to 1"),
|
|
148
|
+
autosaveSeconds: z
|
|
149
|
+
.number()
|
|
150
|
+
.optional()
|
|
151
|
+
.describe("Dirty-flag autosave cadence in seconds; 0 disables the heartbeat (unlocks and BankRun still write through immediately). Defaults to 10"),
|
|
152
|
+
targetId: z
|
|
153
|
+
.string()
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("GUID of a persistent manager GameObject to attach to (only attaches if the type is already loaded — hotload first)"),
|
|
156
|
+
}, async (params) => {
|
|
157
|
+
const res = await bridge.send("create_meta_progression", params);
|
|
158
|
+
if (!res.success) {
|
|
159
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
160
|
+
}
|
|
161
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
162
|
+
});
|
|
163
|
+
// ── add_steam_stat_currency ───────────────────────────────────────
|
|
164
|
+
server.tool("add_steam_stat_currency", "Generate a currency component (sealed) persisted over Sandbox.Services.Stats — Steam-cloud persistence, per Steam account, per package ident, with NO local save file. The stat stores the ABSOLUTE balance: every Add(double)/TrySpend(double) pushes Stats.SetValue(statName, balance); Flush() (and OnDestroy) pushes the buffered writes. On start it reads the balance back asynchronously via Stats.GetLocalPlayerStats(ident) -> Refresh() -> Get(statName).Value and fires the static OnBalanceLoaded(double); wait for IsLoaded before showing the balance. CLOUD SEMANTICS (surprising): stat writes are buffered/rate-limited by the backend and apply ONLY to the LOCAL Steam user — calling this for another player silently does nothing, so attach it to the LOCAL player's GameObject (IsProxy guards keep remote copies inert); read-back is eventually consistent and can lag minutes behind writes — the in-session Balance property is the runtime truth. Dev sessions without a real published package ident may read back nothing (balance starts 0 with a log line). packageIdent defaults to the running package (Game.Ident). Returns { created, path, className, statName, packageIdent, flushEveryChange, placedOn, note, nextSteps }. Next: trigger_hotload, attach to the local player, bind OnBalanceChanged for the HUD. Refused during play mode. Use create_economy_wallet/create_currency_account for in-run networked money, create_signed_save for offline local persistence; pair with create_leaderboard_panel (the same stat can back a leaderboard).", {
|
|
165
|
+
name: z
|
|
166
|
+
.string()
|
|
167
|
+
.optional()
|
|
168
|
+
.describe("Class name for the generated component. Defaults to 'SteamStatCurrency'"),
|
|
169
|
+
directory: z
|
|
170
|
+
.string()
|
|
171
|
+
.optional()
|
|
172
|
+
.describe("Subdirectory for the generated .cs file. Defaults to 'Code'"),
|
|
173
|
+
statName: z
|
|
174
|
+
.string()
|
|
175
|
+
.optional()
|
|
176
|
+
.describe("Sandbox.Services stat that stores the balance (the stat-name string is the contract between write and read-back). Defaults to 'currency'"),
|
|
177
|
+
packageIdent: z
|
|
178
|
+
.string()
|
|
179
|
+
.optional()
|
|
180
|
+
.describe("Package ident to read stats from. Omit/empty = the running package (Game.Ident)"),
|
|
181
|
+
flushEveryChange: z
|
|
182
|
+
.boolean()
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("Call Stats.Flush() after every balance change instead of relying on the buffered flush + OnDestroy flush (the backend rate-limits flushes). Defaults to false"),
|
|
185
|
+
targetId: z
|
|
186
|
+
.string()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe("GUID of the LOCAL player's GameObject to attach to (only attaches if the type is already loaded — hotload first)"),
|
|
189
|
+
}, async (params) => {
|
|
190
|
+
const res = await bridge.send("add_steam_stat_currency", params);
|
|
191
|
+
if (!res.success) {
|
|
192
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
193
|
+
}
|
|
194
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
195
|
+
});
|
|
196
|
+
// ── create_loot_table_resource ────────────────────────────────────
|
|
197
|
+
server.tool("create_loot_table_resource", "Generate GameResource-based loot tables — the data-asset sibling of create_weighted_loot_table. One .cs file containing THREE types: an entry POCO { Name, Weight, optional NestedTable reference }, a [AssetType]-registered GameResource loot-table class (designers author '.loot' files in the editor asset browser — New > Loot Table — after the hotload; NOTE: [AssetType(Name=..., Extension=..., Category=...)] is used because GameResourceAttribute is [Obsolete] on this SDK), and a '<name>Resolver' Component that rolls an assigned table by cumulative weight. Nested tables: an entry with a NestedTable rolls INTO that table instead of dropping its Name, capped at maxDepth (default 4) with a self-reference guard so cycles terminate (at the cap the deepest entry's Name is returned). Resolver.Roll() returns the item name (null + warning when no Table is assigned or the table is empty; entries with weight <= 0 never win; all-zero weights fall back to the first entry) and fires the static OnLoot(GameObject, item) event; roll HOST-SIDE and replicate the result yourself. targetId attaches the RESOLVER (the resource is an asset type, not a component). SURPRISING: pick an extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary picks up engine files as phantom instances. Returns { created, path, className, resolverClass, extension, maxDepth, placedOn, note, nextSteps }. Next: trigger_hotload -> author .loot assets in the editor -> assign the resolver's Table (set_property with the asset path). Refused during play mode. Use create_weighted_loot_table for a single inline component with no asset files; create_gacha_drop_table for pity + duplicate mechanics.", {
|
|
198
|
+
name: z
|
|
199
|
+
.string()
|
|
200
|
+
.optional()
|
|
201
|
+
.describe("Class name for the generated GameResource (the resolver becomes '<name>Resolver'). Defaults to 'LootTableResource'"),
|
|
202
|
+
directory: z
|
|
203
|
+
.string()
|
|
204
|
+
.optional()
|
|
205
|
+
.describe("Subdirectory for the generated .cs file. Defaults to 'Code'"),
|
|
206
|
+
extension: z
|
|
207
|
+
.string()
|
|
208
|
+
.optional()
|
|
209
|
+
.describe("Asset file extension (lowercase alphanumerics; avoid suffixes of built-in extensions like 'cfg'). Defaults to 'loot'"),
|
|
210
|
+
title: z
|
|
211
|
+
.string()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("Display name of the asset type in the editor's New-asset menu. Defaults to 'Loot Table'"),
|
|
214
|
+
maxDepth: z
|
|
215
|
+
.number()
|
|
216
|
+
.int()
|
|
217
|
+
.optional()
|
|
218
|
+
.describe("Default nested-table resolve depth cap baked into the resolver (clamped 0..16; also a [Property]). Defaults to 4"),
|
|
219
|
+
targetId: z
|
|
220
|
+
.string()
|
|
221
|
+
.optional()
|
|
222
|
+
.describe("GUID of a GameObject to attach the RESOLVER component to (only attaches if the type is already loaded — hotload first)"),
|
|
223
|
+
}, async (params) => {
|
|
224
|
+
const res = await bridge.send("create_loot_table_resource", params);
|
|
225
|
+
if (!res.success) {
|
|
226
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
227
|
+
}
|
|
228
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=economysave.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { BridgeClient } from "../transport/bridge-client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Gameplay Recording family — record live PLAY-MODE gameplay to a .movie clip
|
|
5
|
+
* via Sandbox.MovieMaker.MovieRecorder (shipped in the current editor build,
|
|
6
|
+
* verified live 2026-07-12; closes engine-watch #2 in docs/TOOL_BACKLOG.md):
|
|
7
|
+
*
|
|
8
|
+
* - record_gameplay_clip start an async frame-loop recording job
|
|
9
|
+
* - stop_gameplay_recording stop → persist as a project .movie asset
|
|
10
|
+
* - gameplay_recording_status poll the job
|
|
11
|
+
*
|
|
12
|
+
* The counterpart to the MovieMaker playback family (list_movies /
|
|
13
|
+
* add_movie_player / play_movie / stop_movie): those play clips, this CREATES
|
|
14
|
+
* them from live gameplay. None of the three are scene-mutating — they must
|
|
15
|
+
* stay callable during play mode, where recording lives.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerGameplayRecorderTools(server: McpServer, bridge: BridgeClient): void;
|
|
18
|
+
//# sourceMappingURL=gameplayrecorder.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Gameplay Recording family — record live PLAY-MODE gameplay to a .movie clip
|
|
4
|
+
* via Sandbox.MovieMaker.MovieRecorder (shipped in the current editor build,
|
|
5
|
+
* verified live 2026-07-12; closes engine-watch #2 in docs/TOOL_BACKLOG.md):
|
|
6
|
+
*
|
|
7
|
+
* - record_gameplay_clip start an async frame-loop recording job
|
|
8
|
+
* - stop_gameplay_recording stop → persist as a project .movie asset
|
|
9
|
+
* - gameplay_recording_status poll the job
|
|
10
|
+
*
|
|
11
|
+
* The counterpart to the MovieMaker playback family (list_movies /
|
|
12
|
+
* add_movie_player / play_movie / stop_movie): those play clips, this CREATES
|
|
13
|
+
* them from live gameplay. None of the three are scene-mutating — they must
|
|
14
|
+
* stay callable during play mode, where recording lives.
|
|
15
|
+
*/
|
|
16
|
+
export function registerGameplayRecorderTools(server, bridge) {
|
|
17
|
+
// ── record_gameplay_clip ──────────────────────────────────────────
|
|
18
|
+
server.tool("record_gameplay_clip", "Start recording live play-mode gameplay into a Sandbox.MovieMaker clip — REQUIRES play mode (start_play first; errors otherwise). Captures the given GameObjects (ids — recommended: small focused clips) or, when ids is omitted, the WHOLE scene (heavy: every object becomes tracks). Returns { started, jobId, sampleRate, maxSeconds, capture, discarded, note } immediately; recording runs ASYNC in the editor frame loop until stop_gameplay_recording or the maxSeconds safety cap (default 60s of clip time, max 600s). Only one recording at a time (a second call errors while active; a stopped-but-unsaved clip is discarded by a new start, reported in 'discarded'). Combine with playtest or drive_player to record a SCRIPTED run, then stop_gameplay_recording to save the .movie and play_movie to replay it.", {
|
|
19
|
+
ids: z
|
|
20
|
+
.array(z.string())
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("GameObject GUIDs to capture (from get_scene_hierarchy WHILE PLAYING — play-mode ids can differ from editor ids). Omit to capture the whole scene (heavy)"),
|
|
23
|
+
sampleRate: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Samples per second (default 30, clamped 1-120)"),
|
|
28
|
+
maxSeconds: z
|
|
29
|
+
.number()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Safety cap — auto-stops the recording once the clip timeline reaches this many seconds (default 60, clamped 1-600). The clip stays in memory until stop_gameplay_recording saves it"),
|
|
32
|
+
}, async (params) => {
|
|
33
|
+
const res = await bridge.send("record_gameplay_clip", params);
|
|
34
|
+
if (!res.success) {
|
|
35
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
36
|
+
}
|
|
37
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
38
|
+
});
|
|
39
|
+
// ── stop_gameplay_recording ───────────────────────────────────────
|
|
40
|
+
server.tool("stop_gameplay_recording", "Stop the active gameplay recording and persist it as a project .movie asset the editor can load (written to Assets/<folder>/<name>.movie, registered + compiled — list_movies then shows it with hasCompiledClip). Also saves a job that already auto-stopped (maxSeconds cap, or play mode ended). Returns { saved, assetPath, durationSeconds, trackCount, sampleRate, compiled, stopReason, wired, note } — a trackCount of 0 means nothing was captured and the response warns about it. Pass wireToId to auto-wire a MoviePlayer on that GameObject pointed at the new clip (during play mode that wiring is RUNTIME-ONLY and discarded on stop_play; the .movie asset itself always persists). Errors if the target file already exists (the clip stays in memory — retry with another name). discard:true throws the recording away instead. Replay: add_movie_player + play_movie in play mode.", {
|
|
41
|
+
name: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Asset name without extension (default recording_<UTC timestamp>; sanitized to [A-Za-z0-9_-])"),
|
|
45
|
+
folder: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Assets subfolder to save into (default "recordings")'),
|
|
49
|
+
wireToId: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("GameObject GUID to auto-wire a MoviePlayer at the new clip (runtime-only if done during play mode)"),
|
|
53
|
+
discard: z
|
|
54
|
+
.boolean()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Throw the recording away instead of saving it"),
|
|
57
|
+
}, async (params) => {
|
|
58
|
+
const res = await bridge.send("stop_gameplay_recording", params);
|
|
59
|
+
if (!res.success) {
|
|
60
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
61
|
+
}
|
|
62
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
63
|
+
});
|
|
64
|
+
// ── gameplay_recording_status ─────────────────────────────────────
|
|
65
|
+
server.tool("gameplay_recording_status", "Poll the gameplay recording job. While recording returns { recording:true, jobId, elapsedSeconds (clip-timeline seconds), framesWithData, maxSeconds, sampleRate, capture, trackedObjectCount } (trackedObjectCount is -1 for whole-scene capture). After an auto-stop (maxSeconds cap / play mode ended) returns { stopped:true, pendingSave:true, reason } — the clip is in memory awaiting stop_gameplay_recording. After a save/discard returns that last summary (assetPath etc.). Read-only; works during play. No params.", {}, async () => {
|
|
66
|
+
const res = await bridge.send("gameplay_recording_status", {});
|
|
67
|
+
if (!res.success) {
|
|
68
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
69
|
+
}
|
|
70
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=gameplayrecorder.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
|
+
* Movie Authoring family — author a .movie cutscene clip from a declarative
|
|
5
|
+
* shot list, deterministically, in EDIT mode. No Movie Maker dock, no play
|
|
6
|
+
* mode, no real-time waiting: the whole clip bakes inside one handler call.
|
|
7
|
+
*
|
|
8
|
+
* - author_movie_clip shot list → keyframe timeline → baked .movie asset
|
|
9
|
+
*
|
|
10
|
+
* The mechanism (docs/BRIDGE_GOTCHAS.md §13, flipped around): in EDIT mode
|
|
11
|
+
* MovieRecorder does NOT auto-advance, so manual Advance(1/sampleRate) +
|
|
12
|
+
* Capture() per synthetic frame produces EXACT manual durations. This is the
|
|
13
|
+
* OFFICIAL in-editor recording idiom (sbox-docs movie-maker/recording-api.md:
|
|
14
|
+
* "Instead of Start and Stop, call Advance ... and Capture to record a
|
|
15
|
+
* frame"). The handler steps a camera (temp or borrowed) through hold+blend
|
|
16
|
+
* segments and pumps the recorder one frame at a time. Proven live
|
|
17
|
+
* 2026-07-13: positions decode back exactly (x = 100·t), and FOV IS captured
|
|
18
|
+
* when the CameraComponent is explicitly targeted (WithCaptureComponent) —
|
|
19
|
+
* so fovDegrees is real. E2E: a 3-shot 3.5s bake took 6ms, listed loadable,
|
|
20
|
+
* played to positionSeconds=3.5 in play mode, and add_movie_player
|
|
21
|
+
* createTargets:true recreated the destroyed temp camera.
|
|
22
|
+
*
|
|
23
|
+
* Completes the MovieMaker triangle: list/add_movie_player/play/stop PLAY
|
|
24
|
+
* clips, record_gameplay_clip CAPTURES live play-mode gameplay, and this
|
|
25
|
+
* AUTHORS clips from data. Scene-mutating (creates/borrows-and-moves a camera
|
|
26
|
+
* and writes a project asset) — registered in _sceneMutatingCommands; it also
|
|
27
|
+
* refuses play mode itself, because the recorder auto-advances there and a
|
|
28
|
+
* manual bake would double-count.
|
|
29
|
+
*/
|
|
30
|
+
export declare function registerMovieAuthoringTools(server: McpServer, bridge: BridgeClient): void;
|
|
31
|
+
//# sourceMappingURL=movieauthoring.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Movie Authoring family — author a .movie cutscene clip from a declarative
|
|
4
|
+
* shot list, deterministically, in EDIT mode. No Movie Maker dock, no play
|
|
5
|
+
* mode, no real-time waiting: the whole clip bakes inside one handler call.
|
|
6
|
+
*
|
|
7
|
+
* - author_movie_clip shot list → keyframe timeline → baked .movie asset
|
|
8
|
+
*
|
|
9
|
+
* The mechanism (docs/BRIDGE_GOTCHAS.md §13, flipped around): in EDIT mode
|
|
10
|
+
* MovieRecorder does NOT auto-advance, so manual Advance(1/sampleRate) +
|
|
11
|
+
* Capture() per synthetic frame produces EXACT manual durations. This is the
|
|
12
|
+
* OFFICIAL in-editor recording idiom (sbox-docs movie-maker/recording-api.md:
|
|
13
|
+
* "Instead of Start and Stop, call Advance ... and Capture to record a
|
|
14
|
+
* frame"). The handler steps a camera (temp or borrowed) through hold+blend
|
|
15
|
+
* segments and pumps the recorder one frame at a time. Proven live
|
|
16
|
+
* 2026-07-13: positions decode back exactly (x = 100·t), and FOV IS captured
|
|
17
|
+
* when the CameraComponent is explicitly targeted (WithCaptureComponent) —
|
|
18
|
+
* so fovDegrees is real. E2E: a 3-shot 3.5s bake took 6ms, listed loadable,
|
|
19
|
+
* played to positionSeconds=3.5 in play mode, and add_movie_player
|
|
20
|
+
* createTargets:true recreated the destroyed temp camera.
|
|
21
|
+
*
|
|
22
|
+
* Completes the MovieMaker triangle: list/add_movie_player/play/stop PLAY
|
|
23
|
+
* clips, record_gameplay_clip CAPTURES live play-mode gameplay, and this
|
|
24
|
+
* AUTHORS clips from data. Scene-mutating (creates/borrows-and-moves a camera
|
|
25
|
+
* and writes a project asset) — registered in _sceneMutatingCommands; it also
|
|
26
|
+
* refuses play mode itself, because the recorder auto-advances there and a
|
|
27
|
+
* manual bake would double-count.
|
|
28
|
+
*/
|
|
29
|
+
export function registerMovieAuthoringTools(server, bridge) {
|
|
30
|
+
// ── author_movie_clip ─────────────────────────────────────────────
|
|
31
|
+
server.tool("author_movie_clip", "Author a MovieMaker .movie cutscene clip from a declarative shot list — EDIT MODE ONLY, no Movie Maker dock, no play mode, no real-time waiting (a 30s clip bakes in one call, typically <1s). Builds a hold+blend keyframe timeline from the shots (smoothstep ease by default), steps a camera through it, and hand-pumps MovieRecorder Advance/Capture per synthetic frame, then saves Assets/<folder>/<clipName>.movie (registered + compiled; errors if the file exists — the scene itself is NOT saved). Returns { authored, path, name, durationSeconds, frames, sampleRate, shots, tracks, bakeMs, compiled, loadable, camera, nextSteps }. Camera: omit cameraId for a temp camera (destroyed after the bake — play back with add_movie_player createTargets:true so the missing target is recreated), or pass cameraId of an existing camera GameObject (transform + FOV restored EXACTLY afterwards; the clip then animates THAT object on playback). fovDegrees is baked for real (the clip carries a FieldOfView track). Authored clips animate ONLY the camera the bake moves — other scene objects don't move in edit mode (that's what record_gameplay_clip is for). Total timeline capped at 120s, max 32 shots. Errors during play mode (stop_play first). Verify with list_movies; play via add_movie_player + play_movie in play mode.", {
|
|
32
|
+
shots: z
|
|
33
|
+
.array(z.object({
|
|
34
|
+
position: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("Camera position for this shot as 'x,y,z'"),
|
|
37
|
+
lookAt: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Aim target: a GameObject GUID (resolved to its world position at bake time) or a world point 'x,y,z'. Omit to keep the previous shot's rotation (first shot: the camera's starting rotation)"),
|
|
41
|
+
fovDegrees: z
|
|
42
|
+
.number()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Field of view in degrees (clamped 5-170), baked into the clip as a FieldOfView track. Omit to carry the previous shot's FOV (first shot: the camera's current FOV)"),
|
|
45
|
+
holdSeconds: z
|
|
46
|
+
.number()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("How long to hold this shot's pose (default 1, clamped 0-120)"),
|
|
49
|
+
blendSeconds: z
|
|
50
|
+
.number()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Blend time from the previous shot into this one (default 1, clamped 0-120; ignored on the first shot)"),
|
|
53
|
+
ease: z
|
|
54
|
+
.enum(["linear", "smoothstep"])
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Easing of the blend INTO this shot (default smoothstep)"),
|
|
57
|
+
}))
|
|
58
|
+
.describe("The shot list in order (1-32 shots). Timeline = hold₀, then blendᵢ + holdᵢ per following shot"),
|
|
59
|
+
clipName: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Asset name without extension (default authored_<UTC timestamp>; sanitized to [A-Za-z0-9_-])"),
|
|
63
|
+
folder: z
|
|
64
|
+
.string()
|
|
65
|
+
.optional()
|
|
66
|
+
.describe('Assets subfolder to save into (default "movies")'),
|
|
67
|
+
sampleRate: z
|
|
68
|
+
.number()
|
|
69
|
+
.int()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Clip samples per second (default 30, clamped 1-120)"),
|
|
72
|
+
cameraId: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("GUID of an existing camera GameObject (must have a CameraComponent) to bake through — restored EXACTLY afterwards, and playback then animates that object. Omit for a temp camera that is destroyed after the bake"),
|
|
76
|
+
}, async (params) => {
|
|
77
|
+
const res = await bridge.send("author_movie_clip", params);
|
|
78
|
+
if (!res.success) {
|
|
79
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
80
|
+
}
|
|
81
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=movieauthoring.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { BridgeClient } from "../transport/bridge-client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Multiplayer test harness — host a lobby on the RUNNING game and boot real
|
|
5
|
+
* sbox.exe clients that auto-join it (the engine's official test path:
|
|
6
|
+
* networking/testing-multiplayer.md "Join via new instance" / `connect local`,
|
|
7
|
+
* live-verified through the bridge 2026-07-13):
|
|
8
|
+
*
|
|
9
|
+
* - start_multiplayer_test CreateLobby (if needed) + spawn N `sbox.exe -joinlocal`
|
|
10
|
+
* - multiplayer_test_status read-only: networking, connections, client PIDs, join-wait
|
|
11
|
+
* - stop_multiplayer_test kill tracked clients (+ optional disconnect / stray sweep)
|
|
12
|
+
*
|
|
13
|
+
* The composition this unlocks: start_multiplayer_test → poll status until the guest
|
|
14
|
+
* joins (~80 s boot) → drive the HOST player (drive_player / playtest) → assert
|
|
15
|
+
* replication host-side (inspect_networked_object's [Sync] dump, get_runtime_property)
|
|
16
|
+
* → stop_multiplayer_test. HONEST LIMIT: the bridge cannot see or drive the CLIENT
|
|
17
|
+
* instance (no bridge runs inside it) — a human can eyeball the client window.
|
|
18
|
+
*
|
|
19
|
+
* None of the three are scene-mutating — they must stay callable during play mode,
|
|
20
|
+
* where the session lives (record_playtest precedent).
|
|
21
|
+
*/
|
|
22
|
+
export declare function registerMultiplayerTestTools(server: McpServer, bridge: BridgeClient): void;
|
|
23
|
+
//# sourceMappingURL=multiplayertest.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Multiplayer test harness — host a lobby on the RUNNING game and boot real
|
|
4
|
+
* sbox.exe clients that auto-join it (the engine's official test path:
|
|
5
|
+
* networking/testing-multiplayer.md "Join via new instance" / `connect local`,
|
|
6
|
+
* live-verified through the bridge 2026-07-13):
|
|
7
|
+
*
|
|
8
|
+
* - start_multiplayer_test CreateLobby (if needed) + spawn N `sbox.exe -joinlocal`
|
|
9
|
+
* - multiplayer_test_status read-only: networking, connections, client PIDs, join-wait
|
|
10
|
+
* - stop_multiplayer_test kill tracked clients (+ optional disconnect / stray sweep)
|
|
11
|
+
*
|
|
12
|
+
* The composition this unlocks: start_multiplayer_test → poll status until the guest
|
|
13
|
+
* joins (~80 s boot) → drive the HOST player (drive_player / playtest) → assert
|
|
14
|
+
* replication host-side (inspect_networked_object's [Sync] dump, get_runtime_property)
|
|
15
|
+
* → stop_multiplayer_test. HONEST LIMIT: the bridge cannot see or drive the CLIENT
|
|
16
|
+
* instance (no bridge runs inside it) — a human can eyeball the client window.
|
|
17
|
+
*
|
|
18
|
+
* None of the three are scene-mutating — they must stay callable during play mode,
|
|
19
|
+
* where the session lives (record_playtest precedent).
|
|
20
|
+
*/
|
|
21
|
+
export function registerMultiplayerTestTools(server, bridge) {
|
|
22
|
+
const reply = (res) => res.success
|
|
23
|
+
? { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }
|
|
24
|
+
: { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
25
|
+
// ── start_multiplayer_test ────────────────────────────────────────
|
|
26
|
+
server.tool("start_multiplayer_test", [
|
|
27
|
+
"Spin up a REAL multiplayer session for the running game — REQUIRES play mode (start_play first; errors otherwise). Hosts a lobby on the current session if none is active (play mode does NOT host by default) and launches N real sbox.exe game clients with -joinlocal that boot and auto-join — the engine's official multiplayer test path ('Join via new instance' / connect local). The sbox.exe path is auto-resolved (next to the editor process, else Steam library scan) — never hardcoded.",
|
|
28
|
+
"Returns { hosting, isHost, lobbyCreated, maxPlayers, spawned:[pids], sboxExe, resolvedVia, expectedConnections, jobId?, waitForJoinSeconds, note } IMMEDIATELY. With waitForJoinSeconds > 0 an async frame-loop job (jobId) polls Connection.All.Count until it reaches expectedConnections (host + every tracked client) or times out — poll multiplayer_test_status until joinWait.state is 'complete'. A client takes ~80 SECONDS to boot and join, and uses ~4.5 GB RAM (default clients=1, hard cap 2 total).",
|
|
29
|
+
"What to do next: once joined, drive the HOST player via drive_player or playtest, and assert replication HOST-SIDE — inspect_networked_object dumps [Sync] state, get_runtime_property reads live values, multiplayer_test_status shows the connection list. Then stop_multiplayer_test to clean up.",
|
|
30
|
+
"HONEST LIMIT: the bridge CANNOT see or drive the CLIENT instance — no bridge runs inside it. All assertions are host-side; a human can eyeball the client window for visual confirmation.",
|
|
31
|
+
].join("\n"), {
|
|
32
|
+
clients: z
|
|
33
|
+
.number()
|
|
34
|
+
.int()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("How many sbox.exe client instances to launch (default 1, hard cap 2 total tracked alive — each uses ~4.5 GB RAM)"),
|
|
37
|
+
maxPlayers: z
|
|
38
|
+
.number()
|
|
39
|
+
.int()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Lobby capacity (default 4). Only applied when THIS call creates the lobby (via LobbyConfig — Networking.MaxPlayers is read-only); ignored if a session is already active"),
|
|
42
|
+
waitForJoinSeconds: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("How long the async join-wait job polls for all clients to connect (default 120, clamped 0-600). 0 = don't wait, return right after spawning. Clients boot in ~80 s — don't set this below ~100 unless polling manually"),
|
|
47
|
+
}, async (params) => reply(await bridge.send("start_multiplayer_test", params)));
|
|
48
|
+
// ── multiplayer_test_status ───────────────────────────────────────
|
|
49
|
+
server.tool("multiplayer_test_status", "Read-only snapshot of the multiplayer test harness — works whether or not a test is running (no test = honest empty state). Returns { networkingActive, isHost, connectionCount, connections:[{name,id,isHost}], clients:[{pid,alive,uptimeSeconds}], joinWait:{state:'none'|'pending'|'complete'|'timeout', jobId, expectedConnections, currentConnections, elapsedSeconds, note}, playing }. The success signal for a join is connectionCount going 1 → N+1 (host + guests; guests appear with the Steam guest account name). 'timeout' is not fatal — clients boot in ~80 s, so a late join can still land: keep polling. Also your host-side assertion surface: pair it with inspect_networked_object / get_runtime_property to verify replication while the session runs.", {}, async () => reply(await bridge.send("multiplayer_test_status", {})));
|
|
50
|
+
// ── stop_multiplayer_test ─────────────────────────────────────────
|
|
51
|
+
server.tool("stop_multiplayer_test", "Kill every sbox.exe test client spawned by start_multiplayer_test (entire process tree) and clear the harness registry. Returns { stopped, clients:[{pid,killed,note?}], disconnected, strays, note } — a PID that's already dead (crashed / closed by hand) is reported per-PID, not an error. Pass disconnect:true to also Networking.Disconnect() the host session on the running game (default false: the session keeps running for another round). Pass alsoStrays:true as a safety net to kill ANY process named 'sbox' that isn't the editor (never touches sbox-dev / the editor's own PID). Killing a connected client leaves benign TcpChannel.SendThread traces in the editor log — not an error. Safe to call with nothing running.", {
|
|
52
|
+
disconnect: z
|
|
53
|
+
.boolean()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Also Networking.Disconnect() the host session on the running game (default false — leaves the session up)"),
|
|
56
|
+
alsoStrays: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Safety net: also kill ANY process named 'sbox' that isn't the editor or a tracked client (default false). Never touches sbox-dev or the editor's own PID"),
|
|
60
|
+
}, async (params) => reply(await bridge.send("stop_multiplayer_test", params)));
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=multiplayertest.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { BridgeClient } from "../transport/bridge-client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Round-flow & UI pack — three tools:
|
|
5
|
+
*
|
|
6
|
+
* - create_round_timer_hud Razor screen HUD showing round/phase time remaining;
|
|
7
|
+
* binds at runtime (TypeLibrary reflection) to whichever
|
|
8
|
+
* shipped round machine exists — no code coupling
|
|
9
|
+
* - scaffold_map_vote_flow end-of-round map vote: [Rpc.Host] ballots, [Sync]
|
|
10
|
+
* NetList tallies, countdown, deterministic tie-break,
|
|
11
|
+
* winner -> Scene.LoadFromFile, plus a Razor vote panel
|
|
12
|
+
* - add_panel_buildhash FILE-EDIT tool (not a scaffold): patches an existing
|
|
13
|
+
* .razor to add the BuildHash() override razor_lint
|
|
14
|
+
* flags as missing
|
|
15
|
+
*
|
|
16
|
+
* All are file-mutating (refused during play mode by the bridge dispatch). The
|
|
17
|
+
* Razor output is razor_lint-safe by construction (BuildHash override, no
|
|
18
|
+
* switch-expressions or non-ASCII in @code, class-selector SCSS roots), modeled
|
|
19
|
+
* on create_leaderboard_panel / the UI-feedback pack.
|
|
20
|
+
*/
|
|
21
|
+
export declare function registerRoundUiTools(server: McpServer, bridge: BridgeClient): void;
|
|
22
|
+
//# sourceMappingURL=roundui.d.ts.map
|