@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +175 -175
  2. package/dist/bridge/harness.js +14 -5
  3. package/dist/bridge/harness.js.map +1 -1
  4. package/dist/index.js +5 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +62 -25
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/format.js +43 -4
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/livedata.js +24 -1
  11. package/dist/lib/livedata.js.map +1 -1
  12. package/dist/lib/liveops.js +79 -0
  13. package/dist/lib/liveops.js.map +1 -1
  14. package/dist/lib/monetization.js +119 -0
  15. package/dist/lib/monetization.js.map +1 -0
  16. package/dist/lib/opencloud.js +16 -6
  17. package/dist/lib/opencloud.js.map +1 -1
  18. package/dist/lib/pluginbuild.js +10 -0
  19. package/dist/lib/pluginbuild.js.map +1 -1
  20. package/dist/lib/png.js +104 -0
  21. package/dist/lib/png.js.map +1 -1
  22. package/dist/resources.js +3 -2
  23. package/dist/resources.js.map +1 -1
  24. package/dist/tools/api.js +4 -3
  25. package/dist/tools/api.js.map +1 -1
  26. package/dist/tools/audio.js +1 -1
  27. package/dist/tools/audio.js.map +1 -1
  28. package/dist/tools/debug.js +2 -2
  29. package/dist/tools/debug.js.map +1 -1
  30. package/dist/tools/device.js +10 -9
  31. package/dist/tools/device.js.map +1 -1
  32. package/dist/tools/discover.js +15 -4
  33. package/dist/tools/discover.js.map +1 -1
  34. package/dist/tools/exec.js +4 -4
  35. package/dist/tools/exec.js.map +1 -1
  36. package/dist/tools/instances.js +32 -7
  37. package/dist/tools/instances.js.map +1 -1
  38. package/dist/tools/perf.js +27 -5
  39. package/dist/tools/perf.js.map +1 -1
  40. package/dist/tools/playtest.js +35 -10
  41. package/dist/tools/playtest.js.map +1 -1
  42. package/dist/tools/screenshot.js +55 -11
  43. package/dist/tools/screenshot.js.map +1 -1
  44. package/dist/tools/scripts.js +4 -4
  45. package/dist/tools/scripts.js.map +1 -1
  46. package/dist/tools/terrain.js +5 -5
  47. package/dist/tools/terrain.js.map +1 -1
  48. package/dist/tools/universe.js +136 -11
  49. package/dist/tools/universe.js.map +1 -1
  50. package/dist/tools/world.js +38 -15
  51. package/dist/tools/world.js.map +1 -1
  52. package/package.json +75 -75
  53. package/plugin/src/ClientRelay.luau +20 -2
  54. package/plugin/src/Config.luau +1 -1
  55. package/plugin/src/Emulation.luau +44 -16
  56. package/plugin/src/ExecRuntime.luau +168 -161
  57. package/plugin/src/LogBuffer.luau +65 -3
  58. package/plugin/src/Paths.luau +15 -4
  59. package/plugin/src/Phrase.luau +3 -20
  60. package/plugin/src/ScriptEdit.luau +14 -0
  61. package/plugin/src/Serialize.luau +17 -0
  62. package/plugin/src/TextEdit.luau +6 -0
  63. package/plugin/src/handlers/Api.luau +8 -0
  64. package/plugin/src/handlers/Assets.luau +27 -18
  65. package/plugin/src/handlers/Audio.luau +36 -15
  66. package/plugin/src/handlers/Capture.luau +219 -3
  67. package/plugin/src/handlers/Character.luau +38 -23
  68. package/plugin/src/handlers/Data.luau +12 -1
  69. package/plugin/src/handlers/Device.luau +38 -27
  70. package/plugin/src/handlers/Generate.luau +8 -5
  71. package/plugin/src/handlers/Geometry.luau +7 -5
  72. package/plugin/src/handlers/Perf.luau +1 -0
  73. package/plugin/src/handlers/Playtest.luau +40 -3
  74. package/plugin/src/handlers/Terrain.luau +9 -24
  75. package/plugin/src/handlers/Viewport.luau +4 -4
  76. package/plugin/src/handlers/World.luau +17 -1
  77. package/plugin/src/init.server.luau +0 -2
  78. package/scripts/test-tools.mjs +437 -216
  79. package/dist/tools/anim.js +0 -159
  80. package/dist/tools/anim.js.map +0 -1
  81. package/plugin/src/handlers/Anim.luau +0 -897
