@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.
@@ -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
- -- Outcome of the most recent test, which arrives long after the call that
32
- -- started it returned.
33
- local pending = false
34
- local lastResult: any = nil
35
- local lastError: string? = nil
36
- local startedAt: number? = nil
37
-
38
- local function service(): any
39
- return StudioTestService :: any
40
- end
41
-
42
- local function editModeActive(): boolean?
43
- local ok, value = pcall(function()
44
- return service().EditModeActive
45
- end)
46
- return if ok and typeof(value) == "boolean" then value else nil
47
- end
48
-
49
- local function state(): { [string]: any }
50
- return {
51
- isEdit = RunService:IsEdit(),
52
- isRunning = RunService:IsRunning(),
53
- isRunMode = RunService:IsRunMode(),
54
- editModeActive = editModeActive(),
55
- playerCount = #game:GetService("Players"):GetPlayers(),
56
- testPending = pending,
57
- -- Kept from the previous test rather than discarded, since whatever
58
- -- EndTest returned is the only thing a test session can hand back.
59
- lastResult = lastResult,
60
- lastError = lastError,
61
- runningForSeconds = if startedAt then math.floor(os.clock() - startedAt) else nil,
62
- }
63
- end
64
-
65
- --[[
66
- Starts a test without waiting for it to finish.
67
-
68
- Every failure here has to survive on its own thread: nothing is watching it
69
- by the time it fails, so the reason is stored for the next `state` instead of
70
- being raised into a caller that has already been answered.
71
- ]]
72
- local function launch(name: string, call: () -> any)
73
- pending = true
74
- lastResult = nil
75
- lastError = nil
76
- startedAt = os.clock()
77
-
78
- task.spawn(function()
79
- local ok, result = pcall(call)
80
- pending = false
81
- startedAt = nil
82
- if ok then
83
- lastResult = result
84
- else
85
- lastError = string.format("%s failed: %s", name, tostring(result))
86
- end
87
- end)
88
- end
89
-
90
- function Playtest.control(params: { [string]: any }): { [string]: any }
91
- local op = params.op
92
- local before = state()
93
-
94
- if op == "state" then
95
- return { changed = false, state = before }
96
- end
97
-
98
- --[[
99
- Ending a test has to happen inside it.
100
-
101
- `LeaveTest` reads like the plugin-side exit and is not: it refuses with
102
- "can only be called from the client DataModel of a running Studio test
103
- session", and a playtest client can never reach this bridge because Studio
104
- bars client sessions from making HTTP requests. So that route is closed for
105
- good, not merely awkward.
106
-
107
- `EndTest` works from the playtest's *server* session, which does connect --
108
- and its value travels back to whichever plugin called ExecutePlayModeAsync.
109
- The server tool routes a stop to that session; this op is what it calls.
110
- ]]
111
- if op == "endTest" then
112
- --[[
113
- Deferred, because EndTest destroys the DataModel this handler is
114
- running in -- including the connection carrying the reply. Called
115
- inline it does end the test and the caller sees only DISCONNECTED,
116
- which reads as a failed stop for something that worked perfectly.
117
-
118
- The reply goes out first and the teardown follows. Whether it
119
- actually ended is not taken on trust either way: the caller confirms
120
- against the editor session, which survives.
121
- ]]
122
- task.delay(0.25, function()
123
- pcall(function()
124
- service():EndTest(params.value or "stopped by studio-mcp")
125
- end)
126
- end)
127
- return { changed = true, deferred = true, state = before }
128
- end
129
-
130
- if op == "stop" then
131
- if before.isEdit and not before.isRunning and not pending then
132
- return { changed = false, reason = "no test is running", state = before }
133
- end
134
-
135
- -- Only reachable when the caller could not find a playtest session to end
136
- -- from. Stops a Run-mode session, which has no separate DataModel.
137
- pcall(function()
138
- RunService:Stop()
139
- end)
140
-
141
- task.wait(SETTLE_SECONDS)
142
- local after = state()
143
- return {
144
- changed = after.isRunning ~= before.isRunning or after.isEdit ~= before.isEdit,
145
- state = after,
146
- }
147
- end
148
-
149
- if pending then
150
- Dispatch.fail(
151
- "ALREADY_RUNNING",
152
- "A test is already running.",
153
- "Stop it first, or call with op 'state' to see how it is doing."
154
- )
155
- end
156
-
157
- -- ExecutePlayModeAsync rejects a nil argument outright -- "Argument 1 missing
158
- -- or nil" -- even though the parameter is a Variant, so an omitted `args`
159
- -- becomes an empty string rather than a failed launch.
160
- local args = if params.args == nil then "" else params.args
161
- if op == "play" then
162
- launch("ExecutePlayModeAsync", function()
163
- return service():ExecutePlayModeAsync(args)
164
- end)
165
- elseif op == "run" then
166
- launch("ExecuteRunModeAsync", function()
167
- return service():ExecuteRunModeAsync(args)
168
- end)
169
- elseif op == "multiplayer" then
170
- local players = tonumber(params.players) or 2
171
- if players < 1 or players > 8 then
172
- Dispatch.fail("BAD_PARAMS", "players must be between 1 and 8.")
173
- end
174
- launch("ExecuteMultiplayerTestAsync", function()
175
- return service():ExecuteMultiplayerTestAsync(players, args)
176
- end)
177
- else
178
- Dispatch.fail("BAD_PARAMS", string.format("unknown op %q", tostring(op)))
179
- end
180
-
181
- task.wait(SETTLE_SECONDS)
182
- local after = state()
183
-
184
- -- Whether the mode moved, not whether the call was accepted. Accepting and
185
- -- doing nothing is exactly how RunService:Run behaved here.
186
- local moved = after.isRunning ~= before.isRunning
187
- or after.isEdit ~= before.isEdit
188
- or after.editModeActive ~= before.editModeActive
189
-
190
- return {
191
- changed = moved or after.testPending,
192
- reason = if after.lastError then after.lastError
193
- elseif not moved and not after.testPending then "Studio accepted the call but the mode did not change"
194
- else nil,
195
- state = after,
196
- }
197
- end
198
-
199
- function Playtest.register()
200
- Dispatch.registerAll("playtest", {
201
- control = Playtest.control,
202
- })
203
- end
204
-
205
- return Playtest
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