@el4cteo/rbx-studio-mcp 0.8.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/bridge/api.js +2 -2
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +12 -4
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/harness.js +48 -6
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/remote.js +4 -2
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +43 -5
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +26 -1
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/apidump.js +82 -15
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/cloudassets.js +2 -2
- package/dist/lib/cloudassets.js.map +1 -1
- package/dist/lib/livedata.js +24 -1
- package/dist/lib/livedata.js.map +1 -1
- package/dist/lib/liveops.js +79 -0
- package/dist/lib/liveops.js.map +1 -1
- package/dist/lib/monetization.js +119 -0
- package/dist/lib/monetization.js.map +1 -0
- package/dist/lib/opencloud.js +16 -6
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/png.js +19 -31
- package/dist/lib/png.js.map +1 -1
- package/dist/tools/audio.js +1 -1
- package/dist/tools/audio.js.map +1 -1
- package/dist/tools/device.js +8 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +86 -5
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/instances.js +3 -1
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +27 -5
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +35 -10
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/session.js +11 -3
- package/dist/tools/session.js.map +1 -1
- package/dist/tools/terrain.js +2 -2
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/universe.js +136 -11
- package/dist/tools/universe.js.map +1 -1
- package/dist/tools/world.js +30 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +4 -4
- package/plugin/src/ClientRelay.luau +20 -2
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Emulation.luau +44 -16
- package/plugin/src/ExecRuntime.luau +168 -161
- package/plugin/src/LogBuffer.luau +58 -3
- package/plugin/src/Phrase.luau +2 -0
- package/plugin/src/handlers/Assets.luau +27 -18
- package/plugin/src/handlers/Audio.luau +36 -15
- package/plugin/src/handlers/Character.luau +38 -23
- package/plugin/src/handlers/Data.luau +12 -1
- package/plugin/src/handlers/Debug.luau +42 -0
- package/plugin/src/handlers/Device.luau +38 -27
- package/plugin/src/handlers/Exec.luau +5 -1
- package/plugin/src/handlers/Generate.luau +8 -5
- package/plugin/src/handlers/Geometry.luau +7 -5
- package/plugin/src/handlers/Perf.luau +1 -0
- package/plugin/src/handlers/Playtest.luau +40 -3
- package/plugin/src/handlers/Spatial.luau +34 -0
- package/plugin/src/handlers/Terrain.luau +19 -24
- package/plugin/src/handlers/Viewport.luau +37 -5
- package/plugin/src/handlers/World.luau +38 -2
- package/plugin/src/init.server.luau +11 -0
- package/scripts/check-plugin.mjs +54 -0
- package/scripts/test-apidump.mjs +170 -0
- package/scripts/test-bridge.mjs +111 -4
- package/scripts/test-live-tools.mjs +382 -0
- package/scripts/test-tools.mjs +222 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Drives the tools through the real MCP layer against a connected Studio.
|
|
4
|
+
*
|
|
5
|
+
* `test-live.mjs` talks to the plugin over the bridge, which proves the transport
|
|
6
|
+
* and the handlers. This starts an actual server, connects an MCP client to it and
|
|
7
|
+
* calls tools the way an agent does -- so schema validation, output shaping and
|
|
8
|
+
* the plugin are all in the path. It exists because that is where the last two
|
|
9
|
+
* real faults hid: `execute_luau target="client"` had been broken for a release
|
|
10
|
+
* because a module the relay needed was never copied to the client (every offline
|
|
11
|
+
* test stubs that out), and `collision remove` answered "removed" for a group that
|
|
12
|
+
* never existed. Neither shows in a unit test.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here needs a place in any particular state. Everything it makes is under
|
|
15
|
+
* Workspace.__mcp_live_tools and is deleted before it exits, pass or fail, and what
|
|
16
|
+
* it moves (the camera, device emulation, network shaping, a playtest) is put back.
|
|
17
|
+
*
|
|
18
|
+
* The server it starts finds the bridge port taken and proxies to the running one,
|
|
19
|
+
* so it appears in the Studio panel as one more MCP client for the length of a run.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node scripts/test-live-tools.mjs [--port 44755] [--studio-id ID] [--playtest] [--no-camera]
|
|
22
|
+
* --playtest also start a playtest and exercise character, input and the client tools
|
|
23
|
+
* --no-camera leave the viewport camera alone (skips the focus and camera checks)
|
|
24
|
+
*/
|
|
25
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
26
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const flag = (name, fallback) => {
|
|
32
|
+
const at = args.indexOf(`--${name}`);
|
|
33
|
+
return at === -1 ? fallback : args[at + 1];
|
|
34
|
+
};
|
|
35
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
const FIXTURE = "Workspace.__mcp_live_tools";
|
|
37
|
+
const GROUP = "MCPLiveToolsGroup";
|
|
38
|
+
const TERRAIN_AT = "5000, 0, 5000";
|
|
39
|
+
|
|
40
|
+
const client = new Client({ name: "test-live-tools", version: "0" });
|
|
41
|
+
await client.connect(
|
|
42
|
+
new StdioClientTransport({
|
|
43
|
+
command: process.execPath,
|
|
44
|
+
args: [join(root, "dist", "index.js"), ...(flag("port") ? ["--port", flag("port")] : [])],
|
|
45
|
+
stderr: "inherit",
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
let failures = 0;
|
|
50
|
+
let studioId = flag("studio-id");
|
|
51
|
+
|
|
52
|
+
async function call(name, params = {}) {
|
|
53
|
+
const started = performance.now();
|
|
54
|
+
try {
|
|
55
|
+
const result = await client.callTool(
|
|
56
|
+
{ name, arguments: { ...(studioId && !("studioId" in params) ? { studioId } : {}), ...params } },
|
|
57
|
+
undefined,
|
|
58
|
+
{ timeout: 180_000 },
|
|
59
|
+
);
|
|
60
|
+
const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
61
|
+
return { text, isError: result.isError === true, ms: Math.round(performance.now() - started) };
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
// Schema validation is a protocol error rather than an isError result.
|
|
64
|
+
return { text: cause.message, isError: true, ms: Math.round(performance.now() - started) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function report(label, good, { text, ms }) {
|
|
69
|
+
if (!good) failures += 1;
|
|
70
|
+
const brief = text.replace(/\s+/g, " ").slice(0, good ? 84 : 360);
|
|
71
|
+
process.stdout.write(`${good ? "ok " : "FAIL"} ${label.padEnd(50)} ${String(ms).padStart(5)}ms ${brief}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `expect`: "ok" (any success), "refused" (any error), or a RegExp the reply must match. */
|
|
75
|
+
async function check(label, name, params, expect = "ok", { refused = false } = {}) {
|
|
76
|
+
const reply = await call(name, params);
|
|
77
|
+
const matched = expect instanceof RegExp ? expect.test(reply.text) : true;
|
|
78
|
+
report(label, refused ? reply.isError && matched : !reply.isError && matched, reply);
|
|
79
|
+
return reply.text;
|
|
80
|
+
}
|
|
81
|
+
const ok = (label, name, params, expect) => check(label, name, params, expect);
|
|
82
|
+
const refuses = (label, name, params, expect) => check(label, name, params, expect, { refused: true });
|
|
83
|
+
const section = (title) => process.stdout.write(`\n-- ${title}\n`);
|
|
84
|
+
const paths = (text) => text.split("\n").filter((line) => /^\S+ \| /.test(line) && !line.startsWith("path |")).map((line) => line.split(" | ")[0]);
|
|
85
|
+
|
|
86
|
+
const cleanup = [];
|
|
87
|
+
try {
|
|
88
|
+
// ---- which Studio ---------------------------------------------------------------
|
|
89
|
+
const listed = JSON.parse((await call("list_studios")).text.split("\n\nWARNING")[0].split("\n\n")[0]);
|
|
90
|
+
const edits = (listed.studios ?? []).filter((entry) => entry.context === "edit");
|
|
91
|
+
if (studioId === undefined) {
|
|
92
|
+
if (edits.length !== 1) {
|
|
93
|
+
process.stderr.write(
|
|
94
|
+
edits.length === 0
|
|
95
|
+
? "No Studio in edit mode is connected. Open one with the plugin, or stop the playtest.\n"
|
|
96
|
+
: "Several edit sessions are connected: pass --studio-id.\n",
|
|
97
|
+
);
|
|
98
|
+
await client.close();
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
studioId = edits[0].studioId;
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(`Studio: ${edits.find((entry) => entry.studioId === studioId)?.placeName ?? studioId}\n`);
|
|
104
|
+
|
|
105
|
+
// ---- fixture ------------------------------------------------------------------------
|
|
106
|
+
section("fixture");
|
|
107
|
+
await ok("create: nested, typed, tagged", "create", { instances: [{ parent: "Workspace", className: "Folder", name: "__mcp_live_tools", children: [
|
|
108
|
+
{ className: "Part", name: "A", properties: { Anchored: true, Size: "4, 1, 2", Position: "0, 50, 0", Color: "#FF8800", Material: "Neon" },
|
|
109
|
+
attributes: { hp: 5, pos: { type: "Vector3", value: "1, 2, 3" } }, tags: { add: ["MCPLive"] } },
|
|
110
|
+
{ className: "Part", name: "B", properties: { Anchored: true, Size: "4, 1, 2", Position: "1, 50, 0" } },
|
|
111
|
+
{ className: "Model", name: "M", children: [
|
|
112
|
+
{ className: "Part", name: "P1", properties: { Anchored: true, Position: "20, 50, 0" } },
|
|
113
|
+
{ className: "Part", name: "P2", properties: { Anchored: true, Position: "24, 50, 0" } } ] },
|
|
114
|
+
{ className: "Folder", name: "Rows", children: Array.from({ length: 7 }, (_, index) => (
|
|
115
|
+
{ className: "Part", name: `Row${index + 1}`, properties: { Anchored: true, Position: `${index * 6}, 70, 0` } })) },
|
|
116
|
+
...["U1:40", "U2:43", "S1:60", "S2:60", "I1:80", "I2:83", "F1:100", "W1:120"].map((entry) => {
|
|
117
|
+
const [name, x] = entry.split(":");
|
|
118
|
+
return { className: "Part", name, properties: { Anchored: true, Size: name === "W1" ? "1, 8, 8" : name === "S2" ? "4, 4, 4" : name === "S1" ? "8, 8, 8" : "6, 6, 6", Position: `${x}, 50, 0` } };
|
|
119
|
+
}),
|
|
120
|
+
] }] }, /__mcp_live_tools/);
|
|
121
|
+
await ok("script_create: a module and a disabled script", "script_create", { scripts: [
|
|
122
|
+
{ parent: FIXTURE, name: "Mod", className: "ModuleScript", source: "local M = {}\nfunction M.add(a, b)\n\treturn a + b\nend\nreturn M\n" },
|
|
123
|
+
{ parent: FIXTURE, name: "Srv", className: "Script", runContext: "Server", disabled: true, source: "print('x')\n" } ] }, /__mcp_live_tools\.Mod/);
|
|
124
|
+
|
|
125
|
+
// ---- reading --------------------------------------------------------------------------
|
|
126
|
+
section("reading");
|
|
127
|
+
await ok("studio_status", "studio_status", {}, /"placeName"/);
|
|
128
|
+
await ok("list_studios", "list_studios", {}, /studioId/);
|
|
129
|
+
await ok("tree", "tree", { path: FIXTURE, depth: 2 }, /__mcp_live_tools\.M/);
|
|
130
|
+
await ok("tree: concise", "tree", { path: FIXTURE, depth: 1, detail: "concise" }, /className/);
|
|
131
|
+
await ok("find: by class", "find", { className: "BasePart", path: FIXTURE, limit: 5 }, /__mcp_live_tools\.A/);
|
|
132
|
+
await ok("find: tags", "find", { op: "tags" }, /MCPLive/);
|
|
133
|
+
await ok("find: selector", "find", { selector: "Part", path: FIXTURE, limit: 3 }, /Part/);
|
|
134
|
+
await ok("find: nameContains", "find", { nameContains: "row", path: FIXTURE, limit: 3 }, /Row/);
|
|
135
|
+
await ok("inspect: standard", "inspect", { paths: [`${FIXTURE}.A`] }, /Anchored/);
|
|
136
|
+
await ok("inspect: full with physics", "inspect", { paths: [`${FIXTURE}.A`], detail: "full", physics: true }, /density/);
|
|
137
|
+
await ok("inspect: concise", "inspect", { paths: [`${FIXTURE}.A`], detail: "concise" }, /attributes/);
|
|
138
|
+
await ok("inspect: named properties", "inspect", { paths: [`${FIXTURE}.A`], properties: ["Name", "Position"] }, /Position/);
|
|
139
|
+
await ok("script_read", "script_read", { paths: [`${FIXTURE}.Mod`] }, /rev [0-9a-f]+/);
|
|
140
|
+
await ok("script_read: a window", "script_read", { paths: [{ path: `${FIXTURE}.Mod`, startLine: 2, endLine: 3 }] }, /lines 2-3 of 5/);
|
|
141
|
+
await ok("script_grep", "script_grep", { pattern: "add", literal: true, path: FIXTURE }, /Mod:2/);
|
|
142
|
+
await ok("api: describe", "api", { op: "describe", className: "Part" }, /superclass/);
|
|
143
|
+
await ok("api: classes", "api", { op: "classes", contains: "Constraint" }, /HingeConstraint/);
|
|
144
|
+
await ok("console", "console", { limit: 5 });
|
|
145
|
+
await ok("performance: snapshot", "performance", { op: "snapshot" }, /frame/);
|
|
146
|
+
await ok("performance: scene", "performance", { op: "scene", section: "composition" }, /composition/);
|
|
147
|
+
await ok("performance: coverage", "performance", { op: "coverage" });
|
|
148
|
+
await ok("device: list", "device", { op: "list", form: "Phone" }, /Phone/);
|
|
149
|
+
await ok("device: state", "device", { op: "state" });
|
|
150
|
+
await ok("collision: list", "collision", { action: "list" }, /Default/);
|
|
151
|
+
await ok("terrain: stats", "terrain", { op: "stats" }, /cells/);
|
|
152
|
+
await ok("undo: status", "undo", { action: "status" }, /canUndo/);
|
|
153
|
+
await ok("viewport: raycast down onto the fixture", "viewport", { op: "raycast", origin: "0, 120, 0", direction: "0, -1, 0" }, /"hit": ?true/);
|
|
154
|
+
await ok("viewport: textbounds", "viewport", { op: "textbounds", text: "Hello world", textSize: 20 }, /px/);
|
|
155
|
+
await ok("viewport: ui audit", "viewport", { op: "ui" });
|
|
156
|
+
await ok("screenshot", "screenshot", { width: 480 }, /Studio viewport/);
|
|
157
|
+
await ok("screenshot: zoom to a rect", "screenshot", { width: 480, rect: "100, 100, 200, 100" }, /rect/);
|
|
158
|
+
await refuses("screenshot: a bad rect", "screenshot", { rect: "not a rect" }, /BAD_PARAMS/);
|
|
159
|
+
await ok("debug: snapshots", "debug", { op: "snapshots" });
|
|
160
|
+
await ok("playtest: state", "playtest", { op: "state" }, /isEdit/);
|
|
161
|
+
|
|
162
|
+
section("paging and cursors");
|
|
163
|
+
const everyRow = paths(await ok("find: all rows", "find", { className: "BasePart", path: `${FIXTURE}.Rows`, limit: 500 }));
|
|
164
|
+
const seen = [];
|
|
165
|
+
let cursor;
|
|
166
|
+
for (let page = 0; page < 10; page += 1) {
|
|
167
|
+
const got = await call("find", { className: "BasePart", path: `${FIXTURE}.Rows`, limit: 3, ...(cursor ? { cursor } : {}) });
|
|
168
|
+
seen.push(...paths(got.text));
|
|
169
|
+
cursor = /cursor: "([^"]+)"/.exec(got.text)?.[1];
|
|
170
|
+
if (!cursor) break;
|
|
171
|
+
}
|
|
172
|
+
const paged = everyRow.length === 7 && JSON.stringify(seen) === JSON.stringify(everyRow);
|
|
173
|
+
if (!paged) failures += 1;
|
|
174
|
+
process.stdout.write(`${paged ? "ok " : "FAIL"} paging returns every row once, in order (${seen.length} of ${everyRow.length})\n`);
|
|
175
|
+
await refuses("find: a cursor this server did not issue", "find", { className: "Part", path: FIXTURE, cursor: "nope" }, /BAD_CURSOR/);
|
|
176
|
+
const before = await call("console", { limit: 2 });
|
|
177
|
+
const since = /nextCursor: ([^\]\s]+)/.exec(before.text)?.[1];
|
|
178
|
+
await ok("execute_luau: print and warn", "execute_luau", { source: "print('live-tools-marker'); warn('live-tools-warning')" }, /live-tools-marker/);
|
|
179
|
+
await ok("console: only what is newer than the cursor", "console", { since, limit: 50 }, /live-tools-marker/);
|
|
180
|
+
await ok("console: filtered by level and pattern", "console", { level: "warning", pattern: "live%-tools", limit: 10 }, /live-tools-warning/);
|
|
181
|
+
|
|
182
|
+
// ---- writing --------------------------------------------------------------------------------
|
|
183
|
+
section("writing");
|
|
184
|
+
await ok("modify: properties, attributes, tags", "modify", { targets: [{ paths: [`${FIXTURE}.A`], properties: { Transparency: 0.5, Color: "0.2, 0.6, 1" },
|
|
185
|
+
attributes: { hp: "", note: "x" }, tags: { add: ["Extra"], remove: ["MCPLive"] } }] }, /one undo step/);
|
|
186
|
+
await ok("find: the new tag", "find", { tag: "Extra" }, /__mcp_live_tools\.A/);
|
|
187
|
+
await refuses("modify: a value that is not a number", "modify", { targets: [{ paths: [`${FIXTURE}.A`], properties: { Transparency: "nope" } }] }, /BAD_PROPERTY/);
|
|
188
|
+
await ok("move: clone with a new name", "move", { items: [{ path: `${FIXTURE}.A`, to: `${FIXTURE}.M`, mode: "clone", name: "A2" }] }, /A2/);
|
|
189
|
+
await ok("move: reparent", "move", { items: [{ path: `${FIXTURE}.B`, to: `${FIXTURE}.M` }] }, /M\.B/);
|
|
190
|
+
await refuses("move: into itself", "move", { items: [{ path: `${FIXTURE}.M`, to: `${FIXTURE}.M.P1` }] }, /BAD_MOVE/);
|
|
191
|
+
await refuses("create: a class that does not exist", "create", { instances: [{ parent: FIXTURE, className: "Nope" }] }, /UNKNOWN_CLASS/);
|
|
192
|
+
await refuses("create: a property that does not exist", "create", { instances: [{ parent: FIXTURE, className: "Part", properties: { Sizee: "1, 1, 1" } }] }, /Did you mean: Size/);
|
|
193
|
+
|
|
194
|
+
const revision = /rev ([0-9a-f-]+)/.exec(await call("script_read", { paths: [`${FIXTURE}.Mod`] }).then((reply) => reply.text))?.[1];
|
|
195
|
+
await ok("script_edit: find and replace with a revision", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, find: "return a + b", replace: "return a + b + 0", revision }] });
|
|
196
|
+
await ok("script_edit: a line range", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, startLine: 2, endLine: 2, replacement: "function M.add(a, b) -- edited" }] });
|
|
197
|
+
await refuses("script_edit: a stale revision", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, source: "return {}", revision }] }, /STALE_SCRIPT/);
|
|
198
|
+
await refuses("script_edit: text that is not there", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, find: "zzz-absent", replace: "x" }] }, /NO_MATCH/);
|
|
199
|
+
await refuses("script_edit: a line past the end", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, startLine: 99, endLine: 99, replacement: "x" }] }, /BAD_RANGE/);
|
|
200
|
+
await ok("script_read: sees the edit", "script_read", { paths: [`${FIXTURE}.Mod`] }, /edited/);
|
|
201
|
+
|
|
202
|
+
await ok("geometry: union", "geometry", { op: "union", path: `${FIXTURE}.U1`, with: [`${FIXTURE}.U2`], name: "Unioned" }, /Unioned/);
|
|
203
|
+
await ok("geometry: subtract", "geometry", { op: "subtract", path: `${FIXTURE}.S1`, with: [`${FIXTURE}.S2`], name: "Subtracted" }, /Subtracted/);
|
|
204
|
+
await ok("geometry: intersect", "geometry", { op: "intersect", path: `${FIXTURE}.I1`, with: [`${FIXTURE}.I2`], name: "Intersected" }, /Intersected/);
|
|
205
|
+
await ok("geometry: fragment", "geometry", { op: "fragment", path: `${FIXTURE}.F1`, pieces: 4 }, /F1_1/);
|
|
206
|
+
await ok("geometry: sweep", "geometry", { op: "sweep", path: `${FIXTURE}.W1`, to: "120, 60, 0", steps: 4, keep: false, checkAgainst: [] }, /sweep/i);
|
|
207
|
+
await ok("geometry: mirror", "geometry", { op: "mirror", paths: [`${FIXTURE}.A`], axis: "X", copy: true }, /copied/);
|
|
208
|
+
|
|
209
|
+
cleanup.push(() => call("collision", { action: "remove", group: GROUP }));
|
|
210
|
+
await ok("collision: create", "collision", { action: "create", group: GROUP }, /created/);
|
|
211
|
+
await ok("collision: assign", "collision", { action: "assign", group: GROUP, paths: [`${FIXTURE}.A`] }, /assigned/);
|
|
212
|
+
await ok("collision: pass through Default", "collision", { action: "collidable", group: GROUP, with: "Default", collidable: false }, /collidable/);
|
|
213
|
+
await ok("collision: listed", "collision", { action: "list" }, new RegExp(GROUP));
|
|
214
|
+
await ok("collision: cast", "collision", { action: "cast", from: "0, 120, 0", direction: "0, -1, 0", distance: 200 }, /hit/);
|
|
215
|
+
await ok("collision: overlap a box", "collision", { action: "overlap", region: "box", at: "0, 50, 0", size: "10, 10, 10" }, /__mcp_live_tools/);
|
|
216
|
+
await ok("collision: overlap a radius", "collision", { action: "overlap", region: "radius", at: "0, 50, 0", radius: 6 }, /__mcp_live_tools/);
|
|
217
|
+
await ok("collision: remove", "collision", { action: "remove", group: GROUP }, /"removed": ?true/);
|
|
218
|
+
|
|
219
|
+
cleanup.push(() => call("terrain", { op: "clear", position: TERRAIN_AT, size: "40, 40, 40" }));
|
|
220
|
+
await ok("terrain: fill a ball", "terrain", { op: "fill", shapes: [{ shape: "ball", position: TERRAIN_AT, radius: 8, material: "Grass" }] }, /voxels/);
|
|
221
|
+
await ok("terrain: replace a material", "terrain", { op: "replace", position: TERRAIN_AT, size: "40, 40, 40", from: "Grass", to: "Snow" }, /Snow/);
|
|
222
|
+
await ok("terrain: clear the region", "terrain", { op: "clear", position: TERRAIN_AT, size: "40, 40, 40" }, /cleared/);
|
|
223
|
+
|
|
224
|
+
await ok("viewport: select", "viewport", { op: "select", paths: [`${FIXTURE}.A`] }, /1 selected/);
|
|
225
|
+
await ok("viewport: clear the selection", "viewport", { op: "select", paths: [] }, /cleared/i);
|
|
226
|
+
await ok("audio: a whole graph", "audio", { op: "graph", kind: "ui", parent: FIXTURE, name: "LiveAudio", effects: ["AudioFader"] }, /LiveAudio/);
|
|
227
|
+
await ok("audio: read it back", "audio", { op: "inspect", path: FIXTURE }, /LiveAudio/);
|
|
228
|
+
// Breakpoints need "Debugger Luau API" (File > Beta Features), which is off by
|
|
229
|
+
// default -- a setting of the machine, not a fault of the tool.
|
|
230
|
+
const armed = await call("debug", { op: "set", breakpoints: [{ path: `${FIXTURE}.Mod`, line: 3 }] });
|
|
231
|
+
if (/NO_DEBUGGER|NO_BREAKPOINTS_SET/.test(armed.text)) {
|
|
232
|
+
process.stdout.write('skip debug: "Debugger Luau API" is off in this Studio\n');
|
|
233
|
+
} else {
|
|
234
|
+
report("debug: set a breakpoint", !armed.isError && /Verified/.test(armed.text), armed);
|
|
235
|
+
await ok("debug: clear it", "debug", { op: "clear", path: `${FIXTURE}.Mod` }, /removed/);
|
|
236
|
+
}
|
|
237
|
+
await ok("execute_luau: returns values", "execute_luau", { source: "return 1 + 1, {a = 1}" }, /Returned/);
|
|
238
|
+
await refuses("execute_luau: a runtime error", "execute_luau", { source: "error('boom')" }, /boom/);
|
|
239
|
+
await refuses("execute_luau: a syntax error", "execute_luau", { source: "local =" }, /COMPILE_ERROR/);
|
|
240
|
+
await ok("assets: peek inside a model", "assets", { op: "peek", assetId: 6903238241 }, /nothing inserted/);
|
|
241
|
+
await ok("assets: insert without its scripts", "assets", { op: "insert", assetId: 6903238241, parent: FIXTURE, stripScripts: true }, /Glass Door/);
|
|
242
|
+
|
|
243
|
+
const undoable = await call("create", { instances: [{ parent: FIXTURE, className: "Part", name: "UndoMe" }] });
|
|
244
|
+
if (/one undo step/.test(undoable.text)) {
|
|
245
|
+
// Only when the create says it opened a recording: without one, `undo` would
|
|
246
|
+
// reach past this run into somebody else's work.
|
|
247
|
+
await ok("undo: takes the create back", "undo", { action: "undo", steps: 1 }, /"applied": ?1/);
|
|
248
|
+
await ok("find: it is gone", "find", { nameContains: "UndoMe", path: FIXTURE }, /No matches/);
|
|
249
|
+
await ok("redo: brings it back", "undo", { action: "redo", steps: 1 }, /"applied": ?1/);
|
|
250
|
+
await ok("find: it is back", "find", { nameContains: "UndoMe", path: FIXTURE }, /UndoMe/);
|
|
251
|
+
} else {
|
|
252
|
+
process.stdout.write("skip undo: the create opened no recording\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- things that must be refused, or say plainly they did nothing ---------------------------------
|
|
256
|
+
section("refusals");
|
|
257
|
+
const GONE = "Workspace.__mcp_live_tools_absent";
|
|
258
|
+
for (const [label, tool, params, expect] of [
|
|
259
|
+
["delete: a path that is not there", "delete", { paths: [GONE] }, /NOT_FOUND/],
|
|
260
|
+
["delete: a service", "delete", { paths: ["Workspace"] }, /PROTECTED/],
|
|
261
|
+
["modify: a path that is not there", "modify", { targets: [{ paths: [GONE], attributes: { a: 1 } }] }, /NOT_FOUND/],
|
|
262
|
+
["move: to a parent that is not there", "move", { items: [{ path: `${FIXTURE}.A`, to: GONE }] }, /NOT_FOUND/],
|
|
263
|
+
["create: under a parent that is not there", "create", { instances: [{ parent: GONE, className: "Part" }] }, /NOT_FOUND/],
|
|
264
|
+
["tree: a path that is not there", "tree", { path: GONE }, /NOT_FOUND/],
|
|
265
|
+
["script_edit: something that is not a script", "script_edit", { edits: [{ path: `${FIXTURE}.A`, find: "a", replace: "b" }] }, /NOT_A_SCRIPT/],
|
|
266
|
+
["viewport: select what is not there", "viewport", { op: "select", paths: [GONE] }, /NOT_FOUND/],
|
|
267
|
+
["device: a device that does not exist", "device", { op: "set", device: "no-such-device" }, /NO_SUCH_DEVICE/],
|
|
268
|
+
["terrain: clear everything without confirm", "terrain", { op: "clear" }, /CONFIRM_REQUIRED/],
|
|
269
|
+
["terrain: a material that does not exist", "terrain", { op: "fill", shapes: [{ shape: "ball", position: TERRAIN_AT, radius: 4, material: "NotAMaterial" }] }, /BAD_PARAMS/],
|
|
270
|
+
["collision: assign to a group that does not exist", "collision", { action: "assign", group: "NoSuchGroupZ", paths: [`${FIXTURE}.A`] }, /NO_SUCH_GROUP/],
|
|
271
|
+
["geometry: subtract with nothing to subtract", "geometry", { op: "subtract", path: `${FIXTURE}.A` }, /BAD_PARAMS/],
|
|
272
|
+
["character: needs a playtest", "character", { op: "state" }, /NOT_RUNNING/],
|
|
273
|
+
["input: needs a playtest", "input", { steps: [{ kind: "key", key: "E" }] }, /NOT_RUNNING/],
|
|
274
|
+
["execute_luau client: needs a playtest", "execute_luau", { source: "return 1", target: "client" }, /NOT_RUNNING/],
|
|
275
|
+
["playtest: add players to none", "playtest", { op: "addPlayers", players: 1 }, /No playtest is running/],
|
|
276
|
+
["universe: restart needs confirm", "universe", { op: "restart" }, /NEEDS_CONFIRM|NO_CREDENTIALS/],
|
|
277
|
+
["universe: a ban needs confirm", "universe", { op: "ban", userId: "1" }, /NEEDS_CONFIRM|NO_CREDENTIALS/],
|
|
278
|
+
["execute_luau live: needs confirm", "execute_luau", { source: "return 1", target: "live" }, /NEEDS_CONFIRM/],
|
|
279
|
+
["script_edit live: needs confirm", "script_edit", { target: "live", path: "ServerScriptService.X", source: "x" }, /NEEDS_CONFIRM/],
|
|
280
|
+
["datastore live: a write needs confirm", "datastore", { op: "set", target: "live", store: "s", key: "k", value: "1" }, /NEEDS_CONFIRM/],
|
|
281
|
+
["assets: publish needs a file", "assets", { op: "publish" }, /BAD_PARAMS/],
|
|
282
|
+
]) await refuses(label, tool, params, expect);
|
|
283
|
+
await ok("inspect: says a path could not be resolved", "inspect", { paths: [GONE] }, /Could not resolve/);
|
|
284
|
+
await ok("script_read: says a path could not be read", "script_read", { paths: [GONE] }, /Could not read/);
|
|
285
|
+
await ok("collision: removing a missing group says so", "collision", { action: "remove", group: "NoSuchGroupZ" }, /"removed": ?false/);
|
|
286
|
+
await ok("inspect: a mistyped property is named", "inspect", { paths: [`${FIXTURE}.A`], properties: ["Name", "Sizee"] }, /Sizee \(not a property of Part; did you mean Size\?\)/);
|
|
287
|
+
await ok("find: a mistyped class is named", "find", { className: "Prat", path: FIXTURE }, /not a Roblox class/);
|
|
288
|
+
|
|
289
|
+
section("zero-length and empty inputs");
|
|
290
|
+
const rayGuard = await refuses("viewport raycast: a zero direction", "viewport", { op: "raycast", origin: "0, 100, 0", direction: "0, 0, 0" }, /BAD_PARAMS/);
|
|
291
|
+
await refuses("collision cast: from a point to itself", "collision", { action: "cast", from: "0, 80, 0", to: "0, 80, 0" }, /no length/);
|
|
292
|
+
await refuses("collision cast: distance 0", "collision", { action: "cast", from: "0, 80, 0", direction: "0, -1, 0", distance: 0 }, /no length/);
|
|
293
|
+
await refuses("collision cast: a zero direction", "collision", { action: "cast", from: "0, 80, 0", direction: "0, 0, 0" }, /no length/);
|
|
294
|
+
await refuses("collision overlap: a box with no volume", "collision", { action: "overlap", region: "box", at: "0, 80, 0", size: "0, 0, 0" }, /no volume/);
|
|
295
|
+
await refuses("collision overlap: a radius of 0", "collision", { action: "overlap", region: "radius", at: "0, 80, 0", radius: 0 }, /no volume/);
|
|
296
|
+
await refuses("terrain fill: a block with no size", "terrain", { op: "fill", shapes: [{ shape: "block", position: TERRAIN_AT, size: "0, 0, 0", material: "Grass" }] }, /empty/);
|
|
297
|
+
await refuses("terrain replace: a region with no size", "terrain", { op: "replace", position: TERRAIN_AT, size: "0, 4, 4", from: "Grass", to: "Snow" }, /empty/);
|
|
298
|
+
await refuses("debug: a line the script does not have", "debug", { op: "set", breakpoints: [{ path: `${FIXTURE}.Mod`, line: 999 }] }, /BAD_LINE/);
|
|
299
|
+
|
|
300
|
+
// ---- the camera, which is the user's ----------------------------------------------------------------
|
|
301
|
+
section("camera");
|
|
302
|
+
if (args.includes("--no-camera")) {
|
|
303
|
+
process.stdout.write("skip --no-camera\n");
|
|
304
|
+
} else if (!/BAD_PARAMS/.test(rayGuard)) {
|
|
305
|
+
// Without the guard a zero `from` gives the camera a NaN position, so the
|
|
306
|
+
// probes are not sent to a plugin that has not got it.
|
|
307
|
+
failures += 1;
|
|
308
|
+
process.stdout.write("FAIL the plugin does not refuse zero-length input; camera checks skipped to protect the viewport\n");
|
|
309
|
+
} else {
|
|
310
|
+
const original = await call("viewport", { op: "camera" });
|
|
311
|
+
let camera;
|
|
312
|
+
try { camera = JSON.parse(original.text); } catch { camera = undefined; }
|
|
313
|
+
cleanup.push(async () => {
|
|
314
|
+
if (!camera) return;
|
|
315
|
+
const [px, py, pz] = camera.position.split(",").map(Number);
|
|
316
|
+
const [lx, ly, lz] = camera.lookVector.split(",").map(Number);
|
|
317
|
+
await call("viewport", { op: "camera", position: camera.position, lookAt: `${px + lx}, ${py + ly}, ${pz + lz}`, fieldOfView: camera.fieldOfView });
|
|
318
|
+
});
|
|
319
|
+
await refuses("camera: position equal to lookAt", "viewport", { op: "camera", position: "0, 90, 0", lookAt: "0, 90, 0" }, /same point/);
|
|
320
|
+
await refuses("focus: a zero `from`", "viewport", { op: "focus", path: FIXTURE, from: "0, 0, 0" }, /BAD_PARAMS/);
|
|
321
|
+
const unmoved = (await call("viewport", { op: "camera" })).text === original.text;
|
|
322
|
+
if (!unmoved) failures += 1;
|
|
323
|
+
process.stdout.write(`${unmoved ? "ok " : "FAIL"} the refused requests left the camera where it was\n`);
|
|
324
|
+
await ok("focus: on the fixture", "viewport", { op: "focus", path: FIXTURE }, /cameraPosition/);
|
|
325
|
+
await ok("focus: from above", "viewport", { op: "focus", path: FIXTURE, from: "0, 1, 0" }, /cameraPosition/);
|
|
326
|
+
await ok("camera: set it directly", "viewport", { op: "camera", position: "0, 90, 30", lookAt: "0, 50, 0", fieldOfView: 60 }, /position/);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- emulation, put back --------------------------------------------------------------------------------
|
|
330
|
+
section("device and network");
|
|
331
|
+
cleanup.push(() => call("device", { op: "stop" }));
|
|
332
|
+
const phone = /^(\S+) \| /m.exec((await call("device", { op: "list", form: "Phone" })).text.split("\n").slice(1).join("\n"))?.[1];
|
|
333
|
+
if (phone) {
|
|
334
|
+
await ok("device: emulate a phone", "device", { op: "set", device: phone });
|
|
335
|
+
await ok("screenshot: through the emulation", "screenshot", { width: 320 }, /Emulating/);
|
|
336
|
+
await ok("viewport ui: against the phone", "viewport", { op: "ui" }, /emulating/);
|
|
337
|
+
await ok("device: stop", "device", { op: "stop" }, /"emulating": ?false/);
|
|
338
|
+
}
|
|
339
|
+
await ok("device: a 3g connection", "device", { op: "network", preset: "3g" }, /"shaping": ?true|shaping/);
|
|
340
|
+
await ok("device: back to normal", "device", { op: "network", preset: "clear" }, /"shaping": ?false|normal/);
|
|
341
|
+
|
|
342
|
+
// ---- a running game ---------------------------------------------------------------------------------------
|
|
343
|
+
if (args.includes("--playtest")) {
|
|
344
|
+
section("playtest");
|
|
345
|
+
const started = await call("playtest", { op: "play" });
|
|
346
|
+
const playing = /"studioId": "([^"]+)"/.exec(started.text)?.[1];
|
|
347
|
+
const good = Boolean(playing);
|
|
348
|
+
if (!good) failures += 1;
|
|
349
|
+
process.stdout.write(`${good ? "ok " : "FAIL"} playtest: play returns the server's studioId ${started.text.replace(/\s+/g, " ").slice(0, 100)}\n`);
|
|
350
|
+
if (playing) {
|
|
351
|
+
cleanup.push(() => call("playtest", { op: "stop", studioId }));
|
|
352
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
353
|
+
const on = (params) => ({ ...params, studioId: playing });
|
|
354
|
+
await ok("character: state", "character", on({ op: "state" }), /Humanoid/);
|
|
355
|
+
await ok("character: path to a point", "character", on({ op: "path", to: "20, 3, 20" }), /Reachable/);
|
|
356
|
+
await ok("character: walk to a point", "character", on({ op: "moveTo", to: "10, 3, 10" }), /"arrived": ?true/);
|
|
357
|
+
await ok("character: jump", "character", on({ op: "act", action: "jump" }), /jump/);
|
|
358
|
+
await ok("input: keys", "input", on({ cursor: false, steps: [{ kind: "key", key: "W", hold: 0.3 }, { kind: "key", key: "Space" }] }), /delivered/);
|
|
359
|
+
await ok("screenshot: the player's own view", "screenshot", on({ width: 480 }), /playtest client/);
|
|
360
|
+
await ok("console: the server's log", "console", on({ limit: 5 }));
|
|
361
|
+
await ok("console: the client's log", "console", on({ target: "client", limit: 5 }));
|
|
362
|
+
await ok("performance: snapshot", "performance", on({ op: "snapshot" }), /frame/);
|
|
363
|
+
await ok("execute_luau: in the client VM", "execute_luau", on({ target: "client", source: "return game:GetService('Players').LocalPlayer.Name" }), /Returned/);
|
|
364
|
+
await ok("execute_luau: in the server VM", "execute_luau", on({ source: "return #game.Workspace:GetChildren()" }), /Returned/);
|
|
365
|
+
await ok("debug: trace remote traffic", "debug", on({ op: "remotes", seconds: 1 }));
|
|
366
|
+
await ok("playtest: stop", "playtest", { op: "stop" }, /editModeActive": ?true/);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
// Reversed, so what was made last is undone first.
|
|
371
|
+
for (const step of cleanup.reverse()) await step().catch(() => {});
|
|
372
|
+
await call("delete", { paths: [FIXTURE] });
|
|
373
|
+
const gone = await call("find", { nameContains: "__mcp_live_tools", path: "Workspace" });
|
|
374
|
+
if (!/No matches/.test(gone.text)) {
|
|
375
|
+
failures += 1;
|
|
376
|
+
process.stderr.write(`cleanup left the fixture behind; delete ${FIXTURE} by hand\n`);
|
|
377
|
+
}
|
|
378
|
+
await client.close();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
process.stdout.write(failures === 0 ? "\nlive tools: ok\n" : `\nlive tools: ${failures} failure(s)\n`);
|
|
382
|
+
process.exit(failures === 0 ? 0 : 1);
|
package/scripts/test-tools.mjs
CHANGED
|
@@ -148,6 +148,31 @@ process.stdout.write("playtest lock errors and instruction precedence: ok\n");
|
|
|
148
148
|
}
|
|
149
149
|
process.stdout.write("playtest runtime identity: ok\n");
|
|
150
150
|
|
|
151
|
+
// addPlayers goes to the running test's server and waits for the new players.
|
|
152
|
+
{
|
|
153
|
+
const originalSessions = context.bridge.sessions;
|
|
154
|
+
const originalCall = context.bridge.call;
|
|
155
|
+
let joined = 1;
|
|
156
|
+
const sent = [];
|
|
157
|
+
context.bridge.sessions = async () => ({list:[{studioId:"editor",context:"edit"},{studioId:"runtime",context:"playtest server"}]});
|
|
158
|
+
context.bridge.call = async (op, params, options) => {
|
|
159
|
+
sent.push({op: params.op, studioId: options.studioId, players: params.players});
|
|
160
|
+
if (params.op === "addPlayers") return {changed:true,state:{playerCount:joined}};
|
|
161
|
+
joined = 3;
|
|
162
|
+
return {changed:false,state:{playerCount:joined}};
|
|
163
|
+
};
|
|
164
|
+
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"addPlayers",players:2}));
|
|
165
|
+
assert.ok(!result.isError);
|
|
166
|
+
assert.deepEqual(sent[0], {op:"addPlayers",studioId:"runtime",players:2});
|
|
167
|
+
assert.match(result.content[0].text, /"playerCount": ?3/);
|
|
168
|
+
context.bridge.sessions = async () => ({list:[{studioId:"editor",context:"edit"}]});
|
|
169
|
+
const none = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"addPlayers"}));
|
|
170
|
+
assert.equal(none.isError, true);
|
|
171
|
+
context.bridge.sessions = originalSessions;
|
|
172
|
+
context.bridge.call = originalCall;
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write("playtest addPlayers routing: ok\n");
|
|
175
|
+
|
|
151
176
|
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
152
177
|
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
153
178
|
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
@@ -257,3 +282,200 @@ process.stdout.write("client console schema and cursor forwarding: ok\n");
|
|
|
257
282
|
assert.deepEqual([...upscaleNearest(Buffer.from([1,2,3,4,5,6]), 2, 1, 2).rgb], [1,2,3,1,2,3,4,5,6,4,5,6,1,2,3,1,2,3,4,5,6,4,5,6]);
|
|
258
283
|
process.stdout.write("screenshot scaling: ok\n");
|
|
259
284
|
}
|
|
285
|
+
|
|
286
|
+
// The PNG writer: every chunk carries the right checksum and the pixels survive.
|
|
287
|
+
// A wrong CRC still yields a file, and a file every decoder refuses with no hint
|
|
288
|
+
// which byte was at fault -- so the checksums are recomputed here by a separate
|
|
289
|
+
// implementation rather than by the one under test.
|
|
290
|
+
{
|
|
291
|
+
const { encodePng } = await import("../dist/lib/png.js");
|
|
292
|
+
const { inflateSync } = await import("node:zlib");
|
|
293
|
+
const table = new Int32Array(256).map((_, n) => {
|
|
294
|
+
let c = n;
|
|
295
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
296
|
+
return c;
|
|
297
|
+
});
|
|
298
|
+
const reference = (bytes) => {
|
|
299
|
+
let c = -1;
|
|
300
|
+
for (const byte of bytes) c = table[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
301
|
+
return (c ^ -1) >>> 0;
|
|
302
|
+
};
|
|
303
|
+
const width = 3, height = 2;
|
|
304
|
+
const rgb = Buffer.from([255,0,0, 0,255,0, 0,0,255, 10,20,30, 40,50,60, 70,80,90]);
|
|
305
|
+
const png = encodePng(rgb, width, height);
|
|
306
|
+
assert.deepEqual([...png.subarray(0, 8)], [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a], "PNG signature");
|
|
307
|
+
let offset = 8, idat;
|
|
308
|
+
const kinds = [];
|
|
309
|
+
while (offset < png.length) {
|
|
310
|
+
const length = png.readUInt32BE(offset);
|
|
311
|
+
const kind = png.toString("ascii", offset + 4, offset + 8);
|
|
312
|
+
assert.equal(png.readUInt32BE(offset + 8 + length), reference(png.subarray(offset + 4, offset + 8 + length)), `${kind} checksum`);
|
|
313
|
+
if (kind === "IHDR") {
|
|
314
|
+
assert.equal(png.readUInt32BE(offset + 8), width, "IHDR width");
|
|
315
|
+
assert.equal(png.readUInt32BE(offset + 12), height, "IHDR height");
|
|
316
|
+
}
|
|
317
|
+
if (kind === "IDAT") idat = png.subarray(offset + 8, offset + 8 + length);
|
|
318
|
+
kinds.push(kind);
|
|
319
|
+
offset += 12 + length;
|
|
320
|
+
}
|
|
321
|
+
assert.deepEqual(kinds, ["IHDR", "IDAT", "IEND"]);
|
|
322
|
+
const raw = inflateSync(idat);
|
|
323
|
+
assert.equal(raw.length, (width * 3 + 1) * height, "one filter byte per row");
|
|
324
|
+
assert.deepEqual([...raw.subarray(1, 10)], [...rgb.subarray(0, 9)], "first row survives");
|
|
325
|
+
assert.deepEqual([...raw.subarray(11)], [...rgb.subarray(9)], "second row survives");
|
|
326
|
+
assert.throws(() => encodePng(Buffer.alloc(5), 2, 2), /short of the 12 needed/, "short pixel data is refused");
|
|
327
|
+
process.stdout.write("png encoding: ok\n");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// scene: a category of bare counts renders on one line; owned entries keep theirs.
|
|
331
|
+
{
|
|
332
|
+
const perf = registered.get("performance");
|
|
333
|
+
context.bridge.call = async () => ({
|
|
334
|
+
composition: {total: 12, unit: "instances", entries: [
|
|
335
|
+
{name: "Physics", depth: 1, size: 10},
|
|
336
|
+
{name: "Motor6D", depth: 2, size: 6},
|
|
337
|
+
{name: "Attachment", depth: 2, size: 4},
|
|
338
|
+
{name: "Misc", depth: 1, size: 2},
|
|
339
|
+
]},
|
|
340
|
+
animationMemory: {total: 100, unit: "bytes", entries: [
|
|
341
|
+
{name: "Walk", depth: 1, size: 100, owners: ["Workspace.Npc.Animator"]},
|
|
342
|
+
]},
|
|
343
|
+
});
|
|
344
|
+
const result = await perf.handler(z.object(perf.spec.inputSchema).parse({op: "scene"}));
|
|
345
|
+
const out = result.content[0].text;
|
|
346
|
+
assert.match(out, / Physics — 10 instances: Motor6D 6, Attachment 4\n/);
|
|
347
|
+
assert.match(out, / Misc — 2 instances/);
|
|
348
|
+
assert.match(out, / Walk — 100 bytes\n used by Workspace\.Npc\.Animator/);
|
|
349
|
+
process.stdout.write("scene compact rows: ok\n");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// inspect: children come back as compact "Name (Class)" strings.
|
|
353
|
+
{
|
|
354
|
+
const inspect = registered.get("inspect");
|
|
355
|
+
context.bridge.call = async () => ({
|
|
356
|
+
items: [{path: "Workspace.Npc", className: "Model", childCount: 2, properties: {Name: "Npc"},
|
|
357
|
+
children: [{name: "Head", className: "Part"}, {name: "Humanoid", className: "Humanoid"}]}],
|
|
358
|
+
failures: [],
|
|
359
|
+
});
|
|
360
|
+
const result = await inspect.handler(z.object(inspect.spec.inputSchema).parse({paths: ["Workspace.Npc"], properties: ["Name"]}));
|
|
361
|
+
assert.match(result.content[0].text, /"children": ?\["Head \(Part\)", ?"Humanoid"\]/);
|
|
362
|
+
process.stdout.write("inspect compact children: ok\n");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// universe servers/logs: routed to Server Management with the wildcard version,
|
|
366
|
+
// rendered as a table and as log blocks. Fetch is stubbed; nothing leaves.
|
|
367
|
+
{
|
|
368
|
+
const { registerUniverseTools } = await import("../dist/tools/universe.js");
|
|
369
|
+
registerUniverseTools(context);
|
|
370
|
+
const universe = registered.get("universe");
|
|
371
|
+
const saved = { fetch: globalThis.fetch, env: { ...process.env } };
|
|
372
|
+
Object.assign(process.env, { ROBLOX_API_KEY: "test-key", ROBLOX_USER_ID: "1", ROBLOX_UNIVERSE_ID: "10", ROBLOX_PLACE_ID: "20" });
|
|
373
|
+
context.bridge.call = async () => ({ placeId: 20 });
|
|
374
|
+
const urls = [];
|
|
375
|
+
globalThis.fetch = async (url) => {
|
|
376
|
+
urls.push(String(url));
|
|
377
|
+
if (String(url).includes("/universes/v1/places/")) return new Response(JSON.stringify({ universeId: 10 }), { status: 200 });
|
|
378
|
+
const body = String(url).includes("/logs")
|
|
379
|
+
? { gameServerLogs: [{ messageTimestampMs: "0", severity: 3, message: "boom", stackTrace: "Script 'X', Line 4", context: "{\"id\":1}", skippedCount: 2 }], nextPageToken: null }
|
|
380
|
+
: { gameServers: [{ jobId: "job-1", status: "active", occupancy: 3, maxOccupancy: 10, uptime: "120s", frameRate: 59.7, memoryUsageBytes: 104857600, placeVersion: "12" }], nextPageToken: null };
|
|
381
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
382
|
+
};
|
|
383
|
+
try {
|
|
384
|
+
const servers = await universe.handler(z.object(universe.spec.inputSchema).parse({ op: "servers" }));
|
|
385
|
+
assert.match(servers.content[0].text, /job-1 \| active \| 3\/10 \| 120s \| 60 \| 100 \| 12/);
|
|
386
|
+
assert.match(urls.find((u) => u.includes("game-servers")), /\/server-management\/v1\/universes\/10\/places\/20\/versions\/-\/game-servers\?/);
|
|
387
|
+
const logs = await universe.handler(z.object(universe.spec.inputSchema).parse({ op: "logs", jobId: "job-1", severity: "error", search: "boom" }));
|
|
388
|
+
const out = logs.content[0].text;
|
|
389
|
+
assert.match(out, /\[error\] boom \(\+2 similar\)\n Script 'X', Line 4\n context: \{"id":1\}/);
|
|
390
|
+
assert.equal(new URL(urls.find((u) => u.includes("/logs"))).searchParams.get("Filter"), 'severity == 3 && search == "boom"');
|
|
391
|
+
const missing = await universe.handler(z.object(universe.spec.inputSchema).parse({ op: "logs" }));
|
|
392
|
+
assert.equal(missing.isError, true);
|
|
393
|
+
} finally {
|
|
394
|
+
globalThis.fetch = saved.fetch;
|
|
395
|
+
for (const key of ["ROBLOX_API_KEY", "ROBLOX_USER_ID", "ROBLOX_UNIVERSE_ID", "ROBLOX_PLACE_ID"]) {
|
|
396
|
+
if (saved.env[key] === undefined) delete process.env[key]; else process.env[key] = saved.env[key];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
process.stdout.write("universe servers and logs: ok\n");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// console: a structured log's context is shown under its line.
|
|
403
|
+
{
|
|
404
|
+
const consoleTool = registered.get("console");
|
|
405
|
+
context.bridge.call = async () => ({ items: [{ level: "info", message: "Bob leveled up", context: "{\"player\":\"Bob\"}" }], total: 1, dropped: 0, nextCursor: "c" });
|
|
406
|
+
const result = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
|
|
407
|
+
assert.match(result.content[0].text, /\[info\] Bob leveled up\n context: \{"player":"Bob"\}/);
|
|
408
|
+
process.stdout.write("console structured context: ok\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// universe products/sell: lists both kinds, refuses a duplicate name, needs confirm.
|
|
412
|
+
{
|
|
413
|
+
const universe = registered.get("universe");
|
|
414
|
+
const saved = { fetch: globalThis.fetch, env: { ...process.env } };
|
|
415
|
+
Object.assign(process.env, { ROBLOX_API_KEY: "test-key", ROBLOX_USER_ID: "1", ROBLOX_UNIVERSE_ID: "10", ROBLOX_PLACE_ID: "20" });
|
|
416
|
+
context.bridge.call = async () => ({ placeId: 20 });
|
|
417
|
+
const sent = [];
|
|
418
|
+
globalThis.fetch = async (url, init) => {
|
|
419
|
+
const href = String(url);
|
|
420
|
+
sent.push({ href, method: init?.method ?? "GET", body: init?.body });
|
|
421
|
+
if (href.includes("/universes/v1/places/")) return new Response(JSON.stringify({ universeId: 10 }), { status: 200 });
|
|
422
|
+
if (href.includes("developer-products/creator")) return new Response(JSON.stringify({ developerProducts: [{ productId: 5, name: "100 Coins", isForSale: true, priceInformation: { defaultPriceInRobux: 25 } }] }), { status: 200 });
|
|
423
|
+
if (href.includes("game-passes/creator")) return new Response(JSON.stringify({ gamePasses: [{ gamePassId: 7, name: "VIP", isForSale: false }] }), { status: 200 });
|
|
424
|
+
if (init?.method === "POST") return new Response(JSON.stringify({ productId: 6, name: "500 Coins", isForSale: true, priceInformation: { defaultPriceInRobux: 99 } }), { status: 200 });
|
|
425
|
+
return new Response("", { status: 200 });
|
|
426
|
+
};
|
|
427
|
+
const parse = (args) => z.object(universe.spec.inputSchema).parse(args);
|
|
428
|
+
try {
|
|
429
|
+
const list = (await universe.handler(parse({ op: "products" }))).content[0].text;
|
|
430
|
+
assert.match(list, /product \| 5 \| 100 Coins \| 25 \| true/);
|
|
431
|
+
assert.match(list, /pass \| 7 \| VIP \| \| false/);
|
|
432
|
+
const unconfirmed = await universe.handler(parse({ op: "sell", kind: "product", name: "500 Coins", price: 99 }));
|
|
433
|
+
assert.match(unconfirmed.content[0].text, /NEEDS_CONFIRM/);
|
|
434
|
+
const duplicate = await universe.handler(parse({ op: "sell", kind: "product", name: "100 coins", price: 25, confirm: true }));
|
|
435
|
+
assert.match(duplicate.content[0].text, /ALREADY_EXISTS\].*id 5/);
|
|
436
|
+
const created = await universe.handler(parse({ op: "sell", kind: "product", name: "500 Coins", price: 99, forSale: true, confirm: true }));
|
|
437
|
+
assert.match(created.content[0].text, /"id": ?6/);
|
|
438
|
+
const post = sent.find((entry) => entry.method === "POST");
|
|
439
|
+
assert.ok(post.href.endsWith("/developer-products/v2/universes/10/developer-products"));
|
|
440
|
+
assert.equal(post.body.get("price"), "99");
|
|
441
|
+
assert.equal(post.body.get("isForSale"), "true");
|
|
442
|
+
const updated = await universe.handler(parse({ op: "sell", kind: "pass", itemId: "7", price: 150, confirm: true }));
|
|
443
|
+
assert.match(updated.content[0].text, /"action": ?"updated"/);
|
|
444
|
+
assert.ok(sent.some((entry) => entry.method === "PATCH" && entry.href.endsWith("/game-passes/v1/universes/10/game-passes/7")));
|
|
445
|
+
} finally {
|
|
446
|
+
globalThis.fetch = saved.fetch;
|
|
447
|
+
for (const key of ["ROBLOX_API_KEY", "ROBLOX_USER_ID", "ROBLOX_UNIVERSE_ID", "ROBLOX_PLACE_ID"]) {
|
|
448
|
+
if (saved.env[key] === undefined) delete process.env[key]; else process.env[key] = saved.env[key];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
process.stdout.write("universe products and sell: ok\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// datastore live set: keeps the entry's users and attributes, and sends its etag.
|
|
455
|
+
{
|
|
456
|
+
const { registerDataTools } = await import("../dist/tools/data.js");
|
|
457
|
+
registerDataTools(context);
|
|
458
|
+
const datastore = registered.get("datastore");
|
|
459
|
+
const saved = { fetch: globalThis.fetch, env: { ...process.env } };
|
|
460
|
+
Object.assign(process.env, { ROBLOX_API_KEY: "test-key", ROBLOX_USER_ID: "1", ROBLOX_UNIVERSE_ID: "10", ROBLOX_PLACE_ID: "20" });
|
|
461
|
+
context.bridge.call = async () => ({ placeId: 20 });
|
|
462
|
+
let patched;
|
|
463
|
+
globalThis.fetch = async (url, init) => {
|
|
464
|
+
const href = String(url);
|
|
465
|
+
if (href.includes("/universes/v1/places/")) return new Response(JSON.stringify({ universeId: 10 }), { status: 200 });
|
|
466
|
+
if ((init?.method ?? "GET") === "GET") return new Response(JSON.stringify({ value: { coins: 1 }, users: ["users/42"], attributes: { lock: "s1" }, etag: "e1" }), { status: 200 });
|
|
467
|
+
patched = JSON.parse(init.body);
|
|
468
|
+
return new Response(JSON.stringify({ revisionId: "r2" }), { status: 200 });
|
|
469
|
+
};
|
|
470
|
+
try {
|
|
471
|
+
const result = await datastore.handler(z.object(datastore.spec.inputSchema).parse({ target: "live", op: "set", store: "Players", key: "42", value: "{\"coins\":5}", confirm: true }));
|
|
472
|
+
assert.ok(!result.isError, result.content[0].text);
|
|
473
|
+
assert.deepEqual(patched, { value: { coins: 5 }, users: ["users/42"], attributes: { lock: "s1" }, etag: "e1" });
|
|
474
|
+
} finally {
|
|
475
|
+
globalThis.fetch = saved.fetch;
|
|
476
|
+
for (const key of ["ROBLOX_API_KEY", "ROBLOX_USER_ID", "ROBLOX_UNIVERSE_ID", "ROBLOX_PLACE_ID"]) {
|
|
477
|
+
if (saved.env[key] === undefined) delete process.env[key]; else process.env[key] = saved.env[key];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
process.stdout.write("datastore live set keeps users and attributes: ok\n");
|
|
481
|
+
}
|