@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.
- package/README.md +2 -2
- package/dist/lib/apidump.js +24 -2
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/livedata.js +24 -1
- package/dist/lib/livedata.js.map +1 -1
- package/dist/lib/liveops.js +79 -0
- package/dist/lib/liveops.js.map +1 -1
- package/dist/lib/monetization.js +119 -0
- package/dist/lib/monetization.js.map +1 -0
- package/dist/lib/opencloud.js +16 -6
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/tools/audio.js +1 -1
- package/dist/tools/audio.js.map +1 -1
- package/dist/tools/device.js +8 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +8 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/perf.js +27 -5
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +35 -10
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/terrain.js +2 -2
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/universe.js +136 -11
- package/dist/tools/universe.js.map +1 -1
- package/dist/tools/world.js +30 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/ClientRelay.luau +20 -2
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Emulation.luau +44 -16
- package/plugin/src/ExecRuntime.luau +168 -161
- package/plugin/src/LogBuffer.luau +58 -3
- package/plugin/src/Phrase.luau +2 -0
- package/plugin/src/handlers/Assets.luau +27 -18
- package/plugin/src/handlers/Audio.luau +36 -15
- package/plugin/src/handlers/Character.luau +38 -23
- package/plugin/src/handlers/Data.luau +12 -1
- package/plugin/src/handlers/Device.luau +38 -27
- package/plugin/src/handlers/Generate.luau +8 -5
- package/plugin/src/handlers/Geometry.luau +7 -5
- package/plugin/src/handlers/Perf.luau +1 -0
- package/plugin/src/handlers/Playtest.luau +40 -3
- package/plugin/src/handlers/Terrain.luau +9 -24
- package/plugin/src/handlers/Viewport.luau +4 -4
- package/plugin/src/handlers/World.luau +17 -1
- 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.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
93
|
-
if
|
|
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
|
|
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
|
-
|
|
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 =
|
|
275
|
-
to =
|
|
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 =
|
|
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 =
|
|
316
|
-
to =
|
|
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 =
|
|
408
|
-
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 =
|
|
610
|
-
to =
|
|
611
|
-
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
|
134
|
-
the
|
|
135
|
-
|
|
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.
|
|
141
|
-
["3g"] = { latency = 150, jitter = 40, loss =
|
|
142
|
-
poor = { latency = 400, jitter = 120, loss =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
318
|
+
local percent = lossPercent(loss)
|
|
315
319
|
if inbound then
|
|
316
|
-
apply(service, "InboundNetworkLossPercent",
|
|
320
|
+
apply(service, "InboundNetworkLossPercent", percent, refused)
|
|
317
321
|
end
|
|
318
322
|
if outbound then
|
|
319
|
-
apply(service, "OutboundNetworkLossPercent",
|
|
323
|
+
apply(service, "OutboundNetworkLossPercent", percent, refused)
|
|
320
324
|
end
|
|
321
325
|
end
|
|
322
326
|
if memory ~= nil then
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
96
|
+
if text == nil or text == "" then
|
|
94
97
|
return nil
|
|
95
98
|
end
|
|
96
|
-
local
|
|
97
|
-
if
|
|
98
|
-
|
|
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
|
|
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
|
|
62
|
+
if text == nil or text == "" then
|
|
61
63
|
return nil
|
|
62
64
|
end
|
|
63
|
-
local
|
|
64
|
-
if
|
|
65
|
-
|
|
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
|
|
69
|
+
return value :: Vector3
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
local function resolveParts(paths: any, what: string): { BasePart }
|
|
@@ -68,8 +68,12 @@ end
|
|
|
68
68
|
|
|
69
69
|
local function state(): { [string]: any }
|
|
70
70
|
local players = {}
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
not: it
|
|
343
|
-
|
|
344
|
-
|
|
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`
|
|
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
|
-
|
|
358
|
-
|
|
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 =
|
|
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 =
|
|
291
|
-
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
|
-
|
|
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
|