@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.2
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 +175 -175
- package/dist/bridge/harness.js +14 -5
- package/dist/bridge/harness.js.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +62 -25
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/format.js +43 -4
- package/dist/lib/format.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/pluginbuild.js +10 -0
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/lib/png.js +104 -0
- package/dist/lib/png.js.map +1 -1
- package/dist/resources.js +3 -2
- package/dist/resources.js.map +1 -1
- package/dist/tools/api.js +4 -3
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/audio.js +1 -1
- package/dist/tools/audio.js.map +1 -1
- package/dist/tools/debug.js +2 -2
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/device.js +10 -9
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +15 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +4 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +32 -7
- 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/screenshot.js +55 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +4 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/terrain.js +5 -5
- 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 +38 -15
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -75
- 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 +65 -3
- package/plugin/src/Paths.luau +15 -4
- package/plugin/src/Phrase.luau +3 -20
- package/plugin/src/ScriptEdit.luau +14 -0
- package/plugin/src/Serialize.luau +17 -0
- package/plugin/src/TextEdit.luau +6 -0
- package/plugin/src/handlers/Api.luau +8 -0
- package/plugin/src/handlers/Assets.luau +27 -18
- package/plugin/src/handlers/Audio.luau +36 -15
- package/plugin/src/handlers/Capture.luau +219 -3
- package/plugin/src/handlers/Character.luau +38 -23
- package/plugin/src/handlers/Data.luau +12 -1
- package/plugin/src/handlers/Device.luau +38 -27
- 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/Terrain.luau +9 -24
- package/plugin/src/handlers/Viewport.luau +4 -4
- package/plugin/src/handlers/World.luau +17 -1
- package/plugin/src/init.server.luau +0 -2
- package/scripts/test-tools.mjs +437 -216
- package/dist/tools/anim.js +0 -159
- package/dist/tools/anim.js.map +0 -1
- package/plugin/src/handlers/Anim.luau +0 -897
package/scripts/test-tools.mjs
CHANGED
|
@@ -1,216 +1,437 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Offline checks for tool output shaping that needs no Studio.
|
|
3
|
-
*
|
|
4
|
-
* Usage: node scripts/test-tools.mjs
|
|
5
|
-
*/
|
|
6
|
-
import assert from "node:assert/strict";
|
|
7
|
-
import { CHARACTER_LIMIT } from "../dist/lib/format.js";
|
|
8
|
-
import { clipListing, numbered } from "../dist/tools/scripts.js";
|
|
9
|
-
|
|
10
|
-
const item = (path, lineCount) => ({ path, className: "ModuleScript", lineCount, startLine: 1, source: "" });
|
|
11
|
-
const listing = (path, lines) =>
|
|
12
|
-
`${path} (ModuleScript, ${lines} lines)\n` +
|
|
13
|
-
numbered(Array.from({ length: lines }, (_, index) => `local v${index} = ${index}`).join("\n"), 1);
|
|
14
|
-
|
|
15
|
-
// Small reads pass through untouched.
|
|
16
|
-
{
|
|
17
|
-
const blocks = [listing("A", 3)];
|
|
18
|
-
assert.equal(clipListing(blocks, [item("A", 3)], null), blocks[0]);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// A big read is cut on a whole line and names the exact next window.
|
|
22
|
-
{
|
|
23
|
-
const out = clipListing([listing("Big", 5000), listing("After", 5)], [item("Big", 5000), item("After", 5)], null);
|
|
24
|
-
assert.ok(out.length <= CHARACTER_LIMIT, "fits the limit");
|
|
25
|
-
const match = /Big stops at line (\d+) of 5000\. Continue with \{ path: "Big", startLine: (\d+) \}/.exec(out);
|
|
26
|
-
assert.ok(match, "names where to continue");
|
|
27
|
-
assert.equal(Number(match[2]), Number(match[1]) + 1);
|
|
28
|
-
const lastShown = out.split("\n\n[clipped")[0].split("\n").pop();
|
|
29
|
-
assert.match(lastShown, new RegExp(`^\s*${match[1]}│ local v${Number(match[1]) - 1} = ${Number(match[1]) - 1}$`), "last line is whole");
|
|
30
|
-
assert.match(out, /1 more script\(s\) after it were not shown/);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Failures are never the part that gets clipped away.
|
|
34
|
-
{
|
|
35
|
-
const out = clipListing([listing("Big", 5000)], [item("Big", 5000)], "Could not read 1 path(s):\n - Nope");
|
|
36
|
-
assert.ok(out.startsWith("Could not read 1 path(s)"));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
process.stdout.write("tools: ok\n");
|
|
40
|
-
|
|
41
|
-
// Exercise the public schemas and forwarding through the real tool handlers.
|
|
42
|
-
const { z } = await import("zod");
|
|
43
|
-
const { registerExecTools } = await import("../dist/tools/exec.js");
|
|
44
|
-
const { registerInputTools } = await import("../dist/tools/input.js");
|
|
45
|
-
const registered = new Map();
|
|
46
|
-
const calls = [];
|
|
47
|
-
const context = {
|
|
48
|
-
server: { registerTool(name, spec, handler) { registered.set(name, { spec, handler }); } },
|
|
49
|
-
bridge: { async sessions() { return {list:[]}; }, async call(op, params, options) {
|
|
50
|
-
calls.push({op, params, options});
|
|
51
|
-
return op === "exec.run" ? {ok:true,returned:["nil",{answer:42}],output:[],milliseconds:1} : {delivered:true,steps:1,player:"Alice"};
|
|
52
|
-
} },
|
|
53
|
-
};
|
|
54
|
-
registerExecTools(context);
|
|
55
|
-
registerInputTools(context);
|
|
56
|
-
const execute = registered.get("execute_luau");
|
|
57
|
-
const parsed = z.object(execute.spec.inputSchema).parse({source:"return nil",target:"client",player:"Alice",timeoutSeconds:2});
|
|
58
|
-
await execute.handler(parsed);
|
|
59
|
-
assert.equal(calls.at(-1).params.target, "client");
|
|
60
|
-
assert.equal(calls.at(-1).params.player, "Alice");
|
|
61
|
-
assert.equal(calls.at(-1).options.timeoutMs, 12000);
|
|
62
|
-
await execute.handler(z.object(execute.spec.inputSchema).parse({source:"return 1"}));
|
|
63
|
-
assert.equal(calls.at(-1).params.target, "studio");
|
|
64
|
-
assert.equal(calls.at(-1).options.timeoutMs, 60000);
|
|
65
|
-
const input = registered.get("input");
|
|
66
|
-
for (const step of [{kind:"click",target:"PlayerGui.HUD.BuyButton"},{kind:"text",target:"PlayerGui.HUD.NameBox",text:"hello"},{kind:"click",x:100,y:200}]) {
|
|
67
|
-
await input.handler(z.object(input.spec.inputSchema).parse({steps:[step]}));
|
|
68
|
-
assert.deepEqual(calls.at(-1).params.steps[0], step);
|
|
69
|
-
}
|
|
70
|
-
// Issue #4: ordinary decimal holds/waits retain padding and yield whole milliseconds.
|
|
71
|
-
for (const steps of [
|
|
72
|
-
[{kind:"key",key:"E",hold:0.1,after:4.1}, ...Array.from({length:24}, () => ({kind:"key",key:"Left",hold:0.05,after:1.065}))],
|
|
73
|
-
[{kind:"key",key:"E",hold:0.05,after:1.0651}],
|
|
74
|
-
]) {
|
|
75
|
-
await input.handler(z.object(input.spec.inputSchema).parse({steps}));
|
|
76
|
-
const raw = (35 + steps.reduce((sum, step) => sum + step.hold + step.after + 0.5, 0)) * 1000;
|
|
77
|
-
assert.equal(calls.at(-1).options.timeoutMs, Math.ceil(raw));
|
|
78
|
-
assert.ok(Number.isInteger(calls.at(-1).options.timeoutMs));
|
|
79
|
-
assert.deepEqual(calls.at(-1).params.steps, steps, "step timing itself is unchanged");
|
|
80
|
-
}
|
|
81
|
-
assert.deepEqual([...registered.keys()].sort(), ["execute_luau", "input", "viewport"]);
|
|
82
|
-
process.stdout.write("client tool schemas: ok\n");
|
|
83
|
-
|
|
84
|
-
const { registerDebugTools } = await import("../dist/tools/debug.js");
|
|
85
|
-
registerDebugTools(context);
|
|
86
|
-
const debug = registered.get("debug");
|
|
87
|
-
const remoteArgs = z.object(debug.spec.inputSchema).parse({op:"remotes",path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
88
|
-
await debug.handler(remoteArgs);
|
|
89
|
-
assert.equal(calls.at(-1).op, "debug.remotes");
|
|
90
|
-
assert.deepEqual(calls.at(-1).params, {path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
91
|
-
assert.equal(calls.at(-1).options.timeoutMs, 18000);
|
|
92
|
-
assert.throws(() => z.object(debug.spec.inputSchema).parse({op:"remotes",seconds:16}));
|
|
93
|
-
assert.deepEqual([...registered.keys()].sort(), ["debug", "execute_luau", "input", "viewport"]);
|
|
94
|
-
process.stdout.write("remote trace schema: ok\n");
|
|
95
|
-
|
|
96
|
-
const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
|
|
97
|
-
registerPlaytestTools(context);
|
|
98
|
-
const playtest = registered.get("playtest");
|
|
99
|
-
for (const guidance of ["AGENTS.md", "CLAUDE.md", "user instructions", "project guidance", "even when ON", "Do not bypass"]) {
|
|
100
|
-
assert.ok(playtest.spec.description.includes(guidance), `playtest guidance preserves ${guidance}`);
|
|
101
|
-
}
|
|
102
|
-
const normalCall = context.bridge.call;
|
|
103
|
-
context.bridge.call = async (op, params) => {
|
|
104
|
-
if (["play", "run", "multiplayer"].includes(params.op)) {
|
|
105
|
-
const { ToolError } = await import("../dist/lib/errors.js");
|
|
106
|
-
throw new ToolError("PLAYTEST_DISABLED", "Playtests are disabled by the user.", "Do not start or simulate a playtest. Continue using edit-mode tools and static inspection where possible.\nRun `playtests on` in the Studio MCP panel to re-enable playtesting.");
|
|
107
|
-
}
|
|
108
|
-
return {changed:false,state:{playtestsAllowed:false}};
|
|
109
|
-
};
|
|
110
|
-
for (const op of ["play", "run", "multiplayer"]) {
|
|
111
|
-
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op}));
|
|
112
|
-
assert.equal(result.isError, true);
|
|
113
|
-
assert.match(result.content[0].text, /PLAYTEST_DISABLED/);
|
|
114
|
-
assert.match(result.content[0].text, /playtests on/);
|
|
115
|
-
}
|
|
116
|
-
const stateResult = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"state"}));
|
|
117
|
-
assert.ok(!stateResult.isError);
|
|
118
|
-
context.bridge.call = normalCall;
|
|
119
|
-
process.stdout.write("playtest lock errors and instruction precedence: ok\n");
|
|
120
|
-
|
|
121
|
-
// A successful start includes the newly attached runtime session and player
|
|
122
|
-
// names, using the same discovery path as stop. The editor ID is excluded.
|
|
123
|
-
{
|
|
124
|
-
let started = false;
|
|
125
|
-
let operation = "play";
|
|
126
|
-
const originalSessions = context.bridge.sessions;
|
|
127
|
-
const originalCall = context.bridge.call;
|
|
128
|
-
context.bridge.sessions = async () => ({list: started
|
|
129
|
-
? [{studioId:"editor",context:"edit"},{studioId:"runtime",context:"playtest server"}]
|
|
130
|
-
: [{studioId:"editor",context:"edit"}]});
|
|
131
|
-
context.bridge.call = async (op, params, options) => {
|
|
132
|
-
assert.equal(op, "playtest.control");
|
|
133
|
-
if (params.op === operation) {
|
|
134
|
-
started = true;
|
|
135
|
-
return {changed:true,state:{testPending:true,isRunning:false}};
|
|
136
|
-
}
|
|
137
|
-
assert.equal(options.studioId, "runtime");
|
|
138
|
-
return {changed:false,state:{players:operation === "multiplayer" ? [{name:"Alice",userId:123},{name:"Bob",userId:456}] : [{name:"Alice",userId:123}]}};
|
|
139
|
-
};
|
|
140
|
-
for (operation of ["play", "multiplayer"]) {
|
|
141
|
-
started = false;
|
|
142
|
-
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:operation,studioId:"editor"}));
|
|
143
|
-
assert.match(result.content[0].text, /"studioId": "runtime"/);
|
|
144
|
-
assert.match(result.content[0].text, /"name": "Alice"/);
|
|
145
|
-
}
|
|
146
|
-
context.bridge.sessions = originalSessions;
|
|
147
|
-
context.bridge.call = originalCall;
|
|
148
|
-
}
|
|
149
|
-
process.stdout.write("playtest runtime identity: ok\n");
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
[
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
assert.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
assert.
|
|
216
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Offline checks for tool output shaping that needs no Studio.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node scripts/test-tools.mjs
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { CHARACTER_LIMIT } from "../dist/lib/format.js";
|
|
8
|
+
import { clipListing, numbered } from "../dist/tools/scripts.js";
|
|
9
|
+
|
|
10
|
+
const item = (path, lineCount) => ({ path, className: "ModuleScript", lineCount, startLine: 1, source: "" });
|
|
11
|
+
const listing = (path, lines) =>
|
|
12
|
+
`${path} (ModuleScript, ${lines} lines)\n` +
|
|
13
|
+
numbered(Array.from({ length: lines }, (_, index) => `local v${index} = ${index}`).join("\n"), 1);
|
|
14
|
+
|
|
15
|
+
// Small reads pass through untouched.
|
|
16
|
+
{
|
|
17
|
+
const blocks = [listing("A", 3)];
|
|
18
|
+
assert.equal(clipListing(blocks, [item("A", 3)], null), blocks[0]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A big read is cut on a whole line and names the exact next window.
|
|
22
|
+
{
|
|
23
|
+
const out = clipListing([listing("Big", 5000), listing("After", 5)], [item("Big", 5000), item("After", 5)], null);
|
|
24
|
+
assert.ok(out.length <= CHARACTER_LIMIT, "fits the limit");
|
|
25
|
+
const match = /Big stops at line (\d+) of 5000\. Continue with \{ path: "Big", startLine: (\d+) \}/.exec(out);
|
|
26
|
+
assert.ok(match, "names where to continue");
|
|
27
|
+
assert.equal(Number(match[2]), Number(match[1]) + 1);
|
|
28
|
+
const lastShown = out.split("\n\n[clipped")[0].split("\n").pop();
|
|
29
|
+
assert.match(lastShown, new RegExp(`^\s*${match[1]}│ local v${Number(match[1]) - 1} = ${Number(match[1]) - 1}$`), "last line is whole");
|
|
30
|
+
assert.match(out, /1 more script\(s\) after it were not shown/);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Failures are never the part that gets clipped away.
|
|
34
|
+
{
|
|
35
|
+
const out = clipListing([listing("Big", 5000)], [item("Big", 5000)], "Could not read 1 path(s):\n - Nope");
|
|
36
|
+
assert.ok(out.startsWith("Could not read 1 path(s)"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.stdout.write("tools: ok\n");
|
|
40
|
+
|
|
41
|
+
// Exercise the public schemas and forwarding through the real tool handlers.
|
|
42
|
+
const { z } = await import("zod");
|
|
43
|
+
const { registerExecTools } = await import("../dist/tools/exec.js");
|
|
44
|
+
const { registerInputTools } = await import("../dist/tools/input.js");
|
|
45
|
+
const registered = new Map();
|
|
46
|
+
const calls = [];
|
|
47
|
+
const context = {
|
|
48
|
+
server: { registerTool(name, spec, handler) { registered.set(name, { spec, handler }); } },
|
|
49
|
+
bridge: { async sessions() { return {list:[]}; }, async call(op, params, options) {
|
|
50
|
+
calls.push({op, params, options});
|
|
51
|
+
return op === "exec.run" ? {ok:true,returned:["nil",{answer:42}],output:[],milliseconds:1} : {delivered:true,steps:1,player:"Alice"};
|
|
52
|
+
} },
|
|
53
|
+
};
|
|
54
|
+
registerExecTools(context);
|
|
55
|
+
registerInputTools(context);
|
|
56
|
+
const execute = registered.get("execute_luau");
|
|
57
|
+
const parsed = z.object(execute.spec.inputSchema).parse({source:"return nil",target:"client",player:"Alice",timeoutSeconds:2});
|
|
58
|
+
await execute.handler(parsed);
|
|
59
|
+
assert.equal(calls.at(-1).params.target, "client");
|
|
60
|
+
assert.equal(calls.at(-1).params.player, "Alice");
|
|
61
|
+
assert.equal(calls.at(-1).options.timeoutMs, 12000);
|
|
62
|
+
await execute.handler(z.object(execute.spec.inputSchema).parse({source:"return 1"}));
|
|
63
|
+
assert.equal(calls.at(-1).params.target, "studio");
|
|
64
|
+
assert.equal(calls.at(-1).options.timeoutMs, 60000);
|
|
65
|
+
const input = registered.get("input");
|
|
66
|
+
for (const step of [{kind:"click",target:"PlayerGui.HUD.BuyButton"},{kind:"text",target:"PlayerGui.HUD.NameBox",text:"hello"},{kind:"click",x:100,y:200}]) {
|
|
67
|
+
await input.handler(z.object(input.spec.inputSchema).parse({steps:[step]}));
|
|
68
|
+
assert.deepEqual(calls.at(-1).params.steps[0], step);
|
|
69
|
+
}
|
|
70
|
+
// Issue #4: ordinary decimal holds/waits retain padding and yield whole milliseconds.
|
|
71
|
+
for (const steps of [
|
|
72
|
+
[{kind:"key",key:"E",hold:0.1,after:4.1}, ...Array.from({length:24}, () => ({kind:"key",key:"Left",hold:0.05,after:1.065}))],
|
|
73
|
+
[{kind:"key",key:"E",hold:0.05,after:1.0651}],
|
|
74
|
+
]) {
|
|
75
|
+
await input.handler(z.object(input.spec.inputSchema).parse({steps}));
|
|
76
|
+
const raw = (35 + steps.reduce((sum, step) => sum + step.hold + step.after + 0.5, 0)) * 1000;
|
|
77
|
+
assert.equal(calls.at(-1).options.timeoutMs, Math.ceil(raw));
|
|
78
|
+
assert.ok(Number.isInteger(calls.at(-1).options.timeoutMs));
|
|
79
|
+
assert.deepEqual(calls.at(-1).params.steps, steps, "step timing itself is unchanged");
|
|
80
|
+
}
|
|
81
|
+
assert.deepEqual([...registered.keys()].sort(), ["execute_luau", "input", "viewport"]);
|
|
82
|
+
process.stdout.write("client tool schemas: ok\n");
|
|
83
|
+
|
|
84
|
+
const { registerDebugTools } = await import("../dist/tools/debug.js");
|
|
85
|
+
registerDebugTools(context);
|
|
86
|
+
const debug = registered.get("debug");
|
|
87
|
+
const remoteArgs = z.object(debug.spec.inputSchema).parse({op:"remotes",path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
88
|
+
await debug.handler(remoteArgs);
|
|
89
|
+
assert.equal(calls.at(-1).op, "debug.remotes");
|
|
90
|
+
assert.deepEqual(calls.at(-1).params, {path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
91
|
+
assert.equal(calls.at(-1).options.timeoutMs, 18000);
|
|
92
|
+
assert.throws(() => z.object(debug.spec.inputSchema).parse({op:"remotes",seconds:16}));
|
|
93
|
+
assert.deepEqual([...registered.keys()].sort(), ["debug", "execute_luau", "input", "viewport"]);
|
|
94
|
+
process.stdout.write("remote trace schema: ok\n");
|
|
95
|
+
|
|
96
|
+
const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
|
|
97
|
+
registerPlaytestTools(context);
|
|
98
|
+
const playtest = registered.get("playtest");
|
|
99
|
+
for (const guidance of ["AGENTS.md", "CLAUDE.md", "user instructions", "project guidance", "even when ON", "Do not bypass"]) {
|
|
100
|
+
assert.ok(playtest.spec.description.includes(guidance), `playtest guidance preserves ${guidance}`);
|
|
101
|
+
}
|
|
102
|
+
const normalCall = context.bridge.call;
|
|
103
|
+
context.bridge.call = async (op, params) => {
|
|
104
|
+
if (["play", "run", "multiplayer"].includes(params.op)) {
|
|
105
|
+
const { ToolError } = await import("../dist/lib/errors.js");
|
|
106
|
+
throw new ToolError("PLAYTEST_DISABLED", "Playtests are disabled by the user.", "Do not start or simulate a playtest. Continue using edit-mode tools and static inspection where possible.\nRun `playtests on` in the Studio MCP panel to re-enable playtesting.");
|
|
107
|
+
}
|
|
108
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
109
|
+
};
|
|
110
|
+
for (const op of ["play", "run", "multiplayer"]) {
|
|
111
|
+
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op}));
|
|
112
|
+
assert.equal(result.isError, true);
|
|
113
|
+
assert.match(result.content[0].text, /PLAYTEST_DISABLED/);
|
|
114
|
+
assert.match(result.content[0].text, /playtests on/);
|
|
115
|
+
}
|
|
116
|
+
const stateResult = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"state"}));
|
|
117
|
+
assert.ok(!stateResult.isError);
|
|
118
|
+
context.bridge.call = normalCall;
|
|
119
|
+
process.stdout.write("playtest lock errors and instruction precedence: ok\n");
|
|
120
|
+
|
|
121
|
+
// A successful start includes the newly attached runtime session and player
|
|
122
|
+
// names, using the same discovery path as stop. The editor ID is excluded.
|
|
123
|
+
{
|
|
124
|
+
let started = false;
|
|
125
|
+
let operation = "play";
|
|
126
|
+
const originalSessions = context.bridge.sessions;
|
|
127
|
+
const originalCall = context.bridge.call;
|
|
128
|
+
context.bridge.sessions = async () => ({list: started
|
|
129
|
+
? [{studioId:"editor",context:"edit"},{studioId:"runtime",context:"playtest server"}]
|
|
130
|
+
: [{studioId:"editor",context:"edit"}]});
|
|
131
|
+
context.bridge.call = async (op, params, options) => {
|
|
132
|
+
assert.equal(op, "playtest.control");
|
|
133
|
+
if (params.op === operation) {
|
|
134
|
+
started = true;
|
|
135
|
+
return {changed:true,state:{testPending:true,isRunning:false}};
|
|
136
|
+
}
|
|
137
|
+
assert.equal(options.studioId, "runtime");
|
|
138
|
+
return {changed:false,state:{players:operation === "multiplayer" ? [{name:"Alice",userId:123},{name:"Bob",userId:456}] : [{name:"Alice",userId:123}]}};
|
|
139
|
+
};
|
|
140
|
+
for (operation of ["play", "multiplayer"]) {
|
|
141
|
+
started = false;
|
|
142
|
+
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:operation,studioId:"editor"}));
|
|
143
|
+
assert.match(result.content[0].text, /"studioId": ?"runtime"/);
|
|
144
|
+
assert.match(result.content[0].text, /"name": ?"Alice"/);
|
|
145
|
+
}
|
|
146
|
+
context.bridge.sessions = originalSessions;
|
|
147
|
+
context.bridge.call = originalCall;
|
|
148
|
+
}
|
|
149
|
+
process.stdout.write("playtest runtime identity: ok\n");
|
|
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
|
+
|
|
176
|
+
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
177
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
178
|
+
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
179
|
+
const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
|
|
180
|
+
registerDiscoverTools(context);
|
|
181
|
+
registerScriptTools(context);
|
|
182
|
+
registerScreenshotTools(context);
|
|
183
|
+
const editOps = [];
|
|
184
|
+
context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
|
|
185
|
+
context.bridge.call = async (op, params) => {
|
|
186
|
+
editOps.push(op);
|
|
187
|
+
if (op === "playtest.control") {
|
|
188
|
+
if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
|
|
189
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
190
|
+
}
|
|
191
|
+
const results = {
|
|
192
|
+
"exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
|
|
193
|
+
"discover.tree": {items:[],total:0,offset:0},
|
|
194
|
+
"discover.inspect": {items:[],failures:[]},
|
|
195
|
+
"script.read": {items:[],failures:[]},
|
|
196
|
+
"viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
|
|
197
|
+
"capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
|
|
198
|
+
};
|
|
199
|
+
assert.ok(op in results, `unexpected edit-mode operation ${op}`);
|
|
200
|
+
return results[op];
|
|
201
|
+
};
|
|
202
|
+
for (const [name, args, expected] of [
|
|
203
|
+
["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
|
|
204
|
+
["tree",{},"discover.tree"],
|
|
205
|
+
["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
|
|
206
|
+
["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
|
|
207
|
+
["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
|
|
208
|
+
["screenshot",{},"capture.screenshot"],
|
|
209
|
+
]) {
|
|
210
|
+
const tool = registered.get(name);
|
|
211
|
+
const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
212
|
+
assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
|
|
213
|
+
assert.equal(editOps.at(-1),expected);
|
|
214
|
+
}
|
|
215
|
+
assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
|
|
216
|
+
context.bridge.call = normalCall;
|
|
217
|
+
process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
|
|
218
|
+
|
|
219
|
+
const { registerPerfTools } = await import("../dist/tools/perf.js");
|
|
220
|
+
registerPerfTools(context);
|
|
221
|
+
const consoleTool = registered.get("console");
|
|
222
|
+
const consoleArgs = z.object(consoleTool.spec.inputSchema).parse({target:"client",player:"Alice",studioId:"runtime",since:"cursor-1",limit:10});
|
|
223
|
+
context.bridge.call = async (op, params, options) => {
|
|
224
|
+
assert.equal(op, "perf.console");
|
|
225
|
+
assert.equal(params.target, "client");
|
|
226
|
+
assert.equal(params.player, "Alice");
|
|
227
|
+
assert.equal(params.since, "cursor-1");
|
|
228
|
+
assert.equal(options.studioId, "runtime");
|
|
229
|
+
return {items:[],total:0,dropped:0,evicted:2,nextCursor:"cursor-2",capturing:true};
|
|
230
|
+
};
|
|
231
|
+
const consoleResult = await consoleTool.handler(consoleArgs);
|
|
232
|
+
assert.match(consoleResult.content[0].text, /2 older lines were evicted/);
|
|
233
|
+
assert.match(consoleResult.content[0].text, /nextCursor: cursor-2/);
|
|
234
|
+
context.bridge.call = async () => ({
|
|
235
|
+
items: Array.from({length:60}, (_, index) => ({level:"print",message:`${index} ${"x".repeat(1000)}`})),
|
|
236
|
+
total:60,dropped:0,nextCursor:"cursor-3",
|
|
237
|
+
});
|
|
238
|
+
const boundedConsole = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
|
|
239
|
+
assert.ok(boundedConsole.content[0].text.length < 25_000);
|
|
240
|
+
assert.match(boundedConsole.content[0].text, /older matching lines omitted/);
|
|
241
|
+
process.stdout.write("client console schema and cursor forwarding: ok\n");
|
|
242
|
+
|
|
243
|
+
// `create` must not advertise a recursive schema -- Gemini / Vertex AI reject a
|
|
244
|
+
// `$ref` with HTTP 400 -- yet a bad nested child is still refused by name.
|
|
245
|
+
{
|
|
246
|
+
const { registerInstanceTools } = await import("../dist/tools/instances.js");
|
|
247
|
+
const tools = new Map();
|
|
248
|
+
registerInstanceTools({ server: { registerTool(name, spec, handler) { tools.set(name, { spec, handler }); } }, bridge: { async call() { throw new Error("must not reach Studio"); } } });
|
|
249
|
+
const create = tools.get("create");
|
|
250
|
+
const schema = JSON.stringify(z.toJSONSchema(z.object(create.spec.inputSchema)));
|
|
251
|
+
assert.ok(!schema.includes("$ref") && !schema.includes("$defs") && !schema.includes("definitions"), "create schema is not recursive");
|
|
252
|
+
const bad = await create.handler(z.object(create.spec.inputSchema).parse({ instances: [{ parent: "Workspace", className: "Model", children: [{ className: "Part", children: [{ name: "NoClass" }] }] }] }));
|
|
253
|
+
assert.equal(bad.isError, true);
|
|
254
|
+
assert.match(bad.content[0].text, /BAD_PARAMS\] instances\[0\]\.children\[0\]\.children\[0\]\.className/);
|
|
255
|
+
process.stdout.write("create schema without recursion, nested validation: ok\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Compact JSON: short structures on one line, and always parsed back identical.
|
|
259
|
+
{
|
|
260
|
+
const { stringify } = await import("../dist/lib/format.js");
|
|
261
|
+
const value = { rows: [{ path: "Workspace.A", className: "Part" }], nested: { list: [1, "two", null, undefined], skip: undefined, when: new Date(0) }, long: "x".repeat(150) };
|
|
262
|
+
assert.deepEqual(JSON.parse(stringify(value)), JSON.parse(JSON.stringify(value)));
|
|
263
|
+
assert.match(stringify(value), /\n "rows": \[\{"path":"Workspace.A","className":"Part"\}\],\n/, "a short row stays on one line");
|
|
264
|
+
assert.equal(stringify([]), "[]");
|
|
265
|
+
assert.equal(stringify(undefined), "null");
|
|
266
|
+
process.stdout.write("compact json: ok\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Screenshot scaling: box filter averages everything under a pixel; enlarging copies blocks.
|
|
270
|
+
{
|
|
271
|
+
const { boxResample, upscaleNearest } = await import("../dist/lib/png.js");
|
|
272
|
+
const stripes = Buffer.from([0,0,0, 200,200,200, 0,0,0, 200,200,200, 0,0,0, 200,200,200, 0,0,0, 200,200,200]);
|
|
273
|
+
assert.deepEqual([...boxResample(stripes, 4, 2, 2).rgb], [100,100,100, 100,100,100], "averages, never samples");
|
|
274
|
+
const W = 1661, H = 719, line = Buffer.alloc(W * H * 3);
|
|
275
|
+
for (let y = 0; y < H; y += 1) line[(y * W + 831) * 3] = 255;
|
|
276
|
+
const scaled = boxResample(line, W, H, 800);
|
|
277
|
+
assert.equal(scaled.width, 800);
|
|
278
|
+
assert.equal(scaled.height, 346);
|
|
279
|
+
let red = 0; for (let i = 0; i < scaled.rgb.length; i += 3) red = Math.max(red, scaled.rgb[i]);
|
|
280
|
+
assert.ok(red > 100, "a one-pixel line survives a 2x reduction");
|
|
281
|
+
assert.equal(boxResample(stripes, 4, 2, 8).width, 4, "never scales up");
|
|
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]);
|
|
283
|
+
process.stdout.write("screenshot scaling: ok\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// scene: a category of bare counts renders on one line; owned entries keep theirs.
|
|
287
|
+
{
|
|
288
|
+
const perf = registered.get("performance");
|
|
289
|
+
context.bridge.call = async () => ({
|
|
290
|
+
composition: {total: 12, unit: "instances", entries: [
|
|
291
|
+
{name: "Physics", depth: 1, size: 10},
|
|
292
|
+
{name: "Motor6D", depth: 2, size: 6},
|
|
293
|
+
{name: "Attachment", depth: 2, size: 4},
|
|
294
|
+
{name: "Misc", depth: 1, size: 2},
|
|
295
|
+
]},
|
|
296
|
+
animationMemory: {total: 100, unit: "bytes", entries: [
|
|
297
|
+
{name: "Walk", depth: 1, size: 100, owners: ["Workspace.Npc.Animator"]},
|
|
298
|
+
]},
|
|
299
|
+
});
|
|
300
|
+
const result = await perf.handler(z.object(perf.spec.inputSchema).parse({op: "scene"}));
|
|
301
|
+
const out = result.content[0].text;
|
|
302
|
+
assert.match(out, / Physics — 10 instances: Motor6D 6, Attachment 4\n/);
|
|
303
|
+
assert.match(out, / Misc — 2 instances/);
|
|
304
|
+
assert.match(out, / Walk — 100 bytes\n used by Workspace\.Npc\.Animator/);
|
|
305
|
+
process.stdout.write("scene compact rows: ok\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// inspect: children come back as compact "Name (Class)" strings.
|
|
309
|
+
{
|
|
310
|
+
const inspect = registered.get("inspect");
|
|
311
|
+
context.bridge.call = async () => ({
|
|
312
|
+
items: [{path: "Workspace.Npc", className: "Model", childCount: 2, properties: {Name: "Npc"},
|
|
313
|
+
children: [{name: "Head", className: "Part"}, {name: "Humanoid", className: "Humanoid"}]}],
|
|
314
|
+
failures: [],
|
|
315
|
+
});
|
|
316
|
+
const result = await inspect.handler(z.object(inspect.spec.inputSchema).parse({paths: ["Workspace.Npc"], properties: ["Name"]}));
|
|
317
|
+
assert.match(result.content[0].text, /"children": ?\["Head \(Part\)", ?"Humanoid"\]/);
|
|
318
|
+
process.stdout.write("inspect compact children: ok\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// universe servers/logs: routed to Server Management with the wildcard version,
|
|
322
|
+
// rendered as a table and as log blocks. Fetch is stubbed; nothing leaves.
|
|
323
|
+
{
|
|
324
|
+
const { registerUniverseTools } = await import("../dist/tools/universe.js");
|
|
325
|
+
registerUniverseTools(context);
|
|
326
|
+
const universe = registered.get("universe");
|
|
327
|
+
const saved = { fetch: globalThis.fetch, env: { ...process.env } };
|
|
328
|
+
Object.assign(process.env, { ROBLOX_API_KEY: "test-key", ROBLOX_USER_ID: "1", ROBLOX_UNIVERSE_ID: "10", ROBLOX_PLACE_ID: "20" });
|
|
329
|
+
context.bridge.call = async () => ({ placeId: 20 });
|
|
330
|
+
const urls = [];
|
|
331
|
+
globalThis.fetch = async (url) => {
|
|
332
|
+
urls.push(String(url));
|
|
333
|
+
if (String(url).includes("/universes/v1/places/")) return new Response(JSON.stringify({ universeId: 10 }), { status: 200 });
|
|
334
|
+
const body = String(url).includes("/logs")
|
|
335
|
+
? { gameServerLogs: [{ messageTimestampMs: "0", severity: 3, message: "boom", stackTrace: "Script 'X', Line 4", context: "{\"id\":1}", skippedCount: 2 }], nextPageToken: null }
|
|
336
|
+
: { gameServers: [{ jobId: "job-1", status: "active", occupancy: 3, maxOccupancy: 10, uptime: "120s", frameRate: 59.7, memoryUsageBytes: 104857600, placeVersion: "12" }], nextPageToken: null };
|
|
337
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
338
|
+
};
|
|
339
|
+
try {
|
|
340
|
+
const servers = await universe.handler(z.object(universe.spec.inputSchema).parse({ op: "servers" }));
|
|
341
|
+
assert.match(servers.content[0].text, /job-1 \| active \| 3\/10 \| 120s \| 60 \| 100 \| 12/);
|
|
342
|
+
assert.match(urls.find((u) => u.includes("game-servers")), /\/server-management\/v1\/universes\/10\/places\/20\/versions\/-\/game-servers\?/);
|
|
343
|
+
const logs = await universe.handler(z.object(universe.spec.inputSchema).parse({ op: "logs", jobId: "job-1", severity: "error", search: "boom" }));
|
|
344
|
+
const out = logs.content[0].text;
|
|
345
|
+
assert.match(out, /\[error\] boom \(\+2 similar\)\n Script 'X', Line 4\n context: \{"id":1\}/);
|
|
346
|
+
assert.equal(new URL(urls.find((u) => u.includes("/logs"))).searchParams.get("Filter"), 'severity == 3 && search == "boom"');
|
|
347
|
+
const missing = await universe.handler(z.object(universe.spec.inputSchema).parse({ op: "logs" }));
|
|
348
|
+
assert.equal(missing.isError, true);
|
|
349
|
+
} finally {
|
|
350
|
+
globalThis.fetch = saved.fetch;
|
|
351
|
+
for (const key of ["ROBLOX_API_KEY", "ROBLOX_USER_ID", "ROBLOX_UNIVERSE_ID", "ROBLOX_PLACE_ID"]) {
|
|
352
|
+
if (saved.env[key] === undefined) delete process.env[key]; else process.env[key] = saved.env[key];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
process.stdout.write("universe servers and logs: ok\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// console: a structured log's context is shown under its line.
|
|
359
|
+
{
|
|
360
|
+
const consoleTool = registered.get("console");
|
|
361
|
+
context.bridge.call = async () => ({ items: [{ level: "info", message: "Bob leveled up", context: "{\"player\":\"Bob\"}" }], total: 1, dropped: 0, nextCursor: "c" });
|
|
362
|
+
const result = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
|
|
363
|
+
assert.match(result.content[0].text, /\[info\] Bob leveled up\n context: \{"player":"Bob"\}/);
|
|
364
|
+
process.stdout.write("console structured context: ok\n");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// universe products/sell: lists both kinds, refuses a duplicate name, needs confirm.
|
|
368
|
+
{
|
|
369
|
+
const universe = registered.get("universe");
|
|
370
|
+
const saved = { fetch: globalThis.fetch, env: { ...process.env } };
|
|
371
|
+
Object.assign(process.env, { ROBLOX_API_KEY: "test-key", ROBLOX_USER_ID: "1", ROBLOX_UNIVERSE_ID: "10", ROBLOX_PLACE_ID: "20" });
|
|
372
|
+
context.bridge.call = async () => ({ placeId: 20 });
|
|
373
|
+
const sent = [];
|
|
374
|
+
globalThis.fetch = async (url, init) => {
|
|
375
|
+
const href = String(url);
|
|
376
|
+
sent.push({ href, method: init?.method ?? "GET", body: init?.body });
|
|
377
|
+
if (href.includes("/universes/v1/places/")) return new Response(JSON.stringify({ universeId: 10 }), { status: 200 });
|
|
378
|
+
if (href.includes("developer-products/creator")) return new Response(JSON.stringify({ developerProducts: [{ productId: 5, name: "100 Coins", isForSale: true, priceInformation: { defaultPriceInRobux: 25 } }] }), { status: 200 });
|
|
379
|
+
if (href.includes("game-passes/creator")) return new Response(JSON.stringify({ gamePasses: [{ gamePassId: 7, name: "VIP", isForSale: false }] }), { status: 200 });
|
|
380
|
+
if (init?.method === "POST") return new Response(JSON.stringify({ productId: 6, name: "500 Coins", isForSale: true, priceInformation: { defaultPriceInRobux: 99 } }), { status: 200 });
|
|
381
|
+
return new Response("", { status: 200 });
|
|
382
|
+
};
|
|
383
|
+
const parse = (args) => z.object(universe.spec.inputSchema).parse(args);
|
|
384
|
+
try {
|
|
385
|
+
const list = (await universe.handler(parse({ op: "products" }))).content[0].text;
|
|
386
|
+
assert.match(list, /product \| 5 \| 100 Coins \| 25 \| true/);
|
|
387
|
+
assert.match(list, /pass \| 7 \| VIP \| \| false/);
|
|
388
|
+
const unconfirmed = await universe.handler(parse({ op: "sell", kind: "product", name: "500 Coins", price: 99 }));
|
|
389
|
+
assert.match(unconfirmed.content[0].text, /NEEDS_CONFIRM/);
|
|
390
|
+
const duplicate = await universe.handler(parse({ op: "sell", kind: "product", name: "100 coins", price: 25, confirm: true }));
|
|
391
|
+
assert.match(duplicate.content[0].text, /ALREADY_EXISTS\].*id 5/);
|
|
392
|
+
const created = await universe.handler(parse({ op: "sell", kind: "product", name: "500 Coins", price: 99, forSale: true, confirm: true }));
|
|
393
|
+
assert.match(created.content[0].text, /"id": ?6/);
|
|
394
|
+
const post = sent.find((entry) => entry.method === "POST");
|
|
395
|
+
assert.ok(post.href.endsWith("/developer-products/v2/universes/10/developer-products"));
|
|
396
|
+
assert.equal(post.body.get("price"), "99");
|
|
397
|
+
assert.equal(post.body.get("isForSale"), "true");
|
|
398
|
+
const updated = await universe.handler(parse({ op: "sell", kind: "pass", itemId: "7", price: 150, confirm: true }));
|
|
399
|
+
assert.match(updated.content[0].text, /"action": ?"updated"/);
|
|
400
|
+
assert.ok(sent.some((entry) => entry.method === "PATCH" && entry.href.endsWith("/game-passes/v1/universes/10/game-passes/7")));
|
|
401
|
+
} finally {
|
|
402
|
+
globalThis.fetch = saved.fetch;
|
|
403
|
+
for (const key of ["ROBLOX_API_KEY", "ROBLOX_USER_ID", "ROBLOX_UNIVERSE_ID", "ROBLOX_PLACE_ID"]) {
|
|
404
|
+
if (saved.env[key] === undefined) delete process.env[key]; else process.env[key] = saved.env[key];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
process.stdout.write("universe products and sell: ok\n");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// datastore live set: keeps the entry's users and attributes, and sends its etag.
|
|
411
|
+
{
|
|
412
|
+
const { registerDataTools } = await import("../dist/tools/data.js");
|
|
413
|
+
registerDataTools(context);
|
|
414
|
+
const datastore = registered.get("datastore");
|
|
415
|
+
const saved = { fetch: globalThis.fetch, env: { ...process.env } };
|
|
416
|
+
Object.assign(process.env, { ROBLOX_API_KEY: "test-key", ROBLOX_USER_ID: "1", ROBLOX_UNIVERSE_ID: "10", ROBLOX_PLACE_ID: "20" });
|
|
417
|
+
context.bridge.call = async () => ({ placeId: 20 });
|
|
418
|
+
let patched;
|
|
419
|
+
globalThis.fetch = async (url, init) => {
|
|
420
|
+
const href = String(url);
|
|
421
|
+
if (href.includes("/universes/v1/places/")) return new Response(JSON.stringify({ universeId: 10 }), { status: 200 });
|
|
422
|
+
if ((init?.method ?? "GET") === "GET") return new Response(JSON.stringify({ value: { coins: 1 }, users: ["users/42"], attributes: { lock: "s1" }, etag: "e1" }), { status: 200 });
|
|
423
|
+
patched = JSON.parse(init.body);
|
|
424
|
+
return new Response(JSON.stringify({ revisionId: "r2" }), { status: 200 });
|
|
425
|
+
};
|
|
426
|
+
try {
|
|
427
|
+
const result = await datastore.handler(z.object(datastore.spec.inputSchema).parse({ target: "live", op: "set", store: "Players", key: "42", value: "{\"coins\":5}", confirm: true }));
|
|
428
|
+
assert.ok(!result.isError, result.content[0].text);
|
|
429
|
+
assert.deepEqual(patched, { value: { coins: 5 }, users: ["users/42"], attributes: { lock: "s1" }, etag: "e1" });
|
|
430
|
+
} finally {
|
|
431
|
+
globalThis.fetch = saved.fetch;
|
|
432
|
+
for (const key of ["ROBLOX_API_KEY", "ROBLOX_USER_ID", "ROBLOX_UNIVERSE_ID", "ROBLOX_PLACE_ID"]) {
|
|
433
|
+
if (saved.env[key] === undefined) delete process.env[key]; else process.env[key] = saved.env[key];
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
process.stdout.write("datastore live set keeps users and attributes: ok\n");
|
|
437
|
+
}
|