@@ -130,29 +130,21 @@ end
130
130
  `poor` is deliberately harsh -- crowded stadium wifi, a phone on one bar --
131
131
  because the failures worth finding only appear at the far end.
132
132
 
133
- Loss is the percentage of packets thrown away, and it is the field that finds
134
- the interesting bugs: latency makes a game feel slow, loss makes it behave
135
- wrongly.
133
+ Loss is the percentage of packets thrown away. Roblox caps it at 0.5%, so
134
+ the presets climb to that ceiling rather than to real-world figures the
135
+ engine would clamp anyway.
136
136
  ]]
137
137
  local PRESETS: { [string]: { latency: number, jitter: number, loss: number } } = {
138
138
  clear = { latency = 0, jitter = 0, loss = 0 },
139
139
  wifi = { latency = 15, jitter = 5, loss = 0 },
140
- ["4g"] = { latency = 60, jitter = 15, loss = 0.5 },
141
- ["3g"] = { latency = 150, jitter = 40, loss = 2 },
142
- poor = { latency = 400, jitter = 120, loss = 8 },
140
+ ["4g"] = { latency = 60, jitter = 15, loss = 0.1 },
141
+ ["3g"] = { latency = 150, jitter = 40, loss = 0.3 },
142
+ poor = { latency = 400, jitter = 120, loss = 0.5 },
143
143
  }
144
144
 
145
- --[[
146
- Percent in, fraction out.
147
-
148
- `InboundNetworkLossPercent` does not take a percentage: it takes a fraction
149
- and clamps at 0.5. Writing 2 for "2% loss" succeeds, lands at 0.5, and
150
- degrades half the traffic -- a hundred times what was asked for, with no
151
- error anywhere. Every caller of this file speaks percent, so the conversion
152
- is here and only here.
153
- ]]
154
- local function lossFraction(percent: number): number
155
- return math.clamp(percent, 0, Emulation.LOSS_LIMIT_PERCENT) / 100
145
+ -- Percent in, percent out: the property takes a percentage, capped at 0.5.
146
+ local function lossPercent(percent: number): number
147
+ return math.clamp(percent, 0, Emulation.LOSS_LIMIT_PERCENT)
156
148
  end
157
149
 
158
150
  local function presetNames(): string
