rbxstudio-mcp 2.3.2 → 2.4.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/README.md +67 -14
- package/dist/__tests__/bridge-service.test.js +25 -13
- package/dist/__tests__/bridge-service.test.js.map +1 -1
- package/dist/__tests__/bridge-session.test.d.ts +2 -0
- package/dist/__tests__/bridge-session.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-session.test.js +171 -0
- package/dist/__tests__/bridge-session.test.js.map +1 -0
- package/dist/__tests__/chunker.test.d.ts +2 -0
- package/dist/__tests__/chunker.test.d.ts.map +1 -0
- package/dist/__tests__/chunker.test.js +201 -0
- package/dist/__tests__/chunker.test.js.map +1 -0
- package/dist/__tests__/docs-core.test.d.ts +2 -0
- package/dist/__tests__/docs-core.test.d.ts.map +1 -0
- package/dist/__tests__/docs-core.test.js +137 -0
- package/dist/__tests__/docs-core.test.js.map +1 -0
- package/dist/__tests__/docs-fetcher.test.d.ts +2 -0
- package/dist/__tests__/docs-fetcher.test.d.ts.map +1 -0
- package/dist/__tests__/docs-fetcher.test.js +173 -0
- package/dist/__tests__/docs-fetcher.test.js.map +1 -0
- package/dist/__tests__/helpers.d.ts +8 -0
- package/dist/__tests__/helpers.d.ts.map +1 -0
- package/dist/__tests__/helpers.js +23 -0
- package/dist/__tests__/helpers.js.map +1 -0
- package/dist/__tests__/http-routes.test.d.ts +2 -0
- package/dist/__tests__/http-routes.test.d.ts.map +1 -0
- package/dist/__tests__/http-routes.test.js +233 -0
- package/dist/__tests__/http-routes.test.js.map +1 -0
- package/dist/__tests__/http-server.test.js +13 -6
- package/dist/__tests__/http-server.test.js.map +1 -1
- package/dist/__tests__/integration.test.js +9 -4
- package/dist/__tests__/integration.test.js.map +1 -1
- package/dist/__tests__/semantic-search.test.d.ts +2 -0
- package/dist/__tests__/semantic-search.test.d.ts.map +1 -0
- package/dist/__tests__/semantic-search.test.js +202 -0
- package/dist/__tests__/semantic-search.test.js.map +1 -0
- package/dist/__tests__/smoke.test.js +7 -3
- package/dist/__tests__/smoke.test.js.map +1 -1
- package/dist/__tests__/studio-client.test.d.ts +2 -0
- package/dist/__tests__/studio-client.test.d.ts.map +1 -0
- package/dist/__tests__/studio-client.test.js +25 -0
- package/dist/__tests__/studio-client.test.js.map +1 -0
- package/dist/__tests__/tool-nudges.test.d.ts +2 -0
- package/dist/__tests__/tool-nudges.test.d.ts.map +1 -0
- package/dist/__tests__/tool-nudges.test.js +60 -0
- package/dist/__tests__/tool-nudges.test.js.map +1 -0
- package/dist/__tests__/tool-registry.test.d.ts +2 -0
- package/dist/__tests__/tool-registry.test.d.ts.map +1 -0
- package/dist/__tests__/tool-registry.test.js +365 -0
- package/dist/__tests__/tool-registry.test.js.map +1 -0
- package/dist/__tests__/tools-bridge.test.d.ts +2 -0
- package/dist/__tests__/tools-bridge.test.d.ts.map +1 -0
- package/dist/__tests__/tools-bridge.test.js +396 -0
- package/dist/__tests__/tools-bridge.test.js.map +1 -0
- package/dist/__tests__/tools-docs.test.d.ts +2 -0
- package/dist/__tests__/tools-docs.test.d.ts.map +1 -0
- package/dist/__tests__/tools-docs.test.js +112 -0
- package/dist/__tests__/tools-docs.test.js.map +1 -0
- package/dist/__tests__/tools-guards.test.d.ts +2 -0
- package/dist/__tests__/tools-guards.test.d.ts.map +1 -0
- package/dist/__tests__/tools-guards.test.js +131 -0
- package/dist/__tests__/tools-guards.test.js.map +1 -0
- package/dist/__tests__/tools-runtime.test.d.ts +2 -0
- package/dist/__tests__/tools-runtime.test.d.ts.map +1 -0
- package/dist/__tests__/tools-runtime.test.js +214 -0
- package/dist/__tests__/tools-runtime.test.js.map +1 -0
- package/dist/__tests__/tools-visual.test.d.ts +2 -0
- package/dist/__tests__/tools-visual.test.d.ts.map +1 -0
- package/dist/__tests__/tools-visual.test.js +149 -0
- package/dist/__tests__/tools-visual.test.js.map +1 -0
- package/dist/bridge-service.d.ts +99 -12
- package/dist/bridge-service.d.ts.map +1 -1
- package/dist/bridge-service.js +238 -21
- package/dist/bridge-service.js.map +1 -1
- package/dist/docs/cache.d.ts +50 -0
- package/dist/docs/cache.d.ts.map +1 -0
- package/dist/docs/cache.js +123 -0
- package/dist/docs/cache.js.map +1 -0
- package/dist/docs/embeddings/chunker.d.ts +120 -0
- package/dist/docs/embeddings/chunker.d.ts.map +1 -0
- package/dist/docs/embeddings/chunker.js +395 -0
- package/dist/docs/embeddings/chunker.js.map +1 -0
- package/dist/docs/embeddings/embedder.d.ts +41 -0
- package/dist/docs/embeddings/embedder.d.ts.map +1 -0
- package/dist/docs/embeddings/embedder.js +113 -0
- package/dist/docs/embeddings/embedder.js.map +1 -0
- package/dist/docs/embeddings/index.d.ts +102 -0
- package/dist/docs/embeddings/index.d.ts.map +1 -0
- package/dist/docs/embeddings/index.js +250 -0
- package/dist/docs/embeddings/index.js.map +1 -0
- package/dist/docs/embeddings/manager.d.ts +68 -0
- package/dist/docs/embeddings/manager.d.ts.map +1 -0
- package/dist/docs/embeddings/manager.js +97 -0
- package/dist/docs/embeddings/manager.js.map +1 -0
- package/dist/docs/fetcher.d.ts +29 -0
- package/dist/docs/fetcher.d.ts.map +1 -0
- package/dist/docs/fetcher.js +244 -0
- package/dist/docs/fetcher.js.map +1 -0
- package/dist/docs/reference.d.ts +37 -0
- package/dist/docs/reference.d.ts.map +1 -0
- package/dist/docs/reference.js +108 -0
- package/dist/docs/reference.js.map +1 -0
- package/dist/docs/search.d.ts +194 -0
- package/dist/docs/search.d.ts.map +1 -0
- package/dist/docs/search.js +733 -0
- package/dist/docs/search.js.map +1 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +52 -5
- package/dist/http-server.js.map +1 -1
- package/dist/index.d.ts +8 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -1035
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +15 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +26 -0
- package/dist/instructions.js.map +1 -0
- package/dist/tools/defs/attributes.d.ts +6 -0
- package/dist/tools/defs/attributes.d.ts.map +1 -0
- package/dist/tools/defs/attributes.js +85 -0
- package/dist/tools/defs/attributes.js.map +1 -0
- package/dist/tools/defs/docs.d.ts +17 -0
- package/dist/tools/defs/docs.d.ts.map +1 -0
- package/dist/tools/defs/docs.js +151 -0
- package/dist/tools/defs/docs.js.map +1 -0
- package/dist/tools/defs/execute.d.ts +6 -0
- package/dist/tools/defs/execute.d.ts.map +1 -0
- package/dist/tools/defs/execute.js +21 -0
- package/dist/tools/defs/execute.js.map +1 -0
- package/dist/tools/defs/inspection.d.ts +7 -0
- package/dist/tools/defs/inspection.d.ts.map +1 -0
- package/dist/tools/defs/inspection.js +202 -0
- package/dist/tools/defs/inspection.js.map +1 -0
- package/dist/tools/defs/objects.d.ts +6 -0
- package/dist/tools/defs/objects.d.ts.map +1 -0
- package/dist/tools/defs/objects.js +111 -0
- package/dist/tools/defs/objects.js.map +1 -0
- package/dist/tools/defs/properties.d.ts +6 -0
- package/dist/tools/defs/properties.d.ts.map +1 -0
- package/dist/tools/defs/properties.js +71 -0
- package/dist/tools/defs/properties.js.map +1 -0
- package/dist/tools/defs/runtime.d.ts +6 -0
- package/dist/tools/defs/runtime.d.ts.map +1 -0
- package/dist/tools/defs/runtime.js +145 -0
- package/dist/tools/defs/runtime.js.map +1 -0
- package/dist/tools/defs/scripts.d.ts +18 -0
- package/dist/tools/defs/scripts.d.ts.map +1 -0
- package/dist/tools/defs/scripts.js +163 -0
- package/dist/tools/defs/scripts.js.map +1 -0
- package/dist/tools/defs/tags.d.ts +6 -0
- package/dist/tools/defs/tags.d.ts.map +1 -0
- package/dist/tools/defs/tags.js +74 -0
- package/dist/tools/defs/tags.js.map +1 -0
- package/dist/tools/defs/visual.d.ts +7 -0
- package/dist/tools/defs/visual.d.ts.map +1 -0
- package/dist/tools/defs/visual.js +208 -0
- package/dist/tools/defs/visual.js.map +1 -0
- package/dist/tools/index.d.ts +101 -25
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +580 -63
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/nudges.d.ts +25 -0
- package/dist/tools/nudges.d.ts.map +1 -0
- package/dist/tools/nudges.js +34 -0
- package/dist/tools/nudges.js.map +1 -0
- package/dist/tools/registry.d.ts +20 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +65 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/types.d.ts +24 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/package.json +7 -6
- package/studio-plugin/MCPPlugin.rbxmx +3 -238
- package/studio-plugin/plugin.luau +2041 -365
|
@@ -18,7 +18,7 @@ pcall(function()
|
|
|
18
18
|
end)
|
|
19
19
|
|
|
20
20
|
-- ============================================================================
|
|
21
|
-
-- TEST SESSION
|
|
21
|
+
-- TEST SESSION COMPANIONS
|
|
22
22
|
--
|
|
23
23
|
-- StudioTestService:EndTest() can only be called from the Server DataModel of
|
|
24
24
|
-- a running test. The plugin lives in the Edit DataModel, so it cannot call
|
|
@@ -26,29 +26,54 @@ end)
|
|
|
26
26
|
-- ServerScriptService BEFORE calling ExecutePlayModeAsync. When the test
|
|
27
27
|
-- starts, that Script runs in the Server DataModel and:
|
|
28
28
|
-- 1. Streams LogService.MessageOut to the bridge (/test-session/log)
|
|
29
|
-
-- 2. Polls the bridge (/test-session/poll) for
|
|
30
|
-
-- 3. On
|
|
29
|
+
-- 2. Polls the bridge (/test-session/poll) for commands
|
|
30
|
+
-- 3. On 'end', calls StudioTestService:EndTest() — which terminates the
|
|
31
31
|
-- yielding ExecutePlayModeAsync call back in the plugin.
|
|
32
|
+
-- 4. On 'eval', loadstring()s + xpcall()s the supplied code on its own
|
|
33
|
+
-- task thread and POSTs the result to /test-session/eval-result.
|
|
34
|
+
--
|
|
35
|
+
-- We also inject a parallel CLIENT companion (LocalScript) into
|
|
36
|
+
-- StarterPlayer.StarterPlayerScripts. StarterPlayerScripts auto-clones into
|
|
37
|
+
-- each Player's PlayerScripts when they join, so every player in the test
|
|
38
|
+
-- gets their own companion copy. Clients identify themselves to the bridge
|
|
39
|
+
-- by userId so run_live_lua can target a specific player or pick any
|
|
40
|
+
-- available one.
|
|
32
41
|
--
|
|
33
42
|
-- Cleanup strategy:
|
|
34
|
-
-- -
|
|
43
|
+
-- - Both companions tagged with TEST_COMPANION_TAG so we can find orphans
|
|
35
44
|
-- - Swept on plugin activate, on every playSolo start, and after the test
|
|
36
45
|
-- ends (whether via stop_play, natural end, or user clicking Stop)
|
|
46
|
+
--
|
|
47
|
+
-- LLM-stomp prevention:
|
|
48
|
+
-- - Distinctive name + leading "(auto-generated, safe to delete)" comment
|
|
49
|
+
-- - CollectionService tag
|
|
50
|
+
-- - Hard refusals in destructive MCP tools (delete_object, edit_script,
|
|
51
|
+
-- set_script_source, set_property Disabled, move_instance) for any
|
|
52
|
+
-- instance bearing the tag or matching the name
|
|
37
53
|
-- ============================================================================
|
|
38
54
|
local TEST_COMPANION_NAME = "_MCPTestCompanion"
|
|
55
|
+
local TEST_COMPANION_NAME_CLIENT = "_MCPTestCompanion_Client"
|
|
39
56
|
local TEST_COMPANION_TAG = "_MCPTestCompanion"
|
|
40
57
|
|
|
41
|
-
--
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
--
|
|
58
|
+
-- ----------------------------------------------------------------------------
|
|
59
|
+
-- SERVER companion template. Runs as a regular Script in ServerScriptService.
|
|
60
|
+
-- Placeholders __MCP_SERVER_URL__ and __MCP_SESSION_ID__ are substituted at
|
|
61
|
+
-- injection time. We use distinctive placeholders (not %%...%% or {{...}}) to
|
|
62
|
+
-- avoid collisions with Lua's own pattern syntax.
|
|
63
|
+
-- ----------------------------------------------------------------------------
|
|
45
64
|
local COMPANION_SCRIPT_TEMPLATE = [[-- _MCPTestCompanion (auto-generated, safe to delete)
|
|
46
65
|
-- Runs in the Server DataModel of a Studio test session started by the MCP
|
|
47
|
-
-- plugin. Pipes output back to the local MCP bridge
|
|
48
|
-
-- command to terminate the test cleanly via StudioTestService:EndTest()
|
|
66
|
+
-- plugin. Pipes output back to the local MCP bridge, listens for an "end"
|
|
67
|
+
-- command to terminate the test cleanly via StudioTestService:EndTest(), and
|
|
68
|
+
-- runs run_live_lua "eval" commands on the test's actual server.
|
|
69
|
+
--
|
|
70
|
+
-- DO NOT modify or delete while a play test is running — it will be cleaned
|
|
71
|
+
-- up automatically by the MCP plugin.
|
|
49
72
|
|
|
50
73
|
local HttpService = game:GetService("HttpService")
|
|
51
74
|
local LogService = game:GetService("LogService")
|
|
75
|
+
local Players = game:GetService("Players")
|
|
76
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
52
77
|
local StudioTestService
|
|
53
78
|
do
|
|
54
79
|
local ok, svc = pcall(function() return game:GetService("StudioTestService") end)
|
|
@@ -66,6 +91,57 @@ local outputBuffer = {}
|
|
|
66
91
|
local stopRequested = false
|
|
67
92
|
local consecutiveFailures = 0
|
|
68
93
|
|
|
94
|
+
-- Probe loadstring availability ONCE up front. Result goes into the hello
|
|
95
|
+
-- payload so the bridge can refuse run_live_lua with a clear error if the
|
|
96
|
+
-- user hasn't enabled ServerScriptService.LoadStringEnabled.
|
|
97
|
+
local LOADSTRING_READY = false
|
|
98
|
+
do
|
|
99
|
+
local ok, fn = pcall(loadstring, "return 1")
|
|
100
|
+
if ok and type(fn) == "function" then
|
|
101
|
+
local execOk, execVal = pcall(fn)
|
|
102
|
+
if execOk and execVal == 1 then
|
|
103
|
+
LOADSTRING_READY = true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
-- ---------------------------------------------------------------------------
|
|
109
|
+
-- CLIENT RELAY (server-side gateway)
|
|
110
|
+
-- ---------------------------------------------------------------------------
|
|
111
|
+
-- HttpService:RequestAsync is server-only in Roblox: LocalScripts can't make
|
|
112
|
+
-- HTTP requests. So the server companion is the only HTTP gateway, and the
|
|
113
|
+
-- client companion talks to us through these Remotes:
|
|
114
|
+
--
|
|
115
|
+
-- _MCPClientHello RemoteEvent client -> server, register/loadstring info
|
|
116
|
+
-- _MCPClientLogs RemoteEvent client -> server, batched LogService output
|
|
117
|
+
-- _MCPClientRelay RemoteFunction server -> client, dispatch run_live_lua eval
|
|
118
|
+
--
|
|
119
|
+
-- We keep these in ReplicatedStorage with Archivable=false so they don't get
|
|
120
|
+
-- saved into the place file, and they're cleaned up automatically when the
|
|
121
|
+
-- test session ends and the cloned DataModel is torn down.
|
|
122
|
+
-- ---------------------------------------------------------------------------
|
|
123
|
+
local clientRelay
|
|
124
|
+
local clientHello
|
|
125
|
+
local clientLogs
|
|
126
|
+
do
|
|
127
|
+
local function makeRemote(className, name)
|
|
128
|
+
local existing = ReplicatedStorage:FindFirstChild(name)
|
|
129
|
+
if existing and existing.ClassName == className then
|
|
130
|
+
return existing
|
|
131
|
+
elseif existing then
|
|
132
|
+
pcall(function() existing:Destroy() end)
|
|
133
|
+
end
|
|
134
|
+
local inst = Instance.new(className)
|
|
135
|
+
inst.Name = name
|
|
136
|
+
inst.Archivable = false
|
|
137
|
+
inst.Parent = ReplicatedStorage
|
|
138
|
+
return inst
|
|
139
|
+
end
|
|
140
|
+
clientRelay = makeRemote("RemoteFunction", "_MCPClientRelay")
|
|
141
|
+
clientHello = makeRemote("RemoteEvent", "_MCPClientHello")
|
|
142
|
+
clientLogs = makeRemote("RemoteEvent", "_MCPClientLogs")
|
|
143
|
+
end
|
|
144
|
+
|
|
69
145
|
local function postJSON(path, body)
|
|
70
146
|
local ok, response = pcall(function()
|
|
71
147
|
return HttpService:RequestAsync({
|
|
@@ -84,6 +160,147 @@ local function postJSON(path, body)
|
|
|
84
160
|
end
|
|
85
161
|
end
|
|
86
162
|
|
|
163
|
+
-- ---------------------------------------------------------------------------
|
|
164
|
+
-- Client gateway: forward client-side Remote events into the bridge over HTTP.
|
|
165
|
+
-- Clients can't make HTTP requests themselves (HttpService is server-only), so
|
|
166
|
+
-- they fire RemoteEvents at us and we forward.
|
|
167
|
+
-- ---------------------------------------------------------------------------
|
|
168
|
+
clientHello.OnServerEvent:Connect(function(player, payload)
|
|
169
|
+
-- payload = { loadstringReady = bool }
|
|
170
|
+
-- Register the client with the bridge by issuing a single poll on its
|
|
171
|
+
-- behalf with a hello field. The bridge populates its clients map from
|
|
172
|
+
-- this poll, which is what run_live_lua's target='client' resolution
|
|
173
|
+
-- looks at.
|
|
174
|
+
local loadstringReady = false
|
|
175
|
+
if type(payload) == "table" and payload.loadstringReady ~= nil then
|
|
176
|
+
loadstringReady = payload.loadstringReady and true or false
|
|
177
|
+
end
|
|
178
|
+
task.spawn(function()
|
|
179
|
+
postJSON("/test-session/poll", {
|
|
180
|
+
sessionId = SESSION_ID,
|
|
181
|
+
target = "client",
|
|
182
|
+
userId = player.UserId,
|
|
183
|
+
playerName = player.Name,
|
|
184
|
+
hello = { loadstringReady = loadstringReady },
|
|
185
|
+
})
|
|
186
|
+
end)
|
|
187
|
+
end)
|
|
188
|
+
|
|
189
|
+
clientLogs.OnServerEvent:Connect(function(player, messages)
|
|
190
|
+
-- messages: array of { message, messageType, timestamp }
|
|
191
|
+
if type(messages) ~= "table" then return end
|
|
192
|
+
-- Tag each message with the player's name so multi-client tests are
|
|
193
|
+
-- legible in get_playtest_output.
|
|
194
|
+
local tagged = {}
|
|
195
|
+
for i, m in ipairs(messages) do
|
|
196
|
+
if type(m) == "table" then
|
|
197
|
+
tagged[i] = {
|
|
198
|
+
message = "[" .. player.Name .. "] " .. tostring(m.message or ""),
|
|
199
|
+
messageType = m.messageType or "MessageOutput",
|
|
200
|
+
timestamp = m.timestamp or os.time(),
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
if #tagged == 0 then return end
|
|
205
|
+
task.spawn(function()
|
|
206
|
+
postJSON("/test-session/log", {
|
|
207
|
+
sessionId = SESSION_ID,
|
|
208
|
+
source = "client",
|
|
209
|
+
messages = tagged,
|
|
210
|
+
})
|
|
211
|
+
end)
|
|
212
|
+
end)
|
|
213
|
+
|
|
214
|
+
-- ---------------------------------------------------------------------------
|
|
215
|
+
-- Result serializer (mirrors the plugin's executeLua serializer so MCP
|
|
216
|
+
-- clients see the same shapes whether they're running edit-mode execute_lua
|
|
217
|
+
-- or runtime run_live_lua). Depth + cycle guarded.
|
|
218
|
+
-- ---------------------------------------------------------------------------
|
|
219
|
+
local function serializeValue(value, depth, seen)
|
|
220
|
+
depth = depth or 0
|
|
221
|
+
seen = seen or {}
|
|
222
|
+
if depth > 8 then return "<max depth>" end
|
|
223
|
+
|
|
224
|
+
local t = typeof(value)
|
|
225
|
+
if value == nil then
|
|
226
|
+
return nil
|
|
227
|
+
elseif t == "string" then
|
|
228
|
+
-- Cap very long strings so a runaway print doesn't pollute the wire.
|
|
229
|
+
if #value > 65536 then
|
|
230
|
+
return string.sub(value, 1, 65536) .. "...<truncated>"
|
|
231
|
+
end
|
|
232
|
+
return value
|
|
233
|
+
elseif t == "number" then
|
|
234
|
+
-- JSON can't represent NaN/Infinity; coerce to string.
|
|
235
|
+
if value ~= value then return "NaN" end
|
|
236
|
+
if value == math.huge then return "Infinity" end
|
|
237
|
+
if value == -math.huge then return "-Infinity" end
|
|
238
|
+
return value
|
|
239
|
+
elseif t == "boolean" then
|
|
240
|
+
return value
|
|
241
|
+
elseif t == "Vector3" then
|
|
242
|
+
return { type = "Vector3", X = value.X, Y = value.Y, Z = value.Z }
|
|
243
|
+
elseif t == "Vector2" then
|
|
244
|
+
return { type = "Vector2", X = value.X, Y = value.Y }
|
|
245
|
+
elseif t == "CFrame" then
|
|
246
|
+
return { type = "CFrame", components = { value:GetComponents() } }
|
|
247
|
+
elseif t == "Color3" then
|
|
248
|
+
return { type = "Color3", R = value.R, G = value.G, B = value.B }
|
|
249
|
+
elseif t == "BrickColor" then
|
|
250
|
+
return { type = "BrickColor", Name = value.Name }
|
|
251
|
+
elseif t == "UDim" then
|
|
252
|
+
return { type = "UDim", Scale = value.Scale, Offset = value.Offset }
|
|
253
|
+
elseif t == "UDim2" then
|
|
254
|
+
return {
|
|
255
|
+
type = "UDim2",
|
|
256
|
+
X = { Scale = value.X.Scale, Offset = value.X.Offset },
|
|
257
|
+
Y = { Scale = value.Y.Scale, Offset = value.Y.Offset },
|
|
258
|
+
}
|
|
259
|
+
elseif t == "Rect" then
|
|
260
|
+
return { type = "Rect", Min = { X = value.Min.X, Y = value.Min.Y }, Max = { X = value.Max.X, Y = value.Max.Y } }
|
|
261
|
+
elseif t == "Ray" then
|
|
262
|
+
return {
|
|
263
|
+
type = "Ray",
|
|
264
|
+
Origin = { X = value.Origin.X, Y = value.Origin.Y, Z = value.Origin.Z },
|
|
265
|
+
Direction = { X = value.Direction.X, Y = value.Direction.Y, Z = value.Direction.Z },
|
|
266
|
+
}
|
|
267
|
+
elseif t == "NumberRange" then
|
|
268
|
+
return { type = "NumberRange", Min = value.Min, Max = value.Max }
|
|
269
|
+
elseif t == "EnumItem" then
|
|
270
|
+
return { type = "EnumItem", Name = tostring(value), Value = value.Value }
|
|
271
|
+
elseif t == "Instance" then
|
|
272
|
+
local fullName = "<unparented>"
|
|
273
|
+
pcall(function() fullName = value:GetFullName() end)
|
|
274
|
+
return { type = "Instance", ClassName = value.ClassName, Name = value.Name, Path = fullName }
|
|
275
|
+
elseif t == "table" then
|
|
276
|
+
if seen[value] then return "<cycle>" end
|
|
277
|
+
seen[value] = true
|
|
278
|
+
local result = {}
|
|
279
|
+
local count = 0
|
|
280
|
+
for k, v in pairs(value) do
|
|
281
|
+
count += 1
|
|
282
|
+
if count > 200 then
|
|
283
|
+
result["__truncated__"] = true
|
|
284
|
+
break
|
|
285
|
+
end
|
|
286
|
+
local keyStr = type(k) == "string" and k or tostring(k)
|
|
287
|
+
result[keyStr] = serializeValue(v, depth + 1, seen)
|
|
288
|
+
end
|
|
289
|
+
seen[value] = nil
|
|
290
|
+
return result
|
|
291
|
+
elseif t == "function" then
|
|
292
|
+
return "<function>"
|
|
293
|
+
elseif t == "thread" then
|
|
294
|
+
return "<thread>"
|
|
295
|
+
elseif t == "buffer" then
|
|
296
|
+
return "<buffer>"
|
|
297
|
+
else
|
|
298
|
+
-- Fallback: stringify whatever it is.
|
|
299
|
+
local ok, str = pcall(tostring, value)
|
|
300
|
+
return ok and str or ("<" .. t .. ">")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
87
304
|
LogService.MessageOut:Connect(function(message, messageType)
|
|
88
305
|
if #outputBuffer >= MAX_BUFFER then
|
|
89
306
|
-- Drop oldest to keep buffer bounded; we'd rather lose stale output
|
|
@@ -105,6 +322,7 @@ task.spawn(function()
|
|
|
105
322
|
outputBuffer = {}
|
|
106
323
|
local ok = postJSON("/test-session/log", {
|
|
107
324
|
sessionId = SESSION_ID,
|
|
325
|
+
source = "server",
|
|
108
326
|
messages = toSend,
|
|
109
327
|
})
|
|
110
328
|
if not ok then
|
|
@@ -120,10 +338,294 @@ task.spawn(function()
|
|
|
120
338
|
end
|
|
121
339
|
end)
|
|
122
340
|
|
|
341
|
+
-- ---------------------------------------------------------------------------
|
|
342
|
+
-- Eval handler. Compiles user code via loadstring, runs under xpcall on its
|
|
343
|
+
-- own task thread, captures LogService output during the call window, and
|
|
344
|
+
-- POSTs the result back to /test-session/eval-result. A task.delay watchdog
|
|
345
|
+
-- forces a 'timeout' reply if the user code yields too long.
|
|
346
|
+
-- ---------------------------------------------------------------------------
|
|
347
|
+
local function buildEvalEnv()
|
|
348
|
+
-- Mirror executeLua's sandbox so the same conveniences are available
|
|
349
|
+
-- on the in-test side. We expose game/services so the AI can mutate
|
|
350
|
+
-- live state, fire RemoteEvents, etc.
|
|
351
|
+
return setmetatable({
|
|
352
|
+
game = game,
|
|
353
|
+
workspace = workspace,
|
|
354
|
+
Players = Players,
|
|
355
|
+
Workspace = workspace,
|
|
356
|
+
ReplicatedStorage = ReplicatedStorage,
|
|
357
|
+
ServerStorage = game:GetService("ServerStorage"),
|
|
358
|
+
ServerScriptService = game:GetService("ServerScriptService"),
|
|
359
|
+
StarterGui = game:GetService("StarterGui"),
|
|
360
|
+
StarterPack = game:GetService("StarterPack"),
|
|
361
|
+
StarterPlayer = game:GetService("StarterPlayer"),
|
|
362
|
+
Lighting = game:GetService("Lighting"),
|
|
363
|
+
SoundService = game:GetService("SoundService"),
|
|
364
|
+
TweenService = game:GetService("TweenService"),
|
|
365
|
+
RunService = game:GetService("RunService"),
|
|
366
|
+
UserInputService = game:GetService("UserInputService"),
|
|
367
|
+
HttpService = HttpService,
|
|
368
|
+
CollectionService = game:GetService("CollectionService"),
|
|
369
|
+
PhysicsService = game:GetService("PhysicsService"),
|
|
370
|
+
PathfindingService = game:GetService("PathfindingService"),
|
|
371
|
+
TextService = game:GetService("TextService"),
|
|
372
|
+
MarketplaceService = game:GetService("MarketplaceService"),
|
|
373
|
+
TeleportService = game:GetService("TeleportService"),
|
|
374
|
+
DataStoreService = game:GetService("DataStoreService"),
|
|
375
|
+
MemoryStoreService = game:GetService("MemoryStoreService"),
|
|
376
|
+
MessagingService = game:GetService("MessagingService"),
|
|
377
|
+
BadgeService = game:GetService("BadgeService"),
|
|
378
|
+
Instance = Instance,
|
|
379
|
+
Vector3 = Vector3, Vector2 = Vector2, CFrame = CFrame, Color3 = Color3,
|
|
380
|
+
BrickColor = BrickColor, UDim = UDim, UDim2 = UDim2, Rect = Rect, Ray = Ray,
|
|
381
|
+
Region3 = Region3, NumberSequence = NumberSequence,
|
|
382
|
+
NumberSequenceKeypoint = NumberSequenceKeypoint, ColorSequence = ColorSequence,
|
|
383
|
+
ColorSequenceKeypoint = ColorSequenceKeypoint, NumberRange = NumberRange,
|
|
384
|
+
TweenInfo = TweenInfo, Font = Font, Enum = Enum,
|
|
385
|
+
print = print, warn = warn, error = error, assert = assert,
|
|
386
|
+
type = type, typeof = typeof, tostring = tostring, tonumber = tonumber,
|
|
387
|
+
pairs = pairs, ipairs = ipairs, next = next, select = select, unpack = unpack,
|
|
388
|
+
pcall = pcall, xpcall = xpcall,
|
|
389
|
+
rawget = rawget, rawset = rawset, rawequal = rawequal,
|
|
390
|
+
setmetatable = setmetatable, getmetatable = getmetatable, newproxy = newproxy,
|
|
391
|
+
table = table, string = string, math = math,
|
|
392
|
+
os = { time = os.time, date = os.date, difftime = os.difftime, clock = os.clock },
|
|
393
|
+
coroutine = coroutine, bit32 = bit32, utf8 = utf8, buffer = buffer,
|
|
394
|
+
task = task, wait = task.wait, delay = task.delay, spawn = task.spawn, defer = task.defer,
|
|
395
|
+
loadstring = loadstring,
|
|
396
|
+
require = require,
|
|
397
|
+
debug = debug,
|
|
398
|
+
tick = tick,
|
|
399
|
+
}, { __index = _G })
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
local function postEvalResult(replyId, payload)
|
|
403
|
+
payload.sessionId = SESSION_ID
|
|
404
|
+
payload.replyId = replyId
|
|
405
|
+
postJSON("/test-session/eval-result", payload)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
local function handleEvalCommand(args)
|
|
409
|
+
if not args or type(args.replyId) ~= "string" or type(args.code) ~= "string" then
|
|
410
|
+
return -- malformed; bridge will time out, no point replying without replyId
|
|
411
|
+
end
|
|
412
|
+
local replyId = args.replyId
|
|
413
|
+
local code = args.code
|
|
414
|
+
local timeoutMs = type(args.timeoutMs) == "number" and args.timeoutMs or 5000
|
|
415
|
+
local captureLogs = args.captureLogs ~= false -- default true
|
|
416
|
+
|
|
417
|
+
-- Run the eval on its own task so this command poller can keep going
|
|
418
|
+
-- (a slow eval shouldn't block log streaming or further commands).
|
|
419
|
+
task.spawn(function()
|
|
420
|
+
local startedAt = os.clock()
|
|
421
|
+
local replied = false
|
|
422
|
+
local function safeReply(payload)
|
|
423
|
+
if replied then return end
|
|
424
|
+
replied = true
|
|
425
|
+
payload.durationMs = math.floor((os.clock() - startedAt) * 1000)
|
|
426
|
+
postEvalResult(replyId, payload)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
-- ----------------------------------------------------------------
|
|
430
|
+
-- forwardTo path: run_live_lua with target='client' enqueues on the
|
|
431
|
+
-- server queue with forwardTo metadata; we dispatch via InvokeClient
|
|
432
|
+
-- through _MCPClientRelay. The client's OnClientInvoke handler does
|
|
433
|
+
-- the actual eval and returns the payload.
|
|
434
|
+
-- ----------------------------------------------------------------
|
|
435
|
+
if type(args.forwardTo) == "table" then
|
|
436
|
+
local targetUserId = args.forwardTo.userId
|
|
437
|
+
local targetName = args.forwardTo.playerName
|
|
438
|
+
local target = nil
|
|
439
|
+
if type(targetUserId) == "number" then
|
|
440
|
+
for _, p in ipairs(Players:GetPlayers()) do
|
|
441
|
+
if p.UserId == targetUserId then target = p; break end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
if not target and type(targetName) == "string" and #targetName > 0 then
|
|
445
|
+
target = Players:FindFirstChild(targetName)
|
|
446
|
+
end
|
|
447
|
+
if not target then
|
|
448
|
+
safeReply({
|
|
449
|
+
ok = false,
|
|
450
|
+
errorType = "no_such_player",
|
|
451
|
+
error = string.format(
|
|
452
|
+
"Could not resolve target client (userId=%s, playerName=%s) — they may have left the test.",
|
|
453
|
+
tostring(targetUserId), tostring(targetName)
|
|
454
|
+
),
|
|
455
|
+
})
|
|
456
|
+
return
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
-- Server-side watchdog: InvokeClient blocks until the client
|
|
460
|
+
-- returns, but if the client hangs we still want a bounded reply.
|
|
461
|
+
task.delay(timeoutMs / 1000 + 1, function()
|
|
462
|
+
if replied then return end
|
|
463
|
+
safeReply({
|
|
464
|
+
ok = false,
|
|
465
|
+
errorType = "timeout",
|
|
466
|
+
error = string.format(
|
|
467
|
+
"Client eval did not return within %d ms (server-side watchdog).",
|
|
468
|
+
timeoutMs
|
|
469
|
+
),
|
|
470
|
+
})
|
|
471
|
+
end)
|
|
472
|
+
|
|
473
|
+
local invokeOk, payload = pcall(function()
|
|
474
|
+
return clientRelay:InvokeClient(target, {
|
|
475
|
+
replyId = replyId,
|
|
476
|
+
code = code,
|
|
477
|
+
timeoutMs = timeoutMs,
|
|
478
|
+
captureLogs = captureLogs,
|
|
479
|
+
})
|
|
480
|
+
end)
|
|
481
|
+
if not invokeOk then
|
|
482
|
+
safeReply({
|
|
483
|
+
ok = false,
|
|
484
|
+
errorType = "client_disconnected",
|
|
485
|
+
error = "InvokeClient failed (client likely left the test): " .. tostring(payload),
|
|
486
|
+
})
|
|
487
|
+
return
|
|
488
|
+
end
|
|
489
|
+
if type(payload) ~= "table" then
|
|
490
|
+
safeReply({
|
|
491
|
+
ok = false,
|
|
492
|
+
errorType = "client_protocol_error",
|
|
493
|
+
error = "Client returned a non-table payload: " .. typeof(payload),
|
|
494
|
+
})
|
|
495
|
+
return
|
|
496
|
+
end
|
|
497
|
+
safeReply(payload)
|
|
498
|
+
return
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
if not LOADSTRING_READY then
|
|
502
|
+
safeReply({
|
|
503
|
+
ok = false,
|
|
504
|
+
errorType = "loadstring_disabled",
|
|
505
|
+
error = "loadstring is disabled. Enable ServerScriptService.LoadStringEnabled in the Properties pane (it has the NotScriptable tag so it cannot be flipped from code) and start a new play test.",
|
|
506
|
+
})
|
|
507
|
+
return
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
-- Capture logs scoped to this eval call. We collect into a private
|
|
511
|
+
-- buffer via a fresh LogService connection, then disconnect once
|
|
512
|
+
-- we've replied.
|
|
513
|
+
local localLogs = {}
|
|
514
|
+
local logConn = nil
|
|
515
|
+
if captureLogs then
|
|
516
|
+
logConn = LogService.MessageOut:Connect(function(message, messageType)
|
|
517
|
+
if #localLogs >= 200 then return end
|
|
518
|
+
table.insert(localLogs, {
|
|
519
|
+
message = tostring(message),
|
|
520
|
+
messageType = messageType.Name,
|
|
521
|
+
timestamp = os.time(),
|
|
522
|
+
})
|
|
523
|
+
end)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
local function detachLogs()
|
|
527
|
+
if logConn then
|
|
528
|
+
pcall(function() logConn:Disconnect() end)
|
|
529
|
+
logConn = nil
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
-- Compile. NOTE: loadstring returns (nil, errorMessage) on syntax
|
|
534
|
+
-- failure rather than throwing — wrapping in pcall would discard the
|
|
535
|
+
-- error message into pcall's third return slot and leave fnOrErr=nil.
|
|
536
|
+
-- Calling loadstring directly captures both function and error cleanly.
|
|
537
|
+
local fnOrErr, compileErr = loadstring(code, "run_live_lua")
|
|
538
|
+
if type(fnOrErr) ~= "function" then
|
|
539
|
+
detachLogs()
|
|
540
|
+
safeReply({
|
|
541
|
+
ok = false,
|
|
542
|
+
errorType = "compile_error",
|
|
543
|
+
error = "Failed to compile: " .. tostring(compileErr or fnOrErr or "<unknown>"),
|
|
544
|
+
logs = captureLogs and localLogs or nil,
|
|
545
|
+
})
|
|
546
|
+
return
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
setfenv(fnOrErr, buildEvalEnv())
|
|
550
|
+
|
|
551
|
+
-- Watchdog: if the user code yields longer than timeoutMs, reply
|
|
552
|
+
-- with a synthetic timeout. The user thread will keep running
|
|
553
|
+
-- (we can't kill a yielded thread cleanly) but the AI gets a
|
|
554
|
+
-- bounded response.
|
|
555
|
+
task.delay(timeoutMs / 1000, function()
|
|
556
|
+
if replied then return end
|
|
557
|
+
detachLogs()
|
|
558
|
+
safeReply({
|
|
559
|
+
ok = false,
|
|
560
|
+
errorType = "timeout",
|
|
561
|
+
error = string.format("Eval did not return within %d ms (the spawned thread may still be running in the test).", timeoutMs),
|
|
562
|
+
logs = captureLogs and localLogs or nil,
|
|
563
|
+
})
|
|
564
|
+
end)
|
|
565
|
+
|
|
566
|
+
-- Execute. Pack multi-return into an array so the bridge can
|
|
567
|
+
-- preserve `return a, b, c` semantics.
|
|
568
|
+
local results = table.pack(xpcall(fnOrErr, function(err)
|
|
569
|
+
return { error = tostring(err), traceback = debug.traceback(nil, 2) }
|
|
570
|
+
end))
|
|
571
|
+
local execOk = results[1]
|
|
572
|
+
if replied then
|
|
573
|
+
-- Watchdog already responded.
|
|
574
|
+
detachLogs()
|
|
575
|
+
return
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
-- LogService.MessageOut events are deferred and need TWO Heartbeats
|
|
579
|
+
-- to actually invoke listeners (verified empirically — one task.wait
|
|
580
|
+
-- isn't enough). Yield twice so prints fired during the eval land
|
|
581
|
+
-- in localLogs before we detach + reply. Total cost ~32ms.
|
|
582
|
+
if captureLogs then
|
|
583
|
+
task.wait()
|
|
584
|
+
task.wait()
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
if not execOk then
|
|
588
|
+
detachLogs()
|
|
589
|
+
local err = results[2] or {}
|
|
590
|
+
safeReply({
|
|
591
|
+
ok = false,
|
|
592
|
+
errorType = "runtime_error",
|
|
593
|
+
error = (type(err) == "table" and err.error) or tostring(err),
|
|
594
|
+
traceback = (type(err) == "table") and err.traceback or nil,
|
|
595
|
+
logs = captureLogs and localLogs or nil,
|
|
596
|
+
})
|
|
597
|
+
return
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
-- Pack remaining values (results[2..n]) as the multi-return array.
|
|
601
|
+
local values = {}
|
|
602
|
+
for i = 2, results.n do
|
|
603
|
+
values[i - 1] = serializeValue(results[i], 0, {})
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
detachLogs()
|
|
607
|
+
safeReply({
|
|
608
|
+
ok = true,
|
|
609
|
+
values = values,
|
|
610
|
+
logs = captureLogs and localLogs or nil,
|
|
611
|
+
})
|
|
612
|
+
end)
|
|
613
|
+
end
|
|
614
|
+
|
|
123
615
|
-- Command poller
|
|
124
616
|
task.spawn(function()
|
|
617
|
+
-- First poll includes hello so the bridge can record loadstring
|
|
618
|
+
-- readiness for later run_live_lua callers.
|
|
619
|
+
local helloSent = false
|
|
125
620
|
while not stopRequested and consecutiveFailures < MAX_CONSECUTIVE_FAILURES do
|
|
126
|
-
local
|
|
621
|
+
local pollBody = { sessionId = SESSION_ID, target = "server" }
|
|
622
|
+
if not helloSent then
|
|
623
|
+
pollBody.hello = { loadstringReady = LOADSTRING_READY }
|
|
624
|
+
end
|
|
625
|
+
local ok, response = postJSON("/test-session/poll", pollBody)
|
|
626
|
+
if ok then
|
|
627
|
+
helloSent = true
|
|
628
|
+
end
|
|
127
629
|
if ok and response and response.Body then
|
|
128
630
|
local decodeOk, body = pcall(function()
|
|
129
631
|
return HttpService:JSONDecode(response.Body)
|
|
@@ -143,6 +645,7 @@ task.spawn(function()
|
|
|
143
645
|
outputBuffer = {}
|
|
144
646
|
postJSON("/test-session/log", {
|
|
145
647
|
sessionId = SESSION_ID,
|
|
648
|
+
source = "server",
|
|
146
649
|
messages = final,
|
|
147
650
|
})
|
|
148
651
|
end
|
|
@@ -160,6 +663,8 @@ task.spawn(function()
|
|
|
160
663
|
end)
|
|
161
664
|
end
|
|
162
665
|
return
|
|
666
|
+
elseif body.command and body.command.cmd == "eval" then
|
|
667
|
+
handleEvalCommand(body.command.args)
|
|
163
668
|
end
|
|
164
669
|
end
|
|
165
670
|
end
|
|
@@ -168,6 +673,362 @@ task.spawn(function()
|
|
|
168
673
|
end)
|
|
169
674
|
]]
|
|
170
675
|
|
|
676
|
+
-- ----------------------------------------------------------------------------
|
|
677
|
+
-- CLIENT companion template. Runs as a LocalScript in StarterPlayerScripts
|
|
678
|
+
-- (which auto-clones into each Player's PlayerScripts on join). Each Player
|
|
679
|
+
-- gets their own copy and identifies to the bridge by userId. Only handles
|
|
680
|
+
-- 'eval' commands and streams its local LogService.
|
|
681
|
+
--
|
|
682
|
+
-- It does NOT call EndTest (only the server can do that), so the only stop
|
|
683
|
+
-- signal it cares about is the bridge's `ended` flag.
|
|
684
|
+
-- ----------------------------------------------------------------------------
|
|
685
|
+
local CLIENT_COMPANION_SCRIPT_TEMPLATE = [[-- _MCPTestCompanion_Client (auto-generated, safe to delete)
|
|
686
|
+
-- Runs in each Player's PlayerScripts during a Studio test session started by
|
|
687
|
+
-- the MCP plugin. Streams local LogService output to the server companion via
|
|
688
|
+
-- RemoteEvents and runs run_live_lua eval commands dispatched through a
|
|
689
|
+
-- RemoteFunction.
|
|
690
|
+
--
|
|
691
|
+
-- IMPORTANT: HttpService is server-only in Roblox. LocalScripts CANNOT make
|
|
692
|
+
-- HTTP requests (they'll silently fail with HttpError). So everything goes
|
|
693
|
+
-- through Remotes. The server companion in ServerScriptService is the only
|
|
694
|
+
-- HTTP gateway and forwards messages on our behalf.
|
|
695
|
+
--
|
|
696
|
+
-- DO NOT modify or delete while a play test is running — it will be cleaned
|
|
697
|
+
-- up automatically by the MCP plugin.
|
|
698
|
+
|
|
699
|
+
local LogService = game:GetService("LogService")
|
|
700
|
+
local Players = game:GetService("Players")
|
|
701
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
702
|
+
|
|
703
|
+
local LOG_FLUSH_INTERVAL = 0.25
|
|
704
|
+
local MAX_BUFFER = 5000
|
|
705
|
+
local REMOTE_WAIT_TIMEOUT = 30 -- seconds
|
|
706
|
+
|
|
707
|
+
local localPlayer = Players.LocalPlayer
|
|
708
|
+
if not localPlayer then return end -- LocalScript guarantees LocalPlayer; bail otherwise
|
|
709
|
+
local PLAYER_NAME = localPlayer.Name
|
|
710
|
+
|
|
711
|
+
-- Wait for the server companion to create the Remotes in ReplicatedStorage.
|
|
712
|
+
-- The server companion runs slightly before us in test mode (Server scripts
|
|
713
|
+
-- start before LocalScripts), but we still WaitForChild defensively.
|
|
714
|
+
local clientRelay = ReplicatedStorage:WaitForChild("_MCPClientRelay", REMOTE_WAIT_TIMEOUT)
|
|
715
|
+
local clientHello = ReplicatedStorage:WaitForChild("_MCPClientHello", REMOTE_WAIT_TIMEOUT)
|
|
716
|
+
local clientLogs = ReplicatedStorage:WaitForChild("_MCPClientLogs", REMOTE_WAIT_TIMEOUT)
|
|
717
|
+
if not (clientRelay and clientHello and clientLogs) then
|
|
718
|
+
-- Server companion didn't set up Remotes in time. Nothing we can do.
|
|
719
|
+
warn("[_MCPTestCompanion_Client] Remotes not found in ReplicatedStorage — server companion missing or slow")
|
|
720
|
+
return
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
-- Probe loadstring availability ONCE up front. Note: client-side gating may
|
|
724
|
+
-- differ from server's ServerScriptService.LoadStringEnabled, so we probe.
|
|
725
|
+
local LOADSTRING_READY = false
|
|
726
|
+
do
|
|
727
|
+
local ok, fn = pcall(loadstring, "return 1")
|
|
728
|
+
if ok and type(fn) == "function" then
|
|
729
|
+
local execOk, execVal = pcall(fn)
|
|
730
|
+
if execOk and execVal == 1 then
|
|
731
|
+
LOADSTRING_READY = true
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
-- Announce ourselves to the server companion, which will register us with
|
|
737
|
+
-- the bridge over HTTP on our behalf.
|
|
738
|
+
clientHello:FireServer({ loadstringReady = LOADSTRING_READY })
|
|
739
|
+
|
|
740
|
+
-- ---------------------------------------------------------------------------
|
|
741
|
+
-- Result serializer (matches the server companion's serializer 1:1).
|
|
742
|
+
-- ---------------------------------------------------------------------------
|
|
743
|
+
local function serializeValue(value, depth, seen)
|
|
744
|
+
depth = depth or 0
|
|
745
|
+
seen = seen or {}
|
|
746
|
+
if depth > 8 then return "<max depth>" end
|
|
747
|
+
local t = typeof(value)
|
|
748
|
+
if value == nil then
|
|
749
|
+
return nil
|
|
750
|
+
elseif t == "string" then
|
|
751
|
+
if #value > 65536 then return string.sub(value, 1, 65536) .. "...<truncated>" end
|
|
752
|
+
return value
|
|
753
|
+
elseif t == "number" then
|
|
754
|
+
if value ~= value then return "NaN" end
|
|
755
|
+
if value == math.huge then return "Infinity" end
|
|
756
|
+
if value == -math.huge then return "-Infinity" end
|
|
757
|
+
return value
|
|
758
|
+
elseif t == "boolean" then
|
|
759
|
+
return value
|
|
760
|
+
elseif t == "Vector3" then
|
|
761
|
+
return { type = "Vector3", X = value.X, Y = value.Y, Z = value.Z }
|
|
762
|
+
elseif t == "Vector2" then
|
|
763
|
+
return { type = "Vector2", X = value.X, Y = value.Y }
|
|
764
|
+
elseif t == "CFrame" then
|
|
765
|
+
return { type = "CFrame", components = { value:GetComponents() } }
|
|
766
|
+
elseif t == "Color3" then
|
|
767
|
+
return { type = "Color3", R = value.R, G = value.G, B = value.B }
|
|
768
|
+
elseif t == "BrickColor" then
|
|
769
|
+
return { type = "BrickColor", Name = value.Name }
|
|
770
|
+
elseif t == "UDim" then
|
|
771
|
+
return { type = "UDim", Scale = value.Scale, Offset = value.Offset }
|
|
772
|
+
elseif t == "UDim2" then
|
|
773
|
+
return {
|
|
774
|
+
type = "UDim2",
|
|
775
|
+
X = { Scale = value.X.Scale, Offset = value.X.Offset },
|
|
776
|
+
Y = { Scale = value.Y.Scale, Offset = value.Y.Offset },
|
|
777
|
+
}
|
|
778
|
+
elseif t == "Rect" then
|
|
779
|
+
return { type = "Rect", Min = { X = value.Min.X, Y = value.Min.Y }, Max = { X = value.Max.X, Y = value.Max.Y } }
|
|
780
|
+
elseif t == "Ray" then
|
|
781
|
+
return {
|
|
782
|
+
type = "Ray",
|
|
783
|
+
Origin = { X = value.Origin.X, Y = value.Origin.Y, Z = value.Origin.Z },
|
|
784
|
+
Direction = { X = value.Direction.X, Y = value.Direction.Y, Z = value.Direction.Z },
|
|
785
|
+
}
|
|
786
|
+
elseif t == "NumberRange" then
|
|
787
|
+
return { type = "NumberRange", Min = value.Min, Max = value.Max }
|
|
788
|
+
elseif t == "EnumItem" then
|
|
789
|
+
return { type = "EnumItem", Name = tostring(value), Value = value.Value }
|
|
790
|
+
elseif t == "Instance" then
|
|
791
|
+
local fullName = "<unparented>"
|
|
792
|
+
pcall(function() fullName = value:GetFullName() end)
|
|
793
|
+
return { type = "Instance", ClassName = value.ClassName, Name = value.Name, Path = fullName }
|
|
794
|
+
elseif t == "table" then
|
|
795
|
+
if seen[value] then return "<cycle>" end
|
|
796
|
+
seen[value] = true
|
|
797
|
+
local result = {}
|
|
798
|
+
local count = 0
|
|
799
|
+
for k, v in pairs(value) do
|
|
800
|
+
count += 1
|
|
801
|
+
if count > 200 then result["__truncated__"] = true; break end
|
|
802
|
+
local keyStr = type(k) == "string" and k or tostring(k)
|
|
803
|
+
result[keyStr] = serializeValue(v, depth + 1, seen)
|
|
804
|
+
end
|
|
805
|
+
seen[value] = nil
|
|
806
|
+
return result
|
|
807
|
+
elseif t == "function" then
|
|
808
|
+
return "<function>"
|
|
809
|
+
elseif t == "thread" then
|
|
810
|
+
return "<thread>"
|
|
811
|
+
elseif t == "buffer" then
|
|
812
|
+
return "<buffer>"
|
|
813
|
+
else
|
|
814
|
+
local ok, str = pcall(tostring, value)
|
|
815
|
+
return ok and str or ("<" .. t .. ">")
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
-- ---------------------------------------------------------------------------
|
|
820
|
+
-- LogService streaming → batched FireServer
|
|
821
|
+
-- ---------------------------------------------------------------------------
|
|
822
|
+
local outputBuffer = {}
|
|
823
|
+
LogService.MessageOut:Connect(function(message, messageType)
|
|
824
|
+
if #outputBuffer >= MAX_BUFFER then
|
|
825
|
+
table.remove(outputBuffer, 1)
|
|
826
|
+
end
|
|
827
|
+
table.insert(outputBuffer, {
|
|
828
|
+
message = tostring(message),
|
|
829
|
+
messageType = messageType.Name,
|
|
830
|
+
timestamp = os.time(),
|
|
831
|
+
})
|
|
832
|
+
end)
|
|
833
|
+
|
|
834
|
+
task.spawn(function()
|
|
835
|
+
while true do
|
|
836
|
+
if #outputBuffer > 0 then
|
|
837
|
+
local toSend = outputBuffer
|
|
838
|
+
outputBuffer = {}
|
|
839
|
+
-- FireServer is fire-and-forget; the server companion will tag
|
|
840
|
+
-- with our PLAYER_NAME and forward to /test-session/log.
|
|
841
|
+
pcall(function() clientLogs:FireServer(toSend) end)
|
|
842
|
+
end
|
|
843
|
+
task.wait(LOG_FLUSH_INTERVAL)
|
|
844
|
+
end
|
|
845
|
+
end)
|
|
846
|
+
|
|
847
|
+
-- ---------------------------------------------------------------------------
|
|
848
|
+
-- Eval handler. The server companion calls clientRelay:InvokeClient(player,
|
|
849
|
+
-- args), which suspends the server until we return a payload table. We do
|
|
850
|
+
-- the same setfenv/xpcall sandbox dance as the server, with our own watchdog.
|
|
851
|
+
-- ---------------------------------------------------------------------------
|
|
852
|
+
local function buildEvalEnv()
|
|
853
|
+
return setmetatable({
|
|
854
|
+
game = game,
|
|
855
|
+
workspace = workspace,
|
|
856
|
+
Players = Players,
|
|
857
|
+
Workspace = workspace,
|
|
858
|
+
ReplicatedStorage = ReplicatedStorage,
|
|
859
|
+
StarterGui = game:GetService("StarterGui"),
|
|
860
|
+
StarterPlayer = game:GetService("StarterPlayer"),
|
|
861
|
+
Lighting = game:GetService("Lighting"),
|
|
862
|
+
SoundService = game:GetService("SoundService"),
|
|
863
|
+
TweenService = game:GetService("TweenService"),
|
|
864
|
+
RunService = game:GetService("RunService"),
|
|
865
|
+
UserInputService = game:GetService("UserInputService"),
|
|
866
|
+
ContextActionService = game:GetService("ContextActionService"),
|
|
867
|
+
CollectionService = game:GetService("CollectionService"),
|
|
868
|
+
GuiService = game:GetService("GuiService"),
|
|
869
|
+
TextService = game:GetService("TextService"),
|
|
870
|
+
MarketplaceService = game:GetService("MarketplaceService"),
|
|
871
|
+
Instance = Instance,
|
|
872
|
+
Vector3 = Vector3, Vector2 = Vector2, CFrame = CFrame, Color3 = Color3,
|
|
873
|
+
BrickColor = BrickColor, UDim = UDim, UDim2 = UDim2, Rect = Rect, Ray = Ray,
|
|
874
|
+
Region3 = Region3, NumberSequence = NumberSequence,
|
|
875
|
+
NumberSequenceKeypoint = NumberSequenceKeypoint, ColorSequence = ColorSequence,
|
|
876
|
+
ColorSequenceKeypoint = ColorSequenceKeypoint, NumberRange = NumberRange,
|
|
877
|
+
TweenInfo = TweenInfo, Font = Font, Enum = Enum,
|
|
878
|
+
print = print, warn = warn, error = error, assert = assert,
|
|
879
|
+
type = type, typeof = typeof, tostring = tostring, tonumber = tonumber,
|
|
880
|
+
pairs = pairs, ipairs = ipairs, next = next, select = select, unpack = unpack,
|
|
881
|
+
pcall = pcall, xpcall = xpcall,
|
|
882
|
+
rawget = rawget, rawset = rawset, rawequal = rawequal,
|
|
883
|
+
setmetatable = setmetatable, getmetatable = getmetatable, newproxy = newproxy,
|
|
884
|
+
table = table, string = string, math = math,
|
|
885
|
+
os = { time = os.time, date = os.date, difftime = os.difftime, clock = os.clock },
|
|
886
|
+
coroutine = coroutine, bit32 = bit32, utf8 = utf8, buffer = buffer,
|
|
887
|
+
task = task, wait = task.wait, delay = task.delay, spawn = task.spawn, defer = task.defer,
|
|
888
|
+
loadstring = loadstring,
|
|
889
|
+
require = require,
|
|
890
|
+
debug = debug,
|
|
891
|
+
tick = tick,
|
|
892
|
+
LocalPlayer = localPlayer,
|
|
893
|
+
PlayerGui = localPlayer:FindFirstChildOfClass("PlayerGui"),
|
|
894
|
+
}, { __index = _G })
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
local function runEval(args)
|
|
898
|
+
-- args = { replyId, code, timeoutMs, captureLogs }
|
|
899
|
+
if type(args) ~= "table" or type(args.code) ~= "string" then
|
|
900
|
+
return { ok = false, errorType = "client_protocol_error", error = "Invalid eval args" }
|
|
901
|
+
end
|
|
902
|
+
local code = args.code
|
|
903
|
+
local timeoutMs = type(args.timeoutMs) == "number" and args.timeoutMs or 5000
|
|
904
|
+
local captureLogs = args.captureLogs ~= false
|
|
905
|
+
|
|
906
|
+
local startedAt = os.clock()
|
|
907
|
+
local replied = false
|
|
908
|
+
local resultPayload = nil
|
|
909
|
+
local function reply(payload)
|
|
910
|
+
if replied then return end
|
|
911
|
+
replied = true
|
|
912
|
+
payload.durationMs = math.floor((os.clock() - startedAt) * 1000)
|
|
913
|
+
resultPayload = payload
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
if not LOADSTRING_READY then
|
|
917
|
+
reply({
|
|
918
|
+
ok = false,
|
|
919
|
+
errorType = "loadstring_disabled",
|
|
920
|
+
error = "loadstring is disabled on this client. (LocalScript loadstring depends on Studio settings; ServerScriptService.LoadStringEnabled may also be required.)",
|
|
921
|
+
})
|
|
922
|
+
return resultPayload
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
local localLogs = {}
|
|
926
|
+
local logConn = nil
|
|
927
|
+
if captureLogs then
|
|
928
|
+
logConn = LogService.MessageOut:Connect(function(message, messageType)
|
|
929
|
+
if #localLogs >= 200 then return end
|
|
930
|
+
table.insert(localLogs, {
|
|
931
|
+
message = tostring(message),
|
|
932
|
+
messageType = messageType.Name,
|
|
933
|
+
timestamp = os.time(),
|
|
934
|
+
})
|
|
935
|
+
end)
|
|
936
|
+
end
|
|
937
|
+
local function detachLogs()
|
|
938
|
+
if logConn then
|
|
939
|
+
pcall(function() logConn:Disconnect() end)
|
|
940
|
+
logConn = nil
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
-- Capture both return values directly (loadstring returns nil + error
|
|
945
|
+
-- message on syntax failure rather than throwing).
|
|
946
|
+
local fnOrErr, compileErr = loadstring(code, "run_live_lua_client")
|
|
947
|
+
if type(fnOrErr) ~= "function" then
|
|
948
|
+
detachLogs()
|
|
949
|
+
reply({
|
|
950
|
+
ok = false,
|
|
951
|
+
errorType = "compile_error",
|
|
952
|
+
error = "Failed to compile: " .. tostring(compileErr or fnOrErr or "<unknown>"),
|
|
953
|
+
logs = captureLogs and localLogs or nil,
|
|
954
|
+
})
|
|
955
|
+
return resultPayload
|
|
956
|
+
end
|
|
957
|
+
setfenv(fnOrErr, buildEvalEnv())
|
|
958
|
+
|
|
959
|
+
-- Local watchdog: if user code yields longer than timeoutMs, the watchdog
|
|
960
|
+
-- pre-fills resultPayload with a timeout response, then the slow code
|
|
961
|
+
-- still finishes (we can't kill it cleanly) but its result is discarded.
|
|
962
|
+
task.delay(timeoutMs / 1000, function()
|
|
963
|
+
if replied then return end
|
|
964
|
+
detachLogs()
|
|
965
|
+
reply({
|
|
966
|
+
ok = false,
|
|
967
|
+
errorType = "timeout",
|
|
968
|
+
error = string.format("Eval did not return within %d ms (the spawned thread may still be running in the client).", timeoutMs),
|
|
969
|
+
logs = captureLogs and localLogs or nil,
|
|
970
|
+
})
|
|
971
|
+
end)
|
|
972
|
+
|
|
973
|
+
local results = table.pack(xpcall(fnOrErr, function(err)
|
|
974
|
+
return { error = tostring(err), traceback = debug.traceback(nil, 2) }
|
|
975
|
+
end))
|
|
976
|
+
local execOk = results[1]
|
|
977
|
+
if replied then
|
|
978
|
+
detachLogs()
|
|
979
|
+
return resultPayload
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
-- LogService.MessageOut events are deferred and need TWO Heartbeats
|
|
983
|
+
-- to actually invoke listeners (verified empirically — one task.wait
|
|
984
|
+
-- isn't enough). Yield twice so prints fired during the eval land
|
|
985
|
+
-- in localLogs before we detach + reply. Total cost ~32ms.
|
|
986
|
+
if captureLogs then
|
|
987
|
+
task.wait()
|
|
988
|
+
task.wait()
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
if not execOk then
|
|
992
|
+
detachLogs()
|
|
993
|
+
local err = results[2] or {}
|
|
994
|
+
reply({
|
|
995
|
+
ok = false,
|
|
996
|
+
errorType = "runtime_error",
|
|
997
|
+
error = (type(err) == "table" and err.error) or tostring(err),
|
|
998
|
+
traceback = (type(err) == "table") and err.traceback or nil,
|
|
999
|
+
logs = captureLogs and localLogs or nil,
|
|
1000
|
+
})
|
|
1001
|
+
return resultPayload
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
local values = {}
|
|
1005
|
+
for i = 2, results.n do
|
|
1006
|
+
values[i - 1] = serializeValue(results[i], 0, {})
|
|
1007
|
+
end
|
|
1008
|
+
detachLogs()
|
|
1009
|
+
reply({
|
|
1010
|
+
ok = true,
|
|
1011
|
+
values = values,
|
|
1012
|
+
logs = captureLogs and localLogs or nil,
|
|
1013
|
+
})
|
|
1014
|
+
return resultPayload
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
clientRelay.OnClientInvoke = function(args)
|
|
1018
|
+
-- pcall the whole thing so a runtime crash inside runEval still produces
|
|
1019
|
+
-- a deterministic reply for the server-side InvokeClient caller.
|
|
1020
|
+
local ok, payloadOrErr = pcall(runEval, args)
|
|
1021
|
+
if ok and type(payloadOrErr) == "table" then
|
|
1022
|
+
return payloadOrErr
|
|
1023
|
+
end
|
|
1024
|
+
return {
|
|
1025
|
+
ok = false,
|
|
1026
|
+
errorType = "client_protocol_error",
|
|
1027
|
+
error = "runEval crashed: " .. tostring(payloadOrErr),
|
|
1028
|
+
}
|
|
1029
|
+
end
|
|
1030
|
+
]]
|
|
1031
|
+
|
|
171
1032
|
-- gsub-safe replacement (treat replacement as plain string, not a pattern).
|
|
172
1033
|
local function plainReplace(haystack, needle, replacement)
|
|
173
1034
|
-- Use string.find with plain=true to avoid pattern interpretation in needle.
|
|
@@ -194,31 +1055,44 @@ local function cleanupTestCompanions()
|
|
|
194
1055
|
count += 1
|
|
195
1056
|
end
|
|
196
1057
|
end)
|
|
197
|
-
-- Belt & suspenders: also remove any name-matching
|
|
198
|
-
--
|
|
1058
|
+
-- Belt & suspenders: also remove any name-matching scripts in the
|
|
1059
|
+
-- two known parents in case the tag was somehow stripped.
|
|
199
1060
|
pcall(function()
|
|
200
1061
|
local sss = game:GetService("ServerScriptService")
|
|
201
1062
|
for _, child in ipairs(sss:GetChildren()) do
|
|
202
|
-
if child.Name == TEST_COMPANION_NAME then
|
|
1063
|
+
if child.Name == TEST_COMPANION_NAME or child.Name == TEST_COMPANION_NAME_CLIENT then
|
|
203
1064
|
pcall(function() child:Destroy() end)
|
|
204
1065
|
count += 1
|
|
205
1066
|
end
|
|
206
1067
|
end
|
|
207
1068
|
end)
|
|
1069
|
+
pcall(function()
|
|
1070
|
+
local starterPlayer = game:GetService("StarterPlayer")
|
|
1071
|
+
local sps = starterPlayer and starterPlayer:FindFirstChild("StarterPlayerScripts")
|
|
1072
|
+
if sps then
|
|
1073
|
+
for _, child in ipairs(sps:GetChildren()) do
|
|
1074
|
+
if child.Name == TEST_COMPANION_NAME or child.Name == TEST_COMPANION_NAME_CLIENT then
|
|
1075
|
+
pcall(function() child:Destroy() end)
|
|
1076
|
+
count += 1
|
|
1077
|
+
end
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
end)
|
|
208
1081
|
return count
|
|
209
1082
|
end
|
|
210
1083
|
|
|
211
1084
|
local function injectTestCompanion(serverUrl, sessionId)
|
|
212
|
-
-- Always sweep before creating to guarantee at most one companion exists.
|
|
1085
|
+
-- Always sweep before creating to guarantee at most one of each companion exists.
|
|
213
1086
|
cleanupTestCompanions()
|
|
214
1087
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
1088
|
+
-- ----- SERVER companion -----
|
|
1089
|
+
local serverSource = COMPANION_SCRIPT_TEMPLATE
|
|
1090
|
+
serverSource = plainReplace(serverSource, "__MCP_SERVER_URL__", serverUrl)
|
|
1091
|
+
serverSource = plainReplace(serverSource, "__MCP_SESSION_ID__", sessionId)
|
|
218
1092
|
|
|
219
|
-
local
|
|
220
|
-
|
|
221
|
-
|
|
1093
|
+
local serverScript = Instance.new("Script")
|
|
1094
|
+
serverScript.Name = TEST_COMPANION_NAME
|
|
1095
|
+
serverScript.Source = serverSource
|
|
222
1096
|
-- IMPORTANT: Archivable MUST stay true (the default).
|
|
223
1097
|
-- ExecutePlayModeAsync builds the test DataModel by Clone()-ing the Edit
|
|
224
1098
|
-- DataModel, and Instance:Clone() silently returns nil for any instance
|
|
@@ -231,14 +1105,42 @@ local function injectTestCompanion(serverUrl, sessionId)
|
|
|
231
1105
|
-- We rely on the cleanup sweeps (on plugin activate, before each test,
|
|
232
1106
|
-- after each test) to keep companions out of the saved file. The script
|
|
233
1107
|
-- is also tagged with TEST_COMPANION_TAG so any orphan is easy to find.
|
|
234
|
-
|
|
235
|
-
|
|
1108
|
+
serverScript.Archivable = true
|
|
1109
|
+
serverScript.Parent = game:GetService("ServerScriptService")
|
|
1110
|
+
pcall(function()
|
|
1111
|
+
CollectionService:AddTag(serverScript, TEST_COMPANION_TAG)
|
|
1112
|
+
end)
|
|
236
1113
|
|
|
1114
|
+
-- ----- CLIENT companion -----
|
|
1115
|
+
-- LocalScripts in StarterPlayer.StarterPlayerScripts auto-clone into
|
|
1116
|
+
-- each Player's PlayerScripts on join, so this single LocalScript
|
|
1117
|
+
-- yields one companion per player in the test.
|
|
1118
|
+
local clientScript = nil
|
|
237
1119
|
pcall(function()
|
|
238
|
-
|
|
1120
|
+
local clientSource = CLIENT_COMPANION_SCRIPT_TEMPLATE
|
|
1121
|
+
clientSource = plainReplace(clientSource, "__MCP_SERVER_URL__", serverUrl)
|
|
1122
|
+
clientSource = plainReplace(clientSource, "__MCP_SESSION_ID__", sessionId)
|
|
1123
|
+
|
|
1124
|
+
local sp = game:GetService("StarterPlayer")
|
|
1125
|
+
local sps = sp:FindFirstChild("StarterPlayerScripts")
|
|
1126
|
+
if not sps then
|
|
1127
|
+
-- Some empty places ship without StarterPlayerScripts. Create it.
|
|
1128
|
+
sps = Instance.new("StarterPlayerScripts")
|
|
1129
|
+
sps.Parent = sp
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
local ls = Instance.new("LocalScript")
|
|
1133
|
+
ls.Name = TEST_COMPANION_NAME_CLIENT
|
|
1134
|
+
ls.Source = clientSource
|
|
1135
|
+
ls.Archivable = true
|
|
1136
|
+
ls.Parent = sps
|
|
1137
|
+
pcall(function()
|
|
1138
|
+
CollectionService:AddTag(ls, TEST_COMPANION_TAG)
|
|
1139
|
+
end)
|
|
1140
|
+
clientScript = ls
|
|
239
1141
|
end)
|
|
240
1142
|
|
|
241
|
-
return
|
|
1143
|
+
return serverScript, clientScript
|
|
242
1144
|
end
|
|
243
1145
|
|
|
244
1146
|
-- Track output log for get_output
|
|
@@ -282,18 +1184,41 @@ local function serializeValue(value)
|
|
|
282
1184
|
end
|
|
283
1185
|
end
|
|
284
1186
|
|
|
285
|
-
--
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
1187
|
+
-- ============================================
|
|
1188
|
+
-- DEFERRED ACTION LOGGING
|
|
1189
|
+
-- ============================================
|
|
1190
|
+
-- Handlers call logAction() to record what they just did for the
|
|
1191
|
+
-- get_history / undo / redo tools. We don't want a tracked entry sitting
|
|
1192
|
+
-- in actionHistory unless Studio's ChangeHistoryService actually committed
|
|
1193
|
+
-- a waypoint for the same operation — otherwise undo() will pop our
|
|
1194
|
+
-- tracked entry while Studio reverts something completely different.
|
|
1195
|
+
--
|
|
1196
|
+
-- Architecture: logAction() stashes the entry in `_pendingActionLog`.
|
|
1197
|
+
-- processRequest() snapshots GetCanUndo / GetCanRedo around the
|
|
1198
|
+
-- TryBeginRecording / FinishRecording window, then calls
|
|
1199
|
+
-- commitPendingLog() iff a real waypoint was added. Otherwise it calls
|
|
1200
|
+
-- discardPendingLog().
|
|
1201
|
+
local _pendingActionLog = nil -- single-slot buffer for current request
|
|
289
1202
|
|
|
290
|
-
|
|
1203
|
+
local function logAction(actionType, target, summary, details)
|
|
1204
|
+
-- Stash; processRequest decides whether to keep it.
|
|
1205
|
+
_pendingActionLog = {
|
|
291
1206
|
action = actionType,
|
|
292
1207
|
target = target,
|
|
293
1208
|
summary = summary,
|
|
294
1209
|
details = details or {},
|
|
295
|
-
timestamp = os.time()
|
|
296
|
-
}
|
|
1210
|
+
timestamp = os.time(),
|
|
1211
|
+
}
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
local function commitPendingLog()
|
|
1215
|
+
if not _pendingActionLog then return end
|
|
1216
|
+
local entry = _pendingActionLog
|
|
1217
|
+
_pendingActionLog = nil
|
|
1218
|
+
|
|
1219
|
+
-- A new committed action clears the redo branch.
|
|
1220
|
+
redoHistory = {}
|
|
1221
|
+
table.insert(actionHistory, entry)
|
|
297
1222
|
|
|
298
1223
|
-- Keep history from growing too large
|
|
299
1224
|
if #actionHistory > MAX_ACTION_HISTORY then
|
|
@@ -301,6 +1226,10 @@ local function logAction(actionType, target, summary, details)
|
|
|
301
1226
|
end
|
|
302
1227
|
end
|
|
303
1228
|
|
|
1229
|
+
local function discardPendingLog()
|
|
1230
|
+
_pendingActionLog = nil
|
|
1231
|
+
end
|
|
1232
|
+
|
|
304
1233
|
-- Connect to LogService to capture output
|
|
305
1234
|
LogService.MessageOut:Connect(function(message, messageType)
|
|
306
1235
|
table.insert(outputBuffer, {
|
|
@@ -1014,9 +1943,6 @@ local mutationEndpoints = {
|
|
|
1014
1943
|
["/api/set-calculated-property"] = "Set Calculated Property",
|
|
1015
1944
|
["/api/set-relative-property"] = "Set Relative Property",
|
|
1016
1945
|
["/api/set-script-source"] = "Set Script Source",
|
|
1017
|
-
["/api/edit-script-lines"] = "Edit Script Lines",
|
|
1018
|
-
["/api/insert-script-lines"] = "Insert Script Lines",
|
|
1019
|
-
["/api/delete-script-lines"] = "Delete Script Lines",
|
|
1020
1946
|
["/api/set-attribute"] = "Set Attribute",
|
|
1021
1947
|
["/api/delete-attribute"] = "Delete Attribute",
|
|
1022
1948
|
["/api/add-tag"] = "Add Tag",
|
|
@@ -1024,6 +1950,14 @@ local mutationEndpoints = {
|
|
|
1024
1950
|
["/api/clone-instance"] = "Clone Instance",
|
|
1025
1951
|
["/api/move-instance"] = "Move Instance",
|
|
1026
1952
|
["/api/insert-asset"] = "Insert Asset",
|
|
1953
|
+
-- Script editing: modern recording API (migrated from legacy SetWaypoint)
|
|
1954
|
+
["/api/edit-script"] = "Edit Script",
|
|
1955
|
+
["/api/find-and-replace-in-scripts"] = "Find and Replace in Scripts",
|
|
1956
|
+
-- Arbitrary Lua: wraps the WHOLE execution in one waypoint. Auto-rolls
|
|
1957
|
+
-- back on error via FinishRecording(Cancel). Mutations from yielded /
|
|
1958
|
+
-- task.spawn'd code that runs AFTER the handler returns are NOT captured
|
|
1959
|
+
-- (documented limitation).
|
|
1960
|
+
["/api/execute-lua"] = "Execute Lua",
|
|
1027
1961
|
}
|
|
1028
1962
|
|
|
1029
1963
|
-- Endpoint to handler mapping (populated after handlers are defined below)
|
|
@@ -1048,18 +1982,118 @@ processRequest = function(request)
|
|
|
1048
1982
|
-- Check if this is a mutation endpoint
|
|
1049
1983
|
local recordingName = mutationEndpoints[endpoint]
|
|
1050
1984
|
if recordingName then
|
|
1051
|
-
--
|
|
1985
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
1986
|
+
-- Two parallel undo channels — CHS waypoints AND plugin-side log
|
|
1987
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
1988
|
+
-- Most mutations flow through ChangeHistoryService (CHS) waypoints:
|
|
1989
|
+
-- create_object, set_property, delete_object, etc. — these we track
|
|
1990
|
+
-- BOTH in CHS (so Ctrl+Z and our undo tool work) AND in actionHistory
|
|
1991
|
+
-- (so get_history can show readable summaries).
|
|
1992
|
+
--
|
|
1993
|
+
-- BUT script `Source` property changes are architecturally decoupled
|
|
1994
|
+
-- from CHS in Studio: the script editor maintains its OWN undo stack
|
|
1995
|
+
-- separate from CHS. A direct `instance.Source = "..."` assignment
|
|
1996
|
+
-- changes the value, but CHS will NOT capture it in a waypoint — so
|
|
1997
|
+
-- CHS:Undo() won't revert it (it'll skip ahead and revert the previous
|
|
1998
|
+
-- waypoint, destroying something unrelated). This is the root cause
|
|
1999
|
+
-- of "Bug 1: edit_script undo doesn't actually revert source".
|
|
2000
|
+
--
|
|
2001
|
+
-- Fix: for source-only handlers (edit_script / set_script_source /
|
|
2002
|
+
-- find_and_replace_in_scripts) we Cancel the CHS recording (no empty
|
|
2003
|
+
-- waypoint) and instead store a pre/post-Source snapshot inside the
|
|
2004
|
+
-- tracked action's `details.scriptEdits`. Our undo handler detects
|
|
2005
|
+
-- these entries and restores the source manually via direct
|
|
2006
|
+
-- assignment, bypassing CHS entirely. Real undo, no side effects.
|
|
2007
|
+
--
|
|
2008
|
+
-- Result sentinels that drive the decision:
|
|
2009
|
+
-- • result._isSourceEditOnly == true →
|
|
2010
|
+
-- Script source changes only, no CHS waypoint to keep.
|
|
2011
|
+
-- Cancel recording, KEEP tracked log (with snapshots).
|
|
2012
|
+
-- • result._didNotMutate == true →
|
|
2013
|
+
-- Handler knows it did nothing (e.g. find_and_replace 0 matches).
|
|
2014
|
+
-- Cancel recording, DROP tracked log.
|
|
2015
|
+
-- • result._mutatesUnknown == true →
|
|
2016
|
+
-- Arbitrary user code (execute_lua). Use a synchronous
|
|
2017
|
+
-- descendant-count delta to decide.
|
|
2018
|
+
-- • Otherwise →
|
|
2019
|
+
-- Trusted mutator. Commit recording, KEEP tracked log.
|
|
2020
|
+
--
|
|
2021
|
+
-- Why synchronous descendant-count delta (and NOT DescendantAdded)?
|
|
2022
|
+
-- The DescendantAdded / DescendantRemoving signals fire DEFERRED in
|
|
2023
|
+
-- Studio plugins — by the time the handler returns and we'd read a
|
|
2024
|
+
-- counter, no events have fired yet. Earlier v2 used those signals
|
|
2025
|
+
-- and silently rolled back every execute_lua mutation. Synchronous
|
|
2026
|
+
-- snapshot via #game:GetDescendants() works around the deferral —
|
|
2027
|
+
-- it's O(N) but only runs for the unknown-mutator case.
|
|
2028
|
+
_pendingActionLog = nil -- start clean
|
|
2029
|
+
|
|
2030
|
+
-- Snapshot tree size BEFORE handler, only when we'll need it.
|
|
2031
|
+
-- For execute_lua (the only _mutatesUnknown source) we need the
|
|
2032
|
+
-- before/after delta; everything else trusts the handler's sentinel.
|
|
2033
|
+
local descCountBefore
|
|
2034
|
+
if endpoint == "/api/execute-lua" then
|
|
2035
|
+
descCountBefore = #game:GetDescendants()
|
|
2036
|
+
end
|
|
2037
|
+
|
|
1052
2038
|
local recording = ChangeHistoryService:TryBeginRecording("MCP: " .. recordingName)
|
|
1053
2039
|
local success, result = pcall(handler, data)
|
|
1054
2040
|
|
|
2041
|
+
local handlerOK = success and not (result and result.error)
|
|
2042
|
+
|
|
2043
|
+
-- commitCHS: should we keep the CHS waypoint?
|
|
2044
|
+
-- commitLog: should we keep the tracked action entry?
|
|
2045
|
+
local commitCHS = false
|
|
2046
|
+
local commitLog = false
|
|
2047
|
+
|
|
2048
|
+
if handlerOK then
|
|
2049
|
+
local r = type(result) == "table" and result or nil
|
|
2050
|
+
if r and r._isSourceEditOnly == true then
|
|
2051
|
+
-- Source-only: Cancel CHS (no real waypoint), keep tracked log
|
|
2052
|
+
-- with snapshots for plugin-side undo.
|
|
2053
|
+
commitCHS = false
|
|
2054
|
+
commitLog = true
|
|
2055
|
+
elseif r and r._didNotMutate == true then
|
|
2056
|
+
-- Genuine no-op: drop both.
|
|
2057
|
+
commitCHS = false
|
|
2058
|
+
commitLog = false
|
|
2059
|
+
elseif r and r._mutatesUnknown == true then
|
|
2060
|
+
-- execute_lua: synchronous tree-size delta tells us if
|
|
2061
|
+
-- user code added/removed instances. Property-only mutations
|
|
2062
|
+
-- (e.g. just changing Position) won't be detected — that's an
|
|
2063
|
+
-- accepted edge case; users wanting full tracking should use
|
|
2064
|
+
-- the dedicated set_property tool.
|
|
2065
|
+
local treeChanged = (#game:GetDescendants() ~= descCountBefore)
|
|
2066
|
+
commitCHS = treeChanged
|
|
2067
|
+
commitLog = treeChanged
|
|
2068
|
+
else
|
|
2069
|
+
-- Trusted mutator (create_object, set_property, etc.) →
|
|
2070
|
+
-- always commit on handler success.
|
|
2071
|
+
commitCHS = true
|
|
2072
|
+
commitLog = true
|
|
2073
|
+
end
|
|
2074
|
+
end
|
|
2075
|
+
|
|
1055
2076
|
if recording then
|
|
1056
|
-
if
|
|
2077
|
+
if commitCHS then
|
|
1057
2078
|
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
|
1058
2079
|
else
|
|
1059
2080
|
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
1060
2081
|
end
|
|
1061
2082
|
end
|
|
1062
2083
|
|
|
2084
|
+
if commitLog then
|
|
2085
|
+
commitPendingLog()
|
|
2086
|
+
else
|
|
2087
|
+
discardPendingLog()
|
|
2088
|
+
end
|
|
2089
|
+
|
|
2090
|
+
-- Strip internal sentinels from the response so callers don't see them.
|
|
2091
|
+
if type(result) == "table" then
|
|
2092
|
+
result._mutatesUnknown = nil
|
|
2093
|
+
result._didNotMutate = nil
|
|
2094
|
+
result._isSourceEditOnly = nil
|
|
2095
|
+
end
|
|
2096
|
+
|
|
1063
2097
|
if success then
|
|
1064
2098
|
return result
|
|
1065
2099
|
else
|
|
@@ -2719,55 +3753,62 @@ handlers.setScriptSource = function(requestData)
|
|
|
2719
3753
|
sourceToSet = sourceToSet:gsub("\\t", "\t")
|
|
2720
3754
|
sourceToSet = sourceToSet:gsub("\\r", "\r")
|
|
2721
3755
|
sourceToSet = sourceToSet:gsub("\\\\", "\\")
|
|
2722
|
-
local updateSuccess, updateResult = pcall(function()
|
|
2723
|
-
local oldSourceLength = string.len(instance.Source)
|
|
2724
3756
|
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
message = "Script source updated successfully (editor-safe)"
|
|
2738
|
-
}
|
|
2739
|
-
end)
|
|
2740
|
-
|
|
2741
|
-
if updateSuccess then
|
|
2742
|
-
return updateResult
|
|
2743
|
-
end
|
|
3757
|
+
-- Primary path: direct property assignment. Roblox's script editor
|
|
3758
|
+
-- maintains its own undo stack separate from ChangeHistoryService — so
|
|
3759
|
+
-- CHS waypoints will NOT capture this Source change. We work around
|
|
3760
|
+
-- this by snapshotting pre/post source into the tracked action entry;
|
|
3761
|
+
-- the undo handler restores the snapshot directly. The processRequest
|
|
3762
|
+
-- wrapper sees `_isSourceEditOnly` and Cancels the (empty) CHS
|
|
3763
|
+
-- recording so it doesn't pollute Studio's undo stack.
|
|
3764
|
+
--
|
|
3765
|
+
-- Caveat: if the user has unsaved buffer edits in Studio's script
|
|
3766
|
+
-- editor for this script, those edits will be replaced. Intentional —
|
|
3767
|
+
-- consistent with previous behavior, and predictable for callers.
|
|
3768
|
+
local preSource = instance.Source
|
|
2744
3769
|
|
|
2745
|
-
-- Fallback to direct assignment if UpdateSourceAsync fails
|
|
2746
3770
|
local directSuccess, directResult = pcall(function()
|
|
2747
|
-
local
|
|
3771
|
+
local oldSourceLength = string.len(preSource)
|
|
2748
3772
|
instance.Source = sourceToSet
|
|
2749
3773
|
|
|
2750
|
-
ChangeHistoryService:SetWaypoint("Set script source: " .. instance.Name)
|
|
2751
|
-
|
|
2752
3774
|
return {
|
|
2753
3775
|
success = true,
|
|
2754
3776
|
instancePath = instancePath,
|
|
2755
|
-
oldSourceLength =
|
|
3777
|
+
oldSourceLength = oldSourceLength,
|
|
2756
3778
|
newSourceLength = string.len(sourceToSet),
|
|
2757
3779
|
method = "direct",
|
|
2758
|
-
message = "Script source updated successfully
|
|
3780
|
+
message = "Script source updated successfully"
|
|
2759
3781
|
}
|
|
2760
3782
|
end)
|
|
2761
3783
|
|
|
2762
3784
|
if directSuccess then
|
|
2763
|
-
--
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
3785
|
+
-- Snapshot for plugin-side undo restoration. Stored as a list so the
|
|
3786
|
+
-- same code path also handles find_and_replace's batch edits.
|
|
3787
|
+
logAction(
|
|
3788
|
+
"set_script_source",
|
|
3789
|
+
instancePath,
|
|
3790
|
+
"Script source replaced (" .. directResult.newSourceLength .. " chars)",
|
|
3791
|
+
{
|
|
3792
|
+
method = "direct",
|
|
3793
|
+
scriptEdits = {
|
|
3794
|
+
{
|
|
3795
|
+
instancePath = instancePath,
|
|
3796
|
+
preSource = preSource,
|
|
3797
|
+
postSource = sourceToSet,
|
|
3798
|
+
},
|
|
3799
|
+
},
|
|
3800
|
+
}
|
|
3801
|
+
)
|
|
3802
|
+
-- Source-only change: tell processRequest to Cancel the CHS
|
|
3803
|
+
-- recording but keep our tracked log entry (which has the snapshots).
|
|
3804
|
+
directResult._isSourceEditOnly = true
|
|
2767
3805
|
return directResult
|
|
2768
3806
|
end
|
|
2769
3807
|
|
|
2770
|
-
--
|
|
3808
|
+
-- Fallback: replace the script entirely (used when direct assignment
|
|
3809
|
+
-- throws — e.g. for some plugin-managed script kinds). This DOES
|
|
3810
|
+
-- mutate the tree (Destroy + Instance.new), so CHS captures it.
|
|
3811
|
+
-- That makes the action a regular CHS-backed mutation, not source-only.
|
|
2771
3812
|
local replaceSuccess, replaceResult = pcall(function()
|
|
2772
3813
|
local parent = instance.Parent
|
|
2773
3814
|
local name = instance.Name
|
|
@@ -2788,8 +3829,6 @@ handlers.setScriptSource = function(requestData)
|
|
|
2788
3829
|
newScript.Parent = parent
|
|
2789
3830
|
instance:Destroy()
|
|
2790
3831
|
|
|
2791
|
-
ChangeHistoryService:SetWaypoint("Replace script: " .. name)
|
|
2792
|
-
|
|
2793
3832
|
return {
|
|
2794
3833
|
success = true,
|
|
2795
3834
|
instancePath = getInstancePath(newScript),
|
|
@@ -2799,254 +3838,28 @@ handlers.setScriptSource = function(requestData)
|
|
|
2799
3838
|
end)
|
|
2800
3839
|
|
|
2801
3840
|
if replaceSuccess then
|
|
2802
|
-
-- Log for undo tracking
|
|
2803
3841
|
logAction("set_script_source", replaceResult.instancePath, "Script replaced entirely", {
|
|
2804
3842
|
method = "replace"
|
|
2805
3843
|
})
|
|
3844
|
+
-- Replace path mutates the tree (Destroy + Instance.new) — CHS
|
|
3845
|
+
-- captures this naturally, so we let processRequest Commit normally.
|
|
2806
3846
|
return replaceResult
|
|
2807
3847
|
else
|
|
2808
3848
|
return {
|
|
2809
|
-
error = "Failed to set script source.
|
|
2810
|
-
". Direct assignment failed: " .. tostring(directResult) ..
|
|
3849
|
+
error = "Failed to set script source. Direct assignment failed: " .. tostring(directResult) ..
|
|
2811
3850
|
". Replace method failed: " .. tostring(replaceResult)
|
|
2812
3851
|
}
|
|
2813
3852
|
end
|
|
2814
3853
|
end
|
|
2815
3854
|
|
|
2816
|
-
-- Partial Script Editing: Edit specific lines
|
|
2817
|
-
handlers.editScriptLines = function(requestData)
|
|
2818
|
-
local instancePath = requestData.instancePath
|
|
2819
|
-
local startLine = requestData.startLine
|
|
2820
|
-
local endLine = requestData.endLine
|
|
2821
|
-
local newContent = requestData.newContent
|
|
2822
|
-
|
|
2823
|
-
if not instancePath or not startLine or not endLine or not newContent then
|
|
2824
|
-
return { error = "Instance path, startLine, endLine, and newContent are required" }
|
|
2825
|
-
end
|
|
2826
|
-
|
|
2827
|
-
-- Normalize escape sequences that may have been double-escaped
|
|
2828
|
-
newContent = newContent:gsub("\\n", "\n")
|
|
2829
|
-
newContent = newContent:gsub("\\t", "\t")
|
|
2830
|
-
newContent = newContent:gsub("\\r", "\r")
|
|
2831
|
-
newContent = newContent:gsub("\\\\", "\\")
|
|
2832
|
-
|
|
2833
|
-
local instance = getInstanceByPath(instancePath)
|
|
2834
|
-
if not instance then
|
|
2835
|
-
return { error = "Instance not found: " .. instancePath }
|
|
2836
|
-
end
|
|
2837
|
-
|
|
2838
|
-
if not instance:IsA("LuaSourceContainer") then
|
|
2839
|
-
return { error = "Instance is not a script-like object: " .. instance.ClassName }
|
|
2840
|
-
end
|
|
2841
|
-
|
|
2842
|
-
local success, result = pcall(function()
|
|
2843
|
-
local lines, hadTrailingNewline = splitLines(instance.Source)
|
|
2844
|
-
local totalLines = #lines
|
|
2845
|
-
|
|
2846
|
-
if startLine < 1 or startLine > totalLines then
|
|
2847
|
-
error("startLine out of range (1-" .. totalLines .. ")")
|
|
2848
|
-
end
|
|
2849
|
-
if endLine < startLine or endLine > totalLines then
|
|
2850
|
-
error("endLine out of range (" .. startLine .. "-" .. totalLines .. ")")
|
|
2851
|
-
end
|
|
2852
|
-
|
|
2853
|
-
-- Split new content into lines
|
|
2854
|
-
local newLines = select(1, splitLines(newContent))
|
|
2855
|
-
|
|
2856
|
-
-- Build new source: lines before + new content + lines after
|
|
2857
|
-
local resultLines = {}
|
|
2858
|
-
|
|
2859
|
-
-- Lines before the edit
|
|
2860
|
-
for i = 1, startLine - 1 do
|
|
2861
|
-
table.insert(resultLines, lines[i])
|
|
2862
|
-
end
|
|
2863
|
-
|
|
2864
|
-
-- New content lines
|
|
2865
|
-
for _, line in ipairs(newLines) do
|
|
2866
|
-
table.insert(resultLines, line)
|
|
2867
|
-
end
|
|
2868
|
-
|
|
2869
|
-
-- Lines after the edit
|
|
2870
|
-
for i = endLine + 1, totalLines do
|
|
2871
|
-
table.insert(resultLines, lines[i])
|
|
2872
|
-
end
|
|
2873
|
-
|
|
2874
|
-
local newSource = joinLines(resultLines, hadTrailingNewline)
|
|
2875
|
-
|
|
2876
|
-
-- Use UpdateSourceAsync for editor compatibility
|
|
2877
|
-
ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
|
|
2878
|
-
return newSource
|
|
2879
|
-
end)
|
|
2880
|
-
|
|
2881
|
-
ChangeHistoryService:SetWaypoint("Edit script lines " .. startLine .. "-" .. endLine .. ": " .. instance.Name)
|
|
2882
|
-
|
|
2883
|
-
return {
|
|
2884
|
-
success = true,
|
|
2885
|
-
instancePath = instancePath,
|
|
2886
|
-
editedLines = { startLine = startLine, endLine = endLine },
|
|
2887
|
-
linesRemoved = endLine - startLine + 1,
|
|
2888
|
-
linesAdded = #newLines,
|
|
2889
|
-
newLineCount = #resultLines,
|
|
2890
|
-
message = "Script lines edited successfully"
|
|
2891
|
-
}
|
|
2892
|
-
end)
|
|
2893
|
-
|
|
2894
|
-
if success then
|
|
2895
|
-
return result
|
|
2896
|
-
else
|
|
2897
|
-
return { error = "Failed to edit script lines: " .. tostring(result) }
|
|
2898
|
-
end
|
|
2899
|
-
end
|
|
2900
|
-
|
|
2901
|
-
-- Partial Script Editing: Insert lines after a specific line
|
|
2902
|
-
handlers.insertScriptLines = function(requestData)
|
|
2903
|
-
local instancePath = requestData.instancePath
|
|
2904
|
-
local afterLine = requestData.afterLine or 0 -- 0 means insert at beginning
|
|
2905
|
-
local newContent = requestData.newContent
|
|
2906
|
-
|
|
2907
|
-
if not instancePath or not newContent then
|
|
2908
|
-
return { error = "Instance path and newContent are required" }
|
|
2909
|
-
end
|
|
2910
|
-
|
|
2911
|
-
-- Normalize escape sequences that may have been double-escaped
|
|
2912
|
-
newContent = newContent:gsub("\\n", "\n")
|
|
2913
|
-
newContent = newContent:gsub("\\t", "\t")
|
|
2914
|
-
newContent = newContent:gsub("\\r", "\r")
|
|
2915
|
-
newContent = newContent:gsub("\\\\", "\\")
|
|
2916
|
-
|
|
2917
|
-
local instance = getInstanceByPath(instancePath)
|
|
2918
|
-
if not instance then
|
|
2919
|
-
return { error = "Instance not found: " .. instancePath }
|
|
2920
|
-
end
|
|
2921
|
-
|
|
2922
|
-
if not instance:IsA("LuaSourceContainer") then
|
|
2923
|
-
return { error = "Instance is not a script-like object: " .. instance.ClassName }
|
|
2924
|
-
end
|
|
2925
|
-
|
|
2926
|
-
local success, result = pcall(function()
|
|
2927
|
-
local lines, hadTrailingNewline = splitLines(instance.Source)
|
|
2928
|
-
local totalLines = #lines
|
|
2929
|
-
|
|
2930
|
-
if afterLine < 0 or afterLine > totalLines then
|
|
2931
|
-
error("afterLine out of range (0-" .. totalLines .. ")")
|
|
2932
|
-
end
|
|
2933
|
-
|
|
2934
|
-
-- Split new content into lines
|
|
2935
|
-
local newLines = select(1, splitLines(newContent))
|
|
2936
|
-
|
|
2937
|
-
-- Build new source
|
|
2938
|
-
local resultLines = {}
|
|
2939
|
-
|
|
2940
|
-
-- Lines before insertion point
|
|
2941
|
-
for i = 1, afterLine do
|
|
2942
|
-
table.insert(resultLines, lines[i])
|
|
2943
|
-
end
|
|
2944
|
-
|
|
2945
|
-
-- New content lines
|
|
2946
|
-
for _, line in ipairs(newLines) do
|
|
2947
|
-
table.insert(resultLines, line)
|
|
2948
|
-
end
|
|
2949
|
-
|
|
2950
|
-
-- Lines after insertion point
|
|
2951
|
-
for i = afterLine + 1, totalLines do
|
|
2952
|
-
table.insert(resultLines, lines[i])
|
|
2953
|
-
end
|
|
2954
|
-
|
|
2955
|
-
local newSource = joinLines(resultLines, hadTrailingNewline)
|
|
2956
|
-
|
|
2957
|
-
-- Use UpdateSourceAsync for editor compatibility
|
|
2958
|
-
ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
|
|
2959
|
-
return newSource
|
|
2960
|
-
end)
|
|
2961
|
-
|
|
2962
|
-
ChangeHistoryService:SetWaypoint("Insert script lines after line " .. afterLine .. ": " .. instance.Name)
|
|
2963
|
-
|
|
2964
|
-
return {
|
|
2965
|
-
success = true,
|
|
2966
|
-
instancePath = instancePath,
|
|
2967
|
-
insertedAfterLine = afterLine,
|
|
2968
|
-
linesInserted = #newLines,
|
|
2969
|
-
newLineCount = #resultLines,
|
|
2970
|
-
message = "Script lines inserted successfully"
|
|
2971
|
-
}
|
|
2972
|
-
end)
|
|
2973
|
-
|
|
2974
|
-
if success then
|
|
2975
|
-
return result
|
|
2976
|
-
else
|
|
2977
|
-
return { error = "Failed to insert script lines: " .. tostring(result) }
|
|
2978
|
-
end
|
|
2979
|
-
end
|
|
2980
|
-
|
|
2981
|
-
-- Partial Script Editing: Delete specific lines
|
|
2982
|
-
handlers.deleteScriptLines = function(requestData)
|
|
2983
|
-
local instancePath = requestData.instancePath
|
|
2984
|
-
local startLine = requestData.startLine
|
|
2985
|
-
local endLine = requestData.endLine
|
|
2986
|
-
|
|
2987
|
-
if not instancePath or not startLine or not endLine then
|
|
2988
|
-
return { error = "Instance path, startLine, and endLine are required" }
|
|
2989
|
-
end
|
|
2990
|
-
|
|
2991
|
-
local instance = getInstanceByPath(instancePath)
|
|
2992
|
-
if not instance then
|
|
2993
|
-
return { error = "Instance not found: " .. instancePath }
|
|
2994
|
-
end
|
|
2995
|
-
|
|
2996
|
-
if not instance:IsA("LuaSourceContainer") then
|
|
2997
|
-
return { error = "Instance is not a script-like object: " .. instance.ClassName }
|
|
2998
|
-
end
|
|
2999
|
-
|
|
3000
|
-
local success, result = pcall(function()
|
|
3001
|
-
local lines, hadTrailingNewline = splitLines(instance.Source)
|
|
3002
|
-
local totalLines = #lines
|
|
3003
|
-
|
|
3004
|
-
if startLine < 1 or startLine > totalLines then
|
|
3005
|
-
error("startLine out of range (1-" .. totalLines .. ")")
|
|
3006
|
-
end
|
|
3007
|
-
if endLine < startLine or endLine > totalLines then
|
|
3008
|
-
error("endLine out of range (" .. startLine .. "-" .. totalLines .. ")")
|
|
3009
|
-
end
|
|
3010
|
-
|
|
3011
|
-
-- Build new source without the deleted lines
|
|
3012
|
-
local resultLines = {}
|
|
3013
|
-
|
|
3014
|
-
for i = 1, startLine - 1 do
|
|
3015
|
-
table.insert(resultLines, lines[i])
|
|
3016
|
-
end
|
|
3017
|
-
|
|
3018
|
-
for i = endLine + 1, totalLines do
|
|
3019
|
-
table.insert(resultLines, lines[i])
|
|
3020
|
-
end
|
|
3021
|
-
|
|
3022
|
-
local newSource = joinLines(resultLines, hadTrailingNewline)
|
|
3023
|
-
|
|
3024
|
-
-- Use UpdateSourceAsync for editor compatibility
|
|
3025
|
-
ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
|
|
3026
|
-
return newSource
|
|
3027
|
-
end)
|
|
3028
|
-
|
|
3029
|
-
ChangeHistoryService:SetWaypoint("Delete script lines " .. startLine .. "-" .. endLine .. ": " .. instance.Name)
|
|
3030
|
-
|
|
3031
|
-
return {
|
|
3032
|
-
success = true,
|
|
3033
|
-
instancePath = instancePath,
|
|
3034
|
-
deletedLines = { startLine = startLine, endLine = endLine },
|
|
3035
|
-
linesDeleted = endLine - startLine + 1,
|
|
3036
|
-
newLineCount = #resultLines,
|
|
3037
|
-
message = "Script lines deleted successfully"
|
|
3038
|
-
}
|
|
3039
|
-
end)
|
|
3040
|
-
|
|
3041
|
-
if success then
|
|
3042
|
-
return result
|
|
3043
|
-
else
|
|
3044
|
-
return { error = "Failed to delete script lines: " .. tostring(result) }
|
|
3045
|
-
end
|
|
3046
|
-
end
|
|
3047
|
-
|
|
3048
3855
|
-- ============================================
|
|
3049
3856
|
-- CLAUDE CODE-STYLE SCRIPT EDITING TOOLS
|
|
3857
|
+
--
|
|
3858
|
+
-- The legacy line-based partial editors (editScriptLines /
|
|
3859
|
+
-- insertScriptLines / deleteScriptLines) were removed. They've been
|
|
3860
|
+
-- superseded by handlers.editScript below: string-based edits don't
|
|
3861
|
+
-- suffer from line-number drift after each modification, and the
|
|
3862
|
+
-- companion validation step rejects edits that break syntax.
|
|
3050
3863
|
-- ============================================
|
|
3051
3864
|
|
|
3052
3865
|
-- Helper: Count occurrences of a substring
|
|
@@ -3166,12 +3979,20 @@ handlers.editScript = function(requestData)
|
|
|
3166
3979
|
end
|
|
3167
3980
|
end
|
|
3168
3981
|
|
|
3169
|
-
-- Apply
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3982
|
+
-- Apply via direct property assignment. NOTE: this does NOT produce
|
|
3983
|
+
-- a ChangeHistoryService waypoint (Studio's script editor maintains
|
|
3984
|
+
-- its own undo stack disjoint from CHS) — so undo support for this
|
|
3985
|
+
-- edit comes from the pre/post-source SNAPSHOT we attach to the
|
|
3986
|
+
-- tracked action entry below. The processRequest wrapper sees
|
|
3987
|
+
-- `_isSourceEditOnly = true` in our return value and Cancels the
|
|
3988
|
+
-- (empty) CHS recording.
|
|
3989
|
+
--
|
|
3990
|
+
-- We do NOT use ScriptEditorService:UpdateSourceAsync — same
|
|
3991
|
+
-- reason (and it has the same CHS limitation), with the added
|
|
3992
|
+
-- downside of being yield-only and harder to reason about.
|
|
3993
|
+
-- (Caveat: unsaved buffer edits in Studio's script editor will be
|
|
3994
|
+
-- replaced — same trade-off as set_script_source.)
|
|
3995
|
+
instance.Source = newSource
|
|
3175
3996
|
|
|
3176
3997
|
-- Prepare summary for logging
|
|
3177
3998
|
local shortOld = #searchStr > 30 and (string.sub(searchStr, 1, 30) .. "...") or searchStr
|
|
@@ -3187,17 +4008,37 @@ handlers.editScript = function(requestData)
|
|
|
3187
4008
|
message = replaceAll
|
|
3188
4009
|
and ("Replaced " .. occurrences .. " occurrence(s) successfully")
|
|
3189
4010
|
or "Edit applied successfully",
|
|
3190
|
-
_logSummary = shortOld:gsub("\n", "\\n") .. " → " .. shortNew:gsub("\n", "\\n")
|
|
4011
|
+
_logSummary = shortOld:gsub("\n", "\\n") .. " → " .. shortNew:gsub("\n", "\\n"),
|
|
4012
|
+
_preSource = source,
|
|
4013
|
+
_postSource = newSource,
|
|
3191
4014
|
}
|
|
3192
4015
|
end)
|
|
3193
4016
|
|
|
3194
4017
|
if success then
|
|
3195
4018
|
if result.success then
|
|
3196
|
-
-- Log for undo tracking
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
4019
|
+
-- Log for undo tracking. The pre/post-source snapshots live in
|
|
4020
|
+
-- details.scriptEdits — undo restores from preSource, redo from
|
|
4021
|
+
-- postSource.
|
|
4022
|
+
logAction(
|
|
4023
|
+
"edit_script",
|
|
4024
|
+
instancePath,
|
|
4025
|
+
result._logSummary or "Script edited",
|
|
4026
|
+
{
|
|
4027
|
+
replacements = result.replacements,
|
|
4028
|
+
scriptEdits = {
|
|
4029
|
+
{
|
|
4030
|
+
instancePath = instancePath,
|
|
4031
|
+
preSource = result._preSource,
|
|
4032
|
+
postSource = result._postSource,
|
|
4033
|
+
},
|
|
4034
|
+
},
|
|
4035
|
+
}
|
|
4036
|
+
)
|
|
4037
|
+
result._logSummary = nil
|
|
4038
|
+
result._preSource = nil
|
|
4039
|
+
result._postSource = nil
|
|
4040
|
+
-- Source-only change: Cancel CHS recording, keep tracked log.
|
|
4041
|
+
result._isSourceEditOnly = true
|
|
3201
4042
|
end
|
|
3202
4043
|
return result
|
|
3203
4044
|
else
|
|
@@ -3276,6 +4117,217 @@ handlers.searchScript = function(requestData)
|
|
|
3276
4117
|
end
|
|
3277
4118
|
end
|
|
3278
4119
|
|
|
4120
|
+
-- grep: Claude Code-style search across the instance tree.
|
|
4121
|
+
--
|
|
4122
|
+
-- Walks descendants of `path`, pre-filters by `type` (IsA) and `glob`
|
|
4123
|
+
-- (Lua pattern over Name), then scans script Source for `pattern` (either
|
|
4124
|
+
-- line-by-line or whole-source if `multiline`). Three output modes mirror
|
|
4125
|
+
-- `grep -l` / `grep` / `grep -c`.
|
|
4126
|
+
--
|
|
4127
|
+
-- Request shape:
|
|
4128
|
+
-- { pattern, path, glob?, type? (array), caseInsensitive?, after?, before?,
|
|
4129
|
+
-- context?, outputMode? ("files_with_matches"|"content"|"count"),
|
|
4130
|
+
-- headLimit?, multiline? }
|
|
4131
|
+
--
|
|
4132
|
+
-- Response shapes:
|
|
4133
|
+
-- files_with_matches: { mode, pattern, scanned, matches = [{path, className, name}] }
|
|
4134
|
+
-- content: { mode, pattern, scanned, matches = [{path, className, name,
|
|
4135
|
+
-- lines = [{lineNumber, content, context = [{lineNumber, content, type}]}]}],
|
|
4136
|
+
-- totalMatches }
|
|
4137
|
+
-- count: { mode, pattern, scanned, matches = [{path, className, name, count}], totalMatches }
|
|
4138
|
+
handlers.grep = function(requestData)
|
|
4139
|
+
local pattern = requestData.pattern
|
|
4140
|
+
if type(pattern) ~= "string" or #pattern == 0 then
|
|
4141
|
+
return { error = "pattern is required (use \".*\" for name-only searches)" }
|
|
4142
|
+
end
|
|
4143
|
+
|
|
4144
|
+
local rootPath = requestData.path
|
|
4145
|
+
if type(rootPath) ~= "string" or #rootPath == 0 then
|
|
4146
|
+
rootPath = "game"
|
|
4147
|
+
end
|
|
4148
|
+
|
|
4149
|
+
local glob = requestData.glob
|
|
4150
|
+
if type(glob) ~= "string" or #glob == 0 then glob = nil end
|
|
4151
|
+
|
|
4152
|
+
local typeFilter = requestData.type
|
|
4153
|
+
if typeof(typeFilter) ~= "table" or #typeFilter == 0 then
|
|
4154
|
+
typeFilter = { "LuaSourceContainer" }
|
|
4155
|
+
end
|
|
4156
|
+
|
|
4157
|
+
local caseInsensitive = requestData.caseInsensitive == true
|
|
4158
|
+
local outputMode = requestData.outputMode or "files_with_matches"
|
|
4159
|
+
if outputMode ~= "files_with_matches" and outputMode ~= "content" and outputMode ~= "count" then
|
|
4160
|
+
return { error = "outputMode must be one of: files_with_matches, content, count" }
|
|
4161
|
+
end
|
|
4162
|
+
|
|
4163
|
+
local contextAround = tonumber(requestData.context) or 0
|
|
4164
|
+
local contextAfter = tonumber(requestData.after) or 0
|
|
4165
|
+
local contextBefore = tonumber(requestData.before) or 0
|
|
4166
|
+
if contextAround > 0 then
|
|
4167
|
+
-- -C overrides -A/-B, matching grep semantics
|
|
4168
|
+
contextAfter = contextAround
|
|
4169
|
+
contextBefore = contextAround
|
|
4170
|
+
end
|
|
4171
|
+
|
|
4172
|
+
local headLimit = tonumber(requestData.headLimit) or 0
|
|
4173
|
+
local multiline = requestData.multiline == true
|
|
4174
|
+
|
|
4175
|
+
-- Resolve the root instance.
|
|
4176
|
+
local rootInstance
|
|
4177
|
+
if rootPath == "game" or rootPath == "" then
|
|
4178
|
+
rootInstance = game
|
|
4179
|
+
else
|
|
4180
|
+
rootInstance = getInstanceByPath(rootPath)
|
|
4181
|
+
if not rootInstance then
|
|
4182
|
+
return { error = "Path not found: " .. rootPath }
|
|
4183
|
+
end
|
|
4184
|
+
end
|
|
4185
|
+
|
|
4186
|
+
-- Helper: case-insensitive find/match by lowercasing both inputs. We
|
|
4187
|
+
-- can't use a "(?i)" prefix in Lua patterns, so this is the standard
|
|
4188
|
+
-- workaround. We keep the original case of the matched line in the
|
|
4189
|
+
-- output for readability.
|
|
4190
|
+
local function caseFold(s)
|
|
4191
|
+
if caseInsensitive then return string.lower(s) else return s end
|
|
4192
|
+
end
|
|
4193
|
+
|
|
4194
|
+
local patternFolded = caseFold(pattern)
|
|
4195
|
+
local globFolded = glob and caseFold(glob) or nil
|
|
4196
|
+
|
|
4197
|
+
-- Walk descendants and apply filters.
|
|
4198
|
+
local descendants = rootInstance:GetDescendants()
|
|
4199
|
+
-- Include the root itself so e.g. grep on a specific Script path works.
|
|
4200
|
+
table.insert(descendants, 1, rootInstance)
|
|
4201
|
+
|
|
4202
|
+
local scanned = 0
|
|
4203
|
+
local matches = {}
|
|
4204
|
+
local totalMatches = 0
|
|
4205
|
+
local hitLimit = false
|
|
4206
|
+
|
|
4207
|
+
for _, inst in ipairs(descendants) do
|
|
4208
|
+
-- Type filter: pass if `inst:IsA(name)` for ANY name in typeFilter.
|
|
4209
|
+
local typeOk = false
|
|
4210
|
+
for _, t in ipairs(typeFilter) do
|
|
4211
|
+
local ok, isA = pcall(function() return inst:IsA(t) end)
|
|
4212
|
+
if ok and isA then
|
|
4213
|
+
typeOk = true
|
|
4214
|
+
break
|
|
4215
|
+
end
|
|
4216
|
+
end
|
|
4217
|
+
if not typeOk then continue end
|
|
4218
|
+
|
|
4219
|
+
-- Glob filter: Lua-pattern match against Name.
|
|
4220
|
+
if globFolded then
|
|
4221
|
+
local nameFolded = caseFold(inst.Name)
|
|
4222
|
+
if not string.find(nameFolded, globFolded) then
|
|
4223
|
+
continue
|
|
4224
|
+
end
|
|
4225
|
+
end
|
|
4226
|
+
|
|
4227
|
+
scanned += 1
|
|
4228
|
+
|
|
4229
|
+
-- Read Source only if applicable. Non-script instances that pass
|
|
4230
|
+
-- the filters are reported as name-only hits (no line content).
|
|
4231
|
+
local isScript = inst:IsA("LuaSourceContainer")
|
|
4232
|
+
local sourceOk, source = false, ""
|
|
4233
|
+
if isScript then
|
|
4234
|
+
sourceOk, source = pcall(function() return inst.Source end)
|
|
4235
|
+
if not sourceOk then source = "" end
|
|
4236
|
+
end
|
|
4237
|
+
|
|
4238
|
+
-- Apply the content pattern.
|
|
4239
|
+
local instMatchCount = 0
|
|
4240
|
+
local lineHits = nil -- only populated in "content" mode
|
|
4241
|
+
|
|
4242
|
+
if isScript and #source > 0 then
|
|
4243
|
+
if multiline then
|
|
4244
|
+
-- Whole-source match; report a single virtual line (line 1)
|
|
4245
|
+
-- containing the start of the match for simplicity.
|
|
4246
|
+
local hay = caseFold(source)
|
|
4247
|
+
local s, e = string.find(hay, patternFolded)
|
|
4248
|
+
if s then
|
|
4249
|
+
instMatchCount = 1
|
|
4250
|
+
if outputMode == "content" then
|
|
4251
|
+
-- For multiline mode, return the matched substring
|
|
4252
|
+
-- as the "line" content (truncated to keep payload sane).
|
|
4253
|
+
local snippet = string.sub(source, s, math.min(e, s + 500))
|
|
4254
|
+
lineHits = { { lineNumber = 1, content = snippet, context = {} } }
|
|
4255
|
+
end
|
|
4256
|
+
end
|
|
4257
|
+
else
|
|
4258
|
+
local lines, _ = splitLines(source)
|
|
4259
|
+
for lineNum, line in ipairs(lines) do
|
|
4260
|
+
local hay = caseFold(line)
|
|
4261
|
+
if string.find(hay, patternFolded) then
|
|
4262
|
+
instMatchCount += 1
|
|
4263
|
+
if outputMode == "content" then
|
|
4264
|
+
if not lineHits then lineHits = {} end
|
|
4265
|
+
local hit = {
|
|
4266
|
+
lineNumber = lineNum,
|
|
4267
|
+
content = line,
|
|
4268
|
+
context = {},
|
|
4269
|
+
}
|
|
4270
|
+
if contextBefore > 0 then
|
|
4271
|
+
for i = math.max(1, lineNum - contextBefore), lineNum - 1 do
|
|
4272
|
+
table.insert(hit.context, { lineNumber = i, content = lines[i], type = "before" })
|
|
4273
|
+
end
|
|
4274
|
+
end
|
|
4275
|
+
if contextAfter > 0 then
|
|
4276
|
+
for i = lineNum + 1, math.min(#lines, lineNum + contextAfter) do
|
|
4277
|
+
table.insert(hit.context, { lineNumber = i, content = lines[i], type = "after" })
|
|
4278
|
+
end
|
|
4279
|
+
end
|
|
4280
|
+
table.insert(lineHits, hit)
|
|
4281
|
+
end
|
|
4282
|
+
end
|
|
4283
|
+
end
|
|
4284
|
+
end
|
|
4285
|
+
end
|
|
4286
|
+
|
|
4287
|
+
-- Decide whether this instance counts as a "hit".
|
|
4288
|
+
-- For non-script instances that passed name/class filters, we treat
|
|
4289
|
+
-- them as a name-only hit so callers can use grep as a unified search.
|
|
4290
|
+
local isHit = (instMatchCount > 0) or (not isScript)
|
|
4291
|
+
if not isHit then continue end
|
|
4292
|
+
|
|
4293
|
+
totalMatches += instMatchCount
|
|
4294
|
+
|
|
4295
|
+
local entry = {
|
|
4296
|
+
path = getInstancePath(inst),
|
|
4297
|
+
className = inst.ClassName,
|
|
4298
|
+
name = inst.Name,
|
|
4299
|
+
}
|
|
4300
|
+
if outputMode == "content" then
|
|
4301
|
+
entry.lines = lineHits or {}
|
|
4302
|
+
elseif outputMode == "count" then
|
|
4303
|
+
entry.count = instMatchCount
|
|
4304
|
+
end
|
|
4305
|
+
table.insert(matches, entry)
|
|
4306
|
+
|
|
4307
|
+
if headLimit > 0 and #matches >= headLimit then
|
|
4308
|
+
hitLimit = true
|
|
4309
|
+
break
|
|
4310
|
+
end
|
|
4311
|
+
end
|
|
4312
|
+
|
|
4313
|
+
local response = {
|
|
4314
|
+
mode = outputMode,
|
|
4315
|
+
pattern = pattern,
|
|
4316
|
+
path = rootPath,
|
|
4317
|
+
scanned = scanned,
|
|
4318
|
+
matchCount = #matches,
|
|
4319
|
+
matches = matches,
|
|
4320
|
+
}
|
|
4321
|
+
if outputMode ~= "files_with_matches" then
|
|
4322
|
+
response.totalMatches = totalMatches
|
|
4323
|
+
end
|
|
4324
|
+
if hitLimit then
|
|
4325
|
+
response.truncated = true
|
|
4326
|
+
response.note = string.format("Output truncated at head_limit=%d. Increase head_limit or narrow with `glob`/`path`/`type`.", headLimit)
|
|
4327
|
+
end
|
|
4328
|
+
return response
|
|
4329
|
+
end
|
|
4330
|
+
|
|
3279
4331
|
-- get_script_function: Extract a specific function by name
|
|
3280
4332
|
handlers.getScriptFunction = function(requestData)
|
|
3281
4333
|
local instancePath = requestData.instancePath
|
|
@@ -3429,6 +4481,10 @@ handlers.findAndReplaceInScripts = function(requestData)
|
|
|
3429
4481
|
local successCount = 0
|
|
3430
4482
|
local failCount = 0
|
|
3431
4483
|
local skippedCount = 0
|
|
4484
|
+
-- Per-script pre/post-source snapshots. Used by the undo handler to
|
|
4485
|
+
-- revert source-only edits (direct Source = is invisible to CHS — see
|
|
4486
|
+
-- edit_script for the full explanation).
|
|
4487
|
+
local scriptEdits = {}
|
|
3432
4488
|
|
|
3433
4489
|
for _, path in ipairs(paths) do
|
|
3434
4490
|
local instance = getInstanceByPath(path)
|
|
@@ -3451,31 +4507,82 @@ handlers.findAndReplaceInScripts = function(requestData)
|
|
|
3451
4507
|
else
|
|
3452
4508
|
local newSource = string.gsub(source, searchStr:gsub("([^%w])", "%%%1"), replaceStr)
|
|
3453
4509
|
|
|
4510
|
+
-- Direct property assignment. Captured into scriptEdits so
|
|
4511
|
+
-- the undo handler can revert it (CHS doesn't see Source
|
|
4512
|
+
-- changes — see edit_script for the full rationale).
|
|
3454
4513
|
if validateAfter then
|
|
3455
4514
|
local isValid, syntaxErrors = validateLuaSyntax(newSource)
|
|
3456
4515
|
if not isValid then
|
|
3457
4516
|
table.insert(results, { path = path, success = false, error = "Would create invalid syntax", syntaxErrors = syntaxErrors })
|
|
3458
4517
|
failCount = failCount + 1
|
|
3459
4518
|
else
|
|
3460
|
-
pcall(function()
|
|
3461
|
-
|
|
4519
|
+
local assignOk, assignErr = pcall(function()
|
|
4520
|
+
instance.Source = newSource
|
|
3462
4521
|
end)
|
|
3463
|
-
|
|
3464
|
-
|
|
4522
|
+
if assignOk then
|
|
4523
|
+
table.insert(results, { path = path, success = true, replacements = occurrences })
|
|
4524
|
+
table.insert(scriptEdits, {
|
|
4525
|
+
instancePath = path,
|
|
4526
|
+
preSource = source,
|
|
4527
|
+
postSource = newSource,
|
|
4528
|
+
})
|
|
4529
|
+
successCount = successCount + 1
|
|
4530
|
+
else
|
|
4531
|
+
table.insert(results, { path = path, success = false, error = "Source assignment failed: " .. tostring(assignErr) })
|
|
4532
|
+
failCount = failCount + 1
|
|
4533
|
+
end
|
|
3465
4534
|
end
|
|
3466
4535
|
else
|
|
3467
|
-
pcall(function()
|
|
3468
|
-
|
|
4536
|
+
local assignOk, assignErr = pcall(function()
|
|
4537
|
+
instance.Source = newSource
|
|
3469
4538
|
end)
|
|
3470
|
-
|
|
3471
|
-
|
|
4539
|
+
if assignOk then
|
|
4540
|
+
table.insert(results, { path = path, success = true, replacements = occurrences })
|
|
4541
|
+
table.insert(scriptEdits, {
|
|
4542
|
+
instancePath = path,
|
|
4543
|
+
preSource = source,
|
|
4544
|
+
postSource = newSource,
|
|
4545
|
+
})
|
|
4546
|
+
successCount = successCount + 1
|
|
4547
|
+
else
|
|
4548
|
+
table.insert(results, { path = path, success = false, error = "Source assignment failed: " .. tostring(assignErr) })
|
|
4549
|
+
failCount = failCount + 1
|
|
4550
|
+
end
|
|
3472
4551
|
end
|
|
3473
4552
|
end
|
|
3474
4553
|
end
|
|
3475
4554
|
end
|
|
3476
4555
|
|
|
4556
|
+
-- Source edits aren't captured by ChangeHistoryService (see edit_script
|
|
4557
|
+
-- comment). The pre/post-source snapshots stored in details.scriptEdits
|
|
4558
|
+
-- are what the undo handler uses to revert. processRequest sees
|
|
4559
|
+
-- `_isSourceEditOnly = true` and Cancels the empty CHS recording.
|
|
4560
|
+
|
|
4561
|
+
-- Log to MCP action history for get_history visibility (only when at
|
|
4562
|
+
-- least one script was actually modified)
|
|
3477
4563
|
if successCount > 0 then
|
|
3478
|
-
|
|
4564
|
+
local searchPreview = oldString
|
|
4565
|
+
if #searchPreview > 30 then
|
|
4566
|
+
searchPreview = string.sub(searchPreview, 1, 30) .. "..."
|
|
4567
|
+
end
|
|
4568
|
+
local replacePreview = newString
|
|
4569
|
+
if #replacePreview > 30 then
|
|
4570
|
+
replacePreview = string.sub(replacePreview, 1, 30) .. "..."
|
|
4571
|
+
end
|
|
4572
|
+
searchPreview = searchPreview:gsub("\n", "\\n")
|
|
4573
|
+
replacePreview = replacePreview:gsub("\n", "\\n")
|
|
4574
|
+
logAction(
|
|
4575
|
+
"find_and_replace_in_scripts",
|
|
4576
|
+
tostring(successCount) .. " scripts",
|
|
4577
|
+
searchPreview .. " → " .. replacePreview,
|
|
4578
|
+
{
|
|
4579
|
+
total = #paths,
|
|
4580
|
+
successful = successCount,
|
|
4581
|
+
failed = failCount,
|
|
4582
|
+
skipped = skippedCount,
|
|
4583
|
+
scriptEdits = scriptEdits,
|
|
4584
|
+
}
|
|
4585
|
+
)
|
|
3479
4586
|
end
|
|
3480
4587
|
|
|
3481
4588
|
return {
|
|
@@ -3486,7 +4593,13 @@ handlers.findAndReplaceInScripts = function(requestData)
|
|
|
3486
4593
|
successful = successCount,
|
|
3487
4594
|
failed = failCount,
|
|
3488
4595
|
skipped = skippedCount
|
|
3489
|
-
}
|
|
4596
|
+
},
|
|
4597
|
+
-- Source-only edits: Cancel CHS (it doesn't see Source changes
|
|
4598
|
+
-- anyway), keep tracked log so undo can restore from snapshots.
|
|
4599
|
+
-- If nothing actually changed, fall back to _didNotMutate so the
|
|
4600
|
+
-- log entry doesn't get committed either.
|
|
4601
|
+
_isSourceEditOnly = (successCount > 0),
|
|
4602
|
+
_didNotMutate = (successCount == 0),
|
|
3490
4603
|
}
|
|
3491
4604
|
end
|
|
3492
4605
|
|
|
@@ -4169,28 +5282,116 @@ end
|
|
|
4169
5282
|
-- UNDO/REDO HANDLERS (Enhanced with action tracking)
|
|
4170
5283
|
-- ============================================
|
|
4171
5284
|
|
|
5285
|
+
-- Build a compact summary string for an action entry (used in `undone` /
|
|
5286
|
+
-- `redone` and in the `entries` array). Falls back gracefully when the entry
|
|
5287
|
+
-- came from outside MCP (action == nil).
|
|
5288
|
+
local function formatActionEntry(entry)
|
|
5289
|
+
if not entry then
|
|
5290
|
+
return "Unknown action (possibly manual change)"
|
|
5291
|
+
end
|
|
5292
|
+
return entry.action .. " → " .. entry.target .. " (" .. entry.summary .. ")"
|
|
5293
|
+
end
|
|
5294
|
+
|
|
5295
|
+
-- Helper: does this tracked entry represent a pure script-source edit?
|
|
5296
|
+
-- Pure script edits don't produce CHS waypoints (Studio's script editor has
|
|
5297
|
+
-- its own undo stack disjoint from CHS), so the entry will have an
|
|
5298
|
+
-- attached scriptEdits snapshot list AND no other tree/property mutation.
|
|
5299
|
+
-- For these we restore source manually and DO NOT call CHS:Undo() — that
|
|
5300
|
+
-- would skip past the next real waypoint and revert something unrelated.
|
|
5301
|
+
local function entryIsSourceOnly(entry)
|
|
5302
|
+
if not entry or not entry.details or not entry.details.scriptEdits then
|
|
5303
|
+
return false
|
|
5304
|
+
end
|
|
5305
|
+
return #entry.details.scriptEdits > 0
|
|
5306
|
+
end
|
|
5307
|
+
|
|
5308
|
+
-- Restore each script's pre-edit source. Tolerates instances that have
|
|
5309
|
+
-- been re-parented or destroyed since the edit (skips with a warning so
|
|
5310
|
+
-- one missing script doesn't poison the whole undo).
|
|
5311
|
+
local function restoreScriptEdits(scriptEdits, useField)
|
|
5312
|
+
for _, edit in ipairs(scriptEdits) do
|
|
5313
|
+
local instance = getInstanceByPath(edit.instancePath)
|
|
5314
|
+
if instance and instance:IsA("LuaSourceContainer") then
|
|
5315
|
+
pcall(function()
|
|
5316
|
+
instance.Source = edit[useField]
|
|
5317
|
+
end)
|
|
5318
|
+
end
|
|
5319
|
+
end
|
|
5320
|
+
end
|
|
5321
|
+
|
|
5322
|
+
-- Stepwise undo. Calls ChangeHistoryService:Undo() up to `count` times,
|
|
5323
|
+
-- popping one entry off actionHistory per step (matched into redoHistory).
|
|
5324
|
+
-- Studio's undo stack is the source of truth — we stop early if it runs out.
|
|
5325
|
+
-- `count` defaults to 1; agents pass higher values to roll back several
|
|
5326
|
+
-- recent operations in one call without N round-trips.
|
|
5327
|
+
--
|
|
5328
|
+
-- Special case: pure script-source edits (edit_script, find_and_replace,
|
|
5329
|
+
-- set_script_source via direct assignment) don't create CHS waypoints, so
|
|
5330
|
+
-- we restore from the snapshot in details.scriptEdits and SKIP CHS:Undo().
|
|
4172
5331
|
handlers.undo = function(requestData)
|
|
5332
|
+
local count = tonumber(requestData and requestData.count) or 1
|
|
5333
|
+
if count < 1 then count = 1 end
|
|
5334
|
+
if count > 100 then count = 100 end -- safety clamp
|
|
5335
|
+
|
|
4173
5336
|
local success, result = pcall(function()
|
|
4174
|
-
--
|
|
4175
|
-
local
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
--
|
|
4179
|
-
|
|
4180
|
-
|
|
5337
|
+
local entries = {} -- actions actually undone, in undo order
|
|
5338
|
+
local stoppedReason = nil
|
|
5339
|
+
|
|
5340
|
+
for i = 1, count do
|
|
5341
|
+
-- Peek at the next tracked entry. If it's a pure source edit
|
|
5342
|
+
-- we MUST handle it without calling CHS:Undo() (CHS has no
|
|
5343
|
+
-- corresponding waypoint, so :Undo() would revert the wrong
|
|
5344
|
+
-- thing).
|
|
5345
|
+
local top = actionHistory[#actionHistory]
|
|
5346
|
+
if entryIsSourceOnly(top) then
|
|
5347
|
+
local entry = table.remove(actionHistory)
|
|
5348
|
+
restoreScriptEdits(entry.details.scriptEdits, "preSource")
|
|
5349
|
+
table.insert(redoHistory, entry)
|
|
5350
|
+
table.insert(entries, entry)
|
|
5351
|
+
else
|
|
5352
|
+
-- Tree/property mutation (or unknown out-of-band waypoint):
|
|
5353
|
+
-- defer to Studio's undo stack.
|
|
5354
|
+
local canUndo = ChangeHistoryService:GetCanUndo()
|
|
5355
|
+
if not canUndo then
|
|
5356
|
+
stoppedReason = "no_more_studio_undo"
|
|
5357
|
+
break
|
|
5358
|
+
end
|
|
4181
5359
|
|
|
4182
|
-
|
|
5360
|
+
local entry = nil
|
|
5361
|
+
if #actionHistory > 0 then
|
|
5362
|
+
entry = table.remove(actionHistory)
|
|
5363
|
+
table.insert(redoHistory, entry)
|
|
5364
|
+
end
|
|
4183
5365
|
|
|
5366
|
+
ChangeHistoryService:Undo()
|
|
5367
|
+
table.insert(entries, entry) -- may be nil for out-of-band waypoints
|
|
5368
|
+
end
|
|
5369
|
+
end
|
|
5370
|
+
|
|
5371
|
+
local primary = entries[1]
|
|
4184
5372
|
return {
|
|
4185
5373
|
success = true,
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
5374
|
+
undone_count = #entries,
|
|
5375
|
+
requested_count = count,
|
|
5376
|
+
-- Top-level fields describe the FIRST entry undone (for
|
|
5377
|
+
-- backward compatibility with callers expecting one undo).
|
|
5378
|
+
undone = formatActionEntry(primary),
|
|
5379
|
+
action = primary and primary.action or nil,
|
|
5380
|
+
target = primary and primary.target or nil,
|
|
5381
|
+
summary = primary and primary.summary or nil,
|
|
5382
|
+
details = primary and primary.details or nil,
|
|
5383
|
+
-- Full ordered list of entries undone this call.
|
|
5384
|
+
entries = entries,
|
|
4191
5385
|
remaining_undos = #actionHistory,
|
|
4192
5386
|
available_redos = #redoHistory,
|
|
4193
|
-
|
|
5387
|
+
stopped_early = stoppedReason ~= nil,
|
|
5388
|
+
stopped_reason = stoppedReason,
|
|
5389
|
+
message = string.format(
|
|
5390
|
+
"Undone %d/%d action(s)%s",
|
|
5391
|
+
#entries,
|
|
5392
|
+
count,
|
|
5393
|
+
stoppedReason and (" (stopped: " .. stoppedReason .. ")") or ""
|
|
5394
|
+
),
|
|
4194
5395
|
}
|
|
4195
5396
|
end)
|
|
4196
5397
|
|
|
@@ -4201,33 +5402,68 @@ handlers.undo = function(requestData)
|
|
|
4201
5402
|
success = false,
|
|
4202
5403
|
error = "Failed to undo: " .. tostring(result),
|
|
4203
5404
|
remaining_undos = #actionHistory,
|
|
4204
|
-
available_redos = #redoHistory
|
|
5405
|
+
available_redos = #redoHistory,
|
|
4205
5406
|
}
|
|
4206
5407
|
end
|
|
4207
5408
|
end
|
|
4208
5409
|
|
|
4209
5410
|
handlers.redo = function(requestData)
|
|
5411
|
+
local count = tonumber(requestData and requestData.count) or 1
|
|
5412
|
+
if count < 1 then count = 1 end
|
|
5413
|
+
if count > 100 then count = 100 end
|
|
5414
|
+
|
|
4210
5415
|
local success, result = pcall(function()
|
|
4211
|
-
|
|
4212
|
-
local
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
--
|
|
4216
|
-
|
|
4217
|
-
|
|
5416
|
+
local entries = {}
|
|
5417
|
+
local stoppedReason = nil
|
|
5418
|
+
|
|
5419
|
+
for i = 1, count do
|
|
5420
|
+
-- Mirror of undo: pure source edits redo by re-applying the
|
|
5421
|
+
-- postSource snapshot and SKIP CHS:Redo() (no waypoint to redo).
|
|
5422
|
+
local top = redoHistory[#redoHistory]
|
|
5423
|
+
if entryIsSourceOnly(top) then
|
|
5424
|
+
local entry = table.remove(redoHistory)
|
|
5425
|
+
restoreScriptEdits(entry.details.scriptEdits, "postSource")
|
|
5426
|
+
table.insert(actionHistory, entry)
|
|
5427
|
+
table.insert(entries, entry)
|
|
5428
|
+
else
|
|
5429
|
+
local canRedo = ChangeHistoryService:GetCanRedo()
|
|
5430
|
+
if not canRedo then
|
|
5431
|
+
stoppedReason = "no_more_studio_redo"
|
|
5432
|
+
break
|
|
5433
|
+
end
|
|
5434
|
+
|
|
5435
|
+
local entry = nil
|
|
5436
|
+
if #redoHistory > 0 then
|
|
5437
|
+
entry = table.remove(redoHistory)
|
|
5438
|
+
table.insert(actionHistory, entry)
|
|
5439
|
+
end
|
|
4218
5440
|
|
|
4219
|
-
|
|
5441
|
+
ChangeHistoryService:Redo()
|
|
5442
|
+
table.insert(entries, entry)
|
|
5443
|
+
end
|
|
5444
|
+
end
|
|
4220
5445
|
|
|
5446
|
+
local primary = entries[1]
|
|
4221
5447
|
return {
|
|
4222
5448
|
success = true,
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
5449
|
+
redone_count = #entries,
|
|
5450
|
+
requested_count = count,
|
|
5451
|
+
redone = formatActionEntry(primary),
|
|
5452
|
+
action = primary and primary.action or nil,
|
|
5453
|
+
target = primary and primary.target or nil,
|
|
5454
|
+
summary = primary and primary.summary or nil,
|
|
5455
|
+
details = primary and primary.details or nil,
|
|
5456
|
+
entries = entries,
|
|
4228
5457
|
remaining_undos = #actionHistory,
|
|
4229
5458
|
available_redos = #redoHistory,
|
|
4230
|
-
|
|
5459
|
+
stopped_early = stoppedReason ~= nil,
|
|
5460
|
+
stopped_reason = stoppedReason,
|
|
5461
|
+
message = string.format(
|
|
5462
|
+
"Redone %d/%d action(s)%s",
|
|
5463
|
+
#entries,
|
|
5464
|
+
count,
|
|
5465
|
+
stoppedReason and (" (stopped: " .. stoppedReason .. ")") or ""
|
|
5466
|
+
),
|
|
4231
5467
|
}
|
|
4232
5468
|
end)
|
|
4233
5469
|
|
|
@@ -4238,11 +5474,68 @@ handlers.redo = function(requestData)
|
|
|
4238
5474
|
success = false,
|
|
4239
5475
|
error = "Failed to redo: " .. tostring(result),
|
|
4240
5476
|
remaining_undos = #actionHistory,
|
|
4241
|
-
available_redos = #redoHistory
|
|
5477
|
+
available_redos = #redoHistory,
|
|
4242
5478
|
}
|
|
4243
5479
|
end
|
|
4244
5480
|
end
|
|
4245
5481
|
|
|
5482
|
+
-- ============================================
|
|
5483
|
+
-- HISTORY INTROSPECTION
|
|
5484
|
+
-- ============================================
|
|
5485
|
+
|
|
5486
|
+
-- Read-only view of the recent action history + Studio's authoritative
|
|
5487
|
+
-- can_undo/can_redo flags. Useful for agents that want to peek before calling
|
|
5488
|
+
-- undo/redo (e.g. "what am I about to undo?") or to recover state after a
|
|
5489
|
+
-- session resume.
|
|
5490
|
+
handlers.getHistory = function(requestData)
|
|
5491
|
+
local limit = tonumber(requestData and requestData.limit) or 20
|
|
5492
|
+
if limit < 1 then limit = 1 end
|
|
5493
|
+
if limit > MAX_ACTION_HISTORY then limit = MAX_ACTION_HISTORY end
|
|
5494
|
+
local includeDetails = requestData and requestData.includeDetails == true
|
|
5495
|
+
|
|
5496
|
+
local now = os.time()
|
|
5497
|
+
|
|
5498
|
+
-- Slice the top `limit` entries of each stack. Index 0 = the one that
|
|
5499
|
+
-- would be popped next (top of stack); larger indices reach further back.
|
|
5500
|
+
local function sliceStack(stack)
|
|
5501
|
+
local out = {}
|
|
5502
|
+
local n = #stack
|
|
5503
|
+
local take = math.min(limit, n)
|
|
5504
|
+
for i = 0, take - 1 do
|
|
5505
|
+
local entry = stack[n - i]
|
|
5506
|
+
if entry then
|
|
5507
|
+
local sliced = {
|
|
5508
|
+
index = i,
|
|
5509
|
+
action = entry.action,
|
|
5510
|
+
target = entry.target,
|
|
5511
|
+
summary = entry.summary,
|
|
5512
|
+
timestamp = entry.timestamp,
|
|
5513
|
+
age_seconds = now - (entry.timestamp or now),
|
|
5514
|
+
}
|
|
5515
|
+
if includeDetails then
|
|
5516
|
+
sliced.details = entry.details
|
|
5517
|
+
end
|
|
5518
|
+
table.insert(out, sliced)
|
|
5519
|
+
end
|
|
5520
|
+
end
|
|
5521
|
+
return out
|
|
5522
|
+
end
|
|
5523
|
+
|
|
5524
|
+
local canUndo = ChangeHistoryService:GetCanUndo()
|
|
5525
|
+
local canRedo = ChangeHistoryService:GetCanRedo()
|
|
5526
|
+
|
|
5527
|
+
return {
|
|
5528
|
+
success = true,
|
|
5529
|
+
can_undo = canUndo,
|
|
5530
|
+
can_redo = canRedo,
|
|
5531
|
+
tracked_undo_count = #actionHistory,
|
|
5532
|
+
tracked_redo_count = #redoHistory,
|
|
5533
|
+
max_history = MAX_ACTION_HISTORY,
|
|
5534
|
+
undo_stack = sliceStack(actionHistory),
|
|
5535
|
+
redo_stack = sliceStack(redoHistory),
|
|
5536
|
+
}
|
|
5537
|
+
end
|
|
5538
|
+
|
|
4246
5539
|
-- ============================================
|
|
4247
5540
|
-- EXECUTE LUA HANDLER (Run arbitrary Lua code)
|
|
4248
5541
|
-- ============================================
|
|
@@ -4453,11 +5746,31 @@ handlers.executeLua = function(requestData)
|
|
|
4453
5746
|
end
|
|
4454
5747
|
end
|
|
4455
5748
|
|
|
5749
|
+
-- Log to MCP action history for get_history visibility. We can't know
|
|
5750
|
+
-- whether arbitrary user code actually mutated the DataModel, so we
|
|
5751
|
+
-- tag the result with `_mutatesUnknown=true` — processRequest uses
|
|
5752
|
+
-- this sentinel to be conservative: if CHS state didn't visibly
|
|
5753
|
+
-- advance (no canUndo flip, no canRedo clear), the pending log gets
|
|
5754
|
+
-- DISCARDED instead of polluting actionHistory with a phantom entry.
|
|
5755
|
+
local codeLines = 1
|
|
5756
|
+
for _ in string.gmatch(code, "\n") do
|
|
5757
|
+
codeLines = codeLines + 1
|
|
5758
|
+
end
|
|
5759
|
+
local codePreview = code:gsub("^%s+", ""):gsub("\n.*$", "")
|
|
5760
|
+
if #codePreview > 60 then
|
|
5761
|
+
codePreview = string.sub(codePreview, 1, 60) .. "..."
|
|
5762
|
+
end
|
|
5763
|
+
logAction("execute_lua", "<inline>", codePreview, {
|
|
5764
|
+
code_length = #code,
|
|
5765
|
+
line_count = codeLines,
|
|
5766
|
+
})
|
|
5767
|
+
|
|
4456
5768
|
return {
|
|
4457
5769
|
success = true,
|
|
4458
5770
|
result = serializeResult(execResult),
|
|
4459
5771
|
resultType = typeof(execResult),
|
|
4460
|
-
message = "Code executed successfully"
|
|
5772
|
+
message = "Code executed successfully",
|
|
5773
|
+
_mutatesUnknown = true, -- stripped by processRequest
|
|
4461
5774
|
}
|
|
4462
5775
|
end
|
|
4463
5776
|
|
|
@@ -4609,7 +5922,7 @@ handlers.playSolo = function(requestData)
|
|
|
4609
5922
|
cleanupTestCompanions()
|
|
4610
5923
|
|
|
4611
5924
|
local success, result = pcall(function()
|
|
4612
|
-
local
|
|
5925
|
+
local serverCompanion, clientCompanion = injectTestCompanion(pluginState.serverUrl, sessionId)
|
|
4613
5926
|
|
|
4614
5927
|
-- ExecutePlayModeAsync yields until the test ends. Run it on its
|
|
4615
5928
|
-- own thread so the HTTP response for play_solo returns now,
|
|
@@ -4646,8 +5959,9 @@ handlers.playSolo = function(requestData)
|
|
|
4646
5959
|
success = true,
|
|
4647
5960
|
sessionId = sessionId,
|
|
4648
5961
|
method = "StudioTestService:ExecutePlayModeAsync",
|
|
4649
|
-
companionPath = getInstancePath(
|
|
4650
|
-
|
|
5962
|
+
companionPath = getInstancePath(serverCompanion),
|
|
5963
|
+
clientCompanionPath = clientCompanion and getInstancePath(clientCompanion) or nil,
|
|
5964
|
+
message = "Started Play Solo with MCP server + client companions. Use stop_play to end, get_playtest_output to read logs, run_live_lua to execute code in the running test.",
|
|
4651
5965
|
}
|
|
4652
5966
|
end)
|
|
4653
5967
|
|
|
@@ -5375,6 +6689,366 @@ handlers.renderObjectView = function(requestData)
|
|
|
5375
6689
|
end
|
|
5376
6690
|
end
|
|
5377
6691
|
|
|
6692
|
+
-- ============================================
|
|
6693
|
+
-- GUI RENDERING SYSTEM
|
|
6694
|
+
-- ============================================
|
|
6695
|
+
|
|
6696
|
+
-- render_gui: capture a 2D GUI to an image. ViewportFrame is 3D-only, so we
|
|
6697
|
+
-- clone the GUI into an off-screen ScreenGui in CoreGui, take a full-screen
|
|
6698
|
+
-- CaptureService screenshot, then crop.
|
|
6699
|
+
--
|
|
6700
|
+
-- region:
|
|
6701
|
+
-- "element" (default) → crop tight to the target's on-screen rect.
|
|
6702
|
+
-- "screen" → return the whole viewport, so you can verify where
|
|
6703
|
+
-- the element actually lands relative to the screen.
|
|
6704
|
+
--
|
|
6705
|
+
-- Placement is faithful: when the target lives under a ScreenGui we clone the
|
|
6706
|
+
-- WHOLE ScreenGui (preserving siblings, layout, and GUI inset) and locate the
|
|
6707
|
+
-- cloned target by its child-index path, instead of reparenting the element to
|
|
6708
|
+
-- (0,0). StarterGui elements report AbsolutePosition = 0 until rendered in their
|
|
6709
|
+
-- real context, so this is what makes screen-mode placement accurate.
|
|
6710
|
+
handlers.renderGui = function(requestData)
|
|
6711
|
+
local CaptureService = game:GetService("CaptureService")
|
|
6712
|
+
local AssetService = game:GetService("AssetService")
|
|
6713
|
+
local CoreGui = game:GetService("CoreGui")
|
|
6714
|
+
|
|
6715
|
+
-- Walk up to the nearest ScreenGui ancestor (2D overlay LayerCollector).
|
|
6716
|
+
local function findAncestorScreenGui(inst)
|
|
6717
|
+
local node = inst.Parent
|
|
6718
|
+
while node do
|
|
6719
|
+
if node:IsA("ScreenGui") then
|
|
6720
|
+
return node
|
|
6721
|
+
end
|
|
6722
|
+
node = node.Parent
|
|
6723
|
+
end
|
|
6724
|
+
return nil
|
|
6725
|
+
end
|
|
6726
|
+
|
|
6727
|
+
-- Child-index path from ancestor down to descendant (1-based GetChildren idx).
|
|
6728
|
+
local function indexPath(ancestor, descendant)
|
|
6729
|
+
local path = {}
|
|
6730
|
+
local node = descendant
|
|
6731
|
+
while node and node ~= ancestor do
|
|
6732
|
+
local parent = node.Parent
|
|
6733
|
+
if not parent then
|
|
6734
|
+
return nil
|
|
6735
|
+
end
|
|
6736
|
+
local idx = nil
|
|
6737
|
+
local siblings = parent:GetChildren()
|
|
6738
|
+
for i = 1, #siblings do
|
|
6739
|
+
if siblings[i] == node then
|
|
6740
|
+
idx = i
|
|
6741
|
+
break
|
|
6742
|
+
end
|
|
6743
|
+
end
|
|
6744
|
+
if not idx then
|
|
6745
|
+
return nil
|
|
6746
|
+
end
|
|
6747
|
+
table.insert(path, 1, idx)
|
|
6748
|
+
node = parent
|
|
6749
|
+
end
|
|
6750
|
+
if node ~= ancestor then
|
|
6751
|
+
return nil
|
|
6752
|
+
end
|
|
6753
|
+
return path
|
|
6754
|
+
end
|
|
6755
|
+
|
|
6756
|
+
-- Follow a child-index path from root (clone order matches the original).
|
|
6757
|
+
local function followPath(root, path)
|
|
6758
|
+
local node = root
|
|
6759
|
+
for _, idx in ipairs(path) do
|
|
6760
|
+
local children = node:GetChildren()
|
|
6761
|
+
node = children[idx]
|
|
6762
|
+
if not node then
|
|
6763
|
+
return nil
|
|
6764
|
+
end
|
|
6765
|
+
end
|
|
6766
|
+
return node
|
|
6767
|
+
end
|
|
6768
|
+
|
|
6769
|
+
local success, result = pcall(function()
|
|
6770
|
+
-- ──────────── parse params ────────────
|
|
6771
|
+
local instancePath = requestData.instancePath
|
|
6772
|
+
if not instancePath then
|
|
6773
|
+
return { success = false, error = "instancePath is required" }
|
|
6774
|
+
end
|
|
6775
|
+
|
|
6776
|
+
local maxWidth = tonumber(requestData.maxWidth)
|
|
6777
|
+
local maxHeight = tonumber(requestData.maxHeight)
|
|
6778
|
+
local region = (requestData.region == "screen") and "screen" or "element"
|
|
6779
|
+
|
|
6780
|
+
-- ──────────── resolve target ────────────
|
|
6781
|
+
local target = getInstanceByPath(instancePath)
|
|
6782
|
+
if not target then
|
|
6783
|
+
return { success = false, error = "Instance not found: " .. instancePath }
|
|
6784
|
+
end
|
|
6785
|
+
|
|
6786
|
+
-- ──────────── build off-screen host ────────────
|
|
6787
|
+
-- hostGui is whatever we parent into CoreGui; cropElements drive the
|
|
6788
|
+
-- element-mode bounding box; placement records how we positioned things.
|
|
6789
|
+
local hostGui
|
|
6790
|
+
local cropElements = {}
|
|
6791
|
+
local placement = "synthetic"
|
|
6792
|
+
|
|
6793
|
+
if target:IsA("GuiObject") then
|
|
6794
|
+
local ancestorGui = findAncestorScreenGui(target)
|
|
6795
|
+
local path = ancestorGui and indexPath(ancestorGui, target) or nil
|
|
6796
|
+
|
|
6797
|
+
if ancestorGui and path then
|
|
6798
|
+
-- Faithful: clone the whole ScreenGui so siblings/layout/inset
|
|
6799
|
+
-- are intact, then find the cloned target via its index path.
|
|
6800
|
+
local clonedGui = ancestorGui:Clone()
|
|
6801
|
+
clonedGui.Name = "MCPRenderGui"
|
|
6802
|
+
clonedGui.Enabled = true
|
|
6803
|
+
clonedGui.DisplayOrder = 2147483647
|
|
6804
|
+
local clonedTarget = followPath(clonedGui, path)
|
|
6805
|
+
if not clonedTarget then
|
|
6806
|
+
clonedGui:Destroy()
|
|
6807
|
+
return {
|
|
6808
|
+
success = false,
|
|
6809
|
+
error = "Failed to locate cloned target inside ScreenGui clone",
|
|
6810
|
+
}
|
|
6811
|
+
end
|
|
6812
|
+
clonedTarget.Visible = true
|
|
6813
|
+
hostGui = clonedGui
|
|
6814
|
+
table.insert(cropElements, clonedTarget)
|
|
6815
|
+
placement = "faithful"
|
|
6816
|
+
else
|
|
6817
|
+
-- No ScreenGui context (e.g. element parked in ReplicatedStorage):
|
|
6818
|
+
-- drop a synthetic host and park the clone at the origin.
|
|
6819
|
+
hostGui = Instance.new("ScreenGui")
|
|
6820
|
+
hostGui.Name = "MCPRenderGui"
|
|
6821
|
+
hostGui.IgnoreGuiInset = true
|
|
6822
|
+
hostGui.ResetOnSpawn = false
|
|
6823
|
+
hostGui.DisplayOrder = 2147483647
|
|
6824
|
+
hostGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
|
|
6825
|
+
|
|
6826
|
+
local cloned = target:Clone()
|
|
6827
|
+
cloned.Visible = true
|
|
6828
|
+
cloned.AnchorPoint = Vector2.new(0, 0)
|
|
6829
|
+
cloned.Position = UDim2.fromOffset(0, 0)
|
|
6830
|
+
cloned.Parent = hostGui
|
|
6831
|
+
table.insert(cropElements, cloned)
|
|
6832
|
+
end
|
|
6833
|
+
elseif target:IsA("ScreenGui") then
|
|
6834
|
+
-- Faithful: render the whole ScreenGui as it would appear.
|
|
6835
|
+
hostGui = target:Clone()
|
|
6836
|
+
hostGui.Name = "MCPRenderGui"
|
|
6837
|
+
hostGui.Enabled = true
|
|
6838
|
+
hostGui.DisplayOrder = 2147483647
|
|
6839
|
+
placement = "faithful"
|
|
6840
|
+
for _, child in ipairs(hostGui:GetChildren()) do
|
|
6841
|
+
if child:IsA("GuiObject") then
|
|
6842
|
+
child.Visible = true
|
|
6843
|
+
table.insert(cropElements, child)
|
|
6844
|
+
end
|
|
6845
|
+
end
|
|
6846
|
+
if #cropElements == 0 then
|
|
6847
|
+
hostGui:Destroy()
|
|
6848
|
+
return {
|
|
6849
|
+
success = false,
|
|
6850
|
+
error = "GUI has no GuiObject children to render: " .. instancePath,
|
|
6851
|
+
}
|
|
6852
|
+
end
|
|
6853
|
+
elseif target:IsA("LayerCollector") then
|
|
6854
|
+
-- SurfaceGui / BillboardGui render in 3D, not as a 2D overlay, so we
|
|
6855
|
+
-- can't place them faithfully — copy their children into a synthetic
|
|
6856
|
+
-- ScreenGui host instead.
|
|
6857
|
+
hostGui = Instance.new("ScreenGui")
|
|
6858
|
+
hostGui.Name = "MCPRenderGui"
|
|
6859
|
+
hostGui.IgnoreGuiInset = true
|
|
6860
|
+
hostGui.ResetOnSpawn = false
|
|
6861
|
+
hostGui.DisplayOrder = 2147483647
|
|
6862
|
+
hostGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
|
|
6863
|
+
for _, child in ipairs(target:GetChildren()) do
|
|
6864
|
+
if child:IsA("GuiObject") then
|
|
6865
|
+
local cloned = child:Clone()
|
|
6866
|
+
cloned.Visible = true
|
|
6867
|
+
cloned.Parent = hostGui
|
|
6868
|
+
table.insert(cropElements, cloned)
|
|
6869
|
+
end
|
|
6870
|
+
end
|
|
6871
|
+
if #cropElements == 0 then
|
|
6872
|
+
hostGui:Destroy()
|
|
6873
|
+
return {
|
|
6874
|
+
success = false,
|
|
6875
|
+
error = "GUI has no GuiObject children to render: " .. instancePath,
|
|
6876
|
+
}
|
|
6877
|
+
end
|
|
6878
|
+
else
|
|
6879
|
+
return {
|
|
6880
|
+
success = false,
|
|
6881
|
+
error = "render_gui target must be a GuiObject or a ScreenGui/SurfaceGui/BillboardGui, got "
|
|
6882
|
+
.. target.ClassName,
|
|
6883
|
+
}
|
|
6884
|
+
end
|
|
6885
|
+
|
|
6886
|
+
hostGui.Parent = CoreGui
|
|
6887
|
+
|
|
6888
|
+
-- Let layout / AbsolutePosition settle.
|
|
6889
|
+
RunService.Heartbeat:Wait()
|
|
6890
|
+
RunService.Heartbeat:Wait()
|
|
6891
|
+
|
|
6892
|
+
-- ──────────── union bounding box (screen px) ────────────
|
|
6893
|
+
local minX, minY = math.huge, math.huge
|
|
6894
|
+
local maxX, maxY = -math.huge, -math.huge
|
|
6895
|
+
for _, el in ipairs(cropElements) do
|
|
6896
|
+
local pos = el.AbsolutePosition
|
|
6897
|
+
local size = el.AbsoluteSize
|
|
6898
|
+
minX = math.min(minX, pos.X)
|
|
6899
|
+
minY = math.min(minY, pos.Y)
|
|
6900
|
+
maxX = math.max(maxX, pos.X + size.X)
|
|
6901
|
+
maxY = math.max(maxY, pos.Y + size.Y)
|
|
6902
|
+
end
|
|
6903
|
+
|
|
6904
|
+
-- ──────────── capture ────────────
|
|
6905
|
+
local captureComplete = Instance.new("BindableEvent")
|
|
6906
|
+
local captureResult, captureError = nil, nil
|
|
6907
|
+
|
|
6908
|
+
local pcOk, pcErr = pcall(function()
|
|
6909
|
+
CaptureService:CaptureScreenshot(function(contentId)
|
|
6910
|
+
if contentId then
|
|
6911
|
+
captureResult = contentId
|
|
6912
|
+
else
|
|
6913
|
+
captureError = "CaptureScreenshot returned nil"
|
|
6914
|
+
end
|
|
6915
|
+
captureComplete:Fire()
|
|
6916
|
+
end)
|
|
6917
|
+
end)
|
|
6918
|
+
|
|
6919
|
+
if not pcOk then
|
|
6920
|
+
hostGui:Destroy()
|
|
6921
|
+
return { success = false, error = "CaptureScreenshot threw: " .. tostring(pcErr) }
|
|
6922
|
+
end
|
|
6923
|
+
|
|
6924
|
+
local timeoutThread = task.delay(8, function()
|
|
6925
|
+
if not captureResult and not captureError then
|
|
6926
|
+
captureError = "Capture timed out (8s)"
|
|
6927
|
+
captureComplete:Fire()
|
|
6928
|
+
end
|
|
6929
|
+
end)
|
|
6930
|
+
captureComplete.Event:Wait()
|
|
6931
|
+
task.cancel(timeoutThread)
|
|
6932
|
+
captureComplete:Destroy()
|
|
6933
|
+
|
|
6934
|
+
hostGui:Destroy()
|
|
6935
|
+
|
|
6936
|
+
if captureError then
|
|
6937
|
+
return { success = false, error = "Failed to capture screen: " .. captureError }
|
|
6938
|
+
end
|
|
6939
|
+
|
|
6940
|
+
-- ──────────── crop ────────────
|
|
6941
|
+
local sourceImage = AssetService:CreateEditableImageAsync(Content.fromUri(tostring(captureResult)))
|
|
6942
|
+
local sourceSize = sourceImage.Size
|
|
6943
|
+
|
|
6944
|
+
local cropX, cropY, cropW, cropH
|
|
6945
|
+
if region == "screen" then
|
|
6946
|
+
-- Whole viewport: keep the element in its real on-screen context.
|
|
6947
|
+
cropX, cropY = 0, 0
|
|
6948
|
+
cropW, cropH = sourceSize.X, sourceSize.Y
|
|
6949
|
+
else
|
|
6950
|
+
-- Tight crop to the element's on-screen rect.
|
|
6951
|
+
local rx = math.max(math.floor(minX), 0)
|
|
6952
|
+
local ry = math.max(math.floor(minY), 0)
|
|
6953
|
+
local rw = math.max(math.ceil(maxX - rx), 1)
|
|
6954
|
+
local rh = math.max(math.ceil(maxY - ry), 1)
|
|
6955
|
+
cropX = math.clamp(rx, 0, math.max(sourceSize.X - 1, 0))
|
|
6956
|
+
cropY = math.clamp(ry, 0, math.max(sourceSize.Y - 1, 0))
|
|
6957
|
+
cropW = math.clamp(rw, 1, sourceSize.X - cropX)
|
|
6958
|
+
cropH = math.clamp(rh, 1, sourceSize.Y - cropY)
|
|
6959
|
+
end
|
|
6960
|
+
|
|
6961
|
+
local croppedImage = AssetService:CreateEditableImage({ Size = Vector2.new(cropW, cropH) })
|
|
6962
|
+
croppedImage:DrawImageTransformed(
|
|
6963
|
+
Vector2.new(cropW / 2, cropH / 2),
|
|
6964
|
+
Vector2.new(1, 1),
|
|
6965
|
+
0,
|
|
6966
|
+
sourceImage,
|
|
6967
|
+
{
|
|
6968
|
+
CombineType = Enum.ImageCombineType.Overwrite,
|
|
6969
|
+
PivotPoint = Vector2.new(cropX + cropW / 2, cropY + cropH / 2),
|
|
6970
|
+
}
|
|
6971
|
+
)
|
|
6972
|
+
sourceImage:Destroy()
|
|
6973
|
+
|
|
6974
|
+
-- ──────────── downscale (aspect preserved, no upscale) ────────────
|
|
6975
|
+
-- Two independent reasons to shrink, folded into ONE resize pass:
|
|
6976
|
+
-- 1. Caller's optional maxWidth/maxHeight clamp.
|
|
6977
|
+
-- 2. The HTTP-bridge raw-pixel cap (MAX_RAW_PIXEL_BYTES) — mirrors
|
|
6978
|
+
-- captureScreenshot. region="screen" on a hi-DPI display can blow
|
|
6979
|
+
-- past the cap, so this guard is what keeps the POST safe.
|
|
6980
|
+
local scaleW = maxWidth and (maxWidth / cropW) or 1
|
|
6981
|
+
local scaleH = maxHeight and (maxHeight / cropH) or 1
|
|
6982
|
+
local scale = math.min(scaleW, scaleH, 1)
|
|
6983
|
+
local outW = math.max(math.floor(cropW * scale), 1)
|
|
6984
|
+
local outH = math.max(math.floor(cropH * scale), 1)
|
|
6985
|
+
|
|
6986
|
+
local autoShrunk = false
|
|
6987
|
+
local requiredBytes = outW * outH * 4
|
|
6988
|
+
if requiredBytes > MAX_RAW_PIXEL_BYTES then
|
|
6989
|
+
local shrink = math.sqrt(MAX_RAW_PIXEL_BYTES / requiredBytes)
|
|
6990
|
+
outW = math.max(math.floor(outW * shrink), 1)
|
|
6991
|
+
outH = math.max(math.floor(outH * shrink), 1)
|
|
6992
|
+
scale = scale * shrink
|
|
6993
|
+
autoShrunk = true
|
|
6994
|
+
end
|
|
6995
|
+
|
|
6996
|
+
local outputImage = croppedImage
|
|
6997
|
+
if (outW ~= cropW) or (outH ~= cropH) then
|
|
6998
|
+
outputImage = AssetService:CreateEditableImage({ Size = Vector2.new(outW, outH) })
|
|
6999
|
+
outputImage:DrawImageTransformed(
|
|
7000
|
+
Vector2.new(outW / 2, outH / 2),
|
|
7001
|
+
Vector2.new(scale, scale),
|
|
7002
|
+
0,
|
|
7003
|
+
croppedImage,
|
|
7004
|
+
{
|
|
7005
|
+
CombineType = Enum.ImageCombineType.Overwrite,
|
|
7006
|
+
PivotPoint = Vector2.new(cropW / 2, cropH / 2),
|
|
7007
|
+
}
|
|
7008
|
+
)
|
|
7009
|
+
croppedImage:Destroy()
|
|
7010
|
+
end
|
|
7011
|
+
|
|
7012
|
+
local rgbaBuffer = outputImage:ReadPixelsBuffer(Vector2.new(0, 0), outputImage.Size)
|
|
7013
|
+
local rgbaString = buffer.tostring(rgbaBuffer)
|
|
7014
|
+
local base64Data = base64encode(rgbaString)
|
|
7015
|
+
outputImage:Destroy()
|
|
7016
|
+
|
|
7017
|
+
return {
|
|
7018
|
+
success = true,
|
|
7019
|
+
base64 = base64Data,
|
|
7020
|
+
width = outW,
|
|
7021
|
+
height = outH,
|
|
7022
|
+
format = "RGBA",
|
|
7023
|
+
encoding = "base64",
|
|
7024
|
+
guiInfo = {
|
|
7025
|
+
objectName = target.Name,
|
|
7026
|
+
objectClass = target.ClassName,
|
|
7027
|
+
renderedElements = #cropElements,
|
|
7028
|
+
region = region,
|
|
7029
|
+
placement = placement,
|
|
7030
|
+
autoShrunk = autoShrunk,
|
|
7031
|
+
rect = { x = cropX, y = cropY, width = cropW, height = cropH },
|
|
7032
|
+
sourceWidth = sourceSize.X,
|
|
7033
|
+
sourceHeight = sourceSize.Y,
|
|
7034
|
+
},
|
|
7035
|
+
message = string.format(
|
|
7036
|
+
"Rendered GUI %s at %dx%d (region=%s, placement=%s, from %dx%d screen)",
|
|
7037
|
+
target.Name, outW, outH, region, placement, sourceSize.X, sourceSize.Y
|
|
7038
|
+
),
|
|
7039
|
+
}
|
|
7040
|
+
end)
|
|
7041
|
+
|
|
7042
|
+
if success then
|
|
7043
|
+
return result
|
|
7044
|
+
else
|
|
7045
|
+
return {
|
|
7046
|
+
success = false,
|
|
7047
|
+
error = "Failed to render GUI: " .. tostring(result),
|
|
7048
|
+
}
|
|
7049
|
+
end
|
|
7050
|
+
end
|
|
7051
|
+
|
|
5378
7052
|
-- ============================================
|
|
5379
7053
|
-- CAMERA CONTROL SYSTEM
|
|
5380
7054
|
-- ============================================
|
|
@@ -5512,12 +7186,11 @@ endpointHandlers = {
|
|
|
5512
7186
|
["/api/set-relative-property"] = handlers.setRelativeProperty,
|
|
5513
7187
|
["/api/get-script-source"] = handlers.getScriptSource,
|
|
5514
7188
|
["/api/set-script-source"] = handlers.setScriptSource,
|
|
5515
|
-
["/api/edit-script-lines"] = handlers.editScriptLines,
|
|
5516
|
-
["/api/insert-script-lines"] = handlers.insertScriptLines,
|
|
5517
|
-
["/api/delete-script-lines"] = handlers.deleteScriptLines,
|
|
5518
7189
|
-- Claude Code-style script editing tools
|
|
5519
7190
|
["/api/edit-script"] = handlers.editScript,
|
|
5520
7191
|
["/api/search-script"] = handlers.searchScript,
|
|
7192
|
+
-- Claude Code-style grep across the entire instance tree
|
|
7193
|
+
["/api/grep"] = handlers.grep,
|
|
5521
7194
|
["/api/get-script-function"] = handlers.getScriptFunction,
|
|
5522
7195
|
["/api/find-and-replace-in-scripts"] = handlers.findAndReplaceInScripts,
|
|
5523
7196
|
["/api/get-attribute"] = handlers.getAttribute,
|
|
@@ -5536,6 +7209,7 @@ endpointHandlers = {
|
|
|
5536
7209
|
["/api/insert-asset"] = handlers.insertAsset,
|
|
5537
7210
|
["/api/undo"] = handlers.undo,
|
|
5538
7211
|
["/api/redo"] = handlers.redo,
|
|
7212
|
+
["/api/get-history"] = handlers.getHistory,
|
|
5539
7213
|
-- Execute Lua
|
|
5540
7214
|
["/api/execute-lua"] = handlers.executeLua,
|
|
5541
7215
|
-- Playtest control
|
|
@@ -5545,6 +7219,8 @@ endpointHandlers = {
|
|
|
5545
7219
|
["/api/capture-screenshot"] = handlers.captureScreenshot,
|
|
5546
7220
|
-- ViewportFrame rendering
|
|
5547
7221
|
["/api/render-object-view"] = handlers.renderObjectView,
|
|
7222
|
+
-- 2D GUI rendering
|
|
7223
|
+
["/api/render-gui"] = handlers.renderGui,
|
|
5548
7224
|
-- Camera control
|
|
5549
7225
|
["/api/focus-camera"] = handlers.focusCamera,
|
|
5550
7226
|
}
|