@el4cteo/rbx-studio-mcp 0.7.1 → 0.7.5
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/api.js +8 -8
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/rpc.js +18 -18
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +17 -21
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/opencloud.js +7 -7
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/tool.js +7 -0
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +11 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +7 -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/dist/tools/scripts.js +55 -12
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +0 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -74
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +21 -21
- package/plugin/src/Emulation.luau +8 -8
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/ScriptEdit.luau +14 -14
- package/plugin/src/Secret.luau +0 -9
- package/plugin/src/Transport.luau +58 -1
- package/plugin/src/Visuals.luau +8 -8
- package/plugin/src/handlers/Anim.luau +50 -50
- package/plugin/src/handlers/Character.luau +8 -8
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Discover.luau +11 -11
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Instances.luau +5 -5
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/handlers/Scripts.luau +68 -19
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +40 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +107 -99
- package/scripts/test-live.mjs +283 -0
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +151 -0
- package/scripts/test-transport.mjs +71 -58
|
@@ -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
|
|
@@ -56,11 +56,6 @@ local function resolveScript(path: string): LuaSourceContainer
|
|
|
56
56
|
return instance :: LuaSourceContainer
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
--[[
|
|
60
|
-
Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
|
|
61
|
-
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
62
|
-
need no off-by-one conversion between them.
|
|
63
|
-
]]
|
|
64
59
|
--[[
|
|
65
60
|
A short fingerprint of a script's source, for detecting that it moved.
|
|
66
61
|
|
|
@@ -136,6 +131,11 @@ local function syncedFile(target: Instance): string?
|
|
|
136
131
|
return name
|
|
137
132
|
end
|
|
138
133
|
|
|
134
|
+
--[[
|
|
135
|
+
Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
|
|
136
|
+
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
137
|
+
need no off-by-one conversion between them.
|
|
138
|
+
]]
|
|
139
139
|
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
140
140
|
local paths = params.paths
|
|
141
141
|
if typeof(paths) ~= "table" or #paths == 0 then
|
|
@@ -381,12 +381,45 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
381
381
|
)
|
|
382
382
|
end
|
|
383
383
|
|
|
384
|
+
local function badPattern(reason: any): never
|
|
385
|
+
Dispatch.fail(
|
|
386
|
+
"BAD_PATTERN",
|
|
387
|
+
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(reason)),
|
|
388
|
+
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
389
|
+
.. "Set `literal` to search for the text exactly as written."
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
--[[
|
|
394
|
+
One search over the whole file before splitting it into lines.
|
|
395
|
+
|
|
396
|
+
Most scripts in a place do not contain what is being looked for, and
|
|
397
|
+
splitting each into a line table only to find nothing was most of the cost
|
|
398
|
+
of a place-wide search. A pattern that matches no line cannot match the
|
|
399
|
+
whole file either -- except one anchored with `^` or `$`, which mean line
|
|
400
|
+
ends here but file ends there, so those skip this shortcut.
|
|
401
|
+
]]
|
|
402
|
+
-- Any trailing `$` counts, escaped or not: skipping the shortcut costs speed,
|
|
403
|
+
-- taking it wrongly would hide matches.
|
|
404
|
+
local anchored = not literal and (string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$")
|
|
405
|
+
|
|
384
406
|
local matches: { { [string]: any } } = {}
|
|
385
407
|
local total = 0
|
|
386
408
|
local memo: Paths.NameIndex = {}
|
|
387
409
|
|
|
388
410
|
for _, target in targets do
|
|
389
|
-
local
|
|
411
|
+
local source = ScriptEdit.read(target)
|
|
412
|
+
if not anchored then
|
|
413
|
+
local ok, found = pcall(string.find, if ignoreCase then string.lower(source) else source, needle, 1, literal)
|
|
414
|
+
if not ok then
|
|
415
|
+
badPattern(found)
|
|
416
|
+
end
|
|
417
|
+
if not found then
|
|
418
|
+
continue
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
local lines = TextEdit.toLines(source)
|
|
390
423
|
local path: string? = nil
|
|
391
424
|
|
|
392
425
|
for number, line in lines do
|
|
@@ -395,12 +428,7 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
395
428
|
-- has to be caught and reported as a pattern problem, not a no-match.
|
|
396
429
|
local ok, from = pcall(string.find, haystack, needle, 1, literal)
|
|
397
430
|
if not ok then
|
|
398
|
-
|
|
399
|
-
"BAD_PATTERN",
|
|
400
|
-
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
|
|
401
|
-
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
402
|
-
.. "Set `literal` to search for the text exactly as written."
|
|
403
|
-
)
|
|
431
|
+
badPattern(from)
|
|
404
432
|
end
|
|
405
433
|
if not from then
|
|
406
434
|
continue
|
|
@@ -440,12 +468,6 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
|
440
468
|
}
|
|
441
469
|
end
|
|
442
470
|
|
|
443
|
-
--[[
|
|
444
|
-
Creates scripts. Source is assigned directly here rather than through
|
|
445
|
-
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
446
|
-
open in the editor and there is no buffer to conflict with. Every later edit
|
|
447
|
-
goes through the editor path.
|
|
448
|
-
]]
|
|
449
471
|
--[[
|
|
450
472
|
Names the starter container a script was just parented into, or nil.
|
|
451
473
|
|
|
@@ -457,6 +479,14 @@ end
|
|
|
457
479
|
Client over LocalScript" advice writes a double-running script and is given
|
|
458
480
|
no way to find out.
|
|
459
481
|
]]
|
|
482
|
+
--[[
|
|
483
|
+
Assigning `Source` fails at 200,000 characters or more ("Provided string
|
|
484
|
+
length ... is greater than or equal to max length (200000)"), while
|
|
485
|
+
`UpdateSourceAsync` takes far more -- measured writing 279,000. Sources at
|
|
486
|
+
the limit are written through the editor instead of refused.
|
|
487
|
+
]]
|
|
488
|
+
local SOURCE_PROPERTY_LIMIT = 200000
|
|
489
|
+
|
|
460
490
|
local STARTER_CONTAINERS = {
|
|
461
491
|
"StarterGui",
|
|
462
492
|
"StarterPack",
|
|
@@ -473,6 +503,12 @@ local function starterContainer(instance: Instance): string?
|
|
|
473
503
|
return nil
|
|
474
504
|
end
|
|
475
505
|
|
|
506
|
+
--[[
|
|
507
|
+
Creates scripts. Source is assigned directly here rather than through
|
|
508
|
+
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
509
|
+
open in the editor and there is no buffer to conflict with. Every later edit
|
|
510
|
+
goes through the editor path.
|
|
511
|
+
]]
|
|
476
512
|
function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
477
513
|
local requests = params.scripts
|
|
478
514
|
if typeof(requests) ~= "table" or #requests == 0 then
|
|
@@ -503,6 +539,7 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
503
539
|
-- No shared path memo here: each creation changes its parent's children, so a
|
|
504
540
|
-- cached sibling grouping would go stale mid-batch and mis-number the paths.
|
|
505
541
|
local warnings: { string } = {}
|
|
542
|
+
local oversized: { { target: LuaSourceContainer, source: string } } = {}
|
|
506
543
|
|
|
507
544
|
local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
|
|
508
545
|
local created: { { [string]: any } } = {}
|
|
@@ -513,7 +550,11 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
513
550
|
|
|
514
551
|
instance.Name = request.name
|
|
515
552
|
if typeof(request.source) == "string" then
|
|
516
|
-
|
|
553
|
+
if #request.source < SOURCE_PROPERTY_LIMIT then
|
|
554
|
+
(instance :: ScriptEdit.SourceContainer).Source = request.source
|
|
555
|
+
else
|
|
556
|
+
table.insert(oversized, { target = instance, source = request.source })
|
|
557
|
+
end
|
|
517
558
|
end
|
|
518
559
|
|
|
519
560
|
if typeof(request.runContext) == "string" and instance:IsA("Script") then
|
|
@@ -605,6 +646,14 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
|
605
646
|
return created
|
|
606
647
|
end)
|
|
607
648
|
|
|
649
|
+
-- After the recording, because the editor write yields. Undoing the create
|
|
650
|
+
-- still removes these scripts whole, source and all.
|
|
651
|
+
for _, entry in oversized do
|
|
652
|
+
ScriptEdit.write(entry.target, function()
|
|
653
|
+
return entry.source
|
|
654
|
+
end)
|
|
655
|
+
end
|
|
656
|
+
|
|
608
657
|
return {
|
|
609
658
|
items = created,
|
|
610
659
|
undoStep = if recorded then "MCP create script" else nil,
|