@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.8

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.
Files changed (46) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +290 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/audio.js +96 -0
  19. package/dist/tools/audio.js.map +1 -0
  20. package/dist/tools/data.js +133 -3
  21. package/dist/tools/data.js.map +1 -1
  22. package/dist/tools/exec.js +56 -1
  23. package/dist/tools/exec.js.map +1 -1
  24. package/dist/tools/scripts.js +124 -4
  25. package/dist/tools/scripts.js.map +1 -1
  26. package/dist/tools/spatial.js +135 -0
  27. package/dist/tools/spatial.js.map +1 -0
  28. package/dist/tools/universe.js +182 -0
  29. package/dist/tools/universe.js.map +1 -0
  30. package/dist/tools/upload.js +294 -0
  31. package/dist/tools/upload.js.map +1 -0
  32. package/dist/tools/world.js +361 -10
  33. package/dist/tools/world.js.map +1 -1
  34. package/package.json +74 -74
  35. package/plugin/src/Commands.luau +646 -622
  36. package/plugin/src/Config.luau +65 -65
  37. package/plugin/src/Phrase.luau +816 -766
  38. package/plugin/src/Prompt.luau +965 -961
  39. package/plugin/src/Secret.luau +86 -0
  40. package/plugin/src/Serialize.luau +759 -499
  41. package/plugin/src/handlers/Assets.luau +636 -587
  42. package/plugin/src/handlers/Audio.luau +411 -0
  43. package/plugin/src/handlers/Geometry.luau +722 -577
  44. package/plugin/src/handlers/Instances.luau +84 -4
  45. package/plugin/src/handlers/Spatial.luau +334 -0
  46. package/plugin/src/init.server.luau +883 -879
