@el4cteo/rbx-studio-mcp 0.8.0 → 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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/apidump.js +24 -2
  3. package/dist/lib/apidump.js.map +1 -1
  4. package/dist/lib/livedata.js +24 -1
  5. package/dist/lib/livedata.js.map +1 -1
  6. package/dist/lib/liveops.js +79 -0
  7. package/dist/lib/liveops.js.map +1 -1
  8. package/dist/lib/monetization.js +119 -0
  9. package/dist/lib/monetization.js.map +1 -0
  10. package/dist/lib/opencloud.js +16 -6
  11. package/dist/lib/opencloud.js.map +1 -1
  12. package/dist/tools/audio.js +1 -1
  13. package/dist/tools/audio.js.map +1 -1
  14. package/dist/tools/device.js +8 -7
  15. package/dist/tools/device.js.map +1 -1
  16. package/dist/tools/discover.js +8 -1
  17. package/dist/tools/discover.js.map +1 -1
  18. package/dist/tools/perf.js +27 -5
  19. package/dist/tools/perf.js.map +1 -1
  20. package/dist/tools/playtest.js +35 -10
  21. package/dist/tools/playtest.js.map +1 -1
  22. package/dist/tools/terrain.js +2 -2
  23. package/dist/tools/terrain.js.map +1 -1
  24. package/dist/tools/universe.js +136 -11
  25. package/dist/tools/universe.js.map +1 -1
  26. package/dist/tools/world.js +30 -7
  27. package/dist/tools/world.js.map +1 -1
  28. package/package.json +2 -2
  29. package/plugin/src/ClientRelay.luau +20 -2
  30. package/plugin/src/Config.luau +1 -1
  31. package/plugin/src/Emulation.luau +44 -16
  32. package/plugin/src/ExecRuntime.luau +168 -161
  33. package/plugin/src/LogBuffer.luau +58 -3
  34. package/plugin/src/Phrase.luau +2 -0
  35. package/plugin/src/handlers/Assets.luau +27 -18
  36. package/plugin/src/handlers/Audio.luau +36 -15
  37. package/plugin/src/handlers/Character.luau +38 -23
  38. package/plugin/src/handlers/Data.luau +12 -1
  39. package/plugin/src/handlers/Device.luau +38 -27
  40. package/plugin/src/handlers/Generate.luau +8 -5
  41. package/plugin/src/handlers/Geometry.luau +7 -5
  42. package/plugin/src/handlers/Perf.luau +1 -0
  43. package/plugin/src/handlers/Playtest.luau +40 -3
  44. package/plugin/src/handlers/Terrain.luau +9 -24
  45. package/plugin/src/handlers/Viewport.luau +4 -4
  46. package/plugin/src/handlers/World.luau +17 -1
  47. package/scripts/test-tools.mjs +178 -0
@@ -56,6 +56,11 @@ local function pinsOf(instance: Instance): ({ string }, { string })
56
56
  end
57
57
 
58
58
  local function isAudio(instance: Instance): boolean
59
+ -- Every pin-bearing audio class is named Audio*. Checked first so a walk of
60
+ -- the whole place does not throw and catch twice for every part in it.
61
+ if string.sub(instance.ClassName, 1, 5) ~= "Audio" then
62
+ return false
63
+ end
59
64
  local inputs, outputs = pinsOf(instance)
60
65
  return #inputs > 0 or #outputs > 0
61
66
  end
@@ -176,11 +181,11 @@ end
176
181
  Builds a whole working chain in one step.
177
182
 
178
183
  `world` is a sound at a place: AudioPlayer -> [effects] -> AudioEmitter,
179
- parented to a part, heard from wherever it is. This is the common case and
180
- the one people get wrong, because it also needs an `AudioListener` somewhere
181
- -- without one on the camera or the character, a correctly wired emitter is
182
- still silent. Reported rather than created: where the listener belongs is a
183
- game design decision, not a default.
184
+ parented to a part, heard from wherever it is. It also needs something to
185
+ hear it: an `AudioListener`, which the engine adds on its own unless
186
+ `SoundService.DefaultListenerLocation` is None. In that case a missing
187
+ listener is reported rather than created: where it belongs is a game design
188
+ decision, not a default.
184
189
 
185
190
  `ui` is a sound with no position: AudioPlayer -> [effects] ->
186
191
  AudioDeviceOutput. Menu clicks, music.
@@ -258,9 +263,15 @@ function Audio.graph(params: { [string]: any }): { [string]: any }
258
263
  player.AudioContent = Content.fromUri(asset :: string)
259
264
  end)
260
265
  if not okContent then
261
- pcall(function()
266
+ local okAsset = pcall(function()
262
267
  (player :: any).Asset = asset
263
268
  end)
269
+ if not okAsset then
270
+ table.insert(
271
+ warnings,
272
+ string.format("The AudioPlayer took neither AudioContent nor Asset for %q; it has no sound yet.", asset :: string)
273
+ )
274
+ end
264
275
  end
265
276
  end
266
277
  player.Parent = parent
@@ -296,16 +307,26 @@ function Audio.graph(params: { [string]: any }): { [string]: any }
296
307
  way a correct graph stays silent, and it is invisible from the
297
308
  emitter's own properties. Checked and said plainly.
298
309
  ]]
