@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.
Files changed (75) 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 +8 -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 +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -1,618 +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
- ["script.debugSet"] = function()
276
- return "Set breakpoints"
277
- end,
278
- ["debug.set"] = function(params)
279
- local breakpoints = params.breakpoints
280
- local total = count(breakpoints)
281
- if total == 1 then
282
- local one = (breakpoints :: { any })[1]
283
- local name = leaf(one.path)
284
- if name ~= nil then
285
- return string.format("Break on %s:%s", name, tostring(one.line))
286
- end
287
- end
288
- return "Set " .. plural(total, "breakpoint")
289
- end,
290
- ["debug.clear"] = function(params)
291
- local name = leaf(params.path)
292
- return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
293
- end,
294
- ["debug.snapshots"] = function()
295
- return "Read what the breakpoints caught"
296
- end,
297
- ["debug.exceptions"] = function(params)
298
- return string.format("Break on errors: %s", tostring(params.mode or "Unhandled"))
299
- end,
300
-
301
- ["perf.console"] = function(params)
302
- if typeof(params.level) == "string" and params.level ~= "" then
303
- return string.format("Read the %s log", params.level)
304
- end
305
- if typeof(params.pattern) == "string" and params.pattern ~= "" then
306
- return string.format('Search output for "%s"', params.pattern)
307
- end
308
- return "Read the output log"
309
- end,
310
- ["perf.snapshot"] = function()
311
- return "Measure performance"
312
- end,
313
- ["perf.profile"] = function(params)
314
- return string.format("Profile scripts for %ss", tostring(params.seconds or 5))
315
- end,
316
- ["perf.coverage"] = function(params)
317
- local total = count(params.enable)
318
- if total > 0 then
319
- return "Instrument " .. plural(total, "script")
320
- end
321
- return "Read line coverage"
322
- end,
323
-
324
- ["playtest.control"] = function(params)
325
- local op = tostring(params.op or "state")
326
- --[[
327
- `stop` ends a test by polling this from the *surviving* session every
328
- 400ms until the teardown settles, sometimes five or six times in two
329
- seconds. Labelled "Check the playtest" like an agent's own status
330
- check, that read as unexplained repetition with no `stop` line anywhere
331
- to explain it -- the actual stop went to the session that is by then
332
- gone, and its console went with it. This name is why: waiting for the
333
- teardown it just started, not asking again for no reason.
334
- ]]
335
- if op == "state" and params.waitingForStop == true then
336
- return "Wait for the playtest to stop"
337
- end
338
- if op == "play" then
339
- return "Start a playtest"
340
- elseif op == "run" then
341
- return "Run the place"
342
- elseif op == "multiplayer" then
343
- return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
344
- elseif op == "stop" then
345
- return "Stop the playtest"
346
- elseif op == "endTest" then
347
- return "End the test"
348
- end
349
- return "Check the playtest"
350
- end,
351
-
352
- ["exec.run"] = function(params)
353
- local source = if typeof(params.source) == "string" then params.source else ""
354
-
355
- --[[
356
- Says what the code is about, not what its first line says.
357
-
358
- This used to show the opening line verbatim, which produced entries
359
- like "Run: local GenerationService = game:GetServi..." -- a fragment
360
- of a variable declaration, cut mid-word, telling the reader nothing
361
- except that some code ran. The declaration is never the point of a
362
- snippet; it is the setup before it.
363
-
364
- So the services it touches stand in for it. That is the one thing a
365
- snippet reliably says about itself, it is short, and it is what
366
- someone glancing at the console actually wants: which part of Studio
367
- is being poked.
368
- ]]
369
- local services: { string } = {}
370
- local seen: { [string]: boolean } = {}
371
- for name in string.gmatch(source, 'GetService%("([%w_]+)"%)') do
372
- if not seen[name] then
373
- seen[name] = true
374
- table.insert(services, name)
375
- end
376
- end
377
-
378
- local lines = 0
379
- for _ in string.gmatch(source, "[^\r\n]+") do
380
- lines += 1
381
- end
382
-
383
- if #services == 1 then
384
- return "Run Luau on " .. services[1]
385
- elseif #services == 2 then
386
- return string.format("Run Luau on %s and %s", services[1], services[2])
387
- elseif #services > 2 then
388
- return string.format("Run Luau on %s and %d more", services[1], #services - 1)
389
- end
390
- return if lines <= 1 then "Run Luau" else string.format("Run Luau (%d lines)", lines)
391
- end,
392
-
393
- ["viewport.select"] = function(params)
394
- local total = count(params.paths)
395
- if total == 0 then
396
- return "Clear the selection"
397
- end
398
- return "Select " .. subjectOf(params.paths, "instance")
399
- end,
400
- ["viewport.raycast"] = function()
401
- return "Cast a ray"
402
- end,
403
-
404
- ["capture.screenshot"] = function()
405
- return "Take a screenshot"
406
- end,
407
- ["capture.playtestId"] = function()
408
- return "Capture the playtest view"
409
- end,
410
- ["capture.decode"] = function()
411
- return "Read back the capture"
412
- end,
413
-
414
- ["viewport.focus"] = function(params)
415
- local name = leaf(params.path)
416
- return if name ~= nil then "Look at " .. name else "Move the camera"
417
- end,
418
- ["viewport.camera"] = function()
419
- return "Move the camera"
420
- end,
421
-
422
- ["geometry.combine"] = function(params)
423
- local op = tostring(params.op or "union")
424
- local name = leaf(params.path)
425
- local verb = if op == "subtract" then "Cut" elseif op == "intersect" then "Intersect" else "Merge"
426
- if op == "subtract" and name ~= nil then
427
- local from = count(params.with)
428
- return string.format("Cut %s out of %s", plural(from, "shape"), name)
429
- end
430
- return if name ~= nil then string.format("%s %s", verb, name) else verb .. " parts"
431
- end,
432
- ["geometry.fragment"] = function(params)
433
- local name = leaf(params.path)
434
- return if name ~= nil
435
- then string.format("Shatter %s into %s", name, plural(tonumber(params.pieces) or 8, "piece"))
436
- else "Shatter a part"
437
- end,
438
-
439
- ["assets.insert"] = function(params)
440
- return string.format("Insert asset %s", tostring(params.assetId or "?"))
441
- end,
442
-
443
- ["world.history"] = function(params)
444
- local action = tostring(params.action or "status")
445
- if action == "status" then
446
- return "Check the undo history"
447
- end
448
- local steps = tonumber(params.steps) or 1
449
- return string.format("%s %s", if action == "undo" then "Undo" else "Redo", plural(steps, "step"))
450
- end,
451
- ["world.collision"] = function(params)
452
- local action = tostring(params.action or "list")
453
- local group = if typeof(params.group) == "string" then params.group else "?"
454
- if action == "list" then
455
- return "List collision groups"
456
- elseif action == "create" then
457
- return "Create collision group " .. group
458
- elseif action == "assign" then
459
- return string.format("Put %s in %s", plural(count(params.paths), "instance"), group)
460
- elseif action == "remove" then
461
- return "Remove collision group " .. group
462
- end
463
- return string.format("Set %s against %s", group, tostring(params.with or "?"))
464
- end,
465
-
466
- ["character.moveTo"] = function(params)
467
- local name = leaf(params.path)
468
- return if name ~= nil then "Walk to " .. name else string.format("Walk to %s", tostring(params.to or "?"))
469
- end,
470
- ["character.act"] = function(params)
471
- local action = tostring(params.action or "jump")
472
- return titleCase(action) .. " the character"
473
- end,
474
- ["character.state"] = function()
475
- return "Check the character"
476
- end,
477
-
478
- ["studio.status"] = function()
479
- return "Check Studio"
480
- end,
481
- ["studio.ping"] = function()
482
- return "Ping"
483
- end,
484
- ["studio.transport"] = function(params)
485
- local mode = params.mode
486
- return if typeof(mode) == "string" and mode ~= ""
487
- then string.format("Switch to %s transport", mode)
488
- else "Check the transport"
489
- end,
490
-
491
- --[[
492
- The one a person actually watches: a live playtest is the point where
493
- "what is the AI doing" stops being abstract, and a console line reading
494
- "Input send" says nothing a bystander could act on. One step names the
495
- key or point; several collapse to a count, same as every other batch op.
496
- ]]
497
- ["input.send"] = function(params)
498
- local steps = params.steps
499
- local total = count(steps)
500
- if total == 1 then
501
- local step = (steps :: { any })[1]
502
- local kind = tostring(step.kind or "key")
503
- if kind == "key" then
504
- local action = tostring(step.action or "tap")
505
- local verb = if action == "press" then "Hold" elseif action == "release" then "Release" else "Press"
506
- return string.format("%s %s", verb, tostring(step.key or "?"))
507
- elseif kind == "click" then
508
- return string.format("Click (%s, %s)", tostring(step.x or "?"), tostring(step.y or "?"))
509
- elseif kind == "move" then
510
- return "Move the pointer"
511
- elseif kind == "text" then
512
- return string.format('Type "%s"', tostring(step.text or ""))
513
- end
514
- end
515
- return "Send " .. plural(total, "input step")
516
- end,
517
-
518
- ["api.describe"] = function(params)
519
- local className = params.className
520
- return if typeof(className) == "string" and className ~= ""
521
- then "Look up " .. className
522
- else "Look up a class"
523
- end,
524
- ["api.classes"] = function(params)
525
- local needle = params.contains
526
- return if typeof(needle) == "string" and needle ~= ""
527
- then string.format('Search classes for "%s"', needle)
528
- else "List classes"
529
- end,
530
-
531
- ["perf.scene"] = function(params)
532
- local section = params.section
533
- return if typeof(section) == "string" and section ~= ""
534
- then "Measure the " .. section
535
- else "Measure the scene"
536
- end,
537
-
538
- ["device.list"] = function()
539
- return "List devices"
540
- end,
541
- ["device.set"] = function(params)
542
- local id = params.device
543
- return if typeof(id) == "string" and id ~= "" then "Emulate " .. id else "Emulate a device"
544
- end,
545
- ["device.stop"] = function()
546
- return "Stop emulating a device"
547
- end,
548
- ["device.state"] = function()
549
- return "Check the emulated device"
550
- end,
551
- }
552
-
553
- --[[
554
- What kind of work an op is, for anything that wants to colour it.
555
-
556
- Grouped by what it does to the place rather than by which handler owns it,
557
- because that is the distinction someone watching cares about: reading is
558
- safe and constant, writing changes their game, and running executes code.
559
- ]]
560
- local KINDS: { [string]: string } = {
561
- discover = "read",
562
- studio = "read",
563
- viewport = "read",
564
- capture = "read",
565
- instances = "write",
566
- geometry = "write",
567
- assets = "write",
568
- world = "write",
569
- exec = "run",
570
- playtest = "run",
571
- character = "run",
572
- -- Live keys and clicks into a running playtest are an action, not a read,
573
- -- and belong beside character/exec rather than defaulting to "read".
574
- input = "run",
575
- debug = "debug",
576
- perf = "debug",
577
- api = "read",
578
- device = "read",
579
- }
580
-
581
- function Phrase.kindOf(op: string): string
582
- local group = string.match(op, "^([^%.]+)%.") or op
583
- -- Reads within the script group, writes outside it: a script edit changes
584
- -- the game, a read or a grep does not, and they are far apart in weight.
585
- if group == "script" then
586
- local action = string.match(op, "%.(.+)$") or ""
587
- return if action == "read" or action == "grep" then "read" else "write"
588
- end
589
- return KINDS[group] or "read"
590
- end
591
-
592
- --[[
593
- The readable form of one command.
594
-
595
- Never throws and never yields: this runs on the path that logs every call,
596
- including the ones that are about to fail, and a console that errors while
597
- describing an error would take the diagnosis with it.
598
- ]]
599
- function Phrase.of(op: string, params: { [string]: any }?): string
600
- local describer = DESCRIBERS[op]
601
- if describer ~= nil then
602
- local ok, described = pcall(describer, params or {})
603
- if ok and typeof(described) == "string" and described ~= "" then
604
- return described
605
- end
606
- end
607
-
608
- -- Unknown op, or a describer that met a shape it did not expect. Tidy the
609
- -- wire name rather than inventing a subject for it: "instances.create"
610
- -- becomes "Instances create", which is honest about knowing no better.
611
- local group, action = string.match(op, "^([^%.]+)%.(.+)$")
612
- if group ~= nil and action ~= nil then
613
- return titleCase(group) .. " " .. action
614
- end
615
- return titleCase(op)
616
- end
617
-
618
- 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