@@ -1,766 +1,816 @@
1
- --!strict
2
- --[[
3
- Turns a command into something a person can read at a glance.
4
-
5
- The console used to print the wire name: `-> script.edit`, `-> instances.create`.
6
- That tells you the plugin is alive and nothing else. Watching an agent work,
7
- the interesting question is always "what is it touching" -- and the answer is
8
- sitting right there in the parameters, one field away from being shown.
9
-
10
- So `script.edit` on ServerScriptService.KillBrick becomes "Edit KillBrick",
11
- and `instances.create` with eight children becomes "Create 8 instances in
12
- Workspace". The subject is what the user recognises; the op is what the
13
- protocol needed.
14
-
15
- Three rules hold this together:
16
-
17
- Name the subject, not the path. "KillBrick" is what someone called their
18
- script; "ServerScriptService.Systems.KillBrick" is where it lives, and it
19
- buries the recognisable part behind the part that never changes.
20
-
21
- Count when there are several. "Edit 4 scripts" is worth reading. Listing
22
- four paths in a 76-pixel-wide log line is not.
23
-
24
- Never invent. An unknown op falls back to a tidied version of its own name
25
- rather than a guess, because a console that lies about what ran is worse
26
- than one that reads awkwardly.
27
- ]]
28
-
29
- local Phrase = {}
30
-
31
- -- The tail of a dotted path is the bit a human named. Everything before it is
32
- -- Roblox's filing system.
33
- local function leaf(path: any): string?
34
- if typeof(path) ~= "string" or path == "" then
35
- return nil
36
- end
37
- local last = string.match(path, "([^%.]+)$")
38
- if last == nil or last == "" then
39
- return nil
40
- end
41
- -- Array indexing survives into the name -- "Part[3]" is meaningfully
42
- -- different from "Part" when there are several siblings.
43
- return last
44
- end
45
-
46
- local function count(value: any): number
47
- if typeof(value) ~= "table" then
48
- return 0
49
- end
50
- return #(value :: { any })
51
- end
52
-
53
- --[[
54
- "1 script" / "4 scripts", without the caller assembling it each time.
55
- ]]
56
- local function plural(n: number, singular: string, pluralForm: string?): string
57
- local word = if n == 1 then singular else (pluralForm or (singular .. "s"))
58
- return string.format("%d %s", n, word)
59
- end
60
-
61
- --[[
62
- Describes a list of paths: the name when there is one, a count when there are
63
- several. This is the shape most batch tools take.
64
- ]]
65
- local function subjectOf(paths: any, noun: string): string
66
- local total = count(paths)
67
- if total == 1 then
68
- local name = leaf((paths :: { any })[1])
69
- if name ~= nil then
70
- return name
71
- end
72
- end
73
- if total > 1 then
74
- return plural(total, noun)
75
- end
76
- return noun
77
- end
78
-
79
- --[[
80
- Capitalises an op fragment for the fallback. `snapshots` -> `Snapshots`.
81
- ]]
82
- local function titleCase(value: string): string
83
- return (string.upper(string.sub(value, 1, 1)) .. string.sub(value, 2))
84
- end
85
-
86
- type Describer = (params: { [string]: any }) -> string
87
-
88
- --[[
89
- One entry per op. Each reads its own parameters, because what identifies a
90
- call differs completely between them: a script edit is about a path, a
91
- playtest is about a mode, a find is about what was searched for.
92
- ]]
93
- local DESCRIBERS: { [string]: Describer } = {
94
- ["script.read"] = function(params)
95
- return "Read " .. subjectOf(params.paths, "script")
96
- end,
97
- ["script.edit"] = function(params)
98
- local edits = params.edits
99
- local total = count(edits)
100
- if total == 1 then
101
- local name = leaf((edits :: { any })[1].path)
102
- if name ~= nil then
103
- return "Edit " .. name
104
- end
105
- end
106
- -- Several edits often land on one script, and "Edit 3 scripts" would be
107
- -- wrong when it is three changes to one file.
108
- local seen: { [string]: boolean } = {}
109
- local distinct = 0
110
- for _, edit in (edits or {}) :: { { [string]: any } } do
111
- local name = leaf(edit.path)
112
- if name ~= nil and not seen[name] then
113
- seen[name] = true
114
- distinct += 1
115
- end
116
- end
117
- if distinct == 1 then
118
- for name in seen do
119
- return string.format("Edit %s (%s)", name, plural(total, "change"))
120
- end
121
- end
122
- return string.format("Edit %s", plural(distinct, "script"))
123
- end,
124
- ["script.create"] = function(params)
125
- local scripts = params.scripts
126
- if count(scripts) == 1 then
127
- local one = (scripts :: { any })[1]
128
- local className = if typeof(one.className) == "string" then one.className else "Script"
129
- return string.format("Create %s %s", one.name or "script", className)
130
- end
131
- return "Create " .. plural(count(scripts), "script")
132
- end,
133
- ["script.grep"] = function(params)
134
- local pattern = if typeof(params.pattern) == "string" then params.pattern else "?"
135
- return string.format('Search scripts for "%s"', pattern)
136
- end,
137
-
138
- --[[
139
- Names the class, not just whatever the caller happened to call the
140
- thing. "Create MainMenu in StarterGui" was true and useless -- a name is
141
- whatever someone typed, and it says nothing about what actually landed
142
- in the place. `className` is required by the tool itself, so it is
143
- always there; the chosen name rides along in quotes when it was given,
144
- the way script.grep already quotes a search term.
145
- ]]
146
- ["instances.create"] = function(params)
147
- local instances = params.instances
148
- local total = count(instances)
149
- if total == 1 then
150
- local one = (instances :: { any })[1]
151
- local className = if typeof(one.className) == "string" and one.className ~= ""
152
- then one.className
153
- else "instance"
154
- local subject = if typeof(one.name) == "string" and one.name ~= ""
155
- then string.format('%s "%s"', className, one.name)
156
- else className
157
- local where = leaf(one.parent)
158
- return if where ~= nil
159
- then string.format("Create %s in %s", subject, where)
160
- else "Create " .. subject
161
- end
162
- local where = if total > 0 then leaf((instances :: { any })[1].parent) else nil
163
- return if where ~= nil
164
- then string.format("Create %s in %s", plural(total, "instance"), where)
165
- else "Create " .. plural(total, "instance")
166
- end,
167
- ["instances.modify"] = function(params)
168
- local targets = params.targets
169
- -- Each target carries its own path list, so the honest count is the
170
- -- total across them rather than the number of entries.
171
- local total = 0
172
- local onlyName: string? = nil
173
- --[[
174
- Which properties/attributes actually changed, not just how many
175
- instances did. "Modify Sword" said something happened and nothing
176
- about what; a caller watching for "did the color change" had no way
177
- to tell this apart from a rename or an attribute flip.
178
- ]]
179
- local fields: { string } = {}
180
- local seenFields: { [string]: boolean } = {}
181
- local function noteFields(bag: any)
182
- if typeof(bag) ~= "table" then
183
- return
184
- end
185
- for key in bag :: { [string]: any } do
186
- if not seenFields[key] then
187
- seenFields[key] = true
188
- table.insert(fields, key)
189
- end
190
- end
191
- end
192
- for _, target in (targets or {}) :: { { [string]: any } } do
193
- local paths = count(target.paths)
194
- total += paths
195
- if paths >= 1 and onlyName == nil then
196
- onlyName = leaf((target.paths :: { any })[1])
197
- end
198
- noteFields(target.properties)
199
- noteFields(target.attributes)
200
- end
201
- if total == 1 and onlyName ~= nil then
202
- if #fields == 1 then
203
- return string.format("Set %s on %s", fields[1], onlyName)
204
- elseif #fields > 1 then
205
- return string.format("Modify %s (%s)", onlyName, table.concat(fields, ", "))
206
- end
207
- return "Modify " .. onlyName
208
- end
209
- if #fields == 1 then
210
- return string.format("Set %s on %s", fields[1], plural(total, "instance"))
211
- end
212
- return "Modify " .. plural(total, "instance")
213
- end,
214
- ["instances.delete"] = function(params)
215
- return "Delete " .. subjectOf(params.paths, "instance")
216
- end,
217
- ["instances.move"] = function(params)
218
- local items = params.items
219
- local total = count(items)
220
- local cloning = total > 0 and (items :: { any })[1].mode == "clone"
221
- local verb = if cloning then "Clone" else "Move"
222
- if total == 1 then
223
- local one = (items :: { any })[1]
224
- local what = leaf(one.path)
225
- local where = leaf(one.to)
226
- -- A rename in the same call changes what actually ends up in the
227
- -- place; "Clone Sword to Backpack" while it lands as "GoldSword"
228
- -- described an instance that was never created under that name.
229
- local rename = if typeof(one.name) == "string" and one.name ~= "" then one.name else nil
230
- if what ~= nil and where ~= nil then
231
- local base = string.format("%s %s to %s", verb, what, where)
232
- return if rename ~= nil then base .. " as " .. rename else base
233
- end
234
- if what ~= nil then
235
- return if rename ~= nil
236
- then string.format("%s %s as %s", verb, what, rename)
237
- else verb .. " " .. what
238
- end
239
- end
240
- return string.format("%s %s", verb, plural(total, "instance"))
241
- end,
242
-
243
- ["discover.tree"] = function(params)
244
- local where = leaf(params.path)
245
- return if where ~= nil then "Browse " .. where else "Browse the place"
246
- end,
247
- ["discover.inspect"] = function(params)
248
- --[[
249
- `inspect` (no explicit `properties`, not concise detail) and `modify`
250
- (when setting properties) both probe a class before doing the thing
251
- the caller actually asked for -- reading the properties that matter
252
- for it, or typing the values it is about to write. That probe is a
253
- second, genuine call to this same op, and unmarked it logged
254
- identically to the read it precedes: two lines reading "Inspect X"
255
- back to back, indistinguishable from asking twice by mistake.
256
- ]]
257
- if params.probeOnly == true then
258
- return "Check the class of " .. subjectOf(params.paths, "instance")
259
- end
260
- return "Inspect " .. subjectOf(params.paths, "instance")
261
- end,
262
- ["discover.find"] = function(params)
263
- if typeof(params.tag) == "string" and params.tag ~= "" then
264
- return string.format('Find things tagged "%s"', params.tag)
265
- end
266
- if typeof(params.nameContains) == "string" and params.nameContains ~= "" then
267
- return string.format('Find "%s"', params.nameContains)
268
- end
269
- if typeof(params.className) == "string" and params.className ~= "" then
270
- return string.format("Find every %s", params.className)
271
- end
272
- return "Search the place"
273
- end,
274
-
275
- ["debug.set"] = function(params)
276
- local breakpoints = params.breakpoints
277
- local total = count(breakpoints)
278
- if total == 1 then
279
- local one = (breakpoints :: { any })[1]
280
- local name = leaf(one.path)
281
- if name ~= nil then
282
- return string.format("Break on %s:%s", name, tostring(one.line))
283
- end
284
- end
285
- return "Set " .. plural(total, "breakpoint")
286
- end,
287
- ["debug.clear"] = function(params)
288
- local name = leaf(params.path)
289
- return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
290
- end,
291
- ["debug.snapshots"] = function()
292
- return "Read what the breakpoints caught"
293
- end,
294
- ["debug.exceptions"] = function(params)
295
- return string.format("Break on errors: %s", tostring(params.mode or "Unhandled"))
296
- end,
297
-
298
- ["perf.console"] = function(params)
299
- if typeof(params.level) == "string" and params.level ~= "" then
300
- return string.format("Read the %s log", params.level)
301
- end
302
- if typeof(params.pattern) == "string" and params.pattern ~= "" then
303
- return string.format('Search output for "%s"', params.pattern)
304
- end
305
- return "Read the output log"
306
- end,
307
- ["perf.snapshot"] = function()
308
- return "Measure performance"
309
- end,
310
- ["perf.profile"] = function(params)
311
- return string.format("Profile scripts for %ss", tostring(params.seconds or 5))
312
- end,
313
- ["perf.coverage"] = function(params)
314
- local total = count(params.enable)
315
- if total > 0 then
316
- return "Instrument " .. plural(total, "script")
317
- end
318
- return "Read line coverage"
319
- end,
320
-
321
- ["playtest.control"] = function(params)
322
- local op = tostring(params.op or "state")
323
- --[[
324
- `stop` ends a test by polling this from the *surviving* session every
325
- 400ms until the teardown settles, sometimes five or six times in two
326
- seconds. Labelled "Check the playtest" like an agent's own status
327
- check, that read as unexplained repetition with no `stop` line anywhere
328
- to explain it -- the actual stop went to the session that is by then
329
- gone, and its console went with it. This name is why: waiting for the
330
- teardown it just started, not asking again for no reason.
331
- ]]
332
- if op == "state" and params.waitingForStop == true then
333
- return "Wait for the playtest to stop"
334
- end
335
- if op == "play" then
336
- return "Start a playtest"
337
- elseif op == "run" then
338
- return "Run the place"
339
- elseif op == "multiplayer" then
340
- return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
341
- elseif op == "stop" then
342
- return "Stop the playtest"
343
- elseif op == "endTest" then
344
- return "End the test"
345
- end
346
- return "Check the playtest"
347
- end,
348
-
349
- ["exec.run"] = function(params)
350
- local source = if typeof(params.source) == "string" then params.source else ""
351
-
352
- --[[
353
- Says what the code is about, not what its first line says.
354
-
355
- This used to show the opening line verbatim, which produced entries
356
- like "Run: local GenerationService = game:GetServi..." -- a fragment
357
- of a variable declaration, cut mid-word, telling the reader nothing
358
- except that some code ran. The declaration is never the point of a
359
- snippet; it is the setup before it.
360
-
361
- So the services it touches stand in for it. That is the one thing a
362
- snippet reliably says about itself, it is short, and it is what
363
- someone glancing at the console actually wants: which part of Studio
364
- is being poked.
365
- ]]
366
- local services: { string } = {}
367
- local seen: { [string]: boolean } = {}
368
- for name in string.gmatch(source, 'GetService%("([%w_]+)"%)') do
369
- if not seen[name] then
370
- seen[name] = true
371
- table.insert(services, name)
372
- end
373
- end
374
-
375
- local lines = 0
376
- for _ in string.gmatch(source, "[^\r\n]+") do
377
- lines += 1
378
- end
379
-
380
- if #services == 1 then
381
- return "Run Luau on " .. services[1]
382
- elseif #services == 2 then
383
- return string.format("Run Luau on %s and %s", services[1], services[2])
384
- elseif #services > 2 then
385
- return string.format("Run Luau on %s and %d more", services[1], #services - 1)
386
- end
387
- return if lines <= 1 then "Run Luau" else string.format("Run Luau (%d lines)", lines)
388
- end,
389
-
390
- ["viewport.select"] = function(params)
391
- local total = count(params.paths)
392
- if total == 0 then
393
- return "Clear the selection"
394
- end
395
- return "Select " .. subjectOf(params.paths, "instance")
396
- end,
397
- ["viewport.raycast"] = function()
398
- return "Cast a ray"
399
- end,
400
-
401
- ["capture.screenshot"] = function()
402
- return "Take a screenshot"
403
- end,
404
- ["capture.playtestId"] = function()
405
- return "Capture the playtest view"
406
- end,
407
- ["capture.decode"] = function()
408
- return "Read back the capture"
409
- end,
410
-
411
- ["viewport.focus"] = function(params)
412
- local name = leaf(params.path)
413
- return if name ~= nil then "Look at " .. name else "Move the camera"
414
- end,
415
- ["viewport.camera"] = function()
416
- return "Move the camera"
417
- end,
418
-
419
- ["geometry.combine"] = function(params)
420
- local op = tostring(params.op or "union")
421
- local name = leaf(params.path)
422
- local verb = if op == "subtract" then "Cut" elseif op == "intersect" then "Intersect" else "Merge"
423
- if op == "subtract" and name ~= nil then
424
- local from = count(params.with)
425
- return string.format("Cut %s out of %s", plural(from, "shape"), name)
426
- end
427
- return if name ~= nil then string.format("%s %s", verb, name) else verb .. " parts"
428
- end,
429
- ["geometry.fragment"] = function(params)
430
- local name = leaf(params.path)
431
- return if name ~= nil
432
- then string.format("Shatter %s into %s", name, plural(tonumber(params.pieces) or 8, "piece"))
433
- else "Shatter a part"
434
- end,
435
-
436
- ["assets.insert"] = function(params)
437
- return string.format("Insert asset %s", tostring(params.assetId or "?"))
438
- end,
439
-
440
- ["world.history"] = function(params)
441
- local action = tostring(params.action or "status")
442
- if action == "status" then
443
- return "Check the undo history"
444
- end
445
- local steps = tonumber(params.steps) or 1
446
- return string.format("%s %s", if action == "undo" then "Undo" else "Redo", plural(steps, "step"))
447
- end,
448
- ["world.collision"] = function(params)
449
- local action = tostring(params.action or "list")
450
- local group = if typeof(params.group) == "string" then params.group else "?"
451
- if action == "list" then
452
- return "List collision groups"
453
- elseif action == "create" then
454
- return "Create collision group " .. group
455
- elseif action == "assign" then
456
- return string.format("Put %s in %s", plural(count(params.paths), "instance"), group)
457
- elseif action == "remove" then
458
- return "Remove collision group " .. group
459
- end
460
- return string.format("Set %s against %s", group, tostring(params.with or "?"))
461
- end,
462
-
463
- ["character.moveTo"] = function(params)
464
- local name = leaf(params.path)
465
- return if name ~= nil then "Walk to " .. name else string.format("Walk to %s", tostring(params.to or "?"))
466
- end,
467
- ["character.act"] = function(params)
468
- local action = tostring(params.action or "jump")
469
- return titleCase(action) .. " the character"
470
- end,
471
- ["character.state"] = function()
472
- return "Check the character"
473
- end,
474
-
475
- ["studio.status"] = function()
476
- return "Check Studio"
477
- end,
478
- ["studio.ping"] = function()
479
- return "Ping"
480
- end,
481
- ["input.send"] = function(params)
482
- local steps = params.steps
483
- local total = count(steps)
484
- if total == 1 then
485
- local step = (steps :: { any })[1]
486
- local kind = tostring(step.kind or "key")
487
- if kind == "key" then
488
- local action = tostring(step.action or "tap")
489
- local verb = if action == "press" then "Hold" elseif action == "release" then "Release" else "Press"
490
- return string.format("%s %s", verb, tostring(step.key or "?"))
491
- elseif kind == "click" then
492
- return string.format("Click (%s, %s)", tostring(step.x or "?"), tostring(step.y or "?"))
493
- elseif kind == "move" then
494
- return "Move the pointer"
495
- elseif kind == "text" then
496
- return string.format('Type "%s"', tostring(step.text or ""))
497
- end
498
- end
499
- return "Send " .. plural(total, "input step")
500
- end,
501
-
502
- ["api.describe"] = function(params)
503
- local className = params.className
504
- return if typeof(className) == "string" and className ~= ""
505
- then "Look up " .. className
506
- else "Look up a class"
507
- end,
508
- ["api.classes"] = function(params)
509
- local needle = params.contains
510
- return if typeof(needle) == "string" and needle ~= ""
511
- then string.format('Search classes for "%s"', needle)
512
- else "List classes"
513
- end,
514
-
515
- ["perf.scene"] = function(params)
516
- local section = params.section
517
- return if typeof(section) == "string" and section ~= ""
518
- then "Measure the " .. section
519
- else "Measure the scene"
520
- end,
521
-
522
- --[[
523
- Everything below was added after the first pass over this table, and each
524
- one spent time showing as the fallback -- "Anim preview", "Data set",
525
- "Terrain fill". That form is honest but says nothing about the subject,
526
- which is the whole point of this module: the panel is watched to see WHAT
527
- an agent is touching, and 26 of 72 operations were declining to say.
528
- ]]
529
- ["anim.read"] = function(params)
530
- local name = leaf(params.assetId)
531
- return if name ~= nil then "Read the animation " .. name else "Read an animation"
532
- end,
533
- ["anim.build"] = function(params)
534
- local frames = count(params.keyframes)
535
- return string.format("Build an animation from %s", plural(frames, "keyframe"))
536
- end,
537
- ["anim.preview"] = function(params)
538
- local rig = leaf(params.rig) or "a rig"
539
- if params.op == "stop" then
540
- return "Clear the pose on " .. rig
541
- end
542
- local at = tonumber(params.at)
543
- return if at ~= nil
544
- then string.format("Pose %s at %.2fs", rig, at)
545
- else "Pose " .. rig
546
- end,
547
-
548
- ["data.list"] = function(params)
549
- local store = params.store
550
- if typeof(store) == "string" and store ~= "" then
551
- return string.format("List the keys in %s", store)
552
- end
553
- return "List the data stores"
554
- end,
555
- ["data.get"] = function(params)
556
- return string.format("Read %s from %s", tostring(params.key), tostring(params.store))
557
- end,
558
- ["data.versions"] = function(params)
559
- return string.format("Read the history of %s", tostring(params.key))
560
- end,
561
- ["data.set"] = function(params)
562
- return string.format("SAVE over %s in %s", tostring(params.key), tostring(params.store))
563
- end,
564
- ["data.remove"] = function(params)
565
- return string.format("DELETE %s from %s", tostring(params.key), tostring(params.store))
566
- end,
567
-
568
- ["discover.tags"] = function(params)
569
- local where = leaf(params.path)
570
- return if where ~= nil then "List the tags used in " .. where else "List every tag in use"
571
- end,
572
-
573
- ["viewport.ui"] = function()
574
- return "Check the interface for layout faults"
575
- end,
576
- ["viewport.textbounds"] = function(params)
577
- local name = leaf(params.path)
578
- return if name ~= nil then "Measure the text in " .. name else "Measure some text"
579
- end,
580
-
581
- ["perf.audit"] = function()
582
- return "Look for references that point at nothing"
583
- end,
584
-
585
- ["assets.audio"] = function(params)
586
- local keyword = params.keyword
587
- return if typeof(keyword) == "string" and keyword ~= ""
588
- then string.format('Search audio for "%s"', keyword)
589
- else "Search the audio library"
590
- end,
591
- ["assets.peek"] = function(params)
592
- return string.format("Look inside asset %s without inserting it", tostring(params.assetId))
593
- end,
594
- ["assets.bake"] = function(params)
595
- return "Bake " .. subjectOf(params.paths, "instance")
596
- end,
597
-
598
- ["character.path"] = function(params)
599
- local target = leaf(params.toPath)
600
- return if target ~= nil then "Check the route to " .. target else "Check a route"
601
- end,
602
-
603
- ["device.network"] = function(params)
604
- local preset = params.preset
605
- return if typeof(preset) == "string" and preset ~= ""
606
- then string.format("Simulate a %s connection", preset)
607
- else "Shape the network connection"
608
- end,
609
-
610
- ["geometry.sweep"] = function(params)
611
- local name = leaf(params.path)
612
- return if name ~= nil then "Sweep the path of " .. name else "Sweep a motion volume"
613
- end,
614
- ["geometry.mesh"] = function(params)
615
- return "Read the geometry of " .. subjectOf(params.paths, "mesh")
616
- end,
617
-
618
- ["generate.model"] = function(params)
619
- local prompt = params.prompt
620
- return if typeof(prompt) == "string" and prompt ~= ""
621
- then string.format('Generate a model: "%s"', prompt)
622
- else "Generate a model"
623
- end,
624
- ["generate.segment"] = function(params)
625
- local name = leaf(params.path)
626
- return if name ~= nil then "Cut " .. name .. " into named parts" else "Segment a mesh"
627
- end,
628
-
629
- ["script.open"] = function(params)
630
- local name = leaf(params.path)
631
- return if name ~= nil then "Open " .. name .. " in the editor" else "Open a script"
632
- end,
633
-
634
- ["terrain.fill"] = function(params)
635
- --[[
636
- The material lives inside the shapes, not beside them.
637
-
638
- Written first against a top-level `material`, which this op has never
639
- taken, so it always fell through to the bare "Fill terrain" -- seen in
640
- the panel for a call that laid 50 voxels of sand. The parameters a
641
- describer reads have to be the ones the handler reads.
642
- ]]
643
- local shapes = params.shapes
644
- if typeof(shapes) == "table" and #(shapes :: { any }) > 0 then
645
- local list = shapes :: { any }
646
- local first = list[1]
647
- local material = if typeof(first) == "table" then first.material else nil
648
- local named = typeof(material) == "string" and material ~= ""
649
- if #list == 1 then
650
- return if named
651
- then string.format("Fill terrain with %s", material)
652
- else "Fill terrain"
653
- end
654
- return if named
655
- then string.format("Fill %s, starting with %s", plural(#list, "terrain shape"), material)
656
- else string.format("Fill %s", plural(#list, "terrain shape"))
657
- end
658
- return "Fill terrain"
659
- end,
660
- ["terrain.clear"] = function()
661
- return "CLEAR terrain"
662
- end,
663
- ["terrain.replace"] = function(params)
664
- return string.format(
665
- "Replace %s terrain with %s",
666
- tostring(params.from or "one material"),
667
- tostring(params.to or "another")
668
- )
669
- end,
670
- ["terrain.stats"] = function()
671
- return "Measure the terrain"
672
- end,
673
-
674
- ["device.list"] = function()
675
- return "List devices"
676
- end,
677
- ["device.set"] = function(params)
678
- local id = params.device
679
- return if typeof(id) == "string" and id ~= "" then "Emulate " .. id else "Emulate a device"
680
- end,
681
- ["device.stop"] = function()
682
- return "Stop emulating a device"
683
- end,
684
- ["device.state"] = function()
685
- return "Check the emulated device"
686
- end,
687
- }
688
-
689
- --[[
690
- What kind of work an op is, for anything that wants to colour it.
691
-
692
- Grouped by what it does to the place rather than by which handler owns it,
693
- because that is the distinction someone watching cares about: reading is
694
- safe and constant, writing changes their game, and running executes code.
695
- ]]
696
- local KINDS: { [string]: string } = {
697
- discover = "read",
698
- studio = "read",
699
- viewport = "read",
700
- capture = "read",
701
- instances = "write",
702
- geometry = "write",
703
- assets = "write",
704
- world = "write",
705
- exec = "run",
706
- playtest = "run",
707
- character = "run",
708
- -- Live keys and clicks into a running playtest are an action, not a read,
709
- -- and belong beside character/exec rather than defaulting to "read".
710
- input = "run",
711
- debug = "debug",
712
- perf = "debug",
713
- api = "read",
714
- device = "read",
715
- --[[
716
- Absent groups defaulted to "read", which is the wrong way round for a
717
- default: a missing entry made `terrain.clear` and `data.set` -- one of
718
- which erases the map and the other of which overwrites a player's save
719
- with no undo -- show in the panel with the same weight as an inspect.
720
- A kind is a warning, so the ones that change things are named here
721
- explicitly.
722
- ]]
723
- terrain = "write",
724
- generate = "write",
725
- anim = "write",
726
- data = "write",
727
- }
728
-
729
- function Phrase.kindOf(op: string): string
730
- local group = string.match(op, "^([^%.]+)%.") or op
731
- -- Reads within the script group, writes outside it: a script edit changes
732
- -- the game, a read or a grep does not, and they are far apart in weight.
733
- if group == "script" then
734
- local action = string.match(op, "%.(.+)$") or ""
735
- return if action == "read" or action == "grep" then "read" else "write"
736
- end
737
- return KINDS[group] or "read"
738
- end
739
-
740
- --[[
741
- The readable form of one command.
742
-
743
- Never throws and never yields: this runs on the path that logs every call,
744
- including the ones that are about to fail, and a console that errors while
745
- describing an error would take the diagnosis with it.
746
- ]]
747
- function Phrase.of(op: string, params: { [string]: any }?): string
748
- local describer = DESCRIBERS[op]
749
- if describer ~= nil then
750
- local ok, described = pcall(describer, params or {})
751
- if ok and typeof(described) == "string" and described ~= "" then
752
- return described
753
- end
754
- end
755
-
756
- -- Unknown op, or a describer that met a shape it did not expect. Tidy the
757
- -- wire name rather than inventing a subject for it: "instances.create"
758
- -- becomes "Instances create", which is honest about knowing no better.
759
- local group, action = string.match(op, "^([^%.]+)%.(.+)$")
760
- if group ~= nil and action ~= nil then
761
- return titleCase(group) .. " " .. action
762
- end
763
- return titleCase(op)
764
- end
765
-
766
- return Phrase
1
+ --!strict
2
+ --[[
3
+ Turns a command into something a person can read at a glance.
4
+
5
+ The console used to print the wire name: `-> script.edit`, `-> instances.create`.
6
+ That tells you the plugin is alive and nothing else. Watching an agent work,
7
+ the interesting question is always "what is it touching" -- and the answer is
8
+ sitting right there in the parameters, one field away from being shown.
9
+
10
+ So `script.edit` on ServerScriptService.KillBrick becomes "Edit KillBrick",
11
+ and `instances.create` with eight children becomes "Create 8 instances in
12
+ Workspace". The subject is what the user recognises; the op is what the
13
+ protocol needed.
14
+
15
+ Three rules hold this together:
16
+
17
+ Name the subject, not the path. "KillBrick" is what someone called their
18
+ script; "ServerScriptService.Systems.KillBrick" is where it lives, and it
19
+ buries the recognisable part behind the part that never changes.
20
+
21
+ Count when there are several. "Edit 4 scripts" is worth reading. Listing
22
+ four paths in a 76-pixel-wide log line is not.
23
+
24
+ Never invent. An unknown op falls back to a tidied version of its own name
25
+ rather than a guess, because a console that lies about what ran is worse
26
+ than one that reads awkwardly.
27
+ ]]
28
+
29
+ local Phrase = {}
30
+
31
+ -- The tail of a dotted path is the bit a human named. Everything before it is
32
+ -- Roblox's filing system.
33
+ local function leaf(path: any): string?
34
+ if typeof(path) ~= "string" or path == "" then
35
+ return nil
36
+ end
37
+ local last = string.match(path, "([^%.]+)$")
38
+ if last == nil or last == "" then
39
+ return nil
40
+ end
41
+ -- Array indexing survives into the name -- "Part[3]" is meaningfully
42
+ -- different from "Part" when there are several siblings.
43
+ return last
44
+ end
45
+
46
+ local function count(value: any): number
47
+ if typeof(value) ~= "table" then
48
+ return 0
49
+ end
50
+ return #(value :: { any })
51
+ end
52
+
53
+ --[[
54
+ "1 script" / "4 scripts", without the caller assembling it each time.
55
+ ]]
56
+ local function plural(n: number, singular: string, pluralForm: string?): string
57
+ local word = if n == 1 then singular else (pluralForm or (singular .. "s"))
58
+ return string.format("%d %s", n, word)
59
+ end
60
+
61
+ --[[
62
+ Describes a list of paths: the name when there is one, a count when there are
63
+ several. This is the shape most batch tools take.
64
+ ]]
65
+ local function subjectOf(paths: any, noun: string): string
66
+ local total = count(paths)
67
+ if total == 1 then
68
+ local name = leaf((paths :: { any })[1])
69
+ if name ~= nil then
70
+ return name
71
+ end
72
+ end
73
+ if total > 1 then
74
+ return plural(total, noun)
75
+ end
76
+ return noun
77
+ end
78
+
79
+ --[[
80
+ Capitalises an op fragment for the fallback. `snapshots` -> `Snapshots`.
81
+ ]]
82
+ local function titleCase(value: string): string
83
+ return (string.upper(string.sub(value, 1, 1)) .. string.sub(value, 2))
84
+ end
85
+
86
+ type Describer = (params: { [string]: any }) -> string
87
+
88
+ --[[
89
+ One entry per op. Each reads its own parameters, because what identifies a
90
+ call differs completely between them: a script edit is about a path, a
91
+ playtest is about a mode, a find is about what was searched for.
92
+ ]]
93
+ local DESCRIBERS: { [string]: Describer } = {
94
+ ["script.read"] = function(params)
95
+ return "Read " .. subjectOf(params.paths, "script")
96
+ end,
97
+ ["script.edit"] = function(params)
98
+ local edits = params.edits
99
+ local total = count(edits)
100
+ if total == 1 then
101
+ local name = leaf((edits :: { any })[1].path)
102
+ if name ~= nil then
103
+ return "Edit " .. name
104
+ end
105
+ end
106
+ -- Several edits often land on one script, and "Edit 3 scripts" would be
107
+ -- wrong when it is three changes to one file.
108
+ local seen: { [string]: boolean } = {}
109
+ local distinct = 0
110
+ for _, edit in (edits or {}) :: { { [string]: any } } do
111
+ local name = leaf(edit.path)
112
+ if name ~= nil and not seen[name] then
113
+ seen[name] = true
114
+ distinct += 1
115
+ end
116
+ end
117
+ if distinct == 1 then
118
+ for name in seen do
119
+ return string.format("Edit %s (%s)", name, plural(total, "change"))
120
+ end
121
+ end
122
+ return string.format("Edit %s", plural(distinct, "script"))
123
+ end,
124
+ ["script.create"] = function(params)
125
+ local scripts = params.scripts
126
+ if count(scripts) == 1 then
127
+ local one = (scripts :: { any })[1]
128
+ local className = if typeof(one.className) == "string" then one.className else "Script"
129
+ return string.format("Create %s %s", one.name or "script", className)
130
+ end
131
+ return "Create " .. plural(count(scripts), "script")
132
+ end,
133
+ ["script.grep"] = function(params)
134
+ local pattern = if typeof(params.pattern) == "string" then params.pattern else "?"
135
+ return string.format('Search scripts for "%s"', pattern)
136
+ end,
137
+
138
+ --[[
139
+ Names the class, not just whatever the caller happened to call the
140
+ thing. "Create MainMenu in StarterGui" was true and useless -- a name is
141
+ whatever someone typed, and it says nothing about what actually landed
142
+ in the place. `className` is required by the tool itself, so it is
143
+ always there; the chosen name rides along in quotes when it was given,
144
+ the way script.grep already quotes a search term.
145
+ ]]
146
+ ["instances.create"] = function(params)
147
+ local instances = params.instances
148
+ local total = count(instances)
149
+ if total == 1 then
150
+ local one = (instances :: { any })[1]
151
+ local className = if typeof(one.className) == "string" and one.className ~= ""
152
+ then one.className
153
+ else "instance"
154
+ local subject = if typeof(one.name) == "string" and one.name ~= ""
155
+ then string.format('%s "%s"', className, one.name)
156
+ else className
157
+ local where = leaf(one.parent)
158
+ return if where ~= nil
159
+ then string.format("Create %s in %s", subject, where)
160
+ else "Create " .. subject
161
+ end
162
+ local where = if total > 0 then leaf((instances :: { any })[1].parent) else nil
163
+ return if where ~= nil
164
+ then string.format("Create %s in %s", plural(total, "instance"), where)
165
+ else "Create " .. plural(total, "instance")
166
+ end,
167
+ ["instances.modify"] = function(params)
168
+ local targets = params.targets
169
+ -- Each target carries its own path list, so the honest count is the
170
+ -- total across them rather than the number of entries.
171
+ local total = 0
172
+ local onlyName: string? = nil
173
+ --[[
174
+ Which properties/attributes actually changed, not just how many
175
+ instances did. "Modify Sword" said something happened and nothing
176
+ about what; a caller watching for "did the color change" had no way
177
+ to tell this apart from a rename or an attribute flip.
178
+ ]]
179
+ local fields: { string } = {}
180
+ local seenFields: { [string]: boolean } = {}
181
+ local function noteFields(bag: any)
182
+ if typeof(bag) ~= "table" then
183
+ return
184
+ end
185
+ for key in bag :: { [string]: any } do
186
+ if not seenFields[key] then
187
+ seenFields[key] = true
188
+ table.insert(fields, key)
189
+ end
190
+ end
191
+ end
192
+ for _, target in (targets or {}) :: { { [string]: any } } do
193
+ local paths = count(target.paths)
194
+ total += paths
195
+ if paths >= 1 and onlyName == nil then
196
+ onlyName = leaf((target.paths :: { any })[1])
197
+ end
198
+ noteFields(target.properties)
199
+ noteFields(target.attributes)
200
+ end
201
+ if total == 1 and onlyName ~= nil then
202
+ if #fields == 1 then
203
+ return string.format("Set %s on %s", fields[1], onlyName)
204
+ elseif #fields > 1 then
205
+ return string.format("Modify %s (%s)", onlyName, table.concat(fields, ", "))
206
+ end
207
+ return "Modify " .. onlyName
208
+ end
209
+ if #fields == 1 then
210
+ return string.format("Set %s on %s", fields[1], plural(total, "instance"))
211
+ end
212
+ return "Modify " .. plural(total, "instance")
213
+ end,
214
+ ["instances.delete"] = function(params)
215
+ return "Delete " .. subjectOf(params.paths, "instance")
216
+ end,
217
+ ["instances.move"] = function(params)
218
+ local items = params.items
219
+ local total = count(items)
220
+ local cloning = total > 0 and (items :: { any })[1].mode == "clone"
221
+ local verb = if cloning then "Clone" else "Move"
222
+ if total == 1 then
223
+ local one = (items :: { any })[1]
224
+ local what = leaf(one.path)
225
+ local where = leaf(one.to)
226
+ -- A rename in the same call changes what actually ends up in the
227
+ -- place; "Clone Sword to Backpack" while it lands as "GoldSword"
228
+ -- described an instance that was never created under that name.
229
+ local rename = if typeof(one.name) == "string" and one.name ~= "" then one.name else nil
230
+ if what ~= nil and where ~= nil then
231
+ local base = string.format("%s %s to %s", verb, what, where)
232
+ return if rename ~= nil then base .. " as " .. rename else base
233
+ end
234
+ if what ~= nil then
235
+ return if rename ~= nil
236
+ then string.format("%s %s as %s", verb, what, rename)
237
+ else verb .. " " .. what
238
+ end
239
+ end
240
+ return string.format("%s %s", verb, plural(total, "instance"))
241
+ end,
242
+
243
+ ["discover.tree"] = function(params)
244
+ local where = leaf(params.path)
245
+ return if where ~= nil then "Browse " .. where else "Browse the place"
246
+ end,
247
+ ["discover.inspect"] = function(params)
248
+ --[[
249
+ `inspect` (no explicit `properties`, not concise detail) and `modify`
250
+ (when setting properties) both probe a class before doing the thing
251
+ the caller actually asked for -- reading the properties that matter
252
+ for it, or typing the values it is about to write. That probe is a
253
+ second, genuine call to this same op, and unmarked it logged
254
+ identically to the read it precedes: two lines reading "Inspect X"
255
+ back to back, indistinguishable from asking twice by mistake.
256
+ ]]
257
+ if params.probeOnly == true then
258
+ return "Check the class of " .. subjectOf(params.paths, "instance")
259
+ end
260
+ return "Inspect " .. subjectOf(params.paths, "instance")
261
+ end,
262
+ ["discover.find"] = function(params)
263
+ if typeof(params.tag) == "string" and params.tag ~= "" then
264
+ return string.format('Find things tagged "%s"', params.tag)
265
+ end
266
+ if typeof(params.nameContains) == "string" and params.nameContains ~= "" then
267
+ return string.format('Find "%s"', params.nameContains)
268
+ end
269
+ if typeof(params.className) == "string" and params.className ~= "" then
270
+ return string.format("Find every %s", params.className)
271
+ end
272
+ return "Search the place"
273
+ end,
274
+
275
+ ["debug.set"] = function(params)
276
+ local breakpoints = params.breakpoints
277
+ local total = count(breakpoints)
278
+ if total == 1 then
279
+ local one = (breakpoints :: { any })[1]
280
+ local name = leaf(one.path)
281
+ if name ~= nil then
282
+ return string.format("Break on %s:%s", name, tostring(one.line))
283
+ end
284
+ end
285
+ return "Set " .. plural(total, "breakpoint")
286
+ end,
287
+ ["debug.clear"] = function(params)
288
+ local name = leaf(params.path)
289
+ return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
290
+ end,
291
+ ["debug.snapshots"] = function()
292
+ return "Read what the breakpoints caught"
293
+ end,
294
+ ["debug.exceptions"] = function(params)
295
+ return string.format("Break on errors: %s", tostring(params.mode or "Unhandled"))
296
+ end,
297
+
298
+ ["perf.console"] = function(params)
299
+ if typeof(params.level) == "string" and params.level ~= "" then
300
+ return string.format("Read the %s log", params.level)
301
+ end
302
+ if typeof(params.pattern) == "string" and params.pattern ~= "" then
303
+ return string.format('Search output for "%s"', params.pattern)
304
+ end
305
+ return "Read the output log"
306
+ end,
307
+ ["perf.snapshot"] = function()
308
+ return "Measure performance"
309
+ end,
310
+ ["perf.profile"] = function(params)
311
+ return string.format("Profile scripts for %ss", tostring(params.seconds or 5))
312
+ end,
313
+ ["perf.coverage"] = function(params)
314
+ local total = count(params.enable)
315
+ if total > 0 then
316
+ return "Instrument " .. plural(total, "script")
317
+ end
318
+ return "Read line coverage"
319
+ end,
320
+
321
+ ["playtest.control"] = function(params)
322
+ local op = tostring(params.op or "state")
323
+ --[[
324
+ `stop` ends a test by polling this from the *surviving* session every
325
+ 400ms until the teardown settles, sometimes five or six times in two
326
+ seconds. Labelled "Check the playtest" like an agent's own status
327
+ check, that read as unexplained repetition with no `stop` line anywhere
328
+ to explain it -- the actual stop went to the session that is by then
329
+ gone, and its console went with it. This name is why: waiting for the
330
+ teardown it just started, not asking again for no reason.
331
+ ]]
332
+ if op == "state" and params.waitingForStop == true then
333
+ return "Wait for the playtest to stop"
334
+ end
335
+ if op == "play" then
336
+ return "Start a playtest"
337
+ elseif op == "run" then
338
+ return "Run the place"
339
+ elseif op == "multiplayer" then
340
+ return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
341
+ elseif op == "stop" then
342
+ return "Stop the playtest"
343
+ elseif op == "endTest" then
344
+ return "End the test"
345
+ end
346
+ return "Check the playtest"
347
+ end,
348
+
349
+ ["exec.run"] = function(params)
350
+ local source = if typeof(params.source) == "string" then params.source else ""
351
+
352
+ --[[
353
+ Says what the code is about, not what its first line says.
354
+
355
+ This used to show the opening line verbatim, which produced entries
356
+ like "Run: local GenerationService = game:GetServi..." -- a fragment
357
+ of a variable declaration, cut mid-word, telling the reader nothing
358
+ except that some code ran. The declaration is never the point of a
359
+ snippet; it is the setup before it.
360
+
361
+ So the services it touches stand in for it. That is the one thing a
362
+ snippet reliably says about itself, it is short, and it is what
363
+ someone glancing at the console actually wants: which part of Studio
364
+ is being poked.
365
+ ]]
366
+ local services: { string } = {}
367
+ local seen: { [string]: boolean } = {}
368
+ for name in string.gmatch(source, 'GetService%("([%w_]+)"%)') do
369
+ if not seen[name] then
370
+ seen[name] = true
371
+ table.insert(services, name)
372
+ end
373
+ end
374
+
375
+ local lines = 0
376
+ for _ in string.gmatch(source, "[^\r\n]+") do
377
+ lines += 1
378
+ end
379
+
380
+ if #services == 1 then
381
+ return "Run Luau on " .. services[1]
382
+ elseif #services == 2 then
383
+ return string.format("Run Luau on %s and %s", services[1], services[2])
384
+ elseif #services > 2 then
385
+ return string.format("Run Luau on %s and %d more", services[1], #services - 1)
386
+ end
387
+ return if lines <= 1 then "Run Luau" else string.format("Run Luau (%d lines)", lines)
388
+ end,
389
+
390
+ ["viewport.select"] = function(params)
391
+ local total = count(params.paths)
392
+ if total == 0 then
393
+ return "Clear the selection"
394
+ end
395
+ return "Select " .. subjectOf(params.paths, "instance")
396
+ end,
397
+ ["viewport.raycast"] = function()
398
+ return "Cast a ray"
399
+ end,
400
+
401
+ ["capture.screenshot"] = function()
402
+ return "Take a screenshot"
403
+ end,
404
+ ["capture.playtestId"] = function()
405
+ return "Capture the playtest view"
406
+ end,
407
+ ["capture.decode"] = function()
408
+ return "Read back the capture"
409
+ end,
410
+
411
+ ["viewport.focus"] = function(params)
412
+ local name = leaf(params.path)
413
+ return if name ~= nil then "Look at " .. name else "Move the camera"
414
+ end,
415
+ ["viewport.camera"] = function()
416
+ return "Move the camera"
417
+ end,
418
+
419
+ ["geometry.combine"] = function(params)
420
+ local op = tostring(params.op or "union")
421
+ local name = leaf(params.path)
422
+ local verb = if op == "subtract" then "Cut" elseif op == "intersect" then "Intersect" else "Merge"
423
+ if op == "subtract" and name ~= nil then
424
+ local from = count(params.with)
425
+ return string.format("Cut %s out of %s", plural(from, "shape"), name)
426
+ end
427
+ return if name ~= nil then string.format("%s %s", verb, name) else verb .. " parts"
428
+ end,
429
+ ["geometry.fragment"] = function(params)
430
+ local name = leaf(params.path)
431
+ return if name ~= nil
432
+ then string.format("Shatter %s into %s", name, plural(tonumber(params.pieces) or 8, "piece"))
433
+ else "Shatter a part"
434
+ end,
435
+
436
+ ["assets.insert"] = function(params)
437
+ return string.format("Insert asset %s", tostring(params.assetId or "?"))
438
+ end,
439
+
440
+ ["world.history"] = function(params)
441
+ local action = tostring(params.action or "status")
442
+ if action == "status" then
443
+ return "Check the undo history"
444
+ end
445
+ local steps = tonumber(params.steps) or 1
446
+ return string.format("%s %s", if action == "undo" then "Undo" else "Redo", plural(steps, "step"))
447
+ end,
448
+ ["world.collision"] = function(params)
449
+ local action = tostring(params.action or "list")
450
+ local group = if typeof(params.group) == "string" then params.group else "?"
451
+ if action == "list" then
452
+ return "List collision groups"
453
+ elseif action == "create" then
454
+ return "Create collision group " .. group
455
+ elseif action == "assign" then
456
+ return string.format("Put %s in %s", plural(count(params.paths), "instance"), group)
457
+ elseif action == "remove" then
458
+ return "Remove collision group " .. group
459
+ end
460
+ return string.format("Set %s against %s", group, tostring(params.with or "?"))
461
+ end,
462
+
463
+ ["geometry.mirror"] = function(params)
464
+ local axis = string.upper(tostring(params.axis or "X"))
465
+ local what = plural(count(params.paths), "instance")
466
+ if params.copy == false then
467
+ return string.format("Flip %s across %s", what, axis)
468
+ end
469
+ return string.format("Mirror %s across %s", what, axis)
470
+ end,
471
+
472
+ ["spatial.cast"] = function(params)
473
+ local shape = tostring(params.shape or "ray")
474
+ local target = if params.to ~= nil then tostring(params.to) else nil
475
+ if target ~= nil then
476
+ return string.format("Cast a %s at %s", shape, target)
477
+ end
478
+ return string.format("Cast a %s from %s", shape, tostring(params.from or "?"))
479
+ end,
480
+ ["spatial.overlap"] = function(params)
481
+ local region = tostring(params.region or "box")
482
+ if region == "part" then
483
+ local name = leaf(params.path)
484
+ return if name ~= nil then "Find what overlaps " .. name else "Find overlapping parts"
485
+ end
486
+ return string.format("Find parts in a %s at %s", region, tostring(params.at or "?"))
487
+ end,
488
+
489
+ ["audio.wire"] = function(params)
490
+ return string.format("Wire %s to %s", leaf(params.from) or "?", leaf(params.to) or "?")
491
+ end,
492
+ ["audio.graph"] = function(params)
493
+ local kind = tostring(params.kind or "world")
494
+ local where = leaf(params.parent)
495
+ if kind == "ui" then
496
+ return "Build a UI sound"
497
+ end
498
+ return if where ~= nil then "Build a sound on " .. where else "Build a world sound"
499
+ end,
500
+ ["audio.inspect"] = function(params)
501
+ local name = leaf(params.path)
502
+ return if name ~= nil then "Read the audio graph in " .. name else "Read the audio graph"
503
+ end,
504
+
505
+ ["character.moveTo"] = function(params)
506
+ local name = leaf(params.path)
507
+ return if name ~= nil then "Walk to " .. name else string.format("Walk to %s", tostring(params.to or "?"))
508
+ end,
509
+ ["character.act"] = function(params)
510
+ local action = tostring(params.action or "jump")
511
+ return titleCase(action) .. " the character"
512
+ end,
513
+ ["character.state"] = function()
514
+ return "Check the character"
515
+ end,
516
+
517
+ ["studio.status"] = function()
518
+ return "Check Studio"
519
+ end,
520
+ ["studio.ping"] = function()
521
+ return "Ping"
522
+ end,
523
+ ["input.send"] = function(params)
524
+ local steps = params.steps
525
+ local total = count(steps)
526
+ if total == 1 then
527
+ local step = (steps :: { any })[1]
528
+ local kind = tostring(step.kind or "key")
529
+ if kind == "key" then
530
+ local action = tostring(step.action or "tap")
531
+ local verb = if action == "press" then "Hold" elseif action == "release" then "Release" else "Press"
532
+ return string.format("%s %s", verb, tostring(step.key or "?"))
533
+ elseif kind == "click" then
534
+ return string.format("Click (%s, %s)", tostring(step.x or "?"), tostring(step.y or "?"))
535
+ elseif kind == "move" then
536
+ return "Move the pointer"
537
+ elseif kind == "text" then
538
+ return string.format('Type "%s"', tostring(step.text or ""))
539
+ end
540
+ end
541
+ return "Send " .. plural(total, "input step")
542
+ end,
543
+
544
+ ["api.describe"] = function(params)
545
+ local className = params.className
546
+ return if typeof(className) == "string" and className ~= ""
547
+ then "Look up " .. className
548
+ else "Look up a class"
549
+ end,
550
+ ["api.classes"] = function(params)
551
+ local needle = params.contains
552
+ return if typeof(needle) == "string" and needle ~= ""
553
+ then string.format('Search classes for "%s"', needle)
554
+ else "List classes"
555
+ end,
556
+
557
+ ["perf.scene"] = function(params)
558
+ local section = params.section
559
+ return if typeof(section) == "string" and section ~= ""
560
+ then "Measure the " .. section
561
+ else "Measure the scene"
562
+ end,
563
+
564
+ --[[
565
+ Everything below was added after the first pass over this table, and each
566
+ one spent time showing as the fallback -- "Anim preview", "Data set",
567
+ "Terrain fill". That form is honest but says nothing about the subject,
568
+ which is the whole point of this module: the panel is watched to see WHAT
569
+ an agent is touching, and 26 of 72 operations were declining to say.
570
+ ]]
571
+ ["anim.read"] = function(params)
572
+ local name = leaf(params.assetId)
573
+ return if name ~= nil then "Read the animation " .. name else "Read an animation"
574
+ end,
575
+ ["anim.build"] = function(params)
576
+ local frames = count(params.keyframes)
577
+ return string.format("Build an animation from %s", plural(frames, "keyframe"))
578
+ end,
579
+ ["anim.preview"] = function(params)
580
+ local rig = leaf(params.rig) or "a rig"
581
+ if params.op == "stop" then
582
+ return "Clear the pose on " .. rig
583
+ end
584
+ local at = tonumber(params.at)
585
+ return if at ~= nil
586
+ then string.format("Pose %s at %.2fs", rig, at)
587
+ else "Pose " .. rig
588
+ end,
589
+
590
+ ["data.list"] = function(params)
591
+ local store = params.store
592
+ if typeof(store) == "string" and store ~= "" then
593
+ return string.format("List the keys in %s", store)
594
+ end
595
+ return "List the data stores"
596
+ end,
597
+ ["data.get"] = function(params)
598
+ return string.format("Read %s from %s", tostring(params.key), tostring(params.store))
599
+ end,
600
+ ["data.versions"] = function(params)
601
+ return string.format("Read the history of %s", tostring(params.key))
602
+ end,
603
+ ["data.set"] = function(params)
604
+ return string.format("SAVE over %s in %s", tostring(params.key), tostring(params.store))
605
+ end,
606
+ ["data.remove"] = function(params)
607
+ return string.format("DELETE %s from %s", tostring(params.key), tostring(params.store))
608
+ end,
609
+
610
+ ["discover.tags"] = function(params)
611
+ local where = leaf(params.path)
612
+ return if where ~= nil then "List the tags used in " .. where else "List every tag in use"
613
+ end,
614
+
615
+ ["viewport.ui"] = function()
616
+ return "Check the interface for layout faults"
617
+ end,
618
+ ["viewport.textbounds"] = function(params)
619
+ local name = leaf(params.path)
620
+ return if name ~= nil then "Measure the text in " .. name else "Measure some text"
621
+ end,
622
+
623
+ ["perf.audit"] = function()
624
+ return "Look for references that point at nothing"
625
+ end,
626
+
627
+ ["assets.audio"] = function(params)
628
+ local keyword = params.keyword
629
+ return if typeof(keyword) == "string" and keyword ~= ""
630
+ then string.format('Search audio for "%s"', keyword)
631
+ else "Search the audio library"
632
+ end,
633
+ ["assets.peek"] = function(params)
634
+ return string.format("Look inside asset %s without inserting it", tostring(params.assetId))
635
+ end,
636
+ ["assets.bake"] = function(params)
637
+ return "Bake " .. subjectOf(params.paths, "instance")
638
+ end,
639
+
640
+ ["character.path"] = function(params)
641
+ local target = leaf(params.toPath)
642
+ return if target ~= nil then "Check the route to " .. target else "Check a route"
643
+ end,
644
+
645
+ ["device.network"] = function(params)
646
+ local preset = params.preset
647
+ return if typeof(preset) == "string" and preset ~= ""
648
+ then string.format("Simulate a %s connection", preset)
649
+ else "Shape the network connection"
650
+ end,
651
+
652
+ ["geometry.sweep"] = function(params)
653
+ local name = leaf(params.path)
654
+ return if name ~= nil then "Sweep the path of " .. name else "Sweep a motion volume"
655
+ end,
656
+ ["geometry.mesh"] = function(params)
657
+ return "Read the geometry of " .. subjectOf(params.paths, "mesh")
658
+ end,
659
+
660
+ ["generate.model"] = function(params)
661
+ local prompt = params.prompt
662
+ return if typeof(prompt) == "string" and prompt ~= ""
663
+ then string.format('Generate a model: "%s"', prompt)
664
+ else "Generate a model"
665
+ end,
666
+ ["generate.segment"] = function(params)
667
+ local name = leaf(params.path)
668
+ return if name ~= nil then "Cut " .. name .. " into named parts" else "Segment a mesh"
669
+ end,
670
+
671
+ ["script.open"] = function(params)
672
+ local name = leaf(params.path)
673
+ return if name ~= nil then "Open " .. name .. " in the editor" else "Open a script"
674
+ end,
675
+
676
+ ["terrain.fill"] = function(params)
677
+ --[[
678
+ The material lives inside the shapes, not beside them.
679
+
680
+ Written first against a top-level `material`, which this op has never
681
+ taken, so it always fell through to the bare "Fill terrain" -- seen in
682
+ the panel for a call that laid 50 voxels of sand. The parameters a
683
+ describer reads have to be the ones the handler reads.
684
+ ]]
685
+ local shapes = params.shapes
686
+ if typeof(shapes) == "table" and #(shapes :: { any }) > 0 then
687
+ local list = shapes :: { any }
688
+ local first = list[1]
689
+ local material = if typeof(first) == "table" then first.material else nil
690
+ local named = typeof(material) == "string" and material ~= ""
691
+ if #list == 1 then
692
+ return if named
693
+ then string.format("Fill terrain with %s", material)
694
+ else "Fill terrain"
695
+ end
696
+ return if named
697
+ then string.format("Fill %s, starting with %s", plural(#list, "terrain shape"), material)
698
+ else string.format("Fill %s", plural(#list, "terrain shape"))
699
+ end
700
+ return "Fill terrain"
701
+ end,
702
+ ["terrain.clear"] = function()
703
+ return "CLEAR terrain"
704
+ end,
705
+ ["terrain.replace"] = function(params)
706
+ return string.format(
707
+ "Replace %s terrain with %s",
708
+ tostring(params.from or "one material"),
709
+ tostring(params.to or "another")
710
+ )
711
+ end,
712
+ ["terrain.stats"] = function()
713
+ return "Measure the terrain"
714
+ end,
715
+
716
+ ["device.list"] = function()
717
+ return "List devices"
718
+ end,
719
+ ["device.set"] = function(params)
720
+ local id = params.device
721
+ return if typeof(id) == "string" and id ~= "" then "Emulate " .. id else "Emulate a device"
722
+ end,
723
+ ["device.stop"] = function()
724
+ return "Stop emulating a device"
725
+ end,
726
+ ["device.state"] = function()
727
+ return "Check the emulated device"
728
+ end,
729
+ }
730
+
731
+ --[[
732
+ What kind of work an op is, for anything that wants to colour it.
733
+
734
+ Grouped by what it does to the place rather than by which handler owns it,
735
+ because that is the distinction someone watching cares about: reading is
736
+ safe and constant, writing changes their game, and running executes code.
737
+ ]]
738
+ local KINDS: { [string]: string } = {
739
+ discover = "read",
740
+ studio = "read",
741
+ viewport = "read",
742
+ capture = "read",
743
+ instances = "write",
744
+ geometry = "write",
745
+ assets = "write",
746
+ world = "write",
747
+ exec = "run",
748
+ playtest = "run",
749
+ character = "run",
750
+ -- Live keys and clicks into a running playtest are an action, not a read,
751
+ -- and belong beside character/exec rather than defaulting to "read".
752
+ input = "run",
753
+ debug = "debug",
754
+ perf = "debug",
755
+ api = "read",
756
+ device = "read",
757
+ -- Casts and overlap tests only look; nothing in the group writes.
758
+ spatial = "read",
759
+ -- Wiring and graph building write; `audio.inspect` is carved out below.
760
+ audio = "write",
761
+ --[[
762
+ Absent groups defaulted to "read", which is the wrong way round for a
763
+ default: a missing entry made `terrain.clear` and `data.set` -- one of
764
+ which erases the map and the other of which overwrites a player's save
765
+ with no undo -- show in the panel with the same weight as an inspect.
766
+ A kind is a warning, so the ones that change things are named here
767
+ explicitly.
768
+ ]]
769
+ terrain = "write",
770
+ generate = "write",
771
+ anim = "write",
772
+ data = "write",
773
+ }
774
+
775
+ function Phrase.kindOf(op: string): string
776
+ local group = string.match(op, "^([^%.]+)%.") or op
777
+ -- Reads within the script group, writes outside it: a script edit changes
778
+ -- the game, a read or a grep does not, and they are far apart in weight.
779
+ if group == "script" then
780
+ local action = string.match(op, "%.(.+)$") or ""
781
+ return if action == "read" or action == "grep" then "read" else "write"
782
+ end
783
+ -- Same shape as the script group: one read sitting among writes.
784
+ if op == "audio.inspect" then
785
+ return "read"
786
+ end
787
+ return KINDS[group] or "read"
788
+ end
789
+
790
+ --[[
791
+ The readable form of one command.
792
+
793
+ Never throws and never yields: this runs on the path that logs every call,
794
+ including the ones that are about to fail, and a console that errors while
795
+ describing an error would take the diagnosis with it.
796
+ ]]
797
+ function Phrase.of(op: string, params: { [string]: any }?): string
798
+ local describer = DESCRIBERS[op]
799
+ if describer ~= nil then
800
+ local ok, described = pcall(describer, params or {})
801
+ if ok and typeof(described) == "string" and described ~= "" then
802
+ return described
803
+ end
804
+ end
805
+
806
+ -- Unknown op, or a describer that met a shape it did not expect. Tidy the
807
+ -- wire name rather than inventing a subject for it: "instances.create"
808
+ -- becomes "Instances create", which is honest about knowing no better.
809
+ local group, action = string.match(op, "^([^%.]+)%.(.+)$")
810
+ if group ~= nil and action ~= nil then
811
+ return titleCase(group) .. " " .. action
812
+ end
813
+ return titleCase(op)
814
+ end
815
+
816
+ return Phrase