@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,361 @@
1
+ --!strict
2
+ --[[
3
+ Driving the player character during a playtest.
4
+
5
+ This exists because the obvious approach is closed. `VirtualInputManager`
6
+ wants the RobloxScript capability and `VirtualUser` wants LocalUser, and both
7
+ refuse a plugin outright -- measured, not assumed:
8
+
9
+ The current thread cannot call 'SendKeyEvent' (lacking capability RobloxScript)
10
+ The current thread cannot call 'SetKeyDown' (lacking capability LocalUser)
11
+
12
+ Roblox's own MCP server simulates input because it is first-party and runs
13
+ with privileges a plugin is never granted. So this drives the Humanoid
14
+ directly instead.
15
+
16
+ That turns out to be the better instrument anyway. Synthetic keystrokes test
17
+ the input stack; what anyone actually wants to know is whether the character
18
+ can reach the door, whether the trap fires, whether the checkpoint saves --
19
+ all of which are answered more precisely by asking the Humanoid to walk there
20
+ and reporting whether it arrived.
21
+
22
+ Pathfinding is what makes "go there" mean something. `Humanoid:MoveTo` walks
23
+ in a straight line and stops dead against the first wall; a computed path
24
+ routes around geometry, which is the difference between testing a route and
25
+ testing a collision.
26
+
27
+ Everything here needs a running playtest, and the character only exists in
28
+ the playtest's data model, so these must be addressed to that session.
29
+ ]]
30
+
31
+ local PathfindingService = game:GetService("PathfindingService")
32
+ local Players = game:GetService("Players")
33
+ local RunService = game:GetService("RunService")
34
+
35
+ local Dispatch = require(script.Parent.Parent.Dispatch)
36
+ local Paths = require(script.Parent.Parent.Paths)
37
+
38
+ local Character = {}
39
+
40
+ -- How long to wait for a walk before calling it stuck. Long enough to cross a
41
+ -- large room, short enough that a failed route does not hold the request open.
42
+ local MOVE_TIMEOUT = 30
43
+
44
+ -- How close counts as arrived. `MoveToFinished` fires on its own timeout as
45
+ -- well as on arrival, so distance is what actually decides success.
46
+ local ARRIVAL_RADIUS = 6
47
+
48
+ local function parseVector(value: any, what: string): Vector3
49
+ if typeof(value) ~= "string" then
50
+ Dispatch.fail("BAD_PARAMS", string.format("%s must be a string like \"10, 5, 0\".", what))
51
+ end
52
+ local x, y, z = string.match(value, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
53
+ if x == nil then
54
+ Dispatch.fail("BAD_PARAMS", string.format("%s must look like \"10, 5, 0\", got %q.", what, value))
55
+ end
56
+ return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
57
+ end
58
+
59
+ --[[
60
+ The character to drive, and the Humanoid inside it.
61
+
62
+ Named lookup comes first so a multiplayer test can address one player; with
63
+ one player the name is unnecessary and omitting it is the normal case.
64
+ ]]
65
+ local function humanoidOf(params: { [string]: any }): (Model, Humanoid, Player)
66
+ if RunService:IsEdit() then
67
+ Dispatch.fail(
68
+ "NOT_RUNNING",
69
+ "There is no character in an edit session.",
70
+ "Start a playtest with `playtest op=play`, then address this to the playtest's studioId."
71
+ )
72
+ end
73
+
74
+ local player: Player? = nil
75
+ if typeof(params.player) == "string" and params.player ~= "" then
76
+ player = Players:FindFirstChild(params.player) :: Player?
77
+ if player == nil then
78
+ Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this test.", params.player))
79
+ end
80
+ else
81
+ player = Players:GetPlayers()[1]
82
+ if player == nil then
83
+ Dispatch.fail(
84
+ "NO_PLAYER",
85
+ "No players are in this test.",
86
+ "Run mode has no player at all -- use `playtest op=play` for a character."
87
+ )
88
+ end
89
+ end
90
+
91
+ local model = (player :: Player).Character
92
+ if model == nil then
93
+ Dispatch.fail("NO_CHARACTER", "The player has no character right now (probably respawning).")
94
+ end
95
+
96
+ local humanoid = (model :: Model):FindFirstChildOfClass("Humanoid")
97
+ if humanoid == nil then
98
+ Dispatch.fail("NO_HUMANOID", "The character has no Humanoid.")
99
+ end
100
+
101
+ return model :: Model, humanoid :: Humanoid, player :: Player
102
+ end
103
+
104
+ local function positionOf(model: Model): Vector3
105
+ local root = model:FindFirstChild("HumanoidRootPart")
106
+ if root and root:IsA("BasePart") then
107
+ return root.Position
108
+ end
109
+ return model:GetPivot().Position
110
+ end
111
+
112
+ --[[
113
+ Takes physics authority over the character so the server can actually move it.
114
+
115
+ Without this the character does not move at all, and says nothing about why.
116
+ A player's character is network-owned by their client, so a `MoveTo` issued
117
+ from the server is overridden by the client's own controller on the next
118
+ frame -- the humanoid accepts the request, `MoveToFinished` fires, waypoints
119
+ are reported reached, and the character has not travelled a single stud.
120
+ Measured: identical start and end positions across a three-waypoint path that
121
+ reported Success.
122
+
123
+ `SetNetworkOwner(nil)` hands authority to the server, after which the same
124
+ call moved it 7.11 studs. This is what Studio's own "server-side character
125
+ control" does, and it is the difference between driving the character and
126
+ politely asking the client to.
127
+ ]]
128
+ local function takeControl(model: Model): boolean
129
+ local root = model:FindFirstChild("HumanoidRootPart")
130
+ if root == nil or not root:IsA("BasePart") then
131
+ return false
132
+ end
133
+ -- Guarded: an anchored root, or a session where the part is not
134
+ -- network-owned at all, throws rather than returning false.
135
+ local ok = pcall(function()
136
+ (root :: BasePart):SetNetworkOwner(nil)
137
+ end)
138
+ return ok
139
+ end
140
+
141
+ --[[
142
+ Walks to a point, following a computed path around obstacles.
143
+
144
+ Reports where it ended up rather than only whether the call returned, because
145
+ `MoveTo` succeeds at being asked and says nothing about arriving. A route
146
+ blocked by a wall the agent did not know about looks identical to a
147
+ successful walk unless the final distance is measured.
148
+ ]]
149
+ function Character.moveTo(params: { [string]: any }): { [string]: any }
150
+ local model, humanoid = humanoidOf(params)
151
+
152
+ local goal: Vector3
153
+ if typeof(params.path) == "string" and params.path ~= "" then
154
+ local target = Paths.resolve(params.path)
155
+ if target:IsA("BasePart") then
156
+ goal = (target :: BasePart).Position
157
+ elseif target:IsA("Model") then
158
+ goal = (target :: Model):GetPivot().Position
159
+ else
160
+ Dispatch.fail("NOT_POSITIONED", string.format("%s has no position.", params.path))
161
+ end
162
+ else
163
+ goal = parseVector(params.to, "`to`")
164
+ end
165
+
166
+ local start = positionOf(model)
167
+ local controlled = takeControl(model)
168
+
169
+ --[[
170
+ Straight-line movement is available on request, because pathfinding
171
+ deliberately refuses routes it considers unreachable -- and "walk at it
172
+ anyway and tell me what happens" is a legitimate thing to test.
173
+ ]]
174
+ local waypoints: { Vector3 } = {}
175
+ local pathStatus = "direct"
176
+ if params.direct ~= true then
177
+ local path = PathfindingService:CreatePath({
178
+ AgentRadius = 2,
179
+ AgentHeight = 5,
180
+ AgentCanJump = params.canJump ~= false,
181
+ })
182
+ local ok, err = pcall(function()
183
+ path:ComputeAsync(start, goal)
184
+ end)
185
+ if not ok then
186
+ Dispatch.fail("PATH_FAILED", string.format("Could not compute a path: %s", tostring(err)))
187
+ end
188
+ pathStatus = tostring(path.Status)
189
+ if path.Status == Enum.PathStatus.Success then
190
+ for _, waypoint in path:GetWaypoints() do
191
+ table.insert(waypoints, waypoint.Position)
192
+ end
193
+ else
194
+ return {
195
+ arrived = false,
196
+ pathStatus = pathStatus,
197
+ from = tostring(start),
198
+ goal = tostring(goal),
199
+ note = "No route exists. Pass direct=true to walk straight at it regardless.",
200
+ }
201
+ end
202
+ else
203
+ waypoints = { goal }
204
+ end
205
+
206
+ local deadline = os.clock() + MOVE_TIMEOUT
207
+ local reached = 0
208
+ for index, waypoint in waypoints do
209
+ if os.clock() > deadline then
210
+ break
211
+ end
212
+ humanoid:MoveTo(waypoint)
213
+ -- Jumping is per-waypoint: a path that crosses a gap marks the waypoint
214
+ -- before it as a jump, and walking it without jumping falls short.
215
+ if index > 1 and waypoint.Y - waypoints[index - 1].Y > 1.5 then
216
+ humanoid.Jump = true
217
+ end
218
+ local finished = humanoid.MoveToFinished:Wait()
219
+ reached += 1
220
+ if not finished then
221
+ -- MoveToFinished(false) means its own 8-second timeout elapsed, which
222
+ -- in practice means something is in the way.
223
+ break
224
+ end
225
+ end
226
+
227
+ local final = positionOf(model)
228
+ local distance = (final - goal).Magnitude
229
+
230
+ return {
231
+ arrived = distance <= ARRIVAL_RADIUS,
232
+ serverControlled = controlled,
233
+ distance = math.floor(distance * 10 + 0.5) / 10,
234
+ waypoints = #waypoints,
235
+ waypointsReached = reached,
236
+ pathStatus = pathStatus,
237
+ from = tostring(start),
238
+ to = tostring(final),
239
+ goal = tostring(goal),
240
+ }
241
+ end
242
+
243
+ --[[
244
+ One-shot character actions: jump, sit, respawn, and the state changes worth
245
+ testing directly.
246
+ ]]
247
+ function Character.act(params: { [string]: any }): { [string]: any }
248
+ local model, humanoid, player = humanoidOf(params)
249
+ local action = tostring(params.action or "jump")
250
+
251
+ -- Jumping and sitting are driven from the server too, and are overridden by
252
+ -- the owning client for exactly the same reason walking was.
253
+ takeControl(model)
254
+
255
+ if action == "jump" then
256
+ humanoid.Jump = true
257
+ return { action = action, at = tostring(positionOf(model)) }
258
+ elseif action == "stop" then
259
+ humanoid:MoveTo(positionOf(model))
260
+ return { action = action, at = tostring(positionOf(model)) }
261
+ elseif action == "sit" then
262
+ humanoid.Sit = true
263
+ return { action = action, sitting = humanoid.Sit }
264
+ elseif action == "stand" then
265
+ humanoid.Sit = false
266
+ return { action = action, sitting = humanoid.Sit }
267
+ elseif action == "respawn" then
268
+ player:LoadCharacter()
269
+ return { action = action, note = "The character was rebuilt; anything holding the old one is stale." }
270
+ elseif action == "kill" then
271
+ humanoid.Health = 0
272
+ return { action = action, note = "Killed, so the death and respawn path runs." }
273
+ elseif action == "equip" then
274
+ --[[
275
+ Tools are the other half of gameplay input, and the half that is
276
+ actually reachable. Keys and clicks are closed to plugins, but a
277
+ Tool's Activate is a plain server-side call, so "equip the sword and
278
+ swing it" -- which is what most combat tests come down to -- works
279
+ without simulating a single input event.
280
+ ]]
281
+ local name = params.tool
282
+ if typeof(name) ~= "string" or name == "" then
283
+ Dispatch.fail("BAD_PARAMS", "equip needs a `tool` name.")
284
+ end
285
+
286
+ -- Backpack first, then StarterPack, because a tool the player already
287
+ -- holds is what a caller usually means.
288
+ local backpack = player:FindFirstChildOfClass("Backpack")
289
+ local tool = if backpack then backpack:FindFirstChild(name) else nil
290
+ if tool == nil then
291
+ local starter = game:GetService("StarterPack"):FindFirstChild(name)
292
+ if starter then
293
+ tool = starter:Clone()
294
+ ;(tool :: Instance).Parent = backpack
295
+ end
296
+ end
297
+ if tool == nil or not tool:IsA("Tool") then
298
+ Dispatch.fail(
299
+ "NO_TOOL",
300
+ string.format("No Tool named %q in the player's Backpack or StarterPack.", name)
301
+ )
302
+ end
303
+
304
+ humanoid:EquipTool(tool :: Tool)
305
+ return { action = action, tool = name, equipped = (tool :: Tool).Parent == model }
306
+ elseif action == "activate" then
307
+ local equipped = model:FindFirstChildOfClass("Tool")
308
+ if equipped == nil then
309
+ Dispatch.fail(
310
+ "NO_TOOL",
311
+ "The character is not holding a tool.",
312
+ 'Equip one first with action "equip".'
313
+ )
314
+ end
315
+ -- Activate is what a mouse click triggers, so this is the closest thing
316
+ -- to "use it" that exists without input injection.
317
+ equipped:Activate()
318
+ return { action = action, tool = equipped.Name }
319
+ elseif action == "unequip" then
320
+ humanoid:UnequipTools()
321
+ return { action = action, holding = model:FindFirstChildOfClass("Tool") ~= nil }
322
+ elseif action == "teleport" then
323
+ local target = parseVector(params.to, "`to`")
324
+ model:PivotTo(CFrame.new(target))
325
+ return { action = action, at = tostring(positionOf(model)), note = "Moved without walking -- collisions and triggers along the way did not fire." }
326
+ end
327
+
328
+ Dispatch.fail("BAD_PARAMS", string.format("unknown character action %q", action))
329
+ return {}
330
+ end
331
+
332
+ --[[
333
+ Where the character is and what state it is in. Cheap, and the thing to call
334
+ before and after anything else here.
335
+ ]]
336
+ function Character.state(params: { [string]: any }): { [string]: any }
337
+ local model, humanoid, player = humanoidOf(params)
338
+ return {
339
+ player = player.Name,
340
+ position = tostring(positionOf(model)),
341
+ health = humanoid.Health,
342
+ maxHealth = humanoid.MaxHealth,
343
+ walkSpeed = humanoid.WalkSpeed,
344
+ jumpPower = humanoid.JumpPower,
345
+ state = tostring(humanoid:GetState()),
346
+ sitting = humanoid.Sit,
347
+ floor = if humanoid.FloorMaterial ~= Enum.Material.Air
348
+ then tostring(humanoid.FloorMaterial)
349
+ else nil,
350
+ }
351
+ end
352
+
353
+ function Character.register()
354
+ Dispatch.registerAll("character", {
355
+ moveTo = Character.moveTo,
356
+ act = Character.act,
357
+ state = Character.state,
358
+ })
359
+ end
360
+
361
+ return Character