@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7
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 +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
package/dist/tools/world.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { grantAssets, publishPlace, uploadAsset } from "../lib/cloudassets.js";
|
|
3
|
+
import { ToolError } from "../lib/errors.js";
|
|
2
4
|
import { json, table, text, textOf } from "../lib/format.js";
|
|
5
|
+
import { assetQuotas, restartServers } from "../lib/liveops.js";
|
|
6
|
+
import { requireCredentials, requirePlace, requireUniverse } from "../lib/opencloud.js";
|
|
3
7
|
import { defineTool } from "../lib/tool.js";
|
|
4
8
|
/** Roblox's toolbox search. Public, unauthenticated, and the same index Studio's own asset browser uses. */
|
|
5
9
|
/** Segmentation runs the same slow generation backend `generate` does. */
|
|
@@ -15,28 +19,96 @@ const CATEGORIES = { model: 10, decal: 13, mesh: 40, audio: 3 };
|
|
|
15
19
|
* while a plugin making outbound HTTP needs the user to approve each domain in
|
|
16
20
|
* Plugin Management. Searching here means it works the moment the server starts.
|
|
17
21
|
*/
|
|
18
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Pages to pull before filtering gives up looking for more.
|
|
24
|
+
*
|
|
25
|
+
* Filtering happens here rather than at Roblox (see below), so a strict filter
|
|
26
|
+
* over one page of thirty can return nothing while the good results sit on page
|
|
27
|
+
* two. Four pages is enough to find script-free models for any ordinary search
|
|
28
|
+
* without turning one call into a crawl of the whole index.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_PAGES = 4;
|
|
31
|
+
/**
|
|
32
|
+
* Searches the Creator Store from the server rather than the plugin.
|
|
33
|
+
*
|
|
34
|
+
* Node already has internet access and these endpoints answer unauthenticated,
|
|
35
|
+
* while a plugin making outbound HTTP needs the user to approve each domain in
|
|
36
|
+
* Plugin Management. Searching here means it works the moment the server starts.
|
|
37
|
+
*
|
|
38
|
+
* Filtering and ranking are done HERE, on purpose. The endpoint accepts
|
|
39
|
+
* `sortType` and `creatorFilter` and ignores both — measured, Relevance,
|
|
40
|
+
* MostTaken, Favorited and Updated returned byte-identical results for the same
|
|
41
|
+
* keyword, as did a Roblox-only creator filter. Passing them through would have
|
|
42
|
+
* looked like sorting and done nothing, which is worse than not offering it. So
|
|
43
|
+
* the only server-side lever that works is the cursor, and everything else is
|
|
44
|
+
* decided from the details each result carries.
|
|
45
|
+
*/
|
|
46
|
+
async function searchCreatorStore(keyword, category, limit, filters = {}) {
|
|
19
47
|
const categoryId = CATEGORIES[category] ?? 10;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
const kept = [];
|
|
49
|
+
let cursor = "";
|
|
50
|
+
let scanned = 0;
|
|
51
|
+
let total;
|
|
52
|
+
for (let page = 0; page < MAX_PAGES && kept.length < limit; page += 1) {
|
|
53
|
+
const url = `${TOOLBOX_SEARCH}/${categoryId}?keyword=${encodeURIComponent(keyword)}` +
|
|
54
|
+
`&limit=30${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
55
|
+
const found = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
56
|
+
if (!found.ok) {
|
|
57
|
+
throw new Error(`Creator Store search failed (${found.status}). Roblox may be rate-limiting.`);
|
|
58
|
+
}
|
|
59
|
+
const results = (await found.json());
|
|
60
|
+
total = total ?? results.totalResults;
|
|
61
|
+
const ids = (results.data ?? []).map((entry) => entry.id);
|
|
62
|
+
if (ids.length === 0)
|
|
63
|
+
break;
|
|
64
|
+
scanned += ids.length;
|
|
65
|
+
// Search returns bare ids; everything worth showing — name, creator, script
|
|
66
|
+
// count, price — needs the second call.
|
|
67
|
+
const detailed = await fetch(`${TOOLBOX_DETAILS}?assetIds=${ids.join(",")}`, {
|
|
68
|
+
signal: AbortSignal.timeout(15_000),
|
|
69
|
+
});
|
|
70
|
+
if (!detailed.ok) {
|
|
71
|
+
throw new Error(`Could not read asset details (${detailed.status}).`);
|
|
72
|
+
}
|
|
73
|
+
const payload = (await detailed.json());
|
|
74
|
+
for (const entry of payload.data ?? []) {
|
|
75
|
+
if (filters.excludeScripts && entry.asset?.hasScripts)
|
|
76
|
+
continue;
|
|
77
|
+
if (filters.verifiedOnly && !entry.creator?.isVerifiedCreator)
|
|
78
|
+
continue;
|
|
79
|
+
if (filters.freeOnly && entry.fiatProduct?.isFree === false)
|
|
80
|
+
continue;
|
|
81
|
+
if (filters.minVotes !== undefined && (entry.voting?.voteCount ?? 0) < filters.minVotes) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (filters.maxTriangles !== undefined) {
|
|
85
|
+
const triangles = entry.asset?.modelTechnicalDetails?.objectMeshSummary?.triangles;
|
|
86
|
+
if (triangles !== undefined && triangles > filters.maxTriangles)
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
kept.push(entry);
|
|
90
|
+
}
|
|
91
|
+
cursor = results.nextPageCursor ?? "";
|
|
92
|
+
if (!cursor)
|
|
93
|
+
break;
|
|
37
94
|
}
|
|
38
|
-
|
|
39
|
-
|
|
95
|
+
/*
|
|
96
|
+
* Ranked by approval WEIGHTED BY how many people voted.
|
|
97
|
+
*
|
|
98
|
+
* The raw percentage is what the API gives and it is close to meaningless on
|
|
99
|
+
* its own: 82% of 5000 votes and 100% of 2 votes sort the wrong way round
|
|
100
|
+
* every time, and the second is the one nobody should be inserting into their
|
|
101
|
+
* game. Pulling the percentage toward 50 in proportion to how little evidence
|
|
102
|
+
* there is behind it costs nothing and puts the well-used models first.
|
|
103
|
+
*/
|
|
104
|
+
const score = (entry) => {
|
|
105
|
+
const percent = entry.voting?.upVotePercent ?? 50;
|
|
106
|
+
const votes = entry.voting?.voteCount ?? 0;
|
|
107
|
+
const confidence = votes / (votes + 50);
|
|
108
|
+
return 50 + (percent - 50) * confidence;
|
|
109
|
+
};
|
|
110
|
+
kept.sort((a, b) => score(b) - score(a));
|
|
111
|
+
return { items: kept.slice(0, limit), scanned, total };
|
|
40
112
|
}
|
|
41
113
|
export function registerWorldTools(context) {
|
|
42
114
|
const { bridge } = context;
|
|
@@ -70,15 +142,44 @@ export function registerWorldTools(context) {
|
|
|
70
142
|
"Roblox returns bare grey MeshParts, so a brick wall with a hole cut in " +
|
|
71
143
|
"it would otherwise come back as a grey slab - correct geometry that " +
|
|
72
144
|
"looks like a mistake.\n\n" +
|
|
145
|
+
"`mesh` reads the real triangle and vertex counts of MeshParts, which is " +
|
|
146
|
+
"the only way to tell a 40,000-triangle tree from a 400-triangle one — " +
|
|
147
|
+
"they are identical in the Explorer and in Properties, and the difference " +
|
|
148
|
+
"is whether the place runs on a phone. It also reports mesh size against " +
|
|
149
|
+
"part size: the same triangles stretched over a bigger object is the usual " +
|
|
150
|
+
"reason a model costs more than it looks like it should.\n\n" +
|
|
151
|
+
"`mesh` only works on meshes the signed-in Studio user or the experience " +
|
|
152
|
+
"owner OWNS. Roblox refuses to open anyone else's, so a model inserted " +
|
|
153
|
+
"from the Creator Store cannot be measured this way — the tool says which " +
|
|
154
|
+
"parts were skipped rather than failing the whole batch.\n\n" +
|
|
155
|
+
"`mirror` flips instances across a plane and has no engine API behind " +
|
|
156
|
+
"it — Studio simply cannot do this, which is why people ask for it. " +
|
|
157
|
+
"Mirroring about the middle of the selection is the default, because " +
|
|
158
|
+
"mirroring a building at x=200 about the world origin puts it 400 " +
|
|
159
|
+
"studs away rather than flipping it in place. It COPIES by default; " +
|
|
160
|
+
"pass `copy: false` to flip the originals. MeshParts move and rotate " +
|
|
161
|
+
"correctly but their meshes are not remade, so an asymmetric mesh " +
|
|
162
|
+
"still reads the same way round.\n\n" +
|
|
73
163
|
"`segment` runs Roblox's Cube model and takes tens of seconds; the rest " +
|
|
74
164
|
"are fast. Each call is one undo step.",
|
|
75
165
|
inputSchema: {
|
|
76
166
|
op: z
|
|
77
|
-
.enum(["union", "subtract", "intersect", "fragment", "sweep", "segment"])
|
|
167
|
+
.enum(["union", "subtract", "intersect", "fragment", "sweep", "segment", "mesh", "mirror"])
|
|
78
168
|
.describe("'union' merges, 'subtract' cuts `with` out of `path`, 'intersect' " +
|
|
79
169
|
"keeps only the overlap, 'fragment' shatters into debris, 'sweep' " +
|
|
80
170
|
"builds a motion volume, 'segment' cuts a mesh into named parts."),
|
|
81
171
|
path: z.string().describe("The part being operated on - the one cut from, for subtract."),
|
|
172
|
+
about: z
|
|
173
|
+
.string()
|
|
174
|
+
.optional()
|
|
175
|
+
.describe('mirror only: the plane position, e.g. "0, 0, 0". Defaults to ' +
|
|
176
|
+
"the middle of what is being mirrored, which flips it in place."),
|
|
177
|
+
copy: z
|
|
178
|
+
.boolean()
|
|
179
|
+
.optional()
|
|
180
|
+
.describe("mirror only: leave the originals and add mirrored copies. True by " +
|
|
181
|
+
"default — that is what builds a symmetrical structure from half " +
|
|
182
|
+
"of one. False flips the originals in place."),
|
|
82
183
|
with: z
|
|
83
184
|
.array(z.string())
|
|
84
185
|
.max(50)
|
|
@@ -117,7 +218,9 @@ export function registerWorldTools(context) {
|
|
|
117
218
|
axis: z
|
|
118
219
|
.string()
|
|
119
220
|
.optional()
|
|
120
|
-
.describe('sweep
|
|
221
|
+
.describe('sweep: axis to spin around, e.g. "0, 1, 0" (defaults to up). ' +
|
|
222
|
+
'mirror: which axis to flip across — "X", "Y" or "Z", ' +
|
|
223
|
+
"defaulting to X."),
|
|
121
224
|
pivot: z
|
|
122
225
|
.string()
|
|
123
226
|
.optional()
|
|
@@ -176,10 +279,41 @@ export function registerWorldTools(context) {
|
|
|
176
279
|
.boolean()
|
|
177
280
|
.default(false)
|
|
178
281
|
.describe("Return disconnected chunks as separate parts rather than one."),
|
|
282
|
+
paths: z
|
|
283
|
+
.array(z.string())
|
|
284
|
+
.max(50)
|
|
285
|
+
.optional()
|
|
286
|
+
.describe("mesh only: the MeshParts to read geometry from."),
|
|
179
287
|
studioId: z.string().optional().describe("Target Studio; omit for the active one."),
|
|
180
288
|
},
|
|
181
289
|
destructive: true,
|
|
182
290
|
}, async (args) => {
|
|
291
|
+
if (args.op === "mirror") {
|
|
292
|
+
const paths = args.paths ?? (args.path !== undefined ? [args.path] : []);
|
|
293
|
+
if (paths.length === 0) {
|
|
294
|
+
return text("mirror needs `paths` — the instances to flip.");
|
|
295
|
+
}
|
|
296
|
+
return json(await bridge.call("geometry.mirror", { paths, axis: args.axis, about: args.about, copy: args.copy }, { studioId: args.studioId, timeoutMs: 60_000 }));
|
|
297
|
+
}
|
|
298
|
+
if (args.op === "mesh") {
|
|
299
|
+
const paths = args.paths ?? (args.path !== undefined ? [args.path] : []);
|
|
300
|
+
if (paths.length === 0) {
|
|
301
|
+
return text('mesh needs `paths` — the MeshParts to read, e.g. ["Workspace.Tree"].');
|
|
302
|
+
}
|
|
303
|
+
const read = await bridge.call("geometry.mesh", { paths },
|
|
304
|
+
// Each part is a separate download-and-open, so a batch of twenty is
|
|
305
|
+
// twenty round trips to Roblox's asset servers.
|
|
306
|
+
{ studioId: args.studioId, timeoutMs: 120_000 });
|
|
307
|
+
if (read.items.length === 0) {
|
|
308
|
+
return text(read.failures.length > 0
|
|
309
|
+
? `Nothing readable:\n ${read.failures.join("\n ")}`
|
|
310
|
+
: "No MeshParts in that list.");
|
|
311
|
+
}
|
|
312
|
+
const rendered = textOf(table(["name", "triangles", "vertices", "meshSize", "partSize", "renderFidelity", "collisionFidelity"], read.items, { more: `${read.totalTriangles} triangles across ${read.items.length} part(s)` }));
|
|
313
|
+
return text(read.failures.length > 0
|
|
314
|
+
? `${rendered}\n\nSkipped:\n ${read.failures.join("\n ")}`
|
|
315
|
+
: rendered);
|
|
316
|
+
}
|
|
183
317
|
// `segment` is GenerationService, not GeometryService - the same job from
|
|
184
318
|
// the caller's side, a different service underneath, and far slower.
|
|
185
319
|
if (args.op === "segment") {
|
|
@@ -261,13 +395,32 @@ export function registerWorldTools(context) {
|
|
|
261
395
|
title: "Creator Store",
|
|
262
396
|
description: "Searches Roblox's Creator Store and inserts models into the place.\n\n" +
|
|
263
397
|
"`search` looks through the same public index Studio's own asset browser " +
|
|
264
|
-
"uses
|
|
265
|
-
"
|
|
266
|
-
"into the place by id.\n\n" +
|
|
398
|
+
"uses. It reports script COUNT, triangles, whether the creator is " +
|
|
399
|
+
"verified, whether the asset is free, and what Roblox thinks it is " +
|
|
400
|
+
"(\"Door/Furniture\"). `insert` puts one into the place by id.\n\n" +
|
|
401
|
+
"Results are ranked by approval WEIGHTED BY vote count, because the raw " +
|
|
402
|
+
"percentage lies: 100% from two voters outranks 82% from five thousand " +
|
|
403
|
+
"unless the count is taken into account. The vote count is shown beside " +
|
|
404
|
+
"the percentage for the same reason.\n\n" +
|
|
405
|
+
"Filters — `excludeScripts`, `maxTriangles`, `verifiedOnly`, `freeOnly`, " +
|
|
406
|
+
"`minVotes` — are applied here, not by Roblox, and several pages are " +
|
|
407
|
+
"fetched to fill the results. Roblox's own sort and creator filters are " +
|
|
408
|
+
"accepted by the endpoint and silently ignored, so they are not offered.\n\n" +
|
|
267
409
|
"ALWAYS check `hasScripts` before inserting. Free models carrying " +
|
|
268
410
|
"scripts are the oldest hazard on the platform, and a model dropped into " +
|
|
269
411
|
"someone's game can run whatever it likes. The insert reports the script " +
|
|
270
412
|
"count again, and names them, so it can still be undone.\n\n" +
|
|
413
|
+
"`peek` is the safer half of that: it loads the asset in memory WITHOUT " +
|
|
414
|
+
"putting it in the place and tells you exactly what is inside — every " +
|
|
415
|
+
"class, every script by name. Nothing is parented, so there is nothing " +
|
|
416
|
+
"to undo. Use it whenever `hasScripts` says YES and the model still " +
|
|
417
|
+
"looks worth having.\n\n" +
|
|
418
|
+
"Audio searches take a different path from everything else here. They go " +
|
|
419
|
+
"to the engine's own audio index, so results carry duration, artist and " +
|
|
420
|
+
"whether the clip is music or a sound effect — the fields that actually " +
|
|
421
|
+
"decide which sound you want. They return SOUND EFFECTS by default; pass " +
|
|
422
|
+
'`audioType: "Music"` for tracks. Filter with `minDuration` / ' +
|
|
423
|
+
"`maxDuration` — a footstep is under a second and a music bed is minutes.\n\n" +
|
|
271
424
|
"Only public assets can be inserted. A private or deleted id fails with " +
|
|
272
425
|
"a message saying so rather than inserting nothing quietly.\n\n" +
|
|
273
426
|
"`bake` is unrelated to the Creator Store and does not upload anything. " +
|
|
@@ -281,19 +434,162 @@ export function registerWorldTools(context) {
|
|
|
281
434
|
"against a RUNNING playtest server session: pass that `studioId`, and " +
|
|
282
435
|
"baking a mesh the game just built is what lets clients see it.\n\n" +
|
|
283
436
|
"It does not help `generate` at all. Generated meshes hold opaque " +
|
|
284
|
-
"content, which the engine refuses to bake
|
|
437
|
+
"content, which the engine refuses to bake.\n\n" +
|
|
438
|
+
"THE OTHER DIRECTION: `upload` sends a local file TO Roblox and " +
|
|
439
|
+
"gives you the asset id. Audio, an image, a 3D model or a video, " +
|
|
440
|
+
"picked by extension — .mp3/.ogg/.wav/.flac, .png/.jpg/.bmp/.tga, " +
|
|
441
|
+
".fbx/.gltf/.glb, .mp4/.mov. This closes the one hole nothing else " +
|
|
442
|
+
"here covers: a sound effect sitting in a folder on disk used to " +
|
|
443
|
+
"need Studio's import dialog before anything could reference it.\n\n" +
|
|
444
|
+
"Uploads are moderated and count against a real monthly quota. Do not " +
|
|
445
|
+
"guess what it is — Roblox's own guide and the live API disagree, and " +
|
|
446
|
+
"the account's verification level changes it. Ask `op=\"quota\"`. Do " +
|
|
447
|
+
"not upload speculatively, and do not re-upload to retry: the first " +
|
|
448
|
+
"one probably worked.\n\n" +
|
|
449
|
+
"`grant` gives a game or a person permission to use assets you own. " +
|
|
450
|
+
"You do NOT need this for your own assets in your own game — those " +
|
|
451
|
+
"always work. It is for a collaborator's place, or a group game you " +
|
|
452
|
+
"do not own. A grant to a game is PERMANENT; Roblox provides no way " +
|
|
453
|
+
"to revoke one, so it needs `confirm: true`.\n\n" +
|
|
454
|
+
"`publish` sends a .rbxl or .rbxlx from disk to a place. It SAVES a " +
|
|
455
|
+
"new version by default and only goes live with `confirm: true`. " +
|
|
456
|
+
"Note a real limitation: Roblox's publishing API does not update " +
|
|
457
|
+
"EditableImage, EditableMesh, PartOperation, SurfaceAppearance or " +
|
|
458
|
+
"BaseWrap instances, and reports success anyway — publish from " +
|
|
459
|
+
"Studio if the place uses any of those.\n\n" +
|
|
460
|
+
"Publishing alone does NOT move anyone already playing — they stay " +
|
|
461
|
+
"on their server running the old code until it empties. Pass " +
|
|
462
|
+
"`restart: true` to roll live servers onto the new version, which " +
|
|
463
|
+
"bleeds them off over 10 minutes rather than dropping players.\n\n" +
|
|
464
|
+
"`quota` reports how many uploads are left before Roblox starts " +
|
|
465
|
+
"refusing them, per asset type, read from the account itself. Check it " +
|
|
466
|
+
"before a batch rather than discovering the ceiling halfway through.\n\n" +
|
|
467
|
+
"All of these need an Open Cloud API key. The user sets it once by " +
|
|
468
|
+
"typing `cloud` in the Studio panel; never ask them to paste a key " +
|
|
469
|
+
"into this conversation.",
|
|
285
470
|
inputSchema: {
|
|
286
471
|
op: z
|
|
287
|
-
.enum(["search", "insert", "bake"])
|
|
288
|
-
.describe("'search' finds assets, '
|
|
289
|
-
"
|
|
472
|
+
.enum(["search", "peek", "insert", "bake", "upload", "grant", "publish", "quota"])
|
|
473
|
+
.describe("'search' finds assets, 'peek' shows what is inside one without " +
|
|
474
|
+
"inserting it, 'insert' adds one to the place, 'bake' makes " +
|
|
475
|
+
"in-memory mesh and image data replicate, 'upload' sends a " +
|
|
476
|
+
"local file to Roblox, 'grant' shares one you own with " +
|
|
477
|
+
"another game or person, 'publish' pushes a place file live."),
|
|
478
|
+
file: z
|
|
479
|
+
.string()
|
|
480
|
+
.optional()
|
|
481
|
+
.describe("upload/publish: path to the file on disk. Omit on `upload` to " +
|
|
482
|
+
"check whether the credentials are set up without sending " +
|
|
483
|
+
"anything."),
|
|
484
|
+
description: z
|
|
485
|
+
.string()
|
|
486
|
+
.optional()
|
|
487
|
+
.describe("upload only: public description. Moderated."),
|
|
488
|
+
assetType: z
|
|
489
|
+
.enum(["Audio", "Decal", "Model", "Video"])
|
|
490
|
+
.optional()
|
|
491
|
+
.describe("upload only: override the type derived from the extension. " +
|
|
492
|
+
"Rarely right — Roblox validates the type against the file's " +
|
|
493
|
+
"real content."),
|
|
494
|
+
insertAs: z
|
|
495
|
+
.string()
|
|
496
|
+
.optional()
|
|
497
|
+
.describe("upload only: put the finished asset in the place at this " +
|
|
498
|
+
"parent path once it is approved. Decals and Models only — an " +
|
|
499
|
+
"audio id belongs in an AudioPlayer, so use `audio " +
|
|
500
|
+
"op=\"graph\"` with the id this returns."),
|
|
501
|
+
assetIds: z
|
|
502
|
+
.array(z.number().int())
|
|
503
|
+
.max(50)
|
|
504
|
+
.optional()
|
|
505
|
+
.describe("grant only: the assets to share. You must own them."),
|
|
506
|
+
subjectType: z
|
|
507
|
+
.enum(["Universe", "User", "Group"])
|
|
508
|
+
.optional()
|
|
509
|
+
.describe("grant only: who gets access. 'Universe' is a game and is the " +
|
|
510
|
+
"usual one. Defaults to 'Universe'."),
|
|
511
|
+
subjectId: z
|
|
512
|
+
.string()
|
|
513
|
+
.optional()
|
|
514
|
+
.describe("grant only: the universe, user or group id. Omit for a " +
|
|
515
|
+
"Universe grant to use the one set with `cloud universe <id>`."),
|
|
516
|
+
universeId: z.string().optional().describe("publish only: which game. Omit to use `cloud universe`."),
|
|
517
|
+
placeId: z.string().optional().describe("publish only: which place. Omit to use `cloud place`."),
|
|
518
|
+
restart: z
|
|
519
|
+
.boolean()
|
|
520
|
+
.optional()
|
|
521
|
+
.describe("publish only: also roll live servers onto the new version. " +
|
|
522
|
+
"Without this, players already in a server keep running the " +
|
|
523
|
+
"old code until it empties."),
|
|
524
|
+
stripScripts: z
|
|
525
|
+
.boolean()
|
|
526
|
+
.optional()
|
|
527
|
+
.describe("insert only: delete every Script, LocalScript and ModuleScript " +
|
|
528
|
+
"from the asset on the way in. The safe way to take geometry " +
|
|
529
|
+
"from a free model without taking whatever its scripts do."),
|
|
530
|
+
confirm: z
|
|
531
|
+
.boolean()
|
|
532
|
+
.optional()
|
|
533
|
+
.describe("Required to make a `publish` go live rather than only save, " +
|
|
534
|
+
"and required for `grant`, whose effect Roblox cannot undo."),
|
|
290
535
|
keyword: z.string().optional().describe("search only: what to look for, e.g. \"medieval door\"."),
|
|
291
536
|
category: z
|
|
292
537
|
.enum(["model", "decal", "mesh", "audio"])
|
|
293
538
|
.default("model")
|
|
294
539
|
.describe("search only: what kind of asset. Only models insert as instances."),
|
|
295
540
|
limit: z.number().int().min(1).max(20).default(8).describe("search only: how many results."),
|
|
296
|
-
|
|
541
|
+
excludeScripts: z
|
|
542
|
+
.boolean()
|
|
543
|
+
.default(false)
|
|
544
|
+
.describe("search only: drop every result that contains scripts. The single " +
|
|
545
|
+
"safest filter — a free model's scripts run with your game's full " +
|
|
546
|
+
"permissions."),
|
|
547
|
+
maxTriangles: z
|
|
548
|
+
.number()
|
|
549
|
+
.int()
|
|
550
|
+
.min(1)
|
|
551
|
+
.optional()
|
|
552
|
+
.describe("search only: drop models heavier than this. A prop you place fifty " +
|
|
553
|
+
"times wants to be in the hundreds, not the tens of thousands."),
|
|
554
|
+
verifiedOnly: z
|
|
555
|
+
.boolean()
|
|
556
|
+
.default(false)
|
|
557
|
+
.describe("search only: only results from verified creators."),
|
|
558
|
+
freeOnly: z
|
|
559
|
+
.boolean()
|
|
560
|
+
.default(false)
|
|
561
|
+
.describe("search only: drop paid assets, which cannot just be inserted."),
|
|
562
|
+
minVotes: z
|
|
563
|
+
.number()
|
|
564
|
+
.int()
|
|
565
|
+
.min(0)
|
|
566
|
+
.optional()
|
|
567
|
+
.describe("search only: require at least this many votes. Filters out models " +
|
|
568
|
+
"with a perfect score from three people."),
|
|
569
|
+
minDuration: z
|
|
570
|
+
.number()
|
|
571
|
+
.min(0)
|
|
572
|
+
.optional()
|
|
573
|
+
.describe("audio search only: shortest clip to return, in seconds."),
|
|
574
|
+
maxDuration: z
|
|
575
|
+
.number()
|
|
576
|
+
.min(0)
|
|
577
|
+
.optional()
|
|
578
|
+
.describe("audio search only: longest clip to return, in seconds. Set it to 3 " +
|
|
579
|
+
"or so for effects — otherwise full-length music dominates the results."),
|
|
580
|
+
audioType: z
|
|
581
|
+
.enum(["SoundEffect", "Music"])
|
|
582
|
+
.default("SoundEffect")
|
|
583
|
+
.describe("audio search only. Defaults to SoundEffect, which is what a noise in " +
|
|
584
|
+
'a game is. Ask for "Music" only when you want a track — the engine\'s ' +
|
|
585
|
+
'own default is Music, and it makes "footstep" return three-minute ' +
|
|
586
|
+
"ambient songs with footsteps in the title."),
|
|
587
|
+
assetId: z
|
|
588
|
+
.number()
|
|
589
|
+
.int()
|
|
590
|
+
.positive()
|
|
591
|
+
.optional()
|
|
592
|
+
.describe("insert and peek only: the asset id."),
|
|
297
593
|
parent: z.string().optional().describe("insert only: where to put it. Defaults to Workspace."),
|
|
298
594
|
position: z
|
|
299
595
|
.string()
|
|
@@ -309,28 +605,215 @@ export function registerWorldTools(context) {
|
|
|
309
605
|
},
|
|
310
606
|
destructive: false,
|
|
311
607
|
}, async (args) => {
|
|
608
|
+
if (args.op === "peek") {
|
|
609
|
+
if (!args.assetId)
|
|
610
|
+
return text("peek needs an `assetId`.");
|
|
611
|
+
const inside = await bridge.call("assets.peek", { assetId: args.assetId }, { studioId: args.studioId, timeoutMs: 60_000 });
|
|
612
|
+
const lines = [
|
|
613
|
+
`Asset ${inside.assetId}: ${inside.descendants} instances, nothing inserted.`,
|
|
614
|
+
"",
|
|
615
|
+
`Top level: ${inside.roots.join(", ")}`,
|
|
616
|
+
"",
|
|
617
|
+
textOf(table(["className", "count"], inside.classes)),
|
|
618
|
+
];
|
|
619
|
+
lines.push(inside.scriptCount === 0
|
|
620
|
+
? "\nNo scripts — safe to insert."
|
|
621
|
+
: `\n${inside.scriptCount} script(s), and inserting runs them:\n ` +
|
|
622
|
+
inside.scripts.join("\n ") +
|
|
623
|
+
"\nRead them with `script_read` after inserting, or leave this asset alone.");
|
|
624
|
+
return text(lines.join("\n"));
|
|
625
|
+
}
|
|
626
|
+
if (args.op === "search" && args.category === "audio") {
|
|
627
|
+
if (!args.keyword)
|
|
628
|
+
return text("search needs a `keyword`.");
|
|
629
|
+
const found = await bridge.call("assets.audio", {
|
|
630
|
+
keyword: args.keyword,
|
|
631
|
+
limit: args.limit,
|
|
632
|
+
minDuration: args.minDuration,
|
|
633
|
+
maxDuration: args.maxDuration,
|
|
634
|
+
audioType: args.audioType,
|
|
635
|
+
}, { studioId: args.studioId, timeoutMs: 45_000 });
|
|
636
|
+
if (found.items.length === 0) {
|
|
637
|
+
return text(`No audio matched "${args.keyword}".` +
|
|
638
|
+
(args.minDuration !== undefined || args.maxDuration !== undefined
|
|
639
|
+
? " The duration filter may be too narrow — try it without one."
|
|
640
|
+
: ""));
|
|
641
|
+
}
|
|
642
|
+
return text(textOf(table(["assetId", "title", "artist", "duration", "audioType", "endorsed"], found.items, {
|
|
643
|
+
more: `${found.audioType}s only; duration in seconds; use the assetId as a ` +
|
|
644
|
+
"Sound's SoundId" +
|
|
645
|
+
(found.audioType === "SoundEffect"
|
|
646
|
+
? '. Pass audioType="Music" for tracks.'
|
|
647
|
+
: "."),
|
|
648
|
+
})));
|
|
649
|
+
}
|
|
312
650
|
if (args.op === "search") {
|
|
313
651
|
if (!args.keyword)
|
|
314
652
|
return text("search needs a `keyword`.");
|
|
315
|
-
const found = await searchCreatorStore(args.keyword, args.category, args.limit
|
|
316
|
-
|
|
317
|
-
|
|
653
|
+
const found = await searchCreatorStore(args.keyword, args.category, args.limit, {
|
|
654
|
+
excludeScripts: args.excludeScripts,
|
|
655
|
+
maxTriangles: args.maxTriangles,
|
|
656
|
+
verifiedOnly: args.verifiedOnly,
|
|
657
|
+
freeOnly: args.freeOnly,
|
|
658
|
+
minVotes: args.minVotes,
|
|
659
|
+
});
|
|
660
|
+
if (found.items.length === 0) {
|
|
661
|
+
const filtered = args.excludeScripts || args.verifiedOnly || args.freeOnly || args.maxTriangles || args.minVotes;
|
|
662
|
+
return text(`Nothing matched "${args.keyword}" in ${args.category}s` +
|
|
663
|
+
(filtered
|
|
664
|
+
? `. ${found.scanned} result(s) were checked and every one was filtered out — loosen a filter.`
|
|
665
|
+
: "."));
|
|
318
666
|
}
|
|
319
|
-
const rows = found.map((entry) => ({
|
|
667
|
+
const rows = found.items.map((entry) => ({
|
|
320
668
|
assetId: entry.asset?.id ?? 0,
|
|
321
669
|
name: entry.asset?.name ?? "?",
|
|
322
|
-
creator: entry.creator?.name ?? "?",
|
|
323
|
-
|
|
324
|
-
|
|
670
|
+
creator: (entry.creator?.name ?? "?") + (entry.creator?.isVerifiedCreator ? " ✓" : ""),
|
|
671
|
+
/*
|
|
672
|
+
* Votes shown beside the percentage, never alone. "100%" is what two
|
|
673
|
+
* friends upvoting looks like, and it sorts above a model used by
|
|
674
|
+
* thousands unless the count is on screen next to it.
|
|
675
|
+
*/
|
|
676
|
+
approval: entry.voting?.upVotePercent !== undefined
|
|
677
|
+
? `${entry.voting.upVotePercent}% (${entry.voting.voteCount ?? 0})`
|
|
678
|
+
: "—",
|
|
679
|
+
scripts: entry.asset?.hasScripts ? (entry.asset.scriptCount ?? "yes") : "no",
|
|
325
680
|
triangles: entry.asset?.modelTechnicalDetails?.objectMeshSummary?.triangles ?? "—",
|
|
681
|
+
free: entry.fiatProduct?.isFree === false ? "PAID" : "free",
|
|
682
|
+
kind: (entry.asset?.objectTypes ?? []).join("/") || "—",
|
|
683
|
+
}));
|
|
684
|
+
const risky = rows.filter((row) => row.scripts !== "no");
|
|
685
|
+
const paid = rows.filter((row) => row.free === "PAID");
|
|
686
|
+
const notes = [];
|
|
687
|
+
notes.push(`${found.items.length} shown of ${found.scanned} checked` +
|
|
688
|
+
(found.total !== undefined ? ` (${found.total} exist)` : "") +
|
|
689
|
+
"; ranked by approval weighted by vote count.");
|
|
690
|
+
if (risky.length > 0) {
|
|
691
|
+
notes.push(`${risky.length} contain scripts (${risky.map((r) => r.name).join(", ")}). ` +
|
|
692
|
+
"Inserting one runs whatever its author put in it — use `peek` to read them " +
|
|
693
|
+
"first, or pass excludeScripts.");
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
notes.push("None of these contain scripts.");
|
|
697
|
+
}
|
|
698
|
+
if (paid.length > 0) {
|
|
699
|
+
notes.push(`${paid.length} are PAID and cannot simply be inserted: ${paid
|
|
700
|
+
.map((r) => r.name)
|
|
701
|
+
.join(", ")}.`);
|
|
702
|
+
}
|
|
703
|
+
return text(textOf(table(["assetId", "name", "creator", "approval", "scripts", "triangles", "free", "kind"], rows)) +
|
|
704
|
+
"\n\n" +
|
|
705
|
+
notes.join("\n"));
|
|
706
|
+
}
|
|
707
|
+
if (args.op === "quota") {
|
|
708
|
+
return json(await assetQuotas(await requireCredentials()));
|
|
709
|
+
}
|
|
710
|
+
if (args.op === "upload") {
|
|
711
|
+
if (!args.file) {
|
|
712
|
+
const { credentialStatus } = await import("../lib/credentials.js");
|
|
713
|
+
const status = await credentialStatus();
|
|
714
|
+
if (!status.hasKey || status.creatorId === null) {
|
|
715
|
+
throw new ToolError("NO_CREDENTIALS", "No Open Cloud key is set up, so nothing can be uploaded.", "Ask the user to type `cloud` in the Studio panel \u2014 it walks " +
|
|
716
|
+
"through creating the key and stores it safely. Never ask them " +
|
|
717
|
+
"to paste a key into this conversation.");
|
|
718
|
+
}
|
|
719
|
+
return text(`Ready to upload: key set, uploading as ${status.creatorField} ` +
|
|
720
|
+
`${status.creatorId}. Call again with \`file\`.`);
|
|
721
|
+
}
|
|
722
|
+
const credentials = await requireCredentials();
|
|
723
|
+
const uploaded = await uploadAsset(credentials, {
|
|
724
|
+
file: args.file,
|
|
725
|
+
name: args.name,
|
|
726
|
+
description: args.description,
|
|
727
|
+
assetType: args.assetType,
|
|
728
|
+
});
|
|
729
|
+
const assetId = String(uploaded["assetId"]);
|
|
730
|
+
const approved = uploaded["approved"] === true;
|
|
731
|
+
/*
|
|
732
|
+
* Only inserted once Roblox says it is approved. Putting a rejected
|
|
733
|
+
* asset into the place leaves an instance pointing at nothing, which
|
|
734
|
+
* reads as a bug here rather than as a moderation decision.
|
|
735
|
+
*/
|
|
736
|
+
if (args.insertAs && approved) {
|
|
737
|
+
if (uploaded["assetType"] === "Audio") {
|
|
738
|
+
uploaded["inserted"] = false;
|
|
739
|
+
uploaded["note"] =
|
|
740
|
+
"Audio is not inserted on its own \u2014 the id goes into an " +
|
|
741
|
+
`AudioPlayer. Build one with \`audio op="graph" ` +
|
|
742
|
+
`asset="rbxassetid://${assetId}"\`.`;
|
|
743
|
+
}
|
|
744
|
+
else if (uploaded["assetType"] === "Decal") {
|
|
745
|
+
uploaded["inserted"] = await bridge.call("instances.create", {
|
|
746
|
+
instances: [
|
|
747
|
+
{
|
|
748
|
+
className: "Decal",
|
|
749
|
+
name: args.name ?? "Decal",
|
|
750
|
+
parent: args.insertAs,
|
|
751
|
+
properties: {
|
|
752
|
+
Texture: { value: `rbxassetid://${assetId}`, type: "ContentId" },
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
],
|
|
756
|
+
}, { studioId: args.studioId, timeoutMs: 30_000 });
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
uploaded["inserted"] = await bridge.call("assets.insert", { assetId: Number(assetId), parent: args.insertAs }, { studioId: args.studioId, timeoutMs: 90_000 });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return json(uploaded, approved
|
|
763
|
+
? `Use it as rbxassetid://${assetId}.`
|
|
764
|
+
: `Moderation says ${uploaded["moderation"]}. The id exists but may ` +
|
|
765
|
+
"not load until review finishes.");
|
|
766
|
+
}
|
|
767
|
+
if (args.op === "grant") {
|
|
768
|
+
if (!args.assetIds || args.assetIds.length === 0) {
|
|
769
|
+
throw new ToolError("BAD_PARAMS", "grant needs `assetIds`.");
|
|
770
|
+
}
|
|
771
|
+
const subjectType = args.subjectType ?? "Universe";
|
|
772
|
+
const subjectId = subjectType === "Universe"
|
|
773
|
+
? await requireUniverse(args.subjectId)
|
|
774
|
+
: args.subjectId;
|
|
775
|
+
if (!subjectId) {
|
|
776
|
+
throw new ToolError("BAD_PARAMS", `grant to a ${subjectType} needs a \`subjectId\`.`);
|
|
777
|
+
}
|
|
778
|
+
if (args.confirm !== true) {
|
|
779
|
+
throw new ToolError("NEEDS_CONFIRM", "Granting a game access to an asset is permanent.", "Roblox provides no way to revoke it. Pass confirm: true once you " +
|
|
780
|
+
"are sure of the asset ids and the subject.");
|
|
781
|
+
}
|
|
782
|
+
const credentials = await requireCredentials();
|
|
783
|
+
return json(await grantAssets(credentials, {
|
|
784
|
+
assetIds: args.assetIds,
|
|
785
|
+
subjectType,
|
|
786
|
+
subjectId,
|
|
326
787
|
}));
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
788
|
+
}
|
|
789
|
+
if (args.op === "publish") {
|
|
790
|
+
if (!args.file)
|
|
791
|
+
throw new ToolError("BAD_PARAMS", "publish needs a `file` (.rbxl or .rbxlx).");
|
|
792
|
+
const credentials = await requireCredentials();
|
|
793
|
+
const universeId = await requireUniverse(args.universeId);
|
|
794
|
+
const placeId = await requirePlace(args.placeId);
|
|
795
|
+
const published = await publishPlace(credentials, {
|
|
796
|
+
file: args.file,
|
|
797
|
+
universeId,
|
|
798
|
+
placeId,
|
|
799
|
+
publish: args.confirm === true,
|
|
800
|
+
});
|
|
801
|
+
/*
|
|
802
|
+
* Restarting a version that was only SAVED would roll servers onto
|
|
803
|
+
* the version before it, which is the opposite of what was asked
|
|
804
|
+
* for. So the two flags are checked together rather than
|
|
805
|
+
* separately.
|
|
806
|
+
*/
|
|
807
|
+
if (args.restart === true) {
|
|
808
|
+
published['restart'] =
|
|
809
|
+
args.confirm === true
|
|
810
|
+
? await restartServers(credentials, {
|
|
811
|
+
universeId,
|
|
812
|
+
placeIds: [Number(placeId)],
|
|
813
|
+
})
|
|
814
|
+
: "Not restarted: the file was only saved, not published. A restart now would roll servers onto the PREVIOUS version.";
|
|
815
|
+
}
|
|
816
|
+
return json(published);
|
|
334
817
|
}
|
|
335
818
|
if (args.op === "bake") {
|
|
336
819
|
if (!args.paths || args.paths.length === 0) {
|
|
@@ -366,6 +849,7 @@ export function registerWorldTools(context) {
|
|
|
366
849
|
parent: args.parent,
|
|
367
850
|
position: args.position,
|
|
368
851
|
name: args.name,
|
|
852
|
+
stripScripts: args.stripScripts,
|
|
369
853
|
},
|
|
370
854
|
// Downloading an asset goes out to Roblox and back.
|
|
371
855
|
{ studioId: args.studioId, timeoutMs: 90_000 });
|
|
@@ -428,14 +912,108 @@ export function registerWorldTools(context) {
|
|
|
428
912
|
"Groups are not undoable and not scoped to a session: `remove` when one " +
|
|
429
913
|
"was created to try something and is no longer wanted, rather than " +
|
|
430
914
|
"leaving it registered in the place indefinitely. The built-in " +
|
|
431
|
-
"\"Default\" group cannot be removed
|
|
915
|
+
"\"Default\" group cannot be removed.\n\n" +
|
|
916
|
+
"Groups belong to a world, not to the place. The Workspace is the " +
|
|
917
|
+
"default and is what nearly every question is about; a `WorldModel` " +
|
|
918
|
+
"inside a ViewportFrame keeps its own separate registry, so pass " +
|
|
919
|
+
"`worldModel` to reach that one. A group of the same name in each is " +
|
|
920
|
+
"two different groups.\n\n" +
|
|
921
|
+
"THE SAME TOOL ANSWERS WHAT IS ACTUALLY THERE. `cast` fires a ray, " +
|
|
922
|
+
"block or sphere and reports the first thing it meets — the part, the " +
|
|
923
|
+
"hit point, the surface normal, the material and the distance. " +
|
|
924
|
+
"`overlap` lists everything inside a box, a radius, or overlapping " +
|
|
925
|
+
"an existing part.\n\n" +
|
|
926
|
+
"That is the one question the Explorer cannot answer. A path tells " +
|
|
927
|
+
"you an instance exists and where its pivot sits; it does not tell " +
|
|
928
|
+
"you the door frame is clipping into the wall, that the spawn is " +
|
|
929
|
+
"buried a stud inside the floor, or that nothing stands between the " +
|
|
930
|
+
"turret and the player. Geometry wrong in exactly those ways looks " +
|
|
931
|
+
"perfect in `inspect`.\n\n" +
|
|
932
|
+
"The queries live here because they ARE collision queries: they " +
|
|
933
|
+
"honour the very groups the other half of this tool manages. A cast " +
|
|
934
|
+
"run in the wrong `collisionGroup` reports a clear path through a " +
|
|
935
|
+
"wall the player cannot walk through — a wrong answer " +
|
|
936
|
+
"indistinguishable from a right one. A miss comes back as " +
|
|
937
|
+
"`hit: false`, which is a real answer and usually the one being " +
|
|
938
|
+
"checked for.",
|
|
432
939
|
inputSchema: {
|
|
433
940
|
action: z
|
|
434
|
-
.enum(["list", "create", "assign", "collidable", "remove"])
|
|
941
|
+
.enum(["list", "create", "assign", "collidable", "remove", "cast", "overlap"])
|
|
435
942
|
.default("list")
|
|
436
|
-
.describe("'list' shows
|
|
437
|
-
"
|
|
438
|
-
"parts
|
|
943
|
+
.describe("Groups: 'list' shows them and changes nothing, then 'create', " +
|
|
944
|
+
"'assign', 'collidable', 'remove' (which unregisters a group " +
|
|
945
|
+
"entirely — not the same as un-assigning parts). Queries: " +
|
|
946
|
+
"'cast' fires a shape and reports the first hit, 'overlap' " +
|
|
947
|
+
"lists what is inside a volume."),
|
|
948
|
+
shape: z
|
|
949
|
+
.enum(["ray", "block", "sphere"])
|
|
950
|
+
.optional()
|
|
951
|
+
.describe("cast only: 'ray' is a line and the usual choice. 'block' and " +
|
|
952
|
+
"'sphere' sweep a volume along the same path — use them when " +
|
|
953
|
+
"the thing moving has width, e.g. whether a character fits " +
|
|
954
|
+
"through a gap rather than whether a point does."),
|
|
955
|
+
from: z.string().optional().describe('cast only: where the cast starts, e.g. "12, 0, 5".'),
|
|
956
|
+
to: z
|
|
957
|
+
.string()
|
|
958
|
+
.optional()
|
|
959
|
+
.describe("cast only: a point to aim at. Use this for sightlines — it saves " +
|
|
960
|
+
"working out a direction vector, which is where sign errors live."),
|
|
961
|
+
direction: z
|
|
962
|
+
.string()
|
|
963
|
+
.optional()
|
|
964
|
+
.describe('cast only: which way to go, e.g. "0, -1, 0" for down. Used with `distance`.'),
|
|
965
|
+
distance: z
|
|
966
|
+
.number()
|
|
967
|
+
.optional()
|
|
968
|
+
.describe("cast only: how far along `direction`. Defaults to 100."),
|
|
969
|
+
size: z
|
|
970
|
+
.string()
|
|
971
|
+
.optional()
|
|
972
|
+
.describe('cast shape="block" or overlap region="box": the volume size.'),
|
|
973
|
+
radius: z
|
|
974
|
+
.number()
|
|
975
|
+
.optional()
|
|
976
|
+
.describe('cast shape="sphere" or overlap region="radius": the radius.'),
|
|
977
|
+
region: z
|
|
978
|
+
.enum(["box", "radius", "part"])
|
|
979
|
+
.optional()
|
|
980
|
+
.describe("overlap only: 'box' and 'radius' need `at`; 'part' takes " +
|
|
981
|
+
"`path` and reports what overlaps that part — the fastest way " +
|
|
982
|
+
"to find things clipping through each other. Defaults to 'box'."),
|
|
983
|
+
at: z.string().optional().describe('overlap only: the centre, for region "box" or "radius".'),
|
|
984
|
+
path: z.string().optional().describe('overlap region="part" only: the part to test against.'),
|
|
985
|
+
only: z
|
|
986
|
+
.array(z.string())
|
|
987
|
+
.optional()
|
|
988
|
+
.describe("cast/overlap: consider ONLY these instances and their descendants."),
|
|
989
|
+
ignore: z
|
|
990
|
+
.array(z.string())
|
|
991
|
+
.optional()
|
|
992
|
+
.describe("cast/overlap: skip these and their descendants. The usual case " +
|
|
993
|
+
"is the character doing the looking, which otherwise blocks its " +
|
|
994
|
+
"own cast at zero distance."),
|
|
995
|
+
collisionGroup: z
|
|
996
|
+
.string()
|
|
997
|
+
.optional()
|
|
998
|
+
.describe("cast/overlap: run the query as if from a part in this group. " +
|
|
999
|
+
"Required for a truthful answer in any place that uses groups."),
|
|
1000
|
+
respectCanCollide: z
|
|
1001
|
+
.boolean()
|
|
1002
|
+
.optional()
|
|
1003
|
+
.describe("cast/overlap: skip parts with CanCollide off. Off by default, " +
|
|
1004
|
+
"matching the engine — leave it off to ask what is there, turn " +
|
|
1005
|
+
"it on to ask what would stop a player."),
|
|
1006
|
+
ignoreWater: z
|
|
1007
|
+
.boolean()
|
|
1008
|
+
.optional()
|
|
1009
|
+
.describe("cast only: pass through terrain water instead of hitting it."),
|
|
1010
|
+
limit: z
|
|
1011
|
+
.number()
|
|
1012
|
+
.int()
|
|
1013
|
+
.min(1)
|
|
1014
|
+
.max(500)
|
|
1015
|
+
.optional()
|
|
1016
|
+
.describe("overlap only: how many parts to list. Defaults to 50."),
|
|
439
1017
|
group: z.string().optional().describe("The group's name. Required for everything but list."),
|
|
440
1018
|
paths: z
|
|
441
1019
|
.array(z.string())
|
|
@@ -447,22 +1025,68 @@ export function registerWorldTools(context) {
|
|
|
447
1025
|
.boolean()
|
|
448
1026
|
.default(true)
|
|
449
1027
|
.describe("collidable only: whether the two groups collide. False makes them pass through."),
|
|
1028
|
+
worldModel: z
|
|
1029
|
+
.string()
|
|
1030
|
+
.optional()
|
|
1031
|
+
.describe("Path to a WorldModel whose own collision groups this call is " +
|
|
1032
|
+
"about, e.g. \"StarterGui.Preview.Viewport.WorldModel\". Omit " +
|
|
1033
|
+
"for the Workspace, which is what you want unless the parts in " +
|
|
1034
|
+
"question live inside a ViewportFrame."),
|
|
450
1035
|
studioId: z.string().optional().describe("Target Studio; omit for the active one."),
|
|
451
1036
|
},
|
|
452
1037
|
destructive: false,
|
|
453
1038
|
}, async (args) => {
|
|
1039
|
+
if (args.action === "cast" || args.action === "overlap") {
|
|
1040
|
+
const query = await bridge.call(args.action === "cast" ? "spatial.cast" : "spatial.overlap", {
|
|
1041
|
+
shape: args.shape,
|
|
1042
|
+
from: args.from,
|
|
1043
|
+
to: args.to,
|
|
1044
|
+
direction: args.direction,
|
|
1045
|
+
distance: args.distance,
|
|
1046
|
+
size: args.size,
|
|
1047
|
+
radius: args.radius,
|
|
1048
|
+
region: args.region,
|
|
1049
|
+
at: args.at,
|
|
1050
|
+
path: args.path,
|
|
1051
|
+
only: args.only,
|
|
1052
|
+
ignore: args.ignore,
|
|
1053
|
+
collisionGroup: args.collisionGroup,
|
|
1054
|
+
respectCanCollide: args.respectCanCollide,
|
|
1055
|
+
ignoreWater: args.ignoreWater,
|
|
1056
|
+
limit: args.limit,
|
|
1057
|
+
worldModel: args.worldModel,
|
|
1058
|
+
}, { studioId: args.studioId, timeoutMs: 30_000 });
|
|
1059
|
+
if (args.action === "cast" && query["hit"] === false) {
|
|
1060
|
+
return json(query, "Nothing was hit. If that is a surprise: a `direction` pointing " +
|
|
1061
|
+
"the wrong way, a `distance` shorter than the gap, or an " +
|
|
1062
|
+
"`only` filter excluding what you meant to find.");
|
|
1063
|
+
}
|
|
1064
|
+
return json(query);
|
|
1065
|
+
}
|
|
454
1066
|
const response = await bridge.call("world.collision", {
|
|
455
1067
|
action: args.action,
|
|
456
1068
|
group: args.group,
|
|
457
1069
|
paths: args.paths,
|
|
458
1070
|
with: args.with,
|
|
459
1071
|
collidable: args.collidable,
|
|
1072
|
+
worldModel: args.worldModel,
|
|
460
1073
|
}, { studioId: args.studioId });
|
|
461
1074
|
if (args.action === "list") {
|
|
462
1075
|
const groups = response.groups ?? [];
|
|
1076
|
+
const where = response.world ?? "Workspace";
|
|
463
1077
|
if (groups.length === 0)
|
|
464
|
-
return text(
|
|
465
|
-
return text(textOf(table(["name", "mask"], groups)
|
|
1078
|
+
return text(`No collision groups are registered in ${where}.`);
|
|
1079
|
+
return text(textOf(table(["name", "passes through", "mask"], groups.map((group) => ({
|
|
1080
|
+
name: group.name,
|
|
1081
|
+
"passes through": group.passesThrough ?? "?",
|
|
1082
|
+
mask: group.mask,
|
|
1083
|
+
})), {
|
|
1084
|
+
// The ceiling is low enough to hit, and a caller registering
|
|
1085
|
+
// groups in a loop has no other way to find out where it is.
|
|
1086
|
+
more: response.max === undefined
|
|
1087
|
+
? where
|
|
1088
|
+
: `${where}: ${groups.length} of ${response.max} groups used`,
|
|
1089
|
+
})));
|
|
466
1090
|
}
|
|
467
1091
|
return json(response);
|
|
468
1092
|
});
|