@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.6
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/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/remote.js +9 -3
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +2 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/lib/timeout.js +13 -0
- package/dist/lib/timeout.js.map +1 -0
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +13 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +8 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +11 -1
- package/dist/tools/playtest.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +37 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +69 -0
- package/scripts/test-live.mjs +283 -115
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +162 -39
|
@@ -1,205 +1,245 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Driving Studio's playtests.
|
|
4
|
-
|
|
5
|
-
`StudioTestService`, not `RunService:Run`. Run/Pause/Stop are marked
|
|
6
|
-
PluginSecurity and are permitted, and they do nothing: called from a real
|
|
7
|
-
plugin handler -- not just through the loadstring sandbox, which was the first
|
|
8
|
-
wrong explanation -- `Run()` returns successfully and leaves Studio in edit
|
|
9
|
-
mode with nothing logged. StudioTestService is the service Roblox added for
|
|
10
|
-
this, and it reaches further: Play mode with a character, and multiplayer
|
|
11
|
-
tests, neither of which RunService offered at all.
|
|
12
|
-
|
|
13
|
-
The Execute calls yield until the test ends -- specifically until something
|
|
14
|
-
inside the session calls `EndTest`. That is the wrong shape for a request
|
|
15
|
-
that should answer promptly, so the call is started on its own task and the
|
|
16
|
-
reply describes the state it reached. The result, whenever the test finally
|
|
17
|
-
produces one, is kept for a later `state` to collect.
|
|
18
|
-
]]
|
|
19
|
-
|
|
20
|
-
local RunService = game:GetService("RunService")
|
|
21
|
-
local StudioTestService = game:GetService("StudioTestService")
|
|
22
|
-
|
|
23
|
-
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
24
|
-
|
|
25
|
-
-- Long enough for Studio to actually enter the mode before the state is read
|
|
26
|
-
-- back, so the reply describes what happened rather than what was asked for.
|
|
27
|
-
local SETTLE_SECONDS = 1.5
|
|
28
|
-
|
|
29
|
-
local Playtest = {}
|
|
30
|
-
|
|
31
|
-
--
|
|
32
|
-
--
|
|
33
|
-
local
|
|
34
|
-
local
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
local
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Dispatch.fail(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
--
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Driving Studio's playtests.
|
|
4
|
+
|
|
5
|
+
`StudioTestService`, not `RunService:Run`. Run/Pause/Stop are marked
|
|
6
|
+
PluginSecurity and are permitted, and they do nothing: called from a real
|
|
7
|
+
plugin handler -- not just through the loadstring sandbox, which was the first
|
|
8
|
+
wrong explanation -- `Run()` returns successfully and leaves Studio in edit
|
|
9
|
+
mode with nothing logged. StudioTestService is the service Roblox added for
|
|
10
|
+
this, and it reaches further: Play mode with a character, and multiplayer
|
|
11
|
+
tests, neither of which RunService offered at all.
|
|
12
|
+
|
|
13
|
+
The Execute calls yield until the test ends -- specifically until something
|
|
14
|
+
inside the session calls `EndTest`. That is the wrong shape for a request
|
|
15
|
+
that should answer promptly, so the call is started on its own task and the
|
|
16
|
+
reply describes the state it reached. The result, whenever the test finally
|
|
17
|
+
produces one, is kept for a later `state` to collect.
|
|
18
|
+
]]
|
|
19
|
+
|
|
20
|
+
local RunService = game:GetService("RunService")
|
|
21
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
22
|
+
|
|
23
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
24
|
+
|
|
25
|
+
-- Long enough for Studio to actually enter the mode before the state is read
|
|
26
|
+
-- back, so the reply describes what happened rather than what was asked for.
|
|
27
|
+
local SETTLE_SECONDS = 1.5
|
|
28
|
+
|
|
29
|
+
local Playtest = {}
|
|
30
|
+
|
|
31
|
+
-- Supplied by the plugin entrypoint, which owns persistent plugin settings.
|
|
32
|
+
-- Read on every request so another panel context's choice is not cached here.
|
|
33
|
+
local readAllowed: () -> any = function() return nil end
|
|
34
|
+
local writeAllowed: (boolean) -> () = function() error("Playtest settings are not initialized") end
|
|
35
|
+
function Playtest.setup(read: () -> any, write: (boolean) -> ())
|
|
36
|
+
readAllowed, writeAllowed = read, write
|
|
37
|
+
end
|
|
38
|
+
function Playtest.isAllowed(): boolean
|
|
39
|
+
return readAllowed() ~= false
|
|
40
|
+
end
|
|
41
|
+
function Playtest.setAllowed(value: boolean)
|
|
42
|
+
writeAllowed(value)
|
|
43
|
+
end
|
|
44
|
+
local function requireAllowed()
|
|
45
|
+
if not Playtest.isAllowed() then
|
|
46
|
+
Dispatch.fail("PLAYTEST_DISABLED", "Playtests are disabled by the user.",
|
|
47
|
+
"Do not start or simulate a playtest. Continue using edit-mode tools and static inspection where possible.\nRun `playtests on` in the Studio MCP panel to re-enable playtesting.")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
-- Outcome of the most recent test, which arrives long after the call that
|
|
52
|
+
-- started it returned.
|
|
53
|
+
local pending = false
|
|
54
|
+
local lastResult: any = nil
|
|
55
|
+
local lastError: string? = nil
|
|
56
|
+
local startedAt: number? = nil
|
|
57
|
+
|
|
58
|
+
local function service(): any
|
|
59
|
+
return StudioTestService :: any
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
local function editModeActive(): boolean?
|
|
63
|
+
local ok, value = pcall(function()
|
|
64
|
+
return service().EditModeActive
|
|
65
|
+
end)
|
|
66
|
+
return if ok and typeof(value) == "boolean" then value else nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
local function state(): { [string]: any }
|
|
70
|
+
return {
|
|
71
|
+
playtestsAllowed = Playtest.isAllowed(),
|
|
72
|
+
isEdit = RunService:IsEdit(),
|
|
73
|
+
isRunning = RunService:IsRunning(),
|
|
74
|
+
isRunMode = RunService:IsRunMode(),
|
|
75
|
+
editModeActive = editModeActive(),
|
|
76
|
+
playerCount = #game:GetService("Players"):GetPlayers(),
|
|
77
|
+
testPending = pending,
|
|
78
|
+
-- Kept from the previous test rather than discarded, since whatever
|
|
79
|
+
-- EndTest returned is the only thing a test session can hand back.
|
|
80
|
+
lastResult = lastResult,
|
|
81
|
+
lastError = lastError,
|
|
82
|
+
runningForSeconds = if startedAt then math.floor(os.clock() - startedAt) else nil,
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
--[[
|
|
87
|
+
Starts a test without waiting for it to finish.
|
|
88
|
+
|
|
89
|
+
Every failure here has to survive on its own thread: nothing is watching it
|
|
90
|
+
by the time it fails, so the reason is stored for the next `state` instead of
|
|
91
|
+
being raised into a caller that has already been answered.
|
|
92
|
+
]]
|
|
93
|
+
local function launch(name: string, call: () -> any)
|
|
94
|
+
pending = true
|
|
95
|
+
lastResult = nil
|
|
96
|
+
lastError = nil
|
|
97
|
+
startedAt = os.clock()
|
|
98
|
+
|
|
99
|
+
task.spawn(function()
|
|
100
|
+
local ok, result = pcall(function()
|
|
101
|
+
-- Recheck after scheduling: OFF may have been entered while queued.
|
|
102
|
+
requireAllowed()
|
|
103
|
+
return call()
|
|
104
|
+
end)
|
|
105
|
+
pending = false
|
|
106
|
+
startedAt = nil
|
|
107
|
+
if ok then
|
|
108
|
+
lastResult = result
|
|
109
|
+
else
|
|
110
|
+
lastError = if typeof(result) == "table" and result.code == "PLAYTEST_DISABLED"
|
|
111
|
+
then result.code .. ": " .. result.message .. "\n" .. result.hint
|
|
112
|
+
else string.format("%s failed: %s", name, tostring(result))
|
|
113
|
+
end
|
|
114
|
+
end)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
function Playtest.control(params: { [string]: any }): { [string]: any }
|
|
118
|
+
local op = params.op
|
|
119
|
+
if op == "play" or op == "run" or op == "multiplayer" then requireAllowed() end
|
|
120
|
+
local before = state()
|
|
121
|
+
|
|
122
|
+
if op == "state" then
|
|
123
|
+
return { changed = false, state = before }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
--[[
|
|
127
|
+
Ending a test has to happen inside it.
|
|
128
|
+
|
|
129
|
+
`LeaveTest` reads like the plugin-side exit and is not: it refuses with
|
|
130
|
+
"can only be called from the client DataModel of a running Studio test
|
|
131
|
+
session", and a playtest client can never reach this bridge because Studio
|
|
132
|
+
bars client sessions from making HTTP requests. So that route is closed for
|
|
133
|
+
good, not merely awkward.
|
|
134
|
+
|
|
135
|
+
`EndTest` works from the playtest's *server* session, which does connect --
|
|
136
|
+
and its value travels back to whichever plugin called ExecutePlayModeAsync.
|
|
137
|
+
The server tool routes a stop to that session; this op is what it calls.
|
|
138
|
+
]]
|
|
139
|
+
if op == "endTest" then
|
|
140
|
+
--[[
|
|
141
|
+
Deferred, because EndTest destroys the DataModel this handler is
|
|
142
|
+
running in -- including the connection carrying the reply. Called
|
|
143
|
+
inline it does end the test and the caller sees only DISCONNECTED,
|
|
144
|
+
which reads as a failed stop for something that worked perfectly.
|
|
145
|
+
|
|
146
|
+
The reply goes out first and the teardown follows. Whether it
|
|
147
|
+
actually ended is not taken on trust either way: the caller confirms
|
|
148
|
+
against the editor session, which survives.
|
|
149
|
+
]]
|
|
150
|
+
task.delay(0.25, function()
|
|
151
|
+
pcall(function()
|
|
152
|
+
service():EndTest(params.value or "stopped by studio-mcp")
|
|
153
|
+
end)
|
|
154
|
+
end)
|
|
155
|
+
return { changed = true, deferred = true, state = before }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if op == "stop" then
|
|
159
|
+
if before.isEdit and not before.isRunning and not pending then
|
|
160
|
+
return { changed = false, reason = "no test is running", state = before }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
-- Only reachable when the caller could not find a playtest session to end
|
|
164
|
+
-- from. Stops a Run-mode session, which has no separate DataModel.
|
|
165
|
+
pcall(function()
|
|
166
|
+
RunService:Stop()
|
|
167
|
+
end)
|
|
168
|
+
|
|
169
|
+
task.wait(SETTLE_SECONDS)
|
|
170
|
+
local after = state()
|
|
171
|
+
return {
|
|
172
|
+
changed = after.isRunning ~= before.isRunning or after.isEdit ~= before.isEdit,
|
|
173
|
+
state = after,
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if pending then
|
|
178
|
+
Dispatch.fail(
|
|
179
|
+
"ALREADY_RUNNING",
|
|
180
|
+
"A test is already running.",
|
|
181
|
+
"Stop it first, or call with op 'state' to see how it is doing."
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
-- ExecutePlayModeAsync rejects a nil argument outright -- "Argument 1 missing
|
|
186
|
+
-- or nil" -- even though the parameter is a Variant, so an omitted `args`
|
|
187
|
+
-- becomes an empty string rather than a failed launch.
|
|
188
|
+
local args = if params.args == nil then "" else params.args
|
|
189
|
+
if op == "play" then
|
|
190
|
+
launch("ExecutePlayModeAsync", function()
|
|
191
|
+
return service():ExecutePlayModeAsync(args)
|
|
192
|
+
end)
|
|
193
|
+
elseif op == "run" then
|
|
194
|
+
launch("ExecuteRunModeAsync", function()
|
|
195
|
+
return service():ExecuteRunModeAsync(args)
|
|
196
|
+
end)
|
|
197
|
+
elseif op == "multiplayer" then
|
|
198
|
+
local players = tonumber(params.players) or 2
|
|
199
|
+
if players < 1 or players > 8 then
|
|
200
|
+
Dispatch.fail("BAD_PARAMS", "players must be between 1 and 8.")
|
|
201
|
+
end
|
|
202
|
+
launch("ExecuteMultiplayerTestAsync", function()
|
|
203
|
+
return service():ExecuteMultiplayerTestAsync(players, args)
|
|
204
|
+
end)
|
|
205
|
+
else
|
|
206
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown op %q", tostring(op)))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
task.wait(SETTLE_SECONDS)
|
|
210
|
+
local after = state()
|
|
211
|
+
|
|
212
|
+
-- Whether the mode moved, not whether the call was accepted. Accepting and
|
|
213
|
+
-- doing nothing is exactly how RunService:Run behaved here.
|
|
214
|
+
local moved = after.isRunning ~= before.isRunning
|
|
215
|
+
or after.isEdit ~= before.isEdit
|
|
216
|
+
or after.editModeActive ~= before.editModeActive
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
changed = moved or after.testPending,
|
|
220
|
+
reason = if after.lastError then after.lastError
|
|
221
|
+
elseif not moved and not after.testPending then "Studio accepted the call but the mode did not change"
|
|
222
|
+
else nil,
|
|
223
|
+
state = after,
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
-- Panel-only stop, also usable in the client view which cannot make HTTP calls.
|
|
228
|
+
-- No state-change listener is installed: manual Play stays available while OFF.
|
|
229
|
+
function Playtest.stopLocal()
|
|
230
|
+
if not RunService:IsEdit() and RunService:IsClient() and not RunService:IsServer() then
|
|
231
|
+
service():LeaveTest()
|
|
232
|
+
elseif not RunService:IsEdit() and not RunService:IsRunMode() then
|
|
233
|
+
Playtest.control({ op = "endTest" })
|
|
234
|
+
else
|
|
235
|
+
Playtest.control({ op = "stop" })
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
function Playtest.register()
|
|
240
|
+
Dispatch.registerAll("playtest", {
|
|
241
|
+
control = Playtest.control,
|
|
242
|
+
})
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
return Playtest
|