@@ -180,6 +172,19 @@ local function apply(service: any, property: string, value: number, refused: { s
180
172
  end
181
173
  end
182
174
 
175
+ --[[
176
+ Takes the memory cap off by writing the machine's real memory back. 0 is
177
+ not "no cap" to the engine -- it is a device with no memory -- so nothing
178
+ here writes it.
179
+ ]]
180
+ local function uncapMemory(service: any, refused: { string })
181
+ local real = Emulation.machineMemory()
182
+ if real ~= nil then
183
+ apply(service, "EmulatedTotalMemoryInMB", real, refused)
184
+ end
185
+ Emulation.setMemoryCap(nil)
186
+ end
187
+
183
188
  --[[
184
189
  Degrades the connection on purpose.
185
190
 
@@ -224,9 +229,8 @@ function Device.network(params: { [string]: any }): { [string]: any }
224
229
  latency, jitter, loss = preset.latency, preset.jitter, preset.loss
225
230
  -- "clear" is a whole-connection word, so it takes the memory cap with
226
231
  -- it. Any other preset says nothing about memory and leaves it alone.
227
- if name == "clear" then
228
- apply(service, "EmulatedTotalMemoryInMB", 0, {})
229
- Emulation.setMemoryCap(nil)
232
+ if name == "clear" and Emulation.memoryCap() ~= nil then
233
+ uncapMemory(service, {})
230
234
  end
231
235
  end
232
236
 
@@ -311,17 +315,21 @@ function Device.network(params: { [string]: any }): { [string]: any }
311
315
  end
312
316
  end
313
317
  if loss ~= nil then
314
- local fraction = lossFraction(loss)
318
+ local percent = lossPercent(loss)
315
319
  if inbound then
316
- apply(service, "InboundNetworkLossPercent", fraction, refused)
320
+ apply(service, "InboundNetworkLossPercent", percent, refused)
317
321
  end
318
322
  if outbound then
319
- apply(service, "OutboundNetworkLossPercent", fraction, refused)
323
+ apply(service, "OutboundNetworkLossPercent", percent, refused)
320
324
  end
321
325
  end
322
326
  if memory ~= nil then
323
- apply(service, "EmulatedTotalMemoryInMB", memory, refused)
324
- Emulation.setMemoryCap(memory)
327
+ if memory > 0 then
328
+ apply(service, "EmulatedTotalMemoryInMB", memory, refused)
329
+ Emulation.setMemoryCap(memory)
330
+ else
331
+ uncapMemory(service, refused)
332
+ end
325
333
  end
326
334
 
327
335
  local state = Emulation.network()
@@ -358,8 +366,11 @@ function Device.stop(_params: { [string]: any }): { [string]: any }
358
366
  apply(service, "OutboundNetworkMinDelayMs", 0, ignored)
359
367
  apply(service, "OutboundNetworkJitterMs", 0, ignored)
360
368
  apply(service, "OutboundNetworkLossPercent", 0, ignored)
361
- apply(service, "EmulatedTotalMemoryInMB", 0, ignored)
362
- Emulation.setMemoryCap(nil)
369
+ -- Only a cap this plugin set is undone. Studio's own simulator restores
370
+ -- its value when it stops, and writing over that is what left 0 behind.
371
+ if Emulation.memoryCap() ~= nil then
372
+ uncapMemory(service, ignored)
373
+ end
363
374
  end
364
375
 
365
376
  --[[
@@ -25,6 +25,7 @@
25
25
  ]]
26
26
 
27
27
  local Dispatch = require(script.Parent.Parent.Dispatch)
28
+ local Serialize = require(script.Parent.Parent.Serialize)
28
29
  local Paths = require(script.Parent.Parent.Paths)
29
30
  local Undo = require(script.Parent.Parent.Undo)
30
31
 
@@ -89,15 +90,17 @@ local function carryLook(source: MeshPart, model: Model)
89
90
  end
90
91
  end
91
92
 
93
+ -- Absent is nil; present but malformed fails, rather than being taken as absent
94
+ -- and quietly replaced by a default the caller never asked for.
92
95
  local function parseVector(text: any): Vector3?
93
- if typeof(text) ~= "string" or text == "" then
96
+ if text == nil or text == "" then
94
97
  return nil
95
98
  end
96
- local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
97
- if x == nil then
98
- return nil
99
+ local ok, value = Serialize.parse(text, "Vector3")
100
+ if not ok then
101
+ Dispatch.fail("BAD_PARAMS", string.format("%q is not an \"x, y, z\" vector.", tostring(text)))
99
102
  end
100
- return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
103
+ return value :: Vector3
101
104
  end
102
105
 
103
106
  local function service(): any
@@ -56,15 +56,17 @@ local function carryAppearance(source: BasePart, target: BasePart)
56
56
  end
57
57
  end
58
58
 
59
+ -- Absent is nil; present but malformed fails, rather than being taken as absent
60
+ -- and quietly replaced by a default the caller never asked for.
59
61
  local function parseVector(text: any): Vector3?
60
- if typeof(text) ~= "string" or text == "" then
62
+ if text == nil or text == "" then
61
63
  return nil
62
64
  end
63
- local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
64
- if x == nil then
65
- return nil
65
+ local ok, value = Serialize.parse(text, "Vector3")
66
+ if not ok then
67
+ Dispatch.fail("BAD_PARAMS", string.format("%q is not an \"x, y, z\" vector.", tostring(text)))
66
68
  end
67
- return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
69
+ return value :: Vector3
68
70
  end
69
71
 
70
72
  local function resolveParts(paths: any, what: string): { BasePart }
@@ -150,6 +150,7 @@ function Perf.console(params: { [string]: any }): { [string]: any }
150
150
  timestamp = item.timestamp,
151
151
  stack = item.stack,
152
152
  source = item.source,
153
+ context = item.context,
153
154
  })
154
155
  end
155
156
 
@@ -68,8 +68,12 @@ end
68
68
 
69
69
  local function state(): { [string]: any }
70
70
  local players = {}
71
- for _, player in game:GetService("Players"):GetPlayers() do
72
- table.insert(players, { name = player.Name, userId = player.UserId })
71
+ -- Only while a test runs: straight after `stop` the editor still lists the
72
+ -- departing player for a moment, and the reply said one was present.
73
+ if RunService:IsRunning() then
74
+ for _, player in game:GetService("Players"):GetPlayers() do
75
+ table.insert(players, { name = player.Name, userId = player.UserId })
76
+ end
73
77
  end
74
78
  return {
75
79
  playtestsAllowed = Playtest.isAllowed(),
@@ -121,13 +125,46 @@ end
121
125
 
122
126
  function Playtest.control(params: { [string]: any }): { [string]: any }
123
127
  local op = params.op
124
- if op == "play" or op == "run" or op == "multiplayer" then requireAllowed() end
128
+ if op == "play" or op == "run" or op == "multiplayer" or op == "addPlayers" then requireAllowed() end
125
129
  local before = state()
126
130
 
127
131
  if op == "state" then
128
132
  return { changed = false, state = before }
129
133
  end
130
134
 
135
+ --[[
136
+ More players joining a test already running: staggered joins, lobby
137
+ fill-up, the race a second player hits that the first never does.
138
+
139
+ Served by the playtest's server session, the only place AddPlayers may be
140
+ called from; the MCP server routes it there. Returns straight away --
141
+ new clients take seconds to load, and the caller polls `state` for them.
142
+ ]]
143
+ if op == "addPlayers" then
144
+ if RunService:IsEdit() or not RunService:IsServer() then
145
+ Dispatch.fail(
146
+ "NOT_RUNNING",
147
+ "Players can only be added from the server of a running test.",
148
+ "Start one with `playtest op=\"multiplayer\"` first."
149
+ )
150
+ end
151
+ local count = tonumber(params.players) or 1
152
+ if count < 1 or count > 8 then
153
+ Dispatch.fail("BAD_PARAMS", "players must be between 1 and 8.")
154
+ end
155
+ local ok, err = pcall(function()
156
+ service():AddPlayers(count)
157
+ end)
158
+ if not ok then
159
+ Dispatch.fail(
160
+ "ADD_PLAYERS_FAILED",
161
+ string.format("Studio refused to add players: %s", tostring(err)),
162
+ "AddPlayers works in a multiplayer test only, not in Play or Run."
163
+ )
164
+ end
165
+ return { changed = true, state = before }
166
+ end
167
+
131
168
  --[[
132
169
  Ending a test has to happen inside it.
133
170
 
@@ -319,43 +319,28 @@ end
319
319
  What is there, without reading a voxel.
320
320
 
321
321
  `CountCells` is the cheap answer to "does this place even use terrain",
322
- which is the question worth asking before any of the above. The bounding box
323
- tells an agent where to aim without guessing at the origin.
322
+ which is the question worth asking before any of the above.
324
323
  ]]
325
324
  function Terrain.stats(_params: { [string]: any }): { [string]: any }
326
325
  local field = terrain()
327
326
 
328
327
  --[[
329
- Formatted here rather than through `Serialize.value`.
330
-
331
- `MaxExtents` is a Region3int16 and its corners are Vector3int16, which is
332
- a type `Serialize.value` has no case for -- it would have fallen through
333
- to the generic tail and reported whatever `tostring` makes of it. The
334
- extents are also in VOXELS, not studs, which is the sort of unit
335
- confusion that produces a fill a thousand studs from where it was meant,
336
- so they are converted and named for what they are.
337
- ]]
338
- --[[
339
- `MaxExtents` is deliberately not reported.
328
+ No bounds, and no limit.
340
329
 
341
- It reads like the bounding box of the terrain that exists, and it is
342
- not: it is the fixed limit of where terrain is allowed to go, and it
343
- answers -32000, -32000, -32000 to 32000, 32000, 32000 on an empty place
344
- and on a full one alike. Reporting it as "where the terrain sits" was a
345
- misreading of the name -- checked against a place with 2220 cells in a
346
- 160-stud patch, which returned exactly the same numbers as one with
347
- none.
330
+ `MaxExtents` reads like the bounding box of the terrain that exists and is
331
+ not: it answers -32000..32000 on an empty place and a full one alike. Nor
332
+ is it a limit in studs -- a fill at 140,000 studs out still landed -- so
333
+ the `limitStuds = 32000` this used to report was wrong as well.
348
334
 
349
335
  There is no engine call for the occupied box, and finding it means
350
336
  reading every voxel in the world, which is far too expensive for a
351
- question meant to be cheap. `cells` already answers the one that
352
- matters: whether this place uses terrain at all.
337
+ question meant to be cheap. `cells` answers the one that matters.
353
338
  ]]
354
339
  return {
355
340
  cells = field:CountCells(),
356
341
  waterColor = Serialize.value(field.WaterColor),
357
- waterWaveSize = field.WaterWaveSize,
358
- limitStuds = 32000,
342
+ -- Through Serialize so a float32 reads 0.15, not 0.15000000596046448.
343
+ waterWaveSize = Serialize.value(field.WaterWaveSize),
359
344
  }
360
345
  end
361
346
 
@@ -92,7 +92,7 @@ function Viewport.raycast(params: { [string]: any }): { [string]: any }
92
92
  position = Serialize.value(cast.Position),
93
93
  normal = Serialize.value(cast.Normal),
94
94
  distance = math.floor(cast.Distance * 1000 + 0.5) / 1000,
95
- material = Serialize.value(cast.Material),
95
+ material = cast.Material.Name,
96
96
  }
97
97
  end
98
98
 
@@ -287,9 +287,9 @@ function Viewport.camera(params: { [string]: any }): { [string]: any }
287
287
  end
288
288
 
289
289
  return {
290
- position = tostring(view.CFrame.Position),
291
- lookVector = tostring(view.CFrame.LookVector),
292
- fieldOfView = view.FieldOfView,
290
+ position = Serialize.value(view.CFrame.Position),
291
+ lookVector = Serialize.value(view.CFrame.LookVector),
292
+ fieldOfView = Serialize.value(view.FieldOfView),
293
293
  }
294
294
  end
295
295
 
@@ -213,6 +213,19 @@ function World.collision(params: { [string]: any }): { [string]: any }
213
213
  if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
214
214
  Dispatch.fail("BAD_PARAMS", "assign needs a `paths` array.")
215
215
  end
216
+ -- The engine takes any string here, registered or not, and a part in an
217
+ -- unregistered group collides as Default: a typo that changes nothing
218
+ -- and reports success. Measured, so refused up front.
219
+ local okRegistered, registered = pcall(function()
220
+ return world:IsCollisionGroupRegistered(name)
221
+ end)
222
+ if okRegistered and registered == false then
223
+ Dispatch.fail(
224
+ "NO_SUCH_GROUP",
225
+ string.format("No collision group %q is registered in %s.", name, Paths.of(world)),
226
+ 'Create it first with action="create", or check the name with action="list".'
227
+ )
228
+ end
216
229
  local assigned: { string } = {}
217
230
  local _, undoable = Undo.record("MCPCollision", "MCP collision group", function()
218
231
  for _, path in paths :: { string } do
@@ -228,7 +241,10 @@ function World.collision(params: { [string]: any }): { [string]: any }
228
241
  end
229
242
  end
230
243
  end)
231
- return { group = name, assigned = #assigned, parts = assigned, undoable = undoable, world = Paths.of(world) }
244
+ -- A model can hold thousands of parts; the count is the answer and a
245
+ -- sample of names is enough to see it landed where intended.
246
+ local sample = if #assigned > 20 then table.move(assigned, 1, 20, 1, {}) else assigned
247
+ return { group = name, assigned = #assigned, parts = sample, undoable = undoable, world = Paths.of(world) }
232
248
  end
233
249
 
234
250
  if action == "remove" then
@@ -42,7 +42,6 @@ local Viewport = require(script.handlers.Viewport)
42
42
  local Input = require(script.handlers.Input)
43
43
  local Device = require(script.handlers.Device)
44
44
  local Data = require(script.handlers.Data)
45
- local Anim = require(script.handlers.Anim)
46
45
  local Api = require(script.handlers.Api)
47
46
  local Scripts = require(script.handlers.Scripts)
48
47
  local Session = require(script.handlers.Session)
@@ -197,7 +196,6 @@ Scripts.register()
197
196
  Input.register()
198
197
  Device.register()
199
198
  Data.register()
200
- Anim.register()
201
199
  Api.register()
202
200
 
203
201
  --[[