@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +175 -175
  2. package/dist/bridge/harness.js +14 -5
  3. package/dist/bridge/harness.js.map +1 -1
  4. package/dist/index.js +5 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +62 -25
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/format.js +43 -4
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/livedata.js +24 -1
  11. package/dist/lib/livedata.js.map +1 -1
  12. package/dist/lib/liveops.js +79 -0
  13. package/dist/lib/liveops.js.map +1 -1
  14. package/dist/lib/monetization.js +119 -0
  15. package/dist/lib/monetization.js.map +1 -0
  16. package/dist/lib/opencloud.js +16 -6
  17. package/dist/lib/opencloud.js.map +1 -1
  18. package/dist/lib/pluginbuild.js +10 -0
  19. package/dist/lib/pluginbuild.js.map +1 -1
  20. package/dist/lib/png.js +104 -0
  21. package/dist/lib/png.js.map +1 -1
  22. package/dist/resources.js +3 -2
  23. package/dist/resources.js.map +1 -1
  24. package/dist/tools/api.js +4 -3
  25. package/dist/tools/api.js.map +1 -1
  26. package/dist/tools/audio.js +1 -1
  27. package/dist/tools/audio.js.map +1 -1
  28. package/dist/tools/debug.js +2 -2
  29. package/dist/tools/debug.js.map +1 -1
  30. package/dist/tools/device.js +10 -9
  31. package/dist/tools/device.js.map +1 -1
  32. package/dist/tools/discover.js +15 -4
  33. package/dist/tools/discover.js.map +1 -1
  34. package/dist/tools/exec.js +4 -4
  35. package/dist/tools/exec.js.map +1 -1
  36. package/dist/tools/instances.js +32 -7
  37. package/dist/tools/instances.js.map +1 -1
  38. package/dist/tools/perf.js +27 -5
  39. package/dist/tools/perf.js.map +1 -1
  40. package/dist/tools/playtest.js +35 -10
  41. package/dist/tools/playtest.js.map +1 -1
  42. package/dist/tools/screenshot.js +55 -11
  43. package/dist/tools/screenshot.js.map +1 -1
  44. package/dist/tools/scripts.js +4 -4
  45. package/dist/tools/scripts.js.map +1 -1
  46. package/dist/tools/terrain.js +5 -5
  47. package/dist/tools/terrain.js.map +1 -1
  48. package/dist/tools/universe.js +136 -11
  49. package/dist/tools/universe.js.map +1 -1
  50. package/dist/tools/world.js +38 -15
  51. package/dist/tools/world.js.map +1 -1
  52. package/package.json +75 -75
  53. package/plugin/src/ClientRelay.luau +20 -2
  54. package/plugin/src/Config.luau +1 -1
  55. package/plugin/src/Emulation.luau +44 -16
  56. package/plugin/src/ExecRuntime.luau +168 -161
  57. package/plugin/src/LogBuffer.luau +65 -3
  58. package/plugin/src/Paths.luau +15 -4
  59. package/plugin/src/Phrase.luau +3 -20
  60. package/plugin/src/ScriptEdit.luau +14 -0
  61. package/plugin/src/Serialize.luau +17 -0
  62. package/plugin/src/TextEdit.luau +6 -0
  63. package/plugin/src/handlers/Api.luau +8 -0
  64. package/plugin/src/handlers/Assets.luau +27 -18
  65. package/plugin/src/handlers/Audio.luau +36 -15
  66. package/plugin/src/handlers/Capture.luau +219 -3
  67. package/plugin/src/handlers/Character.luau +38 -23
  68. package/plugin/src/handlers/Data.luau +12 -1
  69. package/plugin/src/handlers/Device.luau +38 -27
  70. package/plugin/src/handlers/Generate.luau +8 -5
  71. package/plugin/src/handlers/Geometry.luau +7 -5
  72. package/plugin/src/handlers/Perf.luau +1 -0
  73. package/plugin/src/handlers/Playtest.luau +40 -3
  74. package/plugin/src/handlers/Terrain.luau +9 -24
  75. package/plugin/src/handlers/Viewport.luau +4 -4
  76. package/plugin/src/handlers/World.luau +17 -1
  77. package/plugin/src/init.server.luau +0 -2
  78. package/scripts/test-tools.mjs +437 -216
  79. package/dist/tools/anim.js +0 -159
  80. package/dist/tools/anim.js.map +0 -1
  81. package/plugin/src/handlers/Anim.luau +0 -897
@@ -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 child.Parent ~= nil or child:IsDescendantOf(game) or child.Name ~= "" then
139
- if not child:IsA("LuaSourceContainer") then
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
- if typeof(params.position) == "string" and params.position ~= "" then
165
- local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
166
- if x then
167
- local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
168
- for _, child in children do
169
- pcall(function()
170
- if child:IsA("Model") then
171
- (child :: Model):PivotTo(target)
172
- elseif child:IsA("BasePart") then
173
- (child :: BasePart).CFrame = target
174
- end
175
- end)
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. 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
 
@@ -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
- local function encode(contentId: string, width: number, context: string): { [string]: any }
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
- return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest")
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
- return encode(contentId :: string, width, context)
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 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")