299
- local listeners = 0
300
- for _, descendant in game:GetDescendants() do
301
- if descendant:IsA("AudioListener") then
302
- listeners += 1
310
+ --[[
311
+ Only when the engine will not supply one. SoundService's
312
+ DefaultListenerLocation (Camera in a new place) makes Roblox add a
313
+ listener itself at runtime, and warning then was a false alarm.
314
+ ]]
315
+ local okLocation, location = pcall(function()
316
+ return (game:GetService("SoundService") :: any).DefaultListenerLocation
317
+ end)
318
+ if okLocation and location == Enum.ListenerLocation.None then
319
+ local listeners = 0
320
+ for _, descendant in game:GetDescendants() do
321
+ if descendant:IsA("AudioListener") then
322
+ listeners += 1
323
+ end
324
+ end
325
+ if listeners == 0 then
326
+ note = "SoundService.DefaultListenerLocation is None and nothing in this "
327
+ .. "place has an AudioListener, so no emitter can be heard. Put one on "
328
+ .. "the camera or the character, or set DefaultListenerLocation."
303
329
  end
304
- end
305
- if listeners == 0 then
306
- note = "Nothing in this place has an AudioListener, so no emitter can "
307
- .. "be heard. Put one on the camera or the character -- where it "
308
- .. "goes decides what the player hears, so it is not created here."
309
330
  end
310
331
  end
311
332
 
@@ -35,6 +35,7 @@ local RunService = game:GetService("RunService")
35
35
 
36
36
  local Dispatch = require(script.Parent.Parent.Dispatch)
37
37
  local Paths = require(script.Parent.Parent.Paths)
38
+ local Serialize = require(script.Parent.Parent.Serialize)
38
39
 
39
40
  local Character = {}
40
41
 
@@ -89,11 +90,11 @@ local function parseVector(value: any, what: string): Vector3
89
90
  if typeof(value) ~= "string" then
90
91
  Dispatch.fail("BAD_PARAMS", string.format("%s must be a string like \"10, 5, 0\".", what))
91
92
  end
