@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +38 -23
- 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/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/debug.js +2 -2
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/device.js +2 -2
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +7 -3
- 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/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 +3 -3
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/world.js +8 -8
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/LogBuffer.luau +7 -0
- package/plugin/src/Paths.luau +15 -4
- package/plugin/src/Phrase.luau +1 -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/Capture.luau +219 -3
- package/plugin/src/init.server.luau +0 -2
- package/scripts/test-tools.mjs +259 -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,259 @@
|
|
|
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
|
-
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
152
|
-
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
153
|
-
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
154
|
-
const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
|
|
155
|
-
registerDiscoverTools(context);
|
|
156
|
-
registerScriptTools(context);
|
|
157
|
-
registerScreenshotTools(context);
|
|
158
|
-
const editOps = [];
|
|
159
|
-
context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
|
|
160
|
-
context.bridge.call = async (op, params) => {
|
|
161
|
-
editOps.push(op);
|
|
162
|
-
if (op === "playtest.control") {
|
|
163
|
-
if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
|
|
164
|
-
return {changed:false,state:{playtestsAllowed:false}};
|
|
165
|
-
}
|
|
166
|
-
const results = {
|
|
167
|
-
"exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
|
|
168
|
-
"discover.tree": {items:[],total:0,offset:0},
|
|
169
|
-
"discover.inspect": {items:[],failures:[]},
|
|
170
|
-
"script.read": {items:[],failures:[]},
|
|
171
|
-
"viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
|
|
172
|
-
"capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
|
|
173
|
-
};
|
|
174
|
-
assert.ok(op in results, `unexpected edit-mode operation ${op}`);
|
|
175
|
-
return results[op];
|
|
176
|
-
};
|
|
177
|
-
for (const [name, args, expected] of [
|
|
178
|
-
["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
|
|
179
|
-
["tree",{},"discover.tree"],
|
|
180
|
-
["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
|
|
181
|
-
["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
|
|
182
|
-
["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
|
|
183
|
-
["screenshot",{},"capture.screenshot"],
|
|
184
|
-
]) {
|
|
185
|
-
const tool = registered.get(name);
|
|
186
|
-
const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
187
|
-
assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
|
|
188
|
-
assert.equal(editOps.at(-1),expected);
|
|
189
|
-
}
|
|
190
|
-
assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
|
|
191
|
-
context.bridge.call = normalCall;
|
|
192
|
-
process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
|
|
193
|
-
|
|
194
|
-
const { registerPerfTools } = await import("../dist/tools/perf.js");
|
|
195
|
-
registerPerfTools(context);
|
|
196
|
-
const consoleTool = registered.get("console");
|
|
197
|
-
const consoleArgs = z.object(consoleTool.spec.inputSchema).parse({target:"client",player:"Alice",studioId:"runtime",since:"cursor-1",limit:10});
|
|
198
|
-
context.bridge.call = async (op, params, options) => {
|
|
199
|
-
assert.equal(op, "perf.console");
|
|
200
|
-
assert.equal(params.target, "client");
|
|
201
|
-
assert.equal(params.player, "Alice");
|
|
202
|
-
assert.equal(params.since, "cursor-1");
|
|
203
|
-
assert.equal(options.studioId, "runtime");
|
|
204
|
-
return {items:[],total:0,dropped:0,evicted:2,nextCursor:"cursor-2",capturing:true};
|
|
205
|
-
};
|
|
206
|
-
const consoleResult = await consoleTool.handler(consoleArgs);
|
|
207
|
-
assert.match(consoleResult.content[0].text, /2 older lines were evicted/);
|
|
208
|
-
assert.match(consoleResult.content[0].text, /nextCursor: cursor-2/);
|
|
209
|
-
context.bridge.call = async () => ({
|
|
210
|
-
items: Array.from({length:60}, (_, index) => ({level:"print",message:`${index} ${"x".repeat(1000)}`})),
|
|
211
|
-
total:60,dropped:0,nextCursor:"cursor-3",
|
|
212
|
-
});
|
|
213
|
-
const boundedConsole = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
|
|
214
|
-
assert.ok(boundedConsole.content[0].text.length < 25_000);
|
|
215
|
-
assert.match(boundedConsole.content[0].text, /older matching lines omitted/);
|
|
216
|
-
process.stdout.write("client console schema and cursor forwarding: ok\n");
|
|
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
|
+
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
152
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
153
|
+
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
154
|
+
const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
|
|
155
|
+
registerDiscoverTools(context);
|
|
156
|
+
registerScriptTools(context);
|
|
157
|
+
registerScreenshotTools(context);
|
|
158
|
+
const editOps = [];
|
|
159
|
+
context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
|
|
160
|
+
context.bridge.call = async (op, params) => {
|
|
161
|
+
editOps.push(op);
|
|
162
|
+
if (op === "playtest.control") {
|
|
163
|
+
if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
|
|
164
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
165
|
+
}
|
|
166
|
+
const results = {
|
|
167
|
+
"exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
|
|
168
|
+
"discover.tree": {items:[],total:0,offset:0},
|
|
169
|
+
"discover.inspect": {items:[],failures:[]},
|
|
170
|
+
"script.read": {items:[],failures:[]},
|
|
171
|
+
"viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
|
|
172
|
+
"capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
|
|
173
|
+
};
|
|
174
|
+
assert.ok(op in results, `unexpected edit-mode operation ${op}`);
|
|
175
|
+
return results[op];
|
|
176
|
+
};
|
|
177
|
+
for (const [name, args, expected] of [
|
|
178
|
+
["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
|
|
179
|
+
["tree",{},"discover.tree"],
|
|
180
|
+
["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
|
|
181
|
+
["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
|
|
182
|
+
["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
|
|
183
|
+
["screenshot",{},"capture.screenshot"],
|
|
184
|
+
]) {
|
|
185
|
+
const tool = registered.get(name);
|
|
186
|
+
const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
187
|
+
assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
|
|
188
|
+
assert.equal(editOps.at(-1),expected);
|
|
189
|
+
}
|
|
190
|
+
assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
|
|
191
|
+
context.bridge.call = normalCall;
|
|
192
|
+
process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
|
|
193
|
+
|
|
194
|
+
const { registerPerfTools } = await import("../dist/tools/perf.js");
|
|
195
|
+
registerPerfTools(context);
|
|
196
|
+
const consoleTool = registered.get("console");
|
|
197
|
+
const consoleArgs = z.object(consoleTool.spec.inputSchema).parse({target:"client",player:"Alice",studioId:"runtime",since:"cursor-1",limit:10});
|
|
198
|
+
context.bridge.call = async (op, params, options) => {
|
|
199
|
+
assert.equal(op, "perf.console");
|
|
200
|
+
assert.equal(params.target, "client");
|
|
201
|
+
assert.equal(params.player, "Alice");
|
|
202
|
+
assert.equal(params.since, "cursor-1");
|
|
203
|
+
assert.equal(options.studioId, "runtime");
|
|
204
|
+
return {items:[],total:0,dropped:0,evicted:2,nextCursor:"cursor-2",capturing:true};
|
|
205
|
+
};
|
|
206
|
+
const consoleResult = await consoleTool.handler(consoleArgs);
|
|
207
|
+
assert.match(consoleResult.content[0].text, /2 older lines were evicted/);
|
|
208
|
+
assert.match(consoleResult.content[0].text, /nextCursor: cursor-2/);
|
|
209
|
+
context.bridge.call = async () => ({
|
|
210
|
+
items: Array.from({length:60}, (_, index) => ({level:"print",message:`${index} ${"x".repeat(1000)}`})),
|
|
211
|
+
total:60,dropped:0,nextCursor:"cursor-3",
|
|
212
|
+
});
|
|
213
|
+
const boundedConsole = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
|
|
214
|
+
assert.ok(boundedConsole.content[0].text.length < 25_000);
|
|
215
|
+
assert.match(boundedConsole.content[0].text, /older matching lines omitted/);
|
|
216
|
+
process.stdout.write("client console schema and cursor forwarding: ok\n");
|
|
217
|
+
|
|
218
|
+
// `create` must not advertise a recursive schema -- Gemini / Vertex AI reject a
|
|
219
|
+
// `$ref` with HTTP 400 -- yet a bad nested child is still refused by name.
|
|
220
|
+
{
|
|
221
|
+
const { registerInstanceTools } = await import("../dist/tools/instances.js");
|
|
222
|
+
const tools = new Map();
|
|
223
|
+
registerInstanceTools({ server: { registerTool(name, spec, handler) { tools.set(name, { spec, handler }); } }, bridge: { async call() { throw new Error("must not reach Studio"); } } });
|
|
224
|
+
const create = tools.get("create");
|
|
225
|
+
const schema = JSON.stringify(z.toJSONSchema(z.object(create.spec.inputSchema)));
|
|
226
|
+
assert.ok(!schema.includes("$ref") && !schema.includes("$defs") && !schema.includes("definitions"), "create schema is not recursive");
|
|
227
|
+
const bad = await create.handler(z.object(create.spec.inputSchema).parse({ instances: [{ parent: "Workspace", className: "Model", children: [{ className: "Part", children: [{ name: "NoClass" }] }] }] }));
|
|
228
|
+
assert.equal(bad.isError, true);
|
|
229
|
+
assert.match(bad.content[0].text, /BAD_PARAMS\] instances\[0\]\.children\[0\]\.children\[0\]\.className/);
|
|
230
|
+
process.stdout.write("create schema without recursion, nested validation: ok\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Compact JSON: short structures on one line, and always parsed back identical.
|
|
234
|
+
{
|
|
235
|
+
const { stringify } = await import("../dist/lib/format.js");
|
|
236
|
+
const value = { rows: [{ path: "Workspace.A", className: "Part" }], nested: { list: [1, "two", null, undefined], skip: undefined, when: new Date(0) }, long: "x".repeat(150) };
|
|
237
|
+
assert.deepEqual(JSON.parse(stringify(value)), JSON.parse(JSON.stringify(value)));
|
|
238
|
+
assert.match(stringify(value), /\n "rows": \[\{"path":"Workspace.A","className":"Part"\}\],\n/, "a short row stays on one line");
|
|
239
|
+
assert.equal(stringify([]), "[]");
|
|
240
|
+
assert.equal(stringify(undefined), "null");
|
|
241
|
+
process.stdout.write("compact json: ok\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Screenshot scaling: box filter averages everything under a pixel; enlarging copies blocks.
|
|
245
|
+
{
|
|
246
|
+
const { boxResample, upscaleNearest } = await import("../dist/lib/png.js");
|
|
247
|
+
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]);
|
|
248
|
+
assert.deepEqual([...boxResample(stripes, 4, 2, 2).rgb], [100,100,100, 100,100,100], "averages, never samples");
|
|
249
|
+
const W = 1661, H = 719, line = Buffer.alloc(W * H * 3);
|
|
250
|
+
for (let y = 0; y < H; y += 1) line[(y * W + 831) * 3] = 255;
|
|
251
|
+
const scaled = boxResample(line, W, H, 800);
|
|
252
|
+
assert.equal(scaled.width, 800);
|
|
253
|
+
assert.equal(scaled.height, 346);
|
|
254
|
+
let red = 0; for (let i = 0; i < scaled.rgb.length; i += 3) red = Math.max(red, scaled.rgb[i]);
|
|
255
|
+
assert.ok(red > 100, "a one-pixel line survives a 2x reduction");
|
|
256
|
+
assert.equal(boxResample(stripes, 4, 2, 8).width, 4, "never scales up");
|
|
257
|
+
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
|
+
process.stdout.write("screenshot scaling: ok\n");
|
|
259
|
+
}
|
package/dist/tools/anim.js
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { json, table, text, textOf } from "../lib/format.js";
|
|
3
|
-
import { defineTool } from "../lib/tool.js";
|
|
4
|
-
/** Downloads go to Roblox's asset servers, which are not always quick. */
|
|
5
|
-
const TIMEOUT_MS = 45_000;
|
|
6
|
-
export function registerAnimTools(context) {
|
|
7
|
-
const { bridge } = context;
|
|
8
|
-
defineTool(context, {
|
|
9
|
-
name: "animation",
|
|
10
|
-
title: "Read and build animations",
|
|
11
|
-
description: "Reads an animation's actual keyframes, and builds new ones that play " +
|
|
12
|
-
"immediately in the open Studio.\n\n" +
|
|
13
|
-
"Animations are the one part of a place no other tool can see. " +
|
|
14
|
-
"`inspect` on an Animation instance returns an asset id and stops there " +
|
|
15
|
-
"— the poses live on Roblox's servers. `read` downloads them, so you can " +
|
|
16
|
-
"answer 'how long is it', 'which joints does it move', and 'does it end " +
|
|
17
|
-
"where it started' without opening the Animation Editor and scrubbing.\n\n" +
|
|
18
|
-
"`read` takes an asset id, an rbxassetid:// string, or the path of an " +
|
|
19
|
-
"Animation instance in the place — whichever you already have. It reports " +
|
|
20
|
-
"which RIG the animation was made for, which is the thing most worth " +
|
|
21
|
-
"knowing: an R15 animation on an R6 character does nothing at all — no " +
|
|
22
|
-
"error, no movement — and the asset id gives no hint either way.\n\n" +
|
|
23
|
-
"`build` goes the other way: give it keyframes and it returns a content " +
|
|
24
|
-
"id you can put straight into an Animation's AnimationId. Nothing is " +
|
|
25
|
-
"uploaded and nothing is moderated — the id works in this Studio session " +
|
|
26
|
-
"and nowhere else, which makes it the right way to try an idea and the " +
|
|
27
|
-
"wrong way to ship one.\n\n" +
|
|
28
|
-
"`preview` puts an animation ONTO a rig in the open place and freezes it " +
|
|
29
|
-
"at a chosen moment, so `screenshot` can show you the pose. It works in " +
|
|
30
|
-
"edit mode — no playtest. Ask for several moments in turn to compare " +
|
|
31
|
-
"poses across the animation; the rig stays posed until `stop`.\n\n" +
|
|
32
|
-
"Poses are written the way a CFrame property is: \"0, 1, 0\" for a " +
|
|
33
|
-
"position, \"0, 1, 0 | 0, 45, 0\" to rotate as well.\n\n" +
|
|
34
|
-
"The id `build` returns is a bare hash, not an rbxassetid:// URL. Use it " +
|
|
35
|
-
"exactly as given — prefixing it stops it working.",
|
|
36
|
-
inputSchema: {
|
|
37
|
-
op: z
|
|
38
|
-
.enum(["read", "preview", "build", "stop"])
|
|
39
|
-
.default("read")
|
|
40
|
-
.describe("'read' downloads an existing animation, 'preview' poses a rig at one " +
|
|
41
|
-
"moment of it so you can screenshot it, 'build' makes a new one " +
|
|
42
|
-
"playable here, 'stop' clears a preview."),
|
|
43
|
-
assetId: z
|
|
44
|
-
.union([z.number(), z.string()])
|
|
45
|
-
.optional()
|
|
46
|
-
.describe('read only: animation asset id (12345), "rbxassetid://12345", or the ' +
|
|
47
|
-
'path of an Animation instance ("Workspace.Rig.Animate.run").'),
|
|
48
|
-
keyframes: z
|
|
49
|
-
.array(z.object({
|
|
50
|
-
time: z.number().min(0).describe("Seconds from the start of the animation."),
|
|
51
|
-
name: z.string().optional().describe('Keyframe name, e.g. "Start" or a marker name.'),
|
|
52
|
-
poses: z
|
|
53
|
-
.record(z.string(), z.string())
|
|
54
|
-
.optional()
|
|
55
|
-
.describe('Joint name → CFrame, e.g. { "Right Arm": "0, 0.5, 0 | 0, 0, 45" }. ' +
|
|
56
|
-
"Joint names must match the rig's parts."),
|
|
57
|
-
}))
|
|
58
|
-
.max(200)
|
|
59
|
-
.optional()
|
|
60
|
-
.describe("build only: the keyframes, in any order — they are sorted by time."),
|
|
61
|
-
name: z.string().optional().describe("build only: name for the sequence."),
|
|
62
|
-
root: z
|
|
63
|
-
.string()
|
|
64
|
-
.optional()
|
|
65
|
-
.describe('build only: the rig part every pose hangs from. Defaults to "HumanoidRootPart", ' +
|
|
66
|
-
"which is right for an R15 or R6 character."),
|
|
67
|
-
priority: z
|
|
68
|
-
.enum(["Idle", "Movement", "Action", "Core"])
|
|
69
|
-
.optional()
|
|
70
|
-
.describe("build only: which animations this one plays over."),
|
|
71
|
-
loop: z.boolean().optional().describe("build only: whether it repeats."),
|
|
72
|
-
rig: z
|
|
73
|
-
.string()
|
|
74
|
-
.optional()
|
|
75
|
-
.describe('The model, e.g. "Workspace.Dummy". For preview/stop it is the rig to ' +
|
|
76
|
-
"pose, and needs a Humanoid or an AnimationController inside it. For " +
|
|
77
|
-
"`build` it is optional and is read for its joint layout — pass it for " +
|
|
78
|
-
"anything that is not a standard R6 or R15 character (a custom rig, a " +
|
|
79
|
-
"weapon, a door, a Blender import), or the poses may be attached in the " +
|
|
80
|
-
"wrong order and the animation will move nothing."),
|
|
81
|
-
at: z
|
|
82
|
-
.number()
|
|
83
|
-
.min(0)
|
|
84
|
-
.optional()
|
|
85
|
-
.describe("preview only: the moment to freeze on, in seconds. Ask for several " +
|
|
86
|
-
"in turn to compare poses across the animation."),
|
|
87
|
-
hold: z
|
|
88
|
-
.boolean()
|
|
89
|
-
.default(true)
|
|
90
|
-
.describe("preview only: freeze on that frame. Turn off to let it play, but then " +
|
|
91
|
-
"a screenshot catches whatever pose it happens to be in."),
|
|
92
|
-
studioId: z.string().optional().describe("Target Studio; omit for the active one."),
|
|
93
|
-
},
|
|
94
|
-
readOnly: false,
|
|
95
|
-
destructive: false,
|
|
96
|
-
}, async (args) => {
|
|
97
|
-
if (args.op === "preview" || args.op === "stop") {
|
|
98
|
-
if (!args.rig)
|
|
99
|
-
return text("preview needs a `rig` — the model to pose.");
|
|
100
|
-
const posed = await bridge.call("anim.preview", { op: args.op, rig: args.rig, assetId: args.assetId, at: args.at, hold: args.hold }, { studioId: args.studioId, timeoutMs: TIMEOUT_MS });
|
|
101
|
-
return json(posed);
|
|
102
|
-
}
|
|
103
|
-
if (args.op === "build") {
|
|
104
|
-
if (!args.keyframes || args.keyframes.length === 0) {
|
|
105
|
-
return text("build needs a non-empty `keyframes` array.");
|
|
106
|
-
}
|
|
107
|
-
const built = await bridge.call("anim.build", {
|
|
108
|
-
keyframes: args.keyframes,
|
|
109
|
-
name: args.name,
|
|
110
|
-
root: args.root,
|
|
111
|
-
rig: args.rig,
|
|
112
|
-
priority: args.priority,
|
|
113
|
-
loop: args.loop,
|
|
114
|
-
}, { studioId: args.studioId, timeoutMs: TIMEOUT_MS });
|
|
115
|
-
return json(built, "Set this as an Animation's AnimationId with `modify`, exactly as it appears " +
|
|
116
|
-
"— it is a bare hash, and adding rbxassetid:// in front of it stops it " +
|
|
117
|
-
"working. Local to this Studio session: not uploaded, gone on restart.\n\n" +
|
|
118
|
-
"`hierarchy` says which joint layout the poses were nested against — the " +
|
|
119
|
-
"rig you named, or the R6/R15 standard guessed from the joint names. " +
|
|
120
|
-
"Anything in `unmatchedJoints` is a name that layout does not contain: " +
|
|
121
|
-
"those poses were attached to the root and will almost certainly do " +
|
|
122
|
-
"nothing. Pass `rig` to fix it.");
|
|
123
|
-
}
|
|
124
|
-
if (args.assetId === undefined) {
|
|
125
|
-
return text("read needs an `assetId` — a number, an rbxassetid:// string, or a path to an Animation.");
|
|
126
|
-
}
|
|
127
|
-
const found = await bridge.call("anim.read", { assetId: args.assetId }, { studioId: args.studioId, timeoutMs: TIMEOUT_MS });
|
|
128
|
-
/*
|
|
129
|
-
* A summary first, then the timeline. The three facts at the top are what
|
|
130
|
-
* the question was, and burying them under forty rows of keyframes would
|
|
131
|
-
* make the tool technically complete and practically unreadable.
|
|
132
|
-
*/
|
|
133
|
-
const lines = [
|
|
134
|
-
`${found.name ?? found.assetId} — ${found.rig} rig, ${found.duration}s, ` +
|
|
135
|
-
`${found.keyframeCount} keyframes, ${found.jointCount} joints moved`,
|
|
136
|
-
`Priority ${found.priority}${found.looping ? ", loops" : ", does not loop"}` +
|
|
137
|
-
(found.rigNote !== undefined ? ` — ${found.rigNote}` : ""),
|
|
138
|
-
"",
|
|
139
|
-
`Joints: ${found.joints.join(", ") || "none"}` +
|
|
140
|
-
(found.jointsSampled ? ` … and ${found.jointCount - found.joints.length} more` : ""),
|
|
141
|
-
];
|
|
142
|
-
if (found.rig === "R6" || found.rig === "R15") {
|
|
143
|
-
lines.push("", `This is an ${found.rig} animation. It will do NOTHING on an ${found.rig === "R6" ? "R15" : "R6"} character — no error, no movement, because the joint names do not exist ` +
|
|
144
|
-
"on the other rig. Check the character's rig type before using it.");
|
|
145
|
-
}
|
|
146
|
-
if (found.looping && found.endsWhereItStarted === false) {
|
|
147
|
-
lines.push("", "WARNING: this animation loops, but these joints do not end where they " +
|
|
148
|
-
`started: ${(found.driftingJoints ?? []).join(", ")}. That is what a ` +
|
|
149
|
-
"visible jump at the loop point looks like.");
|
|
150
|
-
}
|
|
151
|
-
lines.push("", textOf(table(["index", "time", "name", "poseCount"], found.keyframes, {
|
|
152
|
-
more: found.truncated
|
|
153
|
-
? `poses expanded for the first 40 keyframes only`
|
|
154
|
-
: undefined,
|
|
155
|
-
})));
|
|
156
|
-
return text(lines.join("\n"));
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
//# sourceMappingURL=anim.js.map
|