@el4cteo/rbx-studio-mcp 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,168 @@
1
+ --!strict
2
+ --[[
3
+ Session and connectivity handlers. `studio.status` is the cheapest call in the
4
+ system and doubles as the liveness probe, so it must never touch anything
5
+ expensive -- notably it counts descendants lazily and caps the selection.
6
+ ]]
7
+
8
+ local MarketplaceService = game:GetService("MarketplaceService")
9
+ local RunService = game:GetService("RunService")
10
+ local Selection = game:GetService("Selection")
11
+
12
+ local Dispatch = require(script.Parent.Parent.Dispatch)
13
+ local Context = require(script.Parent.Parent.Context)
14
+ local Editor = require(script.Parent.Parent.Editor)
15
+ local Emulation = require(script.Parent.Parent.Emulation)
16
+ local Paths = require(script.Parent.Parent.Paths)
17
+
18
+ -- `version()` is marked deprecated but remains the only way to read the Studio
19
+ -- build, and the agent needs it to know which engine APIs actually exist in this
20
+ -- session. Bound indirectly so the deprecation lint stays enabled file-wide.
21
+ local studioVersion: () -> string = (version :: any)
22
+
23
+ -- Selection can be tens of thousands of instances after a rubber-band drag.
24
+ -- Anything past this is noise for an agent and would blow the response budget.
25
+ local MAX_SELECTION = 50
26
+
27
+ -- Counting descendants of a large place is O(n) over the whole DataModel, so it
28
+ -- is cached briefly; status is polled often enough for this to matter.
29
+ local COUNT_CACHE_SECONDS = 5
30
+
31
+ local cachedCounts: { descendants: number, scripts: number }? = nil
32
+ local cachedAt = 0
33
+
34
+ --[[
35
+ Which device Studio is pretending to be, or nil for none.
36
+
37
+ Reported here because nothing else says it. Device emulation resizes the
38
+ viewport and stays on until it is switched off, so every screenshot after it
39
+ comes back the wrong shape with no indication why -- and a caller who did not
40
+ set it, or who set it twenty calls ago, has no way to find out. `status` is
41
+ the call agents are told to make first, which makes it the right place for a
42
+ piece of session state that silently changes what everything else returns.
43
+ ]]
44
+ local function counts(): { descendants: number, scripts: number }
45
+ local now = os.clock()
46
+ local cache = cachedCounts
47
+ if cache and now - cachedAt < COUNT_CACHE_SECONDS then
48
+ return cache
49
+ end
50
+
51
+ local descendants = 0
52
+ local scripts = 0
53
+ for _, instance in game:GetDescendants() do
54
+ descendants += 1
55
+ if instance:IsA("LuaSourceContainer") then
56
+ scripts += 1
57
+ end
58
+ end
59
+
60
+ local fresh = { descendants = descendants, scripts = scripts }
61
+ cachedCounts = fresh
62
+ cachedAt = now
63
+ return fresh
64
+ end
65
+
66
+ --[[
67
+ The name the user would recognise.
68
+
69
+ `game.Name` is the data model's name -- "Place1", "Place5" -- and has nothing
70
+ to do with what the place is called on the Studio tab or the Creator
71
+ Dashboard. Nothing local holds the real name: it lives on Roblox's servers, so
72
+ it takes a lookup. Without it, a user saying "the other place, the published
73
+ one" cannot be matched to any window, which is the whole point of being able
74
+ to drive several Studios at once.
75
+
76
+ Cached, including the failure, because an unpublished place would otherwise
77
+ make a doomed web request on every status call.
78
+ ]]
79
+ local NAME_RETRY_SECONDS = 60
80
+
81
+ local resolvedName: string? = nil
82
+ local resolvedFor = -1
83
+ local resolvedAt = 0
84
+
85
+ local function publishedPlaceName(): string?
86
+ local placeId = game.PlaceId
87
+ -- A place that has never been saved to Roblox has no id and no remote name.
88
+ if placeId == 0 then
89
+ return nil
90
+ end
91
+
92
+ local fresh = resolvedFor == placeId
93
+ and (resolvedName ~= nil or os.clock() - resolvedAt < NAME_RETRY_SECONDS)
94
+ if fresh then
95
+ return resolvedName
96
+ end
97
+
98
+ resolvedFor = placeId
99
+ resolvedAt = os.clock()
100
+
101
+ local ok, info = pcall(function()
102
+ return MarketplaceService:GetProductInfo(placeId, Enum.InfoType.Asset)
103
+ end)
104
+ local name = if ok and typeof(info) == "table" then (info :: any).Name else nil
105
+ resolvedName = if typeof(name) == "string" and name ~= "" then name else nil
106
+ return resolvedName
107
+ end
108
+
109
+ local Session = {}
110
+
111
+ function Session.status(): { [string]: any }
112
+ local selected: { { path: string, className: string } } = {}
113
+ for index, instance in Selection:Get() do
114
+ if index > MAX_SELECTION then
115
+ break
116
+ end
117
+ table.insert(selected, { path = Paths.of(instance), className = instance.ClassName })
118
+ end
119
+
120
+ local totals = counts()
121
+ -- Open tabs ride along with status rather than getting their own tool: this
122
+ -- is the call agents are told to make first, and "which script is the user
123
+ -- looking at" is orientation, not a separate question.
124
+ local openScripts = Editor.documents()
125
+
126
+ local published = publishedPlaceName()
127
+
128
+ return {
129
+ openScripts = if #openScripts > 0 then openScripts else nil,
130
+ placeName = published or (if game.Name ~= "" then game.Name else "Untitled place"),
131
+ -- Kept separate when the two differ, so a path rooted at the data model
132
+ -- name stays explicable next to a place called something else entirely.
133
+ dataModelName = if published and published ~= game.Name then game.Name else nil,
134
+ placeId = game.PlaceId,
135
+ context = Context.of(),
136
+ -- RunService:IsRunning() is true for both Play and Run; IsEdit()
137
+ -- distinguishes the authoring session from a live one.
138
+ isRunning = RunService:IsRunning(),
139
+ isEdit = RunService:IsEdit(),
140
+ isRunMode = RunService:IsRunning() and not RunService:IsClient(),
141
+ isServerView = RunService:IsServer(),
142
+ selection = selected,
143
+ selectionCount = #Selection:Get(),
144
+ descendantCount = totals.descendants,
145
+ scriptCount = totals.scripts,
146
+ studioVersion = studioVersion(),
147
+ emulatedDevice = Emulation.summary(),
148
+ }
149
+ end
150
+
151
+ --[[
152
+ Round-trip probe used by the smoke test and by `studio_status` when the agent
153
+ only needs to know the channel is alive.
154
+ ]]
155
+ function Session.ping(params: { [string]: any }): { [string]: any }
156
+ return { pong = true, echo = params.echo }
157
+ end
158
+
159
+ function Session.register()
160
+ Dispatch.registerAll("studio", {
161
+ status = function()
162
+ return Session.status()
163
+ end,
164
+ ping = Session.ping,
165
+ })
166
+ end
167
+
168
+ return Session
@@ -0,0 +1,302 @@
1
+ --!strict
2
+ --[[
3
+ The viewport and the selection -- what the user is pointing at, and what they
4
+ have highlighted.
5
+
6
+ Selection is the counterpart to `studio_status`, which reports it: this sets
7
+ it. Selecting what a tool just built is the cheapest way to show someone the
8
+ result of a change, and it puts the instance in reach of Studio's own move
9
+ and scale handles.
10
+
11
+ Raycasting answers "what is under this point in the 3D view", which is
12
+ otherwise unanswerable: an agent can enumerate the data model but has no way
13
+ to know what occupies a piece of space.
14
+ ]]
15
+
16
+ local RunService = game:GetService("RunService")
17
+ local Selection = game:GetService("Selection")
18
+ local Workspace = game:GetService("Workspace")
19
+
20
+ local Dispatch = require(script.Parent.Parent.Dispatch)
21
+ local Paths = require(script.Parent.Parent.Paths)
22
+ local Serialize = require(script.Parent.Parent.Serialize)
23
+
24
+ local MAX_SELECTION = 200
25
+ local DEFAULT_RAY_LENGTH = 1000
26
+
27
+ local Viewport = {}
28
+
29
+ --[[
30
+ Whether moving the camera in this session would change what anyone sees.
31
+
32
+ A playtest server has a `CurrentCamera` and accepts every write to it, and
33
+ renders nothing -- the picture on screen comes from the playtest's client. So
34
+ framing a subject here succeeds, reports a plausible camera position, and
35
+ moves no camera the user has. Measured: `focus` against a playtest server
36
+ returned a full result for a shot nobody could ever see.
37
+ ]]
38
+ local function rendersToScreen(): boolean
39
+ return not (RunService:IsRunning() and RunService:IsServer())
40
+ end
41
+
42
+
43
+ local function toVector(value: any, label: string): Vector3
44
+ local ok, parsed = Serialize.parse(value, "Vector3")
45
+ if not ok or typeof(parsed) ~= "Vector3" then
46
+ Dispatch.fail(
47
+ "BAD_PARAMS",
48
+ string.format('%s must be three numbers, e.g. "0, 50, 0".', label)
49
+ )
50
+ end
51
+ return parsed :: Vector3
52
+ end
53
+
54
+ --[[
55
+ Casts a ray through the world and reports the first thing it hits.
56
+
57
+ `Workspace:Raycast`, not `StudioService:GizmoRaycast`. The latter reads like
58
+ the Studio-aware choice and is not: measured against a place with parts under
59
+ the ray, it returns nil every time. It picks Studio's own gizmos and
60
+ adornments, not world geometry.
61
+ ]]
62
+ function Viewport.raycast(params: { [string]: any }): { [string]: any }
63
+ local origin = toVector(params.origin, "origin")
64
+ local direction = toVector(params.direction, "direction")
65
+ local length = tonumber(params.maxDistance) or DEFAULT_RAY_LENGTH
66
+
67
+ local filter: { Instance } = {}
68
+ for _, path in (params.ignore or {}) :: { string } do
69
+ table.insert(filter, Paths.resolve(path))
70
+ end
71
+
72
+ local config = RaycastParams.new()
73
+ config.FilterType = Enum.RaycastFilterType.Exclude
74
+ config.FilterDescendantsInstances = filter
75
+
76
+ local ok, result = pcall(function()
77
+ return Workspace:Raycast(origin, direction.Unit * length, config)
78
+ end)
79
+
80
+ if not ok or result == nil then
81
+ return { hit = false, origin = Serialize.value(origin), direction = Serialize.value(direction.Unit) }
82
+ end
83
+
84
+ local cast = result :: RaycastResult
85
+ return {
86
+ hit = true,
87
+ path = if cast.Instance then Paths.of(cast.Instance) else nil,
88
+ className = if cast.Instance then cast.Instance.ClassName else nil,
89
+ position = Serialize.value(cast.Position),
90
+ normal = Serialize.value(cast.Normal),
91
+ distance = math.floor(cast.Distance * 1000 + 0.5) / 1000,
92
+ material = Serialize.value(cast.Material),
93
+ }
94
+ end
95
+
96
+ --[[
97
+ Sets, extends or shrinks the Studio selection.
98
+ ]]
99
+ function Viewport.select(params: { [string]: any }): { [string]: any }
100
+ local paths = params.paths
101
+ if typeof(paths) ~= "table" then
102
+ Dispatch.fail("BAD_PARAMS", "select requires a `paths` array.")
103
+ end
104
+ if #paths > MAX_SELECTION then
105
+ Dispatch.fail(
106
+ "TOO_MANY",
107
+ string.format("%d paths, over the %d selection limit.", #paths, MAX_SELECTION)
108
+ )
109
+ end
110
+
111
+ local instances: { Instance } = {}
112
+ for _, path in paths :: { string } do
113
+ table.insert(instances, Paths.resolve(path))
114
+ end
115
+
116
+ local mode = params.mode or "set"
117
+ if mode == "add" then
118
+ Selection:Add(instances)
119
+ elseif mode == "remove" then
120
+ Selection:Remove(instances)
121
+ else
122
+ Selection:Set(instances)
123
+ end
124
+
125
+ local current: { { [string]: any } } = {}
126
+ local memo: Paths.NameIndex = {}
127
+ for index, instance in Selection:Get() do
128
+ if index > MAX_SELECTION then
129
+ break
130
+ end
131
+ table.insert(current, { path = Paths.of(instance, memo), className = instance.ClassName })
132
+ end
133
+
134
+ return { items = current, count = #Selection:Get() }
135
+ end
136
+
137
+ --[[
138
+ The world-space box an instance occupies.
139
+
140
+ A Model knows its own extents; a lone part has to be measured from its size
141
+ and orientation. Anything else -- a Folder, a Script -- has no position at
142
+ all, and saying so is better than framing the origin and returning a picture
143
+ of empty ground.
144
+ ]]
145
+ local function extentsOf(instance: Instance): (Vector3, Vector3)
146
+ if instance:IsA("Model") then
147
+ local cframe, size = (instance :: Model):GetBoundingBox()
148
+ return cframe.Position, size
149
+ end
150
+ if instance:IsA("BasePart") then
151
+ local part = instance :: BasePart
152
+ return part.Position, part.Size
153
+ end
154
+
155
+ -- A container is worth framing if anything inside it has a position.
156
+ local minimum: Vector3? = nil
157
+ local maximum: Vector3? = nil
158
+ for _, descendant in instance:GetDescendants() do
159
+ if descendant:IsA("BasePart") then
160
+ local part = descendant :: BasePart
161
+ local half = part.Size / 2
162
+ minimum = if minimum == nil then part.Position - half else (minimum :: Vector3):Min(part.Position - half)
163
+ maximum = if maximum == nil then part.Position + half else (maximum :: Vector3):Max(part.Position + half)
164
+ end
165
+ end
166
+ if minimum == nil or maximum == nil then
167
+ Dispatch.fail(
168
+ "NOT_POSITIONED",
169
+ string.format("%s is a %s and occupies no space in the world.", instance:GetFullName(), instance.ClassName),
170
+ "Frame something with geometry -- a part, a model, or a folder containing them."
171
+ )
172
+ end
173
+ local low, high = minimum :: Vector3, maximum :: Vector3
174
+ return (low + high) / 2, high - low
175
+ end
176
+
177
+ --[[
178
+ Points the Studio camera at something, framed so all of it is on screen.
179
+
180
+ This is what makes `screenshot` worth having. A picture of wherever the user
181
+ last left their camera answers nothing; a picture of the thing just built
182
+ answers "does it look right", which the data model cannot.
183
+
184
+ The distance is computed from the subject's size and the camera's own field
185
+ of view rather than guessed, so a doorway and a whole map both arrive filling
186
+ a similar share of the frame.
187
+ ]]
188
+ function Viewport.focus(params: { [string]: any }): { [string]: any }
189
+ if not rendersToScreen() then
190
+ Dispatch.fail(
191
+ "NOT_RENDERED",
192
+ "This is the playtest's server session, which draws nothing -- moving its "
193
+ .. "camera would change no picture anybody sees.",
194
+ "Address the editor session (see `list_studios`), or stop the playtest first."
195
+ )
196
+ end
197
+ local camera = Workspace.CurrentCamera
198
+ if camera == nil then
199
+ Dispatch.fail("NO_CAMERA", "This session has no camera.")
200
+ end
201
+
202
+ local centre: Vector3
203
+ local size: Vector3
204
+ if typeof(params.path) == "string" and params.path ~= "" then
205
+ centre, size = extentsOf(Paths.resolve(params.path))
206
+ else
207
+ centre = toVector(params.at, "`at`")
208
+ size = Vector3.new(10, 10, 10)
209
+ end
210
+
211
+ --[[
212
+ Half the diagonal, not half the width: a wall seen from an angle presents
213
+ its diagonal, and framing to the width alone crops the corners off
214
+ exactly the shapes most worth looking at.
215
+ ]]
216
+ local radius = math.max(size.Magnitude / 2, 1)
217
+ local fov = math.rad((camera :: Camera).FieldOfView)
218
+ local distance = (radius / math.tan(fov / 2)) * (tonumber(params.padding) or 1.5)
219
+
220
+ -- Down the -Z axis and slightly above by default, which is how a person
221
+ -- would stand to look at something: face on, a little raised, never level
222
+ -- with a flat surface where it would vanish to a line.
223
+ local direction: Vector3
224
+ if typeof(params.from) == "string" and params.from ~= "" then
225
+ direction = toVector(params.from, "`from`").Unit
226
+ else
227
+ direction = Vector3.new(0.45, 0.35, 1).Unit
228
+ end
229
+
230
+ local eye = centre + direction * distance
231
+ ;(camera :: Camera).CFrame = CFrame.lookAt(eye, centre)
232
+ --[[
233
+ `Focus` is not optional here, and leaving it out is a silent failure.
234
+
235
+ Studio's edit camera orbits around Focus and re-derives its heading from
236
+ it every frame. Setting CFrame alone applies -- and is then thrown away
237
+ about a fifth of a second later, keeping the new position and restoring
238
+ the old rotation. Measured: the look vector read back correct
239
+ immediately, and had reverted to the previous heading by the next check,
240
+ so the camera moved to the right place and pointed the wrong way, and a
241
+ screenshot came back showing empty ground.
242
+ ]]
243
+ ;(camera :: Camera).Focus = CFrame.new(centre)
244
+
245
+ return {
246
+ focused = if typeof(params.path) == "string" then params.path else tostring(centre),
247
+ cameraPosition = tostring(eye),
248
+ lookingAt = tostring(centre),
249
+ distance = math.floor(distance * 10 + 0.5) / 10,
250
+ subjectSize = tostring(size),
251
+ }
252
+ end
253
+
254
+ --[[
255
+ Reads or sets the camera directly, for the cases framing cannot express --
256
+ standing inside a room, or matching a specific shot.
257
+ ]]
258
+ function Viewport.camera(params: { [string]: any }): { [string]: any }
259
+ if not rendersToScreen() then
260
+ Dispatch.fail(
261
+ "NOT_RENDERED",
262
+ "This is the playtest's server session, which draws nothing -- moving its "
263
+ .. "camera would change no picture anybody sees.",
264
+ "Address the editor session (see `list_studios`), or stop the playtest first."
265
+ )
266
+ end
267
+ local camera = Workspace.CurrentCamera
268
+ if camera == nil then
269
+ Dispatch.fail("NO_CAMERA", "This session has no camera.")
270
+ end
271
+ local view = camera :: Camera
272
+
273
+ if params.position ~= nil then
274
+ local eye = toVector(params.position, "`position`")
275
+ local target = if params.lookAt ~= nil
276
+ then toVector(params.lookAt, "`lookAt`")
277
+ else eye + view.CFrame.LookVector
278
+ view.CFrame = CFrame.lookAt(eye, target)
279
+ -- Same reason as focus: without this the rotation survives a frame.
280
+ view.Focus = CFrame.new(target)
281
+ end
282
+ if tonumber(params.fieldOfView) ~= nil then
283
+ view.FieldOfView = math.clamp(tonumber(params.fieldOfView) :: number, 1, 120)
284
+ end
285
+
286
+ return {
287
+ position = tostring(view.CFrame.Position),
288
+ lookVector = tostring(view.CFrame.LookVector),
289
+ fieldOfView = view.FieldOfView,
290
+ }
291
+ end
292
+
293
+ function Viewport.register()
294
+ Dispatch.registerAll("viewport", {
295
+ raycast = Viewport.raycast,
296
+ select = Viewport.select,
297
+ focus = Viewport.focus,
298
+ camera = Viewport.camera,
299
+ })
300
+ end
301
+
302
+ return Viewport
@@ -0,0 +1,176 @@
1
+ --!strict
2
+ --[[
3
+ Undo/redo, and collision groups.
4
+
5
+ Two small things that share a file because neither is big enough to deserve
6
+ its own and both are about the state of the place rather than its contents.
7
+
8
+ Undo matters more than it looks. Every write this server makes is already
9
+ wrapped in a ChangeHistoryService recording, so the user can revert any of it
10
+ by hand -- but the agent that made the mess could not clean it up, which made
11
+ "undo that" a request only a human could carry out. Now it can.
12
+
13
+ Collision groups are the engine's answer to "these parts should pass through
14
+ each other". Without them the alternative is toggling CanCollide, which turns
15
+ collision off against everything rather than against one class of thing.
16
+ ]]
17
+
18
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
19
+ local PhysicsService = game:GetService("PhysicsService")
20
+
21
+ local Dispatch = require(script.Parent.Parent.Dispatch)
22
+ local Paths = require(script.Parent.Parent.Paths)
23
+ local Undo = require(script.Parent.Parent.Undo)
24
+
25
+ local World = {}
26
+
27
+ --[[
28
+ Steps the undo stack.
29
+
30
+ Reports what the stack looks like afterwards, because "undo" that quietly did
31
+ nothing -- an empty stack, or history disabled -- is indistinguishable from
32
+ one that worked unless the caller is told.
33
+ ]]
34
+ function World.history(params: { [string]: any }): { [string]: any }
35
+ local action = tostring(params.action or "status")
36
+ local steps = math.clamp(tonumber(params.steps) or 1, 1, 25)
37
+
38
+ if action == "status" then
39
+ return {
40
+ canUndo = ChangeHistoryService:GetCanUndo(),
41
+ canRedo = ChangeHistoryService:GetCanRedo(),
42
+ }
43
+ end
44
+
45
+ if action ~= "undo" and action ~= "redo" then
46
+ Dispatch.fail("BAD_PARAMS", string.format("unknown history action %q", action))
47
+ end
48
+
49
+ local applied = 0
50
+ for _ = 1, steps do
51
+ local can = if action == "undo"
52
+ then ChangeHistoryService:GetCanUndo()
53
+ else ChangeHistoryService:GetCanRedo()
54
+ if not can then
55
+ break
56
+ end
57
+ local ok = pcall(function()
58
+ if action == "undo" then
59
+ ChangeHistoryService:Undo()
60
+ else
61
+ ChangeHistoryService:Redo()
62
+ end
63
+ end)
64
+ if not ok then
65
+ break
66
+ end
67
+ applied += 1
68
+ end
69
+
70
+ return {
71
+ action = action,
72
+ requested = steps,
73
+ applied = applied,
74
+ canUndo = ChangeHistoryService:GetCanUndo(),
75
+ canRedo = ChangeHistoryService:GetCanRedo(),
76
+ -- Said plainly rather than left to be inferred from applied == 0, which
77
+ -- is the case someone is most likely to misread as success.
78
+ note = if applied == 0
79
+ then string.format("Nothing to %s -- the history stack is empty in that direction.", action)
80
+ elseif applied < steps then string.format("Only %d of %d steps were available.", applied, steps)
81
+ else nil,
82
+ }
83
+ end
84
+
85
+ --[[
86
+ Collision groups: create them, set which collide with which, assign parts.
87
+
88
+ One call does all three, because they are never useful separately -- a group
89
+ nothing is assigned to has no effect, and an assignment to a group that does
90
+ not exist is an error.
91
+ ]]
92
+ function World.collision(params: { [string]: any }): { [string]: any }
93
+ local action = tostring(params.action or "list")
94
+
95
+ if action == "list" then
96
+ local groups: { { [string]: any } } = {}
97
+ local ok, registered = pcall(function()
98
+ return PhysicsService:GetRegisteredCollisionGroups()
99
+ end)
100
+ if ok and typeof(registered) == "table" then
101
+ for _, group in registered :: { any } do
102
+ table.insert(groups, { name = group.name, mask = group.mask })
103
+ end
104
+ end
105
+ return { groups = groups }
106
+ end
107
+
108
+ local name = params.group
109
+ if typeof(name) ~= "string" or name == "" then
110
+ Dispatch.fail("BAD_PARAMS", "collision needs a `group` name.")
111
+ end
112
+
113
+ if action == "create" then
114
+ local ok, err = pcall(function()
115
+ PhysicsService:RegisterCollisionGroup(name)
116
+ end)
117
+ if not ok then
118
+ -- Already existing is the common case and is not a failure worth
119
+ -- refusing over: the caller wanted the group to exist, and it does.
120
+ if not string.find(tostring(err), "already", 1, true) then
121
+ Dispatch.fail("COLLISION_FAILED", string.format("Could not create %q: %s", name, tostring(err)))
122
+ end
123
+ end
124
+ return { group = name, created = ok, existed = not ok }
125
+ end
126
+
127
+ if action == "assign" then
128
+ local paths = params.paths
129
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
130
+ Dispatch.fail("BAD_PARAMS", "assign needs a `paths` array.")
131
+ end
132
+ local assigned: { string } = {}
133
+ local _, undoable = Undo.record("MCPCollision", "MCP collision group", function()
134
+ for _, path in paths :: { string } do
135
+ local instance = Paths.resolve(path)
136
+ -- A model is what a caller usually means; assigning its parts is
137
+ -- what they want, and refusing would just make them enumerate.
138
+ local targets = if instance:IsA("BasePart") then { instance } else instance:GetDescendants()
139
+ for _, target in targets do
140
+ if target:IsA("BasePart") then
141
+ (target :: BasePart).CollisionGroup = name
142
+ table.insert(assigned, Paths.of(target))
143
+ end
144
+ end
145
+ end
146
+ end)
147
+ return { group = name, assigned = #assigned, parts = assigned, undoable = undoable }
148
+ end
149
+
150
+ if action == "collidable" then
151
+ local other = params.with
152
+ if typeof(other) ~= "string" or other == "" then
153
+ Dispatch.fail("BAD_PARAMS", "collidable needs `with`, the other group's name.")
154
+ end
155
+ local collidable = params.collidable ~= false
156
+ local ok, err = pcall(function()
157
+ PhysicsService:CollisionGroupSetCollidable(name, other, collidable)
158
+ end)
159
+ if not ok then
160
+ Dispatch.fail("COLLISION_FAILED", string.format("Could not set collidability: %s", tostring(err)))
161
+ end
162
+ return { group = name, with = other, collidable = collidable }
163
+ end
164
+
165
+ Dispatch.fail("BAD_PARAMS", string.format("unknown collision action %q", action))
166
+ return {}
167
+ end
168
+
169
+ function World.register()
170
+ Dispatch.registerAll("world", {
171
+ history = World.history,
172
+ collision = World.collision,
173
+ })
174
+ end
175
+
176
+ return World