92
- local x, y, z = string.match(value, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
93
- if x == nil then
93
+ local ok, parsed = Serialize.parse(value, "Vector3")
94
+ if not ok then
94
95
  Dispatch.fail("BAD_PARAMS", string.format("%s must look like \"10, 5, 0\", got %q.", what, value))
95
96
  end
96
- return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
97
+ return parsed :: Vector3
97
98
  end
98
99
 
99
100
  --[[
@@ -249,7 +250,21 @@ end
249
250
  which is usually the finding rather than the failure.
250
251
  ]]
251
252
  function Character.path(params: { [string]: any }): { [string]: any }
252
- local start = endpointOf(params, "fromPath", "from", "`from`")
253
+ -- No start given means "from the character", as the tool documents. That
254
+ -- only exists in a running game; in edit mode the start must be named.
255
+ local start: Vector3
256
+ local hasStart = params.from ~= nil or (typeof(params.fromPath) == "string" and params.fromPath ~= "")
257
+ if hasStart then
258
+ start = endpointOf(params, "fromPath", "from", "`from`")
259
+ elseif RunService:IsRunning() then
260
+ start = positionOf((humanoidOf(params)))
261
+ else
262
+ Dispatch.fail(
263
+ "BAD_PARAMS",
264
+ "path needs `from` or `fromPath` in edit mode.",
265
+ "Only a running playtest has a character to start from."
266
+ )
267
+ end
253
268
  local goal = endpointOf(params, "toPath", "to", "`to`")
254
269
 
255
270
  local path = PathfindingService:CreatePath(agentOf(params))
@@ -271,8 +286,8 @@ function Character.path(params: { [string]: any }): { [string]: any }
271
286
  return {
272
287
  reachable = false,
273
288
  status = status,
274
- from = tostring(start),
275
- to = tostring(goal),
289
+ from = Serialize.value(start),
290
+ to = Serialize.value(goal),
276
291
  straightLineDistance = math.round(straightLine * 10) / 10,
277
292
  hint = "Nothing walkable connects these points for an agent this size. "
278
293
  .. "A wall, a gap wider than a jump, or a drop too far. Try a smaller "
@@ -303,7 +318,7 @@ function Character.path(params: { [string]: any }): { [string]: any }
303
318
  table.insert(rows, {
304
319
  index = index,
305
320
  action = action,
306
- position = tostring(waypoint.Position),
321
+ position = Serialize.value(waypoint.Position),
307
322
  label = if waypoint.Label ~= "" then waypoint.Label else nil,
308
323
  })
309
324
  end
@@ -312,8 +327,8 @@ function Character.path(params: { [string]: any }): { [string]: any }
312
327
  return {
313
328
  reachable = true,
314
329
  status = status,
315
- from = tostring(start),
316
- to = tostring(goal),
330
+ from = Serialize.value(start),
331
+ to = Serialize.value(goal),
317
332
  waypointCount = #waypoints,
318
333
  pathDistance = math.round(travelled * 10) / 10,
319
334
  straightLineDistance = math.round(straightLine * 10) / 10,
@@ -404,8 +419,8 @@ function Character.moveTo(params: { [string]: any }): { [string]: any }
404
419
  return {
405
420
  arrived = false,
406
421
  pathStatus = pathStatus,
407
- from = tostring(start),
408
- goal = tostring(goal),
422
+ from = Serialize.value(start),
423
+ goal = Serialize.value(goal),
409
424
  note = "No route exists. Pass direct=true to walk straight at it regardless.",
410
425
  }
411
426
  end
@@ -606,13 +621,13 @@ function Character.moveTo(params: { [string]: any }): { [string]: any }
606
621
  -- the character had to be rescued along.
607
622
  repaths = repaths,
608
623
  pathStatus = pathStatus,
609
- from = tostring(start),
610
- to = tostring(final),
611
- goal = tostring(goal),
624
+ from = Serialize.value(start),
625
+ to = Serialize.value(final),
626
+ goal = Serialize.value(goal),
612
627
  }
613
628
 
614
629
  if not arrived then
615
- report.stuckAt = if stuckAt ~= nil then tostring(stuckAt) else nil
630
+ report.stuckAt = if stuckAt ~= nil then Serialize.value(stuckAt) else nil
616
631
  report.blockedBy = blockedBy
617
632
  --[[
618
633
  Said plainly, because the numbers alone read as success to anything
@@ -654,10 +669,10 @@ function Character.act(params: { [string]: any }): { [string]: any }
654
669
 
655
670
  if action == "jump" then
656
671
  humanoid.Jump = true
657
- return { action = action, at = tostring(positionOf(model)) }
672
+ return { action = action, at = Serialize.value(positionOf(model)) }
658
673
  elseif action == "stop" then
659
674
  humanoid:MoveTo(positionOf(model))
660
- return { action = action, at = tostring(positionOf(model)) }
675
+ return { action = action, at = Serialize.value(positionOf(model)) }
661
676
  elseif action == "sit" then
662
677
  humanoid.Sit = true
663
678
  return { action = action, sitting = humanoid.Sit }
@@ -783,7 +798,7 @@ function Character.act(params: { [string]: any }): { [string]: any }
783
798
  elseif action == "teleport" then
784
799
  local target = parseVector(params.to, "`to`")
785
800
  model:PivotTo(CFrame.new(target))
786
- return { action = action, at = tostring(positionOf(model)), note = "Moved without walking -- collisions and triggers along the way did not fire." }
801
+ return { action = action, at = Serialize.value(positionOf(model)), note = "Moved without walking -- collisions and triggers along the way did not fire." }
787
802
  end
788
803
 
789
804
  Dispatch.fail("BAD_PARAMS", string.format("unknown character action %q", action))
@@ -798,11 +813,11 @@ function Character.state(params: { [string]: any }): { [string]: any }
798
813
  local model, humanoid, player = humanoidOf(params)
799
814
  return {
800
815
  player = player.Name,
801
- position = tostring(positionOf(model)),
802
- health = humanoid.Health,
803
- maxHealth = humanoid.MaxHealth,
804
- walkSpeed = humanoid.WalkSpeed,
805
- jumpPower = humanoid.JumpPower,
816
+ position = Serialize.value(positionOf(model)),
817
+ health = Serialize.value(humanoid.Health),
818
+ maxHealth = Serialize.value(humanoid.MaxHealth),
819
+ walkSpeed = Serialize.value(humanoid.WalkSpeed),
820
+ jumpPower = Serialize.value(humanoid.JumpPower),
806
821
  state = tostring(humanoid:GetState()),
807
822
  sitting = humanoid.Sit,
808
823
  floor = if humanoid.FloorMaterial ~= Enum.Material.Air
@@ -457,8 +457,19 @@ function Data.set(params: { [string]: any }): { [string]: any }
457
457
  end
458
458
 
459
459
  local store = storeOf(params)
460
+ --[[
461
+ UpdateAsync, not SetAsync. SetAsync replaces the key's UserIds and
462
+ metadata along with its value, so a plain write stripped the GDPR user
463
+ tags and whatever metadata the game keeps there (session locks
464
+ included). Handing the old ones back keeps everything but the value.
465
+ ]]
460
466
  local ok, err = pcall(function()
461
- store:SetAsync(key, value)
467
+ store:UpdateAsync(key, function(_, keyInfo: any)
468
+ if keyInfo == nil then
469
+ return value
470
+ end
471
+ return value, keyInfo:GetUserIds(), keyInfo:GetMetadata()
472
+ end)
462
473
  end)
463
474
  if not ok then
464
475
  gate(err, "writing the key")
@@ -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