@el4cteo/rbx-studio-mcp 0.8.0 → 0.8.3
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 +5 -3
- package/dist/bridge/api.js +2 -2
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +12 -4
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/harness.js +48 -6
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/remote.js +4 -2
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +43 -5
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +26 -1
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/apidump.js +82 -15
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/cloudassets.js +2 -2
- package/dist/lib/cloudassets.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/lib/png.js +19 -31
- package/dist/lib/png.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 +86 -5
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/instances.js +3 -1
- package/dist/tools/instances.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/session.js +11 -3
- package/dist/tools/session.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 +4 -4
- 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/Debug.luau +42 -0
- package/plugin/src/handlers/Device.luau +38 -27
- package/plugin/src/handlers/Exec.luau +5 -1
- 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/Spatial.luau +34 -0
- package/plugin/src/handlers/Terrain.luau +19 -24
- package/plugin/src/handlers/Viewport.luau +37 -5
- package/plugin/src/handlers/World.luau +38 -2
- package/plugin/src/init.server.luau +11 -0
- package/scripts/check-plugin.mjs +54 -0
- package/scripts/test-apidump.mjs +170 -0
- package/scripts/test-bridge.mjs +111 -4
- package/scripts/test-live-tools.mjs +382 -0
- package/scripts/test-tools.mjs +222 -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")
|
|
@@ -69,6 +69,8 @@ local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
|
69
69
|
local Paths = require(script.Parent.Parent.Paths)
|
|
70
70
|
local ClientRelay = require(script.Parent.Parent.ClientRelay)
|
|
71
71
|
local RemoteTrace = require(script.Parent.Parent.RemoteTrace)
|
|
72
|
+
local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
|
|
73
|
+
local TextEdit = require(script.Parent.Parent.TextEdit)
|
|
72
74
|
|
|
73
75
|
-- Snapshots are far heavier than log lines: each carries a stack and its
|
|
74
76
|
-- variables. A tight cap keeps a breakpoint inside a loop from exhausting memory
|
|
@@ -288,6 +290,9 @@ function Debug.set(params: { [string]: any }): { [string]: any }
|
|
|
288
290
|
|
|
289
291
|
local added: { { [string]: any } } = {}
|
|
290
292
|
local failed: { { [string]: any } } = {}
|
|
293
|
+
-- How many of `failed` were lines the script does not have, as opposed to
|
|
294
|
+
-- refusals from the debugger itself. See below.
|
|
295
|
+
local outside = 0
|
|
291
296
|
|
|
292
297
|
for _, item in requested :: { { [string]: any } } do
|
|
293
298
|
local target = Paths.resolve(item.path)
|
|
@@ -298,6 +303,31 @@ function Debug.set(params: { [string]: any }): { [string]: any }
|
|
|
298
303
|
)
|
|
299
304
|
end
|
|
300
305
|
|
|
306
|
+
--[[
|
|
307
|
+
A line the script does not have is knowable here, so it is refused here.
|
|
308
|
+
|
|
309
|
+
It used to go to `AddBreakpoint` like any other, which refuses it with
|
|
310
|
+
nothing to say -- and the refusal below then blamed the "Debugger Luau
|
|
311
|
+
API" beta, so a mistyped line number sent people off to change a Studio
|
|
312
|
+
setting that was already on.
|
|
313
|
+
]]
|
|
314
|
+
local wanted = tonumber(item.line)
|
|
315
|
+
local lineCount = #TextEdit.toLines(ScriptEdit.read(target :: LuaSourceContainer))
|
|
316
|
+
if wanted == nil or wanted < 1 or wanted > lineCount then
|
|
317
|
+
outside += 1
|
|
318
|
+
table.insert(failed, {
|
|
319
|
+
path = item.path,
|
|
320
|
+
line = wanted,
|
|
321
|
+
error = string.format(
|
|
322
|
+
"the script has %d line%s, so line %s does not exist",
|
|
323
|
+
lineCount,
|
|
324
|
+
if lineCount == 1 then "" else "s",
|
|
325
|
+
tostring(item.line)
|
|
326
|
+
),
|
|
327
|
+
})
|
|
328
|
+
continue
|
|
329
|
+
end
|
|
330
|
+
|
|
301
331
|
--[[
|
|
302
332
|
Capitalised keys, from Roblox's example -- `Line`, `Condition`,
|
|
303
333
|
`LogMessage`, `ContinueExecution`. Lowercase `line` was rejected.
|
|
@@ -372,6 +402,18 @@ function Debug.set(params: { [string]: any }): { [string]: any }
|
|
|
372
402
|
a user's script just to answer a status question. So the hint says what
|
|
373
403
|
to check first and what else it could be, and does not claim to know.
|
|
374
404
|
]]
|
|
405
|
+
if #added == 0 and outside > 0 and outside == #failed then
|
|
406
|
+
local reasons: { string } = {}
|
|
407
|
+
for _, entry in failed do
|
|
408
|
+
table.insert(reasons, string.format("%s: %s", tostring(entry.path), tostring(entry.error)))
|
|
409
|
+
end
|
|
410
|
+
Dispatch.fail(
|
|
411
|
+
"BAD_LINE",
|
|
412
|
+
string.format("No breakpoint was set: %s.", table.concat(reasons, "; ")),
|
|
413
|
+
"Read the script with `script_read` for its line numbers -- they shift after every edit."
|
|
414
|
+
)
|
|
415
|
+
end
|
|
416
|
+
|
|
375
417
|
if #added == 0 and #failed > 0 then
|
|
376
418
|
Dispatch.fail(
|
|
377
419
|
"NO_BREAKPOINTS_SET",
|
|
@@ -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
|
--[[
|
|
@@ -160,7 +160,11 @@ end)
|
|
|
160
160
|
report:FireServer(if ok then result else { ok = false, error = tostring(result), output = {}, milliseconds = 0 })
|
|
161
161
|
]==], { Timeout = timeout }, timeout + 5, function(relay)
|
|
162
162
|
-- These modules are required ONLY by the LocalScript, sharing the game's client VM.
|
|
163
|
-
|
|
163
|
+
-- Everything ExecRuntime requires has to be listed too: the plugin's own module
|
|
164
|
+
-- tree does not exist in the client, so `script.Parent.LogBuffer` finds only
|
|
165
|
+
-- what is cloned beside it. Leaving LogBuffer out made every client run fail
|
|
166
|
+
-- with "Requested module experienced an error while loading".
|
|
167
|
+
for _, name in { "ExecRuntime", "LogBuffer", "Serialize", "Paths", "Dispatch" } do
|
|
164
168
|
local copy = script.Parent.Parent[name]:Clone()
|
|
165
169
|
copy.Parent = relay
|
|
166
170
|
end
|
|
@@ -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
|
|
|
@@ -191,6 +191,24 @@ function Spatial.cast(params: { [string]: any }): { [string]: any }
|
|
|
191
191
|
end
|
|
192
192
|
local distance = tonumber(params.distance) or 100
|
|
193
193
|
direction = towards.Unit * distance
|
|
194
|
+
--[[
|
|
195
|
+
Checked below, once `direction` is settled: a zero `direction` has a NaN
|
|
196
|
+
`Unit`, so it only shows up as a NaN length by the time it gets there.
|
|
197
|
+
]]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
--[[
|
|
201
|
+
A cast with no length is not a cast. `to` equal to `from`, a zero `direction`
|
|
202
|
+
(whose `Unit` is NaN) or a zero `distance` all end up here, and every shape
|
|
203
|
+
then "misses" -- reported as a clear line of sight, which is the opposite of
|
|
204
|
+
"nothing was checked".
|
|
205
|
+
]]
|
|
206
|
+
if direction.Magnitude == 0 or direction.Magnitude ~= direction.Magnitude then
|
|
207
|
+
Dispatch.fail(
|
|
208
|
+
"BAD_PARAMS",
|
|
209
|
+
"The cast has no length, so nothing was checked.",
|
|
210
|
+
"`to` is the same point as `from`, or `direction` or `distance` is zero. Give it somewhere to go."
|
|
211
|
+
)
|
|
194
212
|
end
|
|
195
213
|
|
|
196
214
|
local query = buildParams(params)
|
|
@@ -259,11 +277,27 @@ function Spatial.overlap(params: { [string]: any }): { [string]: any }
|
|
|
259
277
|
if region == "box" then
|
|
260
278
|
local centre = vector(params.at, "at")
|
|
261
279
|
local size = optionalVector(params.size, "size") or Vector3.new(4, 4, 4)
|
|
280
|
+
-- A box with no volume overlaps nothing by construction, which then reads
|
|
281
|
+
-- as "that space is clear" -- an answer to a question nobody asked.
|
|
282
|
+
if size.X <= 0 or size.Y <= 0 or size.Z <= 0 then
|
|
283
|
+
Dispatch.fail(
|
|
284
|
+
"BAD_PARAMS",
|
|
285
|
+
string.format("`size` is %s, which has no volume.", tostring(size)),
|
|
286
|
+
"Every side has to be greater than zero."
|
|
287
|
+
)
|
|
288
|
+
end
|
|
262
289
|
found = world:GetPartBoundsInBox(CFrame.new(centre), size, overlap)
|
|
263
290
|
about.at, about.size = Serialize.value(centre), Serialize.value(size)
|
|
264
291
|
elseif region == "radius" then
|
|
265
292
|
local centre = vector(params.at, "at")
|
|
266
293
|
local radius = tonumber(params.radius) or 4
|
|
294
|
+
if radius <= 0 then
|
|
295
|
+
Dispatch.fail(
|
|
296
|
+
"BAD_PARAMS",
|
|
297
|
+
string.format("`radius` is %s, which has no volume.", tostring(radius)),
|
|
298
|
+
"Give a radius greater than zero."
|
|
299
|
+
)
|
|
300
|
+
end
|
|
267
301
|
found = world:GetPartBoundsInRadius(centre, radius, overlap)
|
|
268
302
|
about.at, about.radius = Serialize.value(centre), radius
|
|
269
303
|
elseif region == "part" then
|