@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,261 @@
1
+ --!strict
2
+ --[[
3
+ Solid modelling: union, subtract, intersect, fragment.
4
+
5
+ `GeometryService` is the modern replacement for the old `BasePart:UnionAsync`
6
+ pair, and nothing else in this space exposes it. It is how a shape that is
7
+ not a box gets built without importing a mesh: cut a doorway out of a wall,
8
+ round a corner off a platform, punch windows through a facade.
9
+
10
+ Every operation returns *new* parts rather than editing in place, and the
11
+ originals are left alone unless the caller asks for them to go. That is the
12
+ engine's design and it is worth preserving: a subtraction that silently ate
13
+ its inputs would make an unwanted result unrecoverable.
14
+
15
+ The results carry the source part's appearance. Roblox hands back bare
16
+ MeshParts with default material and colour, which for a wall cut from brick
17
+ produces a grey slab where a brick wall was -- correct geometry that looks
18
+ like a mistake, and the sort of thing a caller then has to notice and fix.
19
+ ]]
20
+
21
+ local GeometryService = game:GetService("GeometryService")
22
+
23
+ local Dispatch = require(script.Parent.Parent.Dispatch)
24
+ local Paths = require(script.Parent.Parent.Paths)
25
+ local Undo = require(script.Parent.Parent.Undo)
26
+
27
+ local Geometry = {}
28
+
29
+ -- Appearance carried from the source part onto every result.
30
+ local CARRIED = {
31
+ "Material", "Color", "Transparency", "Reflectance", "CastShadow",
32
+ "Anchored", "CanCollide", "CanTouch", "CanQuery", "CollisionGroup",
33
+ }
34
+
35
+ local OPERATIONS: { [string]: string } = {
36
+ union = "UnionAsync",
37
+ subtract = "SubtractAsync",
38
+ intersect = "IntersectAsync",
39
+ }
40
+
41
+ --[[
42
+ Copies the look of the original onto a result.
43
+
44
+ Guarded per property: the results are MeshParts and the source may be any
45
+ BasePart, so a property that does not exist on one of them should cost that
46
+ property rather than the whole operation.
47
+ ]]
48
+ local function carryAppearance(source: BasePart, target: BasePart)
49
+ for _, property in CARRIED do
50
+ pcall(function()
51
+ (target :: any)[property] = (source :: any)[property]
52
+ end)
53
+ end
54
+ end
55
+
56
+ local function resolveParts(paths: any, what: string): { BasePart }
57
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
58
+ Dispatch.fail("BAD_PARAMS", string.format("geometry needs %s.", what))
59
+ end
60
+ local parts: { BasePart } = {}
61
+ for _, path in paths :: { string } do
62
+ local instance = Paths.resolve(path)
63
+ if not instance:IsA("BasePart") then
64
+ Dispatch.fail(
65
+ "NOT_A_PART",
66
+ string.format("%s is a %s, not a part.", path, instance.ClassName)
67
+ )
68
+ end
69
+ table.insert(parts, instance :: BasePart)
70
+ end
71
+ return parts
72
+ end
73
+
74
+ function Geometry.combine(params: { [string]: any }): { [string]: any }
75
+ local op = tostring(params.op or "union")
76
+ local method = OPERATIONS[op]
77
+ if method == nil then
78
+ Dispatch.fail("BAD_PARAMS", string.format("unknown geometry op %q", op))
79
+ end
80
+
81
+ local subject = resolveParts({ params.path }, "a `path`")[1]
82
+ local others = resolveParts(params.with, "a `with` list of parts")
83
+
84
+ -- CollisionFidelity defaults to Default rather than Precise: precise
85
+ -- collision on a heavily-cut mesh is expensive, and a caller who needs it
86
+ -- for a walkable surface can say so.
87
+ local options = {
88
+ CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
89
+ or Enum.CollisionFidelity.Default,
90
+ RenderFidelity = Enum.RenderFidelity[tostring(params.renderFidelity or "Automatic")]
91
+ or Enum.RenderFidelity.Automatic,
92
+ SplitApart = params.splitApart == true,
93
+ }
94
+
95
+ local ok, results = pcall(function()
96
+ return (GeometryService :: any)[method](GeometryService, subject, others, options)
97
+ end)
98
+ if not ok then
99
+ Dispatch.fail(
100
+ "GEOMETRY_FAILED",
101
+ string.format("%s refused: %s", method, tostring(results)),
102
+ "Parts must overlap for subtract and intersect to produce anything."
103
+ )
104
+ end
105
+
106
+ -- Same unwrapping guard as fragment, for the same reason: these three do
107
+ -- return parts directly today, but nothing documents that, and the cost of
108
+ -- being wrong is destroying the inputs and creating nothing.
109
+ local produced: { BasePart } = {}
110
+ for _, entry in results :: { any } do
111
+ local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
112
+ if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
113
+ table.insert(produced, instance :: BasePart)
114
+ end
115
+ end
116
+
117
+ if #produced == 0 then
118
+ -- Not an error at the API level, but almost never what was wanted: it
119
+ -- means the solids did not overlap the way the caller assumed.
120
+ Dispatch.fail(
121
+ "EMPTY_RESULT",
122
+ string.format("%s produced no parts.", op),
123
+ "The parts probably do not overlap. Check their positions with `inspect`."
124
+ )
125
+ end
126
+
127
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
128
+ then Paths.resolve(params.parent)
129
+ else subject.Parent
130
+
131
+ local created: { string } = {}
132
+ local removed: { string } = {}
133
+
134
+ local _, undoable = Undo.record("MCPGeometry", "MCP " .. op, function()
135
+ for index, part in produced do
136
+ carryAppearance(subject, part)
137
+ part.Name = if typeof(params.name) == "string" and params.name ~= ""
138
+ then (if #produced == 1 then params.name else string.format("%s%d", params.name, index))
139
+ else subject.Name
140
+ part.Parent = parent
141
+ table.insert(created, Paths.of(part))
142
+ end
143
+
144
+ -- Removing the inputs is opt-in, and only once the results exist: an
145
+ -- operation that destroyed them first and then failed would leave
146
+ -- nothing to recover.
147
+ if params.keepOriginals ~= true then
148
+ for _, part in { subject, table.unpack(others) } do
149
+ table.insert(removed, Paths.of(part))
150
+ part:Destroy()
151
+ end
152
+ end
153
+ end)
154
+
155
+ return { created = created, removed = removed, undoable = undoable }
156
+ end
157
+
158
+ --[[
159
+ Breaks a part into pieces, for destruction and debris.
160
+
161
+ Sites are where the fractures radiate from. Left unspecified the engine
162
+ picks them, which is what most callers want; naming them is for aiming a
163
+ break at a point of impact.
164
+ ]]
165
+ function Geometry.fragment(params: { [string]: any }): { [string]: any }
166
+ local subject = resolveParts({ params.path }, "a `path`")[1]
167
+ local count = math.clamp(tonumber(params.pieces) or 8, 2, 100)
168
+
169
+ local sites: { Vector3 } = {}
170
+ local okSites, generated = pcall(function()
171
+ return (GeometryService :: any):GenerateFragmentSites(subject, { Count = count })
172
+ end)
173
+ if okSites and typeof(generated) == "table" then
174
+ sites = generated :: { Vector3 }
175
+ else
176
+ -- Fall back to points scattered inside the part's own volume, so a
177
+ -- missing helper costs randomness rather than the whole feature.
178
+ for _ = 1, count do
179
+ table.insert(
180
+ sites,
181
+ subject.Position
182
+ + Vector3.new(
183
+ (math.random() - 0.5) * subject.Size.X,
184
+ (math.random() - 0.5) * subject.Size.Y,
185
+ (math.random() - 0.5) * subject.Size.Z
186
+ )
187
+ )
188
+ end
189
+ end
190
+
191
+ local ok, results = pcall(function()
192
+ return (GeometryService :: any):FragmentAsync(subject, sites, {})
193
+ end)
194
+ if not ok then
195
+ Dispatch.fail("GEOMETRY_FAILED", string.format("FragmentAsync refused: %s", tostring(results)))
196
+ end
197
+
198
+ --[[
199
+ `FragmentAsync` does not return parts. It returns wrappers:
200
+
201
+ { Index = 1, Instance = MeshPart }
202
+
203
+ which is unlike `UnionAsync` and its siblings, which hand back the parts
204
+ themselves. Reading them as parts is silently destructive rather than
205
+ merely wrong, and this file did exactly that: `piece.Parent = folder` set
206
+ a key on a Lua table, changing nothing in the data model, while
207
+ `Paths.of` then walked that table's freshly-assigned `Name` and `Parent`
208
+ fields and produced a completely plausible path -- so the call reported
209
+ eight new parts by name, created none of them, and destroyed the
210
+ original. Measured, after the pieces failed to appear in a tree listing.
211
+
212
+ Hence the check below rather than a cast: this shape is undocumented, so
213
+ the next engine update changing it should stop the tool loudly instead of
214
+ eating someone's geometry again.
215
+ ]]
216
+ local produced: { BasePart } = {}
217
+ for _, entry in results :: { any } do
218
+ local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
219
+ if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
220
+ table.insert(produced, instance :: BasePart)
221
+ end
222
+ end
223
+
224
+ if #produced == 0 then
225
+ -- Refused before the subject is touched, so a shape this file no longer
226
+ -- understands costs the operation and not the part.
227
+ Dispatch.fail(
228
+ "GEOMETRY_FAILED",
229
+ string.format(
230
+ "FragmentAsync returned %d result(s) but none were parts this version understands.",
231
+ #(results :: { any })
232
+ ),
233
+ "The original is untouched. This usually means the engine's return shape changed."
234
+ )
235
+ end
236
+ local parent = subject.Parent
237
+ local created: { string } = {}
238
+
239
+ local _, undoable = Undo.record("MCPFragment", "MCP fragment", function()
240
+ for index, part in produced do
241
+ carryAppearance(subject, part)
242
+ part.Name = string.format("%s_%d", subject.Name, index)
243
+ part.Parent = parent
244
+ table.insert(created, Paths.of(part))
245
+ end
246
+ if params.keepOriginals ~= true then
247
+ subject:Destroy()
248
+ end
249
+ end)
250
+
251
+ return { created = created, pieces = #produced, undoable = undoable }
252
+ end
253
+
254
+ function Geometry.register()
255
+ Dispatch.registerAll("geometry", {
256
+ combine = Geometry.combine,
257
+ fragment = Geometry.fragment,
258
+ })
259
+ end
260
+
261
+ return Geometry
@@ -0,0 +1,287 @@
1
+ --!strict
2
+ --[[
3
+ Synthetic keyboard and mouse input, delivered to a running playtest.
4
+
5
+ This was written off twice before it was found. `VirtualInputManager` needs
6
+ the RobloxScript capability and `VirtualUser` needs LocalUser -- both closed
7
+ to plugins, both the obvious place to look, and both dead ends. The third
8
+ door is `UserInputService:CreateVirtualInput()`, which is Security None and
9
+ hands back a `VirtualInput` with SendKey, SendMouseButton, SendMousePosition,
10
+ SendMouseDelta, SendTextInput and SendPointerAction. Measured: a synthetic W
11
+ walked the character 25.6 studs.
12
+
13
+ The catch is where it has to happen. Input belongs to the DataModel that
14
+ creates the VirtualInput, and the character is driven by the *client*. Called
15
+ from the playtest's server session -- the one this plugin can reach -- every
16
+ send succeeds and nothing moves. That is the worst possible failure: a green
17
+ result for an action that did nothing, which is the exact class of bug this
18
+ project keeps finding in other people's tools.
19
+
20
+ So the server does not send the input. It parents a LocalScript into the
21
+ player's PlayerGui, which runs on their client, and that script does the
22
+ sending and reports back over a RemoteEvent. Nothing here reports success
23
+ until the client says it happened.
24
+ ]]
25
+
26
+ local HttpService = game:GetService("HttpService")
27
+ local Players = game:GetService("Players")
28
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
29
+ local RunService = game:GetService("RunService")
30
+
31
+ local Dispatch = require(script.Parent.Parent.Dispatch)
32
+
33
+ local Input = {}
34
+
35
+ -- Long enough for a slow client to start the script and work through a batch,
36
+ -- short enough that a client which never reports fails inside a tool call.
37
+ local ACK_TIMEOUT = 20
38
+ local MAX_STEPS = 40
39
+ local MAX_HOLD = 10
40
+
41
+ --[[
42
+ The relay, fixed rather than generated.
43
+
44
+ Building Luau from the request would mean pasting caller-supplied names into
45
+ source, so a key called `A) print("owned") --` would run. The plan travels as
46
+ JSON on an attribute instead, and this script only ever indexes enums by name
47
+ and checks the result.
48
+ ]]
49
+ local RELAY_SOURCE = [==[
50
+ local UserInputService = game:GetService("UserInputService")
51
+ local HttpService = game:GetService("HttpService")
52
+
53
+ local relay = script
54
+ local report = relay:WaitForChild("Report", 10)
55
+ if report == nil then
56
+ return
57
+ end
58
+
59
+ local ok, plan = pcall(function()
60
+ return HttpService:JSONDecode(relay:GetAttribute("Plan"))
61
+ end)
62
+ if not ok or typeof(plan) ~= "table" then
63
+ report:FireServer({ ok = false, reason = "the plan did not decode on the client" })
64
+ return
65
+ end
66
+
67
+ local virtual = UserInputService:CreateVirtualInput()
68
+ local performed = {}
69
+
70
+ for _, step in plan do
71
+ local kind = step.kind
72
+ if kind == "key" then
73
+ local code = Enum.KeyCode[step.key]
74
+ if step.action == "release" then
75
+ virtual:SendKey(false, code, false)
76
+ else
77
+ virtual:SendKey(true, code, false)
78
+ if step.action ~= "press" then
79
+ task.wait(step.hold or 0.08)
80
+ virtual:SendKey(false, code, false)
81
+ end
82
+ end
83
+ elseif kind == "move" then
84
+ virtual:SendMousePosition(Vector2.new(step.x, step.y))
85
+ elseif kind == "click" then
86
+ local button = Enum.UserInputType[step.button or "MouseButton1"]
87
+ local at = Vector2.new(step.x, step.y)
88
+ virtual:SendMousePosition(at)
89
+ virtual:SendMouseButton(at, button, true, 0)
90
+ if step.action ~= "press" then
91
+ task.wait(step.hold or 0.05)
92
+ virtual:SendMouseButton(at, button, false, 0)
93
+ end
94
+ elseif kind == "text" then
95
+ virtual:SendTextInput(step.text)
96
+ end
97
+ table.insert(performed, kind)
98
+ if step.after ~= nil and step.after > 0 then
99
+ task.wait(step.after)
100
+ end
101
+ end
102
+
103
+ report:FireServer({ ok = true, performed = performed })
104
+ ]==]
105
+
106
+ local function playerFor(name: string?): Player
107
+ local players = Players:GetPlayers()
108
+ if #players == 0 then
109
+ Dispatch.fail(
110
+ "NO_PLAYER",
111
+ "No player is in this session.",
112
+ "Input needs a running playtest with a character. Use `playtest op=\"play\"`, "
113
+ .. "then address this at the playtest's studioId."
114
+ )
115
+ end
116
+ if name == nil or name == "" then
117
+ if #players > 1 then
118
+ Dispatch.fail(
119
+ "AMBIGUOUS_PLAYER",
120
+ string.format("%d players are in this session.", #players),
121
+ "Name one with `player`."
122
+ )
123
+ end
124
+ return players[1]
125
+ end
126
+ for _, player in players do
127
+ if player.Name == name then
128
+ return player
129
+ end
130
+ end
131
+ Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this session.", name))
132
+ return players[1]
133
+ end
134
+
135
+ --[[
136
+ Turns the request into steps the relay understands, validating names here
137
+ rather than on the client where a bad one would silently do nothing.
138
+ ]]
139
+ local function planFrom(params: { [string]: any }): { { [string]: any } }
140
+ local steps = params.steps
141
+ if typeof(steps) ~= "table" or #(steps :: { any }) == 0 then
142
+ Dispatch.fail("BAD_PARAMS", "input needs a `steps` array.")
143
+ end
144
+ if #(steps :: { any }) > MAX_STEPS then
145
+ Dispatch.fail(
146
+ "TOO_MANY",
147
+ string.format("%d steps, over the limit of %d.", #(steps :: { any }), MAX_STEPS)
148
+ )
149
+ end
150
+
151
+ local plan: { { [string]: any } } = {}
152
+ for index, raw in steps :: { { [string]: any } } do
153
+ local kind = tostring(raw.kind or "key")
154
+ local step: { [string]: any } = { kind = kind }
155
+
156
+ if raw.after ~= nil then
157
+ step.after = math.clamp(tonumber(raw.after) or 0, 0, MAX_HOLD)
158
+ end
159
+ if raw.hold ~= nil then
160
+ step.hold = math.clamp(tonumber(raw.hold) or 0, 0, MAX_HOLD)
161
+ end
162
+
163
+ if kind == "key" then
164
+ local key = tostring(raw.key or "")
165
+ local okKey = pcall(function()
166
+ return (Enum.KeyCode :: any)[key]
167
+ end)
168
+ if not okKey or key == "" then
169
+ Dispatch.fail(
170
+ "BAD_KEY",
171
+ string.format("Step %d: %q is not a KeyCode.", index, key),
172
+ 'Use names from Enum.KeyCode, e.g. "W", "Space", "LeftShift", "E".'
173
+ )
174
+ end
175
+ step.key = key
176
+ step.action = tostring(raw.action or "tap")
177
+ elseif kind == "click" or kind == "move" then
178
+ step.x = tonumber(raw.x) or 0
179
+ step.y = tonumber(raw.y) or 0
180
+ if kind == "click" then
181
+ local button = tostring(raw.button or "MouseButton1")
182
+ local okButton = pcall(function()
183
+ return (Enum.UserInputType :: any)[button]
184
+ end)
185
+ if not okButton then
186
+ Dispatch.fail(
187
+ "BAD_PARAMS",
188
+ string.format("Step %d: %q is not a UserInputType.", index, button),
189
+ 'Use "MouseButton1", "MouseButton2" or "MouseButton3".'
190
+ )
191
+ end
192
+ step.button = button
193
+ step.action = tostring(raw.action or "tap")
194
+ end
195
+ elseif kind == "text" then
196
+ step.text = tostring(raw.text or "")
197
+ else
198
+ Dispatch.fail(
199
+ "BAD_PARAMS",
200
+ string.format("Step %d: unknown kind %q.", index, kind),
201
+ 'Use "key", "click", "move" or "text".'
202
+ )
203
+ end
204
+
205
+ table.insert(plan, step)
206
+ end
207
+ return plan
208
+ end
209
+
210
+ function Input.send(params: { [string]: any }): { [string]: any }
211
+ --[[
212
+ Refused rather than attempted. In an edit session VirtualInput does work,
213
+ but it delivers into the editor's own DataModel where there is no game
214
+ and no character -- so it would look like it ran and change nothing.
215
+ ]]
216
+ if not RunService:IsRunning() then
217
+ Dispatch.fail(
218
+ "NOT_RUNNING",
219
+ "Input needs a running playtest; this is an edit session.",
220
+ "Start one with `playtest op=\"play\"`, then address this at the playtest's studioId."
221
+ )
222
+ end
223
+
224
+ local plan = planFrom(params)
225
+ local player = playerFor(if typeof(params.player) == "string" then params.player else nil)
226
+ local playerGui = player:FindFirstChildOfClass("PlayerGui")
227
+ if playerGui == nil then
228
+ Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name))
229
+ end
230
+
231
+ local remote = Instance.new("RemoteEvent")
232
+ remote.Name = "Report"
233
+
234
+ local relay = Instance.new("LocalScript")
235
+ relay.Name = "MCPInputRelay"
236
+ relay.Source = RELAY_SOURCE
237
+ relay:SetAttribute("Plan", HttpService:JSONEncode(plan))
238
+ remote.Parent = relay
239
+
240
+ local answer: { [string]: any }? = nil
241
+ local connection = remote.OnServerEvent:Connect(function(_from, payload)
242
+ if typeof(payload) == "table" then
243
+ answer = payload :: { [string]: any }
244
+ end
245
+ end)
246
+
247
+ -- Parenting last: the script starts the moment it lands, and it waits for
248
+ -- the RemoteEvent, so both must already be in place.
249
+ relay.Parent = playerGui
250
+
251
+ local deadline = os.clock() + ACK_TIMEOUT
252
+ while answer == nil and os.clock() < deadline do
253
+ task.wait(0.05)
254
+ end
255
+
256
+ connection:Disconnect()
257
+ relay:Destroy()
258
+
259
+ if answer == nil then
260
+ Dispatch.fail(
261
+ "NO_ACK",
262
+ string.format("The client did not confirm the input within %ds.", ACK_TIMEOUT),
263
+ "Nothing here can tell whether some of it was delivered. Read `character op=\"state\"` "
264
+ .. "to see where things actually are."
265
+ )
266
+ end
267
+
268
+ local result = answer :: { [string]: any }
269
+ if result.ok ~= true then
270
+ Dispatch.fail("INPUT_FAILED", tostring(result.reason or "the client refused the plan"))
271
+ end
272
+
273
+ return {
274
+ delivered = true,
275
+ steps = #plan,
276
+ player = player.Name,
277
+ performed = result.performed,
278
+ }
279
+ end
280
+
281
+ function Input.register()
282
+ Dispatch.registerAll("input", {
283
+ send = Input.send,
284
+ })
285
+ end
286
+
287
+ return Input