@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.
- package/README.md +175 -175
- package/dist/bridge/harness.js +14 -5
- package/dist/bridge/harness.js.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +62 -25
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/format.js +43 -4
- package/dist/lib/format.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/pluginbuild.js +10 -0
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/lib/png.js +104 -0
- package/dist/lib/png.js.map +1 -1
- package/dist/resources.js +3 -2
- package/dist/resources.js.map +1 -1
- package/dist/tools/api.js +4 -3
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/audio.js +1 -1
- package/dist/tools/audio.js.map +1 -1
- package/dist/tools/debug.js +2 -2
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/device.js +10 -9
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +15 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +4 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +32 -7
- 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/screenshot.js +55 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +4 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/terrain.js +5 -5
- 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 +38 -15
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -75
- 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 +65 -3
- package/plugin/src/Paths.luau +15 -4
- package/plugin/src/Phrase.luau +3 -20
- package/plugin/src/ScriptEdit.luau +14 -0
- package/plugin/src/Serialize.luau +17 -0
- package/plugin/src/TextEdit.luau +6 -0
- package/plugin/src/handlers/Api.luau +8 -0
- package/plugin/src/handlers/Assets.luau +27 -18
- package/plugin/src/handlers/Audio.luau +36 -15
- package/plugin/src/handlers/Capture.luau +219 -3
- 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/plugin/src/init.server.luau +0 -2
- package/scripts/test-tools.mjs +437 -216
- package/dist/tools/anim.js +0 -159
- package/dist/tools/anim.js.map +0 -1
- package/plugin/src/handlers/Anim.luau +0 -897
|
@@ -17,6 +17,7 @@ local InsertService = game:GetService("InsertService")
|
|
|
17
17
|
|
|
18
18
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
19
19
|
local Paths = require(script.Parent.Parent.Paths)
|
|
20
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
20
21
|
local Undo = require(script.Parent.Parent.Undo)
|
|
21
22
|
|
|
22
23
|
local Assets = {}
|
|
@@ -53,6 +54,17 @@ function Assets.insert(params: { [string]: any }): { [string]: any }
|
|
|
53
54
|
then Paths.resolve(params.parent)
|
|
54
55
|
else workspace
|
|
55
56
|
|
|
57
|
+
-- Parsed before anything loads. A position that did not parse used to be
|
|
58
|
+
-- skipped in silence, and the model landed wherever it was saved.
|
|
59
|
+
local position: Vector3? = nil
|
|
60
|
+
if typeof(params.position) == "string" and params.position ~= "" then
|
|
61
|
+
local ok, value, why = Serialize.parse(params.position, "Vector3")
|
|
62
|
+
if not ok then
|
|
63
|
+
Dispatch.fail("BAD_PARAMS", string.format("`position` is not a Vector3: %s", tostring(why)), 'Write it as "x, y, z".')
|
|
64
|
+
end
|
|
65
|
+
position = value :: Vector3
|
|
66
|
+
end
|
|
67
|
+
|
|
56
68
|
--[[
|
|
57
69
|
`game:GetObjects`, not `InsertService:LoadAsset`.
|
|
58
70
|
|
|
@@ -135,10 +147,8 @@ function Assets.insert(params: { [string]: any }): { [string]: any }
|
|
|
135
147
|
-- nothing silently is worse than saying so.
|
|
136
148
|
local remaining: { Instance } = {}
|
|
137
149
|
for _, child in children do
|
|
138
|
-
if
|
|
139
|
-
|
|
140
|
-
table.insert(remaining, child)
|
|
141
|
-
end
|
|
150
|
+
if not child:IsA("LuaSourceContainer") then
|
|
151
|
+
table.insert(remaining, child)
|
|
142
152
|
end
|
|
143
153
|
end
|
|
144
154
|
children = remaining
|
|
@@ -160,20 +170,19 @@ function Assets.insert(params: { [string]: any }): { [string]: any }
|
|
|
160
170
|
table.insert(inserted, Paths.of(child))
|
|
161
171
|
end
|
|
162
172
|
|
|
163
|
-
-- Position after parenting, so PrimaryPart and pivot are settled.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
end
|
|
173
|
+
-- Position after parenting, so PrimaryPart and pivot are settled. Only
|
|
174
|
+
-- the position moves: the asset keeps the rotation it was saved with.
|
|
175
|
+
if position then
|
|
176
|
+
for _, child in children do
|
|
177
|
+
pcall(function()
|
|
178
|
+
if child:IsA("Model") then
|
|
179
|
+
local model = child :: Model
|
|
180
|
+
model:PivotTo(model:GetPivot().Rotation + position)
|
|
181
|
+
elseif child:IsA("BasePart") then
|
|
182
|
+
local part = child :: BasePart
|
|
183
|
+
part.CFrame = part.CFrame.Rotation + position
|
|
184
|
+
end
|
|
185
|
+
end)
|
|
177
186
|
end
|
|
178
187
|
end
|
|
179
188
|
end)
|
|
@@ -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
|
|
|
@@ -21,6 +21,7 @@ local RunService = game:GetService("RunService")
|
|
|
21
21
|
|
|
22
22
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
23
23
|
local Emulation = require(script.Parent.Parent.Emulation)
|
|
24
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
24
25
|
local Png = require(script.Parent.Parent.Png)
|
|
25
26
|
|
|
26
27
|
-- Wide enough to read a GUI label, small enough that the encode stays quick and
|
|
@@ -445,7 +446,98 @@ end
|
|
|
445
446
|
and read in another: the id is all that travels, and this is the reading
|
|
446
447
|
half, which needs plugin identity and therefore an editor session.
|
|
447
448
|
]]
|
|
448
|
-
|
|
449
|
+
-- In capture pixels, or in viewport pixels when `viewportWidth` says so.
|
|
450
|
+
export type Region = { x: number, y: number, width: number, height: number, viewportWidth: number? }
|
|
451
|
+
|
|
452
|
+
--[[
|
|
453
|
+
The full-resolution path: the captured pixels themselves, cropped to `region`
|
|
454
|
+
when one is given, compressed for the wire and scaled down in Node.
|
|
455
|
+
|
|
456
|
+
The engine resample below is bilinear, which only averages the pixels it
|
|
457
|
+
merges at exactly 2x -- beyond that it skips source pixels, and a 1661-wide
|
|
458
|
+
viewport scaled to 800 lost thin edges and broke small GUI text apart. Node
|
|
459
|
+
scales with a box filter instead, where every source pixel counts. Measured
|
|
460
|
+
cost of sending the full frame: ~60ms of a ~550ms call, most of which is the
|
|
461
|
+
engine's own capture.
|
|
462
|
+
|
|
463
|
+
Returns nil when this Studio cannot do it (no EncodingService), so the caller
|
|
464
|
+
falls back to the engine resample.
|
|
465
|
+
]]
|
|
466
|
+
local function encodeFull(
|
|
467
|
+
image: any,
|
|
468
|
+
sourceWidth: number,
|
|
469
|
+
sourceHeight: number,
|
|
470
|
+
width: number,
|
|
471
|
+
context: string,
|
|
472
|
+
region: Region?
|
|
473
|
+
): { [string]: any }?
|
|
474
|
+
local x, y, w, h = 0, 0, sourceWidth, sourceHeight
|
|
475
|
+
if region then
|
|
476
|
+
-- Viewport and capture pixels differ under display scaling.
|
|
477
|
+
local ratio = if region.viewportWidth then sourceWidth / region.viewportWidth else 1
|
|
478
|
+
region = {
|
|
479
|
+
x = region.x * ratio,
|
|
480
|
+
y = region.y * ratio,
|
|
481
|
+
width = region.width * ratio,
|
|
482
|
+
height = region.height * ratio,
|
|
483
|
+
}
|
|
484
|
+
x = math.clamp(math.floor(region.x), 0, sourceWidth)
|
|
485
|
+
y = math.clamp(math.floor(region.y), 0, sourceHeight)
|
|
486
|
+
w = math.min(math.ceil(region.width), sourceWidth - x)
|
|
487
|
+
h = math.min(math.ceil(region.height), sourceHeight - y)
|
|
488
|
+
if w < 1 or h < 1 then
|
|
489
|
+
Dispatch.fail(
|
|
490
|
+
"REGION_OFFSCREEN",
|
|
491
|
+
string.format(
|
|
492
|
+
"The region (%d, %d, %d x %d) is outside the %d x %d viewport.",
|
|
493
|
+
region.x,
|
|
494
|
+
region.y,
|
|
495
|
+
region.width,
|
|
496
|
+
region.height,
|
|
497
|
+
sourceWidth,
|
|
498
|
+
sourceHeight
|
|
499
|
+
),
|
|
500
|
+
"Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot."
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
local ok, result = pcall(function()
|
|
506
|
+
local rgba = image:ReadPixelsBuffer(Vector2.new(x, y), Vector2.new(w, h))
|
|
507
|
+
local rgb = stripAlpha(rgba, w, h)
|
|
508
|
+
local compressed = (EncodingService :: any):CompressBuffer(
|
|
509
|
+
rgb,
|
|
510
|
+
(Enum :: any).CompressionAlgorithm.Zstd,
|
|
511
|
+
COMPRESSION_LEVEL
|
|
512
|
+
)
|
|
513
|
+
return {
|
|
514
|
+
rgb = rgb,
|
|
515
|
+
packed = buffer.tostring((EncodingService :: any):Base64Encode(compressed)),
|
|
516
|
+
}
|
|
517
|
+
end)
|
|
518
|
+
if not ok then
|
|
519
|
+
return nil
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
encoding = "zstd-rgb",
|
|
524
|
+
data = result.packed,
|
|
525
|
+
width = w,
|
|
526
|
+
height = h,
|
|
527
|
+
-- Node scales to this with a box filter; see above.
|
|
528
|
+
scaleTo = width,
|
|
529
|
+
sourceWidth = sourceWidth,
|
|
530
|
+
sourceHeight = sourceHeight,
|
|
531
|
+
region = if region then { x = x, y = y, width = w, height = h } else nil,
|
|
532
|
+
rawBytes = buffer.len(result.rgb),
|
|
533
|
+
bytes = #result.packed,
|
|
534
|
+
context = context,
|
|
535
|
+
black = isFlat(result.rgb),
|
|
536
|
+
device = Emulation.deviceId(),
|
|
537
|
+
}
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
local function encode(contentId: string, width: number, context: string, region: Region?): { [string]: any }
|
|
449
541
|
local okImage, image = pcall(function()
|
|
450
542
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
451
543
|
end)
|
|
@@ -460,6 +552,21 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
460
552
|
local sourceWidth = math.floor(size.X)
|
|
461
553
|
local sourceHeight = math.floor(size.Y)
|
|
462
554
|
|
|
555
|
+
local full = encodeFull(image, sourceWidth, sourceHeight, width, context, region)
|
|
556
|
+
if full then
|
|
557
|
+
pcall(function()
|
|
558
|
+
(image :: any):Destroy()
|
|
559
|
+
end)
|
|
560
|
+
return full
|
|
561
|
+
end
|
|
562
|
+
if region then
|
|
563
|
+
Dispatch.fail(
|
|
564
|
+
"REGION_UNSUPPORTED",
|
|
565
|
+
"This Studio build cannot crop a screenshot (EncodingService is missing).",
|
|
566
|
+
"Take the full screenshot instead, or update Studio."
|
|
567
|
+
)
|
|
568
|
+
end
|
|
569
|
+
|
|
463
570
|
local rgb, outWidth, outHeight = resample(image, sourceWidth, sourceHeight, width)
|
|
464
571
|
|
|
465
572
|
if rgb == nil then
|
|
@@ -544,6 +651,111 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
544
651
|
}
|
|
545
652
|
end
|
|
546
653
|
|
|
654
|
+
-- Room left around a zoomed subject, as a share of its size, so its edges are
|
|
655
|
+
-- visible rather than cut exactly at the frame.
|
|
656
|
+
local REGION_PADDING = 0.08
|
|
657
|
+
|
|
658
|
+
--[[
|
|
659
|
+
Where on screen a GUI element or a piece of the world is, in viewport pixels.
|
|
660
|
+
|
|
661
|
+
GUI positions leave out the top inset, so it is added back; a 3D subject is
|
|
662
|
+
the screen box around its bounding box's eight corners. A subject partly
|
|
663
|
+
behind the camera has no honest box, so that is refused with the fix.
|
|
664
|
+
]]
|
|
665
|
+
local function boundsOf(target: Instance): (Vector2, Vector2)
|
|
666
|
+
if target:IsA("GuiObject") then
|
|
667
|
+
local inset = game:GetService("GuiService"):GetGuiInset()
|
|
668
|
+
return target.AbsolutePosition + inset, target.AbsolutePosition + target.AbsoluteSize + inset
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
local cframe: CFrame, size: Vector3
|
|
672
|
+
if target:IsA("BasePart") then
|
|
673
|
+
cframe, size = target.CFrame, target.Size
|
|
674
|
+
elseif target:IsA("Model") then
|
|
675
|
+
cframe, size = target:GetBoundingBox()
|
|
676
|
+
else
|
|
677
|
+
local low, high = Vector3.one * math.huge, -Vector3.one * math.huge
|
|
678
|
+
local counted = 0
|
|
679
|
+
for _, descendant in target:GetDescendants() do
|
|
680
|
+
if descendant:IsA("BasePart") then
|
|
681
|
+
local half = descendant.Size / 2
|
|
682
|
+
for _, corner in { Vector3.new(-1, -1, -1), Vector3.new(1, 1, 1), Vector3.new(-1, 1, -1), Vector3.new(1, -1, 1) } do
|
|
683
|
+
local point = descendant.CFrame:PointToWorldSpace(half * corner)
|
|
684
|
+
low, high = low:Min(point), high:Max(point)
|
|
685
|
+
end
|
|
686
|
+
counted += 1
|
|
687
|
+
if counted >= 2000 then
|
|
688
|
+
break
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
if counted == 0 then
|
|
693
|
+
Dispatch.fail(
|
|
694
|
+
"NOT_VISUAL",
|
|
695
|
+
string.format("%s has no GUI or parts to zoom to.", target:GetFullName()),
|
|
696
|
+
"Zoom to a GuiObject, a BasePart, a Model, or a folder containing parts."
|
|
697
|
+
)
|
|
698
|
+
end
|
|
699
|
+
cframe, size = CFrame.new((low + high) / 2), high - low
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
local camera = workspace.CurrentCamera
|
|
703
|
+
local low, high = Vector2.one * math.huge, -Vector2.one * math.huge
|
|
704
|
+
for _, sx in { -1, 1 } do
|
|
705
|
+
for _, sy in { -1, 1 } do
|
|
706
|
+
for _, sz in { -1, 1 } do
|
|
707
|
+
local point, _ = camera:WorldToViewportPoint(cframe:PointToWorldSpace(size / 2 * Vector3.new(sx, sy, sz)))
|
|
708
|
+
if point.Z <= 0 then
|
|
709
|
+
Dispatch.fail(
|
|
710
|
+
"REGION_OFFSCREEN",
|
|
711
|
+
string.format("%s is partly behind the camera.", target:GetFullName()),
|
|
712
|
+
"Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot."
|
|
713
|
+
)
|
|
714
|
+
end
|
|
715
|
+
local flat = Vector2.new(point.X, point.Y)
|
|
716
|
+
low, high = low:Min(flat), high:Max(flat)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
return low, high
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
--[[
|
|
724
|
+
The part of the capture to keep, in capture pixels.
|
|
725
|
+
|
|
726
|
+
`path` zooms to an instance; `rect` is "x, y, width, height" in the pixels of
|
|
727
|
+
the full-resolution capture, which every screenshot caption states. A path
|
|
728
|
+
is resolved before the capture, so a bad one fails without taking it.
|
|
729
|
+
]]
|
|
730
|
+
local function regionFor(params: { [string]: any }): Region?
|
|
731
|
+
if typeof(params.rect) == "string" and params.rect ~= "" then
|
|
732
|
+
local numbers = {}
|
|
733
|
+
for token in string.gmatch(params.rect, "-?[%d%.]+") do
|
|
734
|
+
table.insert(numbers, tonumber(token))
|
|
735
|
+
end
|
|
736
|
+
if #numbers ~= 4 or numbers[3] <= 0 or numbers[4] <= 0 then
|
|
737
|
+
Dispatch.fail("BAD_PARAMS", 'rect must be "x, y, width, height", e.g. "400, 200, 320, 180".')
|
|
738
|
+
end
|
|
739
|
+
return { x = numbers[1], y = numbers[2], width = numbers[3], height = numbers[4] }
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
if typeof(params.path) ~= "string" or params.path == "" then
|
|
743
|
+
return nil
|
|
744
|
+
end
|
|
745
|
+
local target = Paths.resolve(params.path)
|
|
746
|
+
local low, high = boundsOf(target)
|
|
747
|
+
local extent = high - low
|
|
748
|
+
local pad = Vector2.new(math.max(8, extent.X * REGION_PADDING), math.max(8, extent.Y * REGION_PADDING))
|
|
749
|
+
low, high = low - pad, high + pad
|
|
750
|
+
return {
|
|
751
|
+
x = low.X,
|
|
752
|
+
y = low.Y,
|
|
753
|
+
width = high.X - low.X,
|
|
754
|
+
height = high.Y - low.Y,
|
|
755
|
+
viewportWidth = workspace.CurrentCamera.ViewportSize.X,
|
|
756
|
+
}
|
|
757
|
+
end
|
|
758
|
+
|
|
547
759
|
function Capture.screenshot(params: { [string]: any }): { [string]: any }
|
|
548
760
|
--[[
|
|
549
761
|
Refused rather than half-done. A playtest screenshot needs two sessions
|
|
@@ -562,7 +774,9 @@ function Capture.screenshot(params: { [string]: any }): { [string]: any }
|
|
|
562
774
|
end
|
|
563
775
|
|
|
564
776
|
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
565
|
-
|
|
777
|
+
-- Before the capture, so a path that does not resolve costs no screenshot.
|
|
778
|
+
local region = regionFor(params)
|
|
779
|
+
return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest", region)
|
|
566
780
|
end
|
|
567
781
|
|
|
568
782
|
--[[
|
|
@@ -579,7 +793,9 @@ function Capture.decode(params: { [string]: any }): { [string]: any }
|
|
|
579
793
|
|
|
580
794
|
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
581
795
|
local context = if typeof(params.context) == "string" then params.context else "playtest client"
|
|
582
|
-
|
|
796
|
+
-- Only `rect` here: a path would have to be resolved in the client's view.
|
|
797
|
+
local region = if typeof(params.rect) == "string" then regionFor({ rect = params.rect }) else nil
|
|
798
|
+
return encode(contentId :: string, width, context, region)
|
|
583
799
|
end
|
|
584
800
|
|
|
585
801
|
function Capture.register()
|
|
@@ -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")
|