@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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- 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
|