@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +33 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +16 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- package/dist/tools/upload.js.map +0 -1
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The Studio half of `sync`: scripts mirrored to files on disk, and instance
|
|
4
|
+
trees exported to build files and rebuilt from them.
|
|
5
|
+
|
|
6
|
+
Everything that decides WHAT to sync lives in the server, which can see the
|
|
7
|
+
disk. This side answers four questions and carries out one kind of order:
|
|
8
|
+
|
|
9
|
+
scan which scripts exist, where, and at what revision
|
|
10
|
+
read their source
|
|
11
|
+
changes what changed since the last time anyone asked (for `watch`)
|
|
12
|
+
export an instance tree as the same spec `create` takes
|
|
13
|
+
|
|
14
|
+
apply write, create, delete and move scripts, each item checked against
|
|
15
|
+
the revision the server last saw, so nothing the user changed in
|
|
16
|
+
the meantime is overwritten
|
|
17
|
+
build replace an instance tree from a spec, keeping its scripts
|
|
18
|
+
|
|
19
|
+
Revisions are `ScriptEdit.fingerprint`, the same `rev` script_read hands out,
|
|
20
|
+
so a sync and an agent editing through the script tools agree on what
|
|
21
|
+
"unchanged" means.
|
|
22
|
+
]]
|
|
23
|
+
|
|
24
|
+
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
25
|
+
local HttpService = game:GetService("HttpService")
|
|
26
|
+
local CollectionService = game:GetService("CollectionService")
|
|
27
|
+
|
|
28
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
29
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
30
|
+
local Scope = require(script.Parent.Parent.Scope)
|
|
31
|
+
local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
|
|
32
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
33
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
34
|
+
local Instances = require(script.Parent.Instances)
|
|
35
|
+
|
|
36
|
+
local Sync = {}
|
|
37
|
+
|
|
38
|
+
-- Past this, a place is better synced a service at a time: every scan reads
|
|
39
|
+
-- and fingerprints every script's buffer on Studio's main thread.
|
|
40
|
+
local MAX_SCRIPTS = 5_000
|
|
41
|
+
|
|
42
|
+
-- Where scripts are looked for by default. `Players` is left out on purpose:
|
|
43
|
+
-- in edit it is empty, and in a playtest its scripts are copies the engine
|
|
44
|
+
-- made from StarterPlayer, which would sync back as duplicates.
|
|
45
|
+
local DEFAULT_ROOTS = {
|
|
46
|
+
"Workspace",
|
|
47
|
+
"ReplicatedFirst",
|
|
48
|
+
"ReplicatedStorage",
|
|
49
|
+
"ServerScriptService",
|
|
50
|
+
"ServerStorage",
|
|
51
|
+
"StarterGui",
|
|
52
|
+
"StarterPack",
|
|
53
|
+
"StarterPlayer",
|
|
54
|
+
"SoundService",
|
|
55
|
+
"Lighting",
|
|
56
|
+
"Teams",
|
|
57
|
+
"Chat",
|
|
58
|
+
"TextChatService",
|
|
59
|
+
"TestService",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
-- The panel's own `copy` output. A log, not code, and it is rewritten on every
|
|
63
|
+
-- `copy`, so syncing it would only ever produce noise on disk.
|
|
64
|
+
local IGNORED_NAMES = {
|
|
65
|
+
["rbx-studio log"] = true,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
-- Same limit as `script_create`: past it `.Source` refuses and only the editor
|
|
69
|
+
-- path takes the text.
|
|
70
|
+
local SOURCE_PROPERTY_LIMIT = 200_000
|
|
71
|
+
|
|
72
|
+
export type Logger = (level: string, message: string, detail: string?) -> ()
|
|
73
|
+
local log: Logger = function() end
|
|
74
|
+
|
|
75
|
+
--[[
|
|
76
|
+
Hands in the panel's logger. A handler requiring the console would pull the
|
|
77
|
+
whole widget into every test harness that loads this file.
|
|
78
|
+
]]
|
|
79
|
+
function Sync.setup(logger: Logger)
|
|
80
|
+
log = logger
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
local function isScript(instance: Instance): boolean
|
|
84
|
+
return instance:IsA("LuaSourceContainer")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function rootsOf(params: { [string]: any }): { Instance }
|
|
88
|
+
local wanted = params.roots
|
|
89
|
+
local roots: { Instance } = {}
|
|
90
|
+
if typeof(wanted) == "table" and #wanted > 0 then
|
|
91
|
+
for _, path in wanted do
|
|
92
|
+
table.insert(roots, Paths.resolve(tostring(path)))
|
|
93
|
+
end
|
|
94
|
+
return roots
|
|
95
|
+
end
|
|
96
|
+
for _, name in DEFAULT_ROOTS do
|
|
97
|
+
local service = game:FindFirstChild(name)
|
|
98
|
+
if service ~= nil then
|
|
99
|
+
table.insert(roots, service)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
return roots
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
--[[
|
|
106
|
+
Whether Studio's own Script Sync already binds this script to a file.
|
|
107
|
+
|
|
108
|
+
Two syncs writing the same script is a race neither can see, so such scripts
|
|
109
|
+
are reported and left alone. Silent when the service is unavailable.
|
|
110
|
+
]]
|
|
111
|
+
local function fileSynced(target: Instance): boolean
|
|
112
|
+
local service = game:FindService("InstanceFileSyncService")
|
|
113
|
+
if service == nil then
|
|
114
|
+
return false
|
|
115
|
+
end
|
|
116
|
+
local ok, status = pcall(function()
|
|
117
|
+
return (service :: any):GetStatus(target)
|
|
118
|
+
end)
|
|
119
|
+
if not ok or status == nil then
|
|
120
|
+
return false
|
|
121
|
+
end
|
|
122
|
+
local name = tostring(status)
|
|
123
|
+
return not (string.find(name, "NotSynced") or string.find(name, "Unknown") or name == "nil")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
-- Scripts under the roots, in a fixed order (see `stableOrder` in Discover).
|
|
128
|
+
local function collect(roots: { Instance }): { LuaSourceContainer }
|
|
129
|
+
local found: { LuaSourceContainer } = {}
|
|
130
|
+
local seen: { [Instance]: boolean } = {}
|
|
131
|
+
local function consider(instance: Instance)
|
|
132
|
+
if seen[instance] or not isScript(instance) or IGNORED_NAMES[instance.Name] then
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
if Scope.isNoisy(instance) then
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
seen[instance] = true
|
|
139
|
+
table.insert(found, instance :: LuaSourceContainer)
|
|
140
|
+
end
|
|
141
|
+
for _, root in roots do
|
|
142
|
+
consider(root)
|
|
143
|
+
for _, descendant in root:GetDescendants() do
|
|
144
|
+
consider(descendant)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
if #found > MAX_SCRIPTS then
|
|
148
|
+
Dispatch.fail(
|
|
149
|
+
"TOO_BROAD",
|
|
150
|
+
string.format("%d scripts, over the %d a sync handles at once.", #found, MAX_SCRIPTS),
|
|
151
|
+
"Pass `roots` to sync a few services or folders at a time."
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
local keys: { [Instance]: string } = {}
|
|
155
|
+
for _, target in found do
|
|
156
|
+
local ok, id = pcall(function()
|
|
157
|
+
return target:GetDebugId()
|
|
158
|
+
end)
|
|
159
|
+
keys[target] = target:GetFullName() .. "\0" .. (if ok then id else "")
|
|
160
|
+
end
|
|
161
|
+
table.sort(found, function(a: Instance, b: Instance): boolean
|
|
162
|
+
return keys[a] < keys[b]
|
|
163
|
+
end)
|
|
164
|
+
return found
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
--[[
|
|
168
|
+
Every script under the roots: where it is, what it is, and -- unless the
|
|
169
|
+
caller only wants the shape -- its revision.
|
|
170
|
+
|
|
171
|
+
The server lays files out by the chain of instances above each script, and
|
|
172
|
+
needs to know which links are scripts themselves (a script with scripts
|
|
173
|
+
inside becomes a folder with an `init` file). That chain is shared by every
|
|
174
|
+
script in a folder, so it comes back once, as `nodes`: each instance on any
|
|
175
|
+
chain, with its parent's position in the same list (0 for the DataModel)
|
|
176
|
+
and its sibling ordinal when its name is not unique. Scripts point into it.
|
|
177
|
+
]]
|
|
178
|
+
function Sync.scan(params: { [string]: any }): { [string]: any }
|
|
179
|
+
local withRevisions = params.revisions ~= false
|
|
180
|
+
local memo: Paths.NameIndex = {}
|
|
181
|
+
local nodes: { { [string]: any } } = {}
|
|
182
|
+
local indexOf: { [Instance]: number } = {}
|
|
183
|
+
|
|
184
|
+
local function nodeOf(instance: Instance): number
|
|
185
|
+
if instance == game then
|
|
186
|
+
return 0
|
|
187
|
+
end
|
|
188
|
+
local known = indexOf[instance]
|
|
189
|
+
if known ~= nil then
|
|
190
|
+
return known
|
|
191
|
+
end
|
|
192
|
+
local parent = nodeOf(instance.Parent :: Instance)
|
|
193
|
+
table.insert(nodes, {
|
|
194
|
+
path = Paths.of(instance, memo),
|
|
195
|
+
name = instance.Name,
|
|
196
|
+
className = instance.ClassName,
|
|
197
|
+
parent = parent,
|
|
198
|
+
ordinal = Paths.ordinal(instance, memo),
|
|
199
|
+
})
|
|
200
|
+
indexOf[instance] = #nodes
|
|
201
|
+
return #nodes
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
local items: { { [string]: any } } = {}
|
|
205
|
+
for _, target in collect(rootsOf(params)) do
|
|
206
|
+
table.insert(items, {
|
|
207
|
+
node = nodeOf(target),
|
|
208
|
+
revision = if withRevisions then ScriptEdit.revisionOf(target) else nil,
|
|
209
|
+
fileSynced = if fileSynced(target) then true else nil,
|
|
210
|
+
})
|
|
211
|
+
end
|
|
212
|
+
local roots: { string } = {}
|
|
213
|
+
for _, root in rootsOf(params) do
|
|
214
|
+
table.insert(roots, Paths.of(root, memo))
|
|
215
|
+
end
|
|
216
|
+
return { nodes = nodes, items = items, roots = roots }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
--[[
|
|
220
|
+
Every synced script's path, one string. `watch` compares it every few
|
|
221
|
+
seconds to notice what no event reports -- a folder renamed moves every
|
|
222
|
+
script inside it -- without reading or fingerprinting a single script.
|
|
223
|
+
]]
|
|
224
|
+
function Sync.shape(params: { [string]: any }): { [string]: any }
|
|
225
|
+
local memo: Paths.NameIndex = {}
|
|
226
|
+
local paths: { string } = {}
|
|
227
|
+
for _, target in collect(rootsOf(params)) do
|
|
228
|
+
table.insert(paths, Paths.of(target, memo))
|
|
229
|
+
end
|
|
230
|
+
return { shape = table.concat(paths, "\n") }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
-- Declared ahead of `revisions`, which needs it.
|
|
234
|
+
local scriptAt: (path: string) -> LuaSourceContainer
|
|
235
|
+
|
|
236
|
+
--[[
|
|
237
|
+
Revisions only, for `watch` to tell a real Studio edit from the echo of a
|
|
238
|
+
write it made itself -- without shipping any source back.
|
|
239
|
+
]]
|
|
240
|
+
function Sync.revisions(params: { [string]: any }): { [string]: any }
|
|
241
|
+
local items: { { [string]: any } } = {}
|
|
242
|
+
for _, path in (if typeof(params.paths) == "table" then params.paths else {}) :: { any } do
|
|
243
|
+
local ok, revision = pcall(function()
|
|
244
|
+
return ScriptEdit.revisionOf(scriptAt(tostring(path)))
|
|
245
|
+
end)
|
|
246
|
+
table.insert(items, if ok then { path = path, revision = revision } else { path = path, missing = true })
|
|
247
|
+
end
|
|
248
|
+
return { items = items }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
-- Resolves a path and insists it is a script, or explains why not.
|
|
252
|
+
function scriptAt(path: string): LuaSourceContainer
|
|
253
|
+
local instance = Paths.resolve(path)
|
|
254
|
+
if not isScript(instance) then
|
|
255
|
+
Dispatch.fail("NOT_A_SCRIPT", string.format("%s is a %s, not a script.", path, instance.ClassName))
|
|
256
|
+
end
|
|
257
|
+
return instance :: LuaSourceContainer
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
local function reasonOf(err: any): (string, string)
|
|
261
|
+
if typeof(err) == "table" then
|
|
262
|
+
return tostring(err.code or "FAILED"), tostring(err.message)
|
|
263
|
+
end
|
|
264
|
+
return "FAILED", tostring(err)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
-- Source and revision of each path. Per path, so one missing script does not
|
|
268
|
+
-- sink a read of three hundred.
|
|
269
|
+
function Sync.read(params: { [string]: any }): { [string]: any }
|
|
270
|
+
local paths = params.paths
|
|
271
|
+
if typeof(paths) ~= "table" then
|
|
272
|
+
Dispatch.fail("BAD_PARAMS", "sync.read requires `paths`.")
|
|
273
|
+
end
|
|
274
|
+
local items: { { [string]: any } } = {}
|
|
275
|
+
for _, path in paths do
|
|
276
|
+
local ok, result = pcall(function()
|
|
277
|
+
local target = scriptAt(tostring(path))
|
|
278
|
+
local source = ScriptEdit.read(target)
|
|
279
|
+
return { path = path, source = source, revision = ScriptEdit.revisionOf(target, source), className = target.ClassName }
|
|
280
|
+
end)
|
|
281
|
+
if ok then
|
|
282
|
+
table.insert(items, result)
|
|
283
|
+
else
|
|
284
|
+
local code, message = reasonOf(result)
|
|
285
|
+
table.insert(items, { path = path, code = code, message = message })
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
return { items = items }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
--[[
|
|
292
|
+
Walks a chain of names from the DataModel, creating what is missing.
|
|
293
|
+
|
|
294
|
+
By name rather than through `Paths.resolve`: the chain comes from a folder
|
|
295
|
+
on disk, which knows names and nothing about sibling indexes. Missing links
|
|
296
|
+
become the class the chain asks for -- a Folder, unless the file says the
|
|
297
|
+
link is a script -- and a missing SERVICE is refused, since that is a
|
|
298
|
+
misspelled top-level folder, not something to invent.
|
|
299
|
+
]]
|
|
300
|
+
local function ensureChain(start: any, chain: { { [string]: any } }, create: boolean): Instance?
|
|
301
|
+
local current: Instance = if typeof(start) == "string" and start ~= "" then Paths.resolve(start) else game
|
|
302
|
+
for index, link in chain do
|
|
303
|
+
local name = tostring(link.name)
|
|
304
|
+
local child = current:FindFirstChild(name)
|
|
305
|
+
if child == nil then
|
|
306
|
+
if current == game then
|
|
307
|
+
local ok, service = pcall(function()
|
|
308
|
+
return game:GetService(name)
|
|
309
|
+
end)
|
|
310
|
+
if ok and service ~= nil then
|
|
311
|
+
child = service
|
|
312
|
+
else
|
|
313
|
+
Dispatch.fail(
|
|
314
|
+
"UNKNOWN_SERVICE",
|
|
315
|
+
string.format('"%s" is not a service.', name),
|
|
316
|
+
"Top-level folders in the sync folder are service names, e.g. ServerScriptService."
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
elseif not create then
|
|
320
|
+
return nil
|
|
321
|
+
else
|
|
322
|
+
local className = if typeof(link.className) == "string" then link.className else "Folder"
|
|
323
|
+
local made = Instance.new(className)
|
|
324
|
+
made.Name = name
|
|
325
|
+
made.Parent = current
|
|
326
|
+
child = made
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
current = child :: Instance
|
|
330
|
+
end
|
|
331
|
+
return current
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
-- Refuses unless `target` is still at the revision the server last saw.
|
|
335
|
+
local function assertRevision(target: LuaSourceContainer, revision: any)
|
|
336
|
+
if typeof(revision) ~= "string" then
|
|
337
|
+
return
|
|
338
|
+
end
|
|
339
|
+
local live = ScriptEdit.revisionOf(target)
|
|
340
|
+
if live ~= revision then
|
|
341
|
+
Dispatch.fail("STALE_SCRIPT", string.format("changed in Studio since the last sync (now %s)", live))
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
--[[
|
|
346
|
+
Carries out one batch of the server's decisions.
|
|
347
|
+
|
|
348
|
+
Structural changes -- creates, deletes, moves -- share one undo step, "MCP
|
|
349
|
+
sync", so a sync that put files where they should not be is one Ctrl+Z.
|
|
350
|
+
Source writes go through the script editor after that, with its own
|
|
351
|
+
per-script undo, exactly like `script_edit`.
|
|
352
|
+
|
|
353
|
+
Every item is checked against the revision the server based its decision
|
|
354
|
+
on, and fails alone: a sync is many independent files, and one the user
|
|
355
|
+
touched a second ago must not hold the other forty back. Each item reports
|
|
356
|
+
what happened, and the new revision where there is one.
|
|
357
|
+
]]
|
|
358
|
+
function Sync.apply(params: { [string]: any }): { [string]: any }
|
|
359
|
+
local writes = if typeof(params.writes) == "table" then params.writes else {}
|
|
360
|
+
local creates = if typeof(params.creates) == "table" then params.creates else {}
|
|
361
|
+
local deletes = if typeof(params.deletes) == "table" then params.deletes else {}
|
|
362
|
+
local moves = if typeof(params.moves) == "table" then params.moves else {}
|
|
363
|
+
|
|
364
|
+
local results = { writes = {}, creates = {}, deletes = {}, moves = {} }
|
|
365
|
+
type Late = { target: LuaSourceContainer, source: string, result: { [string]: any } }
|
|
366
|
+
local late: { Late } = {}
|
|
367
|
+
|
|
368
|
+
local structural = #creates + #deletes + #moves > 0
|
|
369
|
+
local recorded = false
|
|
370
|
+
if structural then
|
|
371
|
+
local _, didRecord = Undo.recordPartial("StudioMCP.Sync", "MCP sync", function()
|
|
372
|
+
for _, item in creates do
|
|
373
|
+
local ok, err = pcall(function()
|
|
374
|
+
local parent = ensureChain(item.parentPath, item.parents or {}, true) :: Instance
|
|
375
|
+
local name = tostring(item.name)
|
|
376
|
+
local existing = parent:FindFirstChild(name)
|
|
377
|
+
if existing ~= nil and isScript(existing) then
|
|
378
|
+
Dispatch.fail("EXISTS", string.format("%s already has a script named %s", parent:GetFullName(), name))
|
|
379
|
+
end
|
|
380
|
+
local made = Instance.new(item.className) :: LuaSourceContainer
|
|
381
|
+
made.Name = name
|
|
382
|
+
local source = if typeof(item.source) == "string" then item.source else ""
|
|
383
|
+
local result: { [string]: any } = { ok = true }
|
|
384
|
+
if #source < SOURCE_PROPERTY_LIMIT then
|
|
385
|
+
(made :: ScriptEdit.SourceContainer).Source = source
|
|
386
|
+
else
|
|
387
|
+
table.insert(late, { target = made, source = source, result = result })
|
|
388
|
+
end
|
|
389
|
+
made.Parent = parent
|
|
390
|
+
result.path = Paths.of(made)
|
|
391
|
+
result.revision = ScriptEdit.revisionOf(made, if #source < SOURCE_PROPERTY_LIMIT then source else "")
|
|
392
|
+
return result
|
|
393
|
+
end)
|
|
394
|
+
if ok then
|
|
395
|
+
table.insert(results.creates, err)
|
|
396
|
+
else
|
|
397
|
+
local code, message = reasonOf(err)
|
|
398
|
+
table.insert(results.creates, { ok = false, code = code, message = message })
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
for _, item in deletes do
|
|
403
|
+
local ok, err = pcall(function()
|
|
404
|
+
local target = scriptAt(tostring(item.path))
|
|
405
|
+
assertRevision(target, item.revision)
|
|
406
|
+
target:Destroy()
|
|
407
|
+
end)
|
|
408
|
+
if ok then
|
|
409
|
+
table.insert(results.deletes, { ok = true, path = item.path })
|
|
410
|
+
else
|
|
411
|
+
local code, message = reasonOf(err)
|
|
412
|
+
table.insert(results.deletes, { ok = false, path = item.path, code = code, message = message })
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
for _, item in moves do
|
|
417
|
+
local ok, err = pcall(function()
|
|
418
|
+
local target = scriptAt(tostring(item.path))
|
|
419
|
+
assertRevision(target, item.revision)
|
|
420
|
+
local parent = ensureChain(item.parentPath, item.parents or {}, true) :: Instance
|
|
421
|
+
target.Name = tostring(item.name)
|
|
422
|
+
target.Parent = parent
|
|
423
|
+
return Paths.of(target)
|
|
424
|
+
end)
|
|
425
|
+
if ok then
|
|
426
|
+
table.insert(results.moves, { ok = true, from = item.path, path = err })
|
|
427
|
+
else
|
|
428
|
+
local code, message = reasonOf(err)
|
|
429
|
+
table.insert(results.moves, { ok = false, from = item.path, code = code, message = message })
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
return true
|
|
433
|
+
end)
|
|
434
|
+
recorded = didRecord
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
-- Sources too long for `.Source`, written now that the scripts exist.
|
|
438
|
+
for _, entry in late do
|
|
439
|
+
local ok, err = pcall(function()
|
|
440
|
+
Undo.exclusive(function()
|
|
441
|
+
ScriptEdit.write(entry.target, function(current)
|
|
442
|
+
if current ~= "" then
|
|
443
|
+
Dispatch.fail("STALE_SCRIPT", "edited before its source could be written; that text was kept")
|
|
444
|
+
end
|
|
445
|
+
return entry.source
|
|
446
|
+
end)
|
|
447
|
+
end)
|
|
448
|
+
end)
|
|
449
|
+
if ok then
|
|
450
|
+
entry.result.revision = ScriptEdit.revisionOf(entry.target)
|
|
451
|
+
else
|
|
452
|
+
local code, message = reasonOf(err)
|
|
453
|
+
entry.result.ok = false
|
|
454
|
+
entry.result.code = code
|
|
455
|
+
entry.result.message = "created, but the source was not written: " .. message
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
if #writes > 0 then
|
|
460
|
+
Undo.exclusive(function()
|
|
461
|
+
for _, item in writes do
|
|
462
|
+
local ok, err = pcall(function()
|
|
463
|
+
local target = scriptAt(tostring(item.path))
|
|
464
|
+
local source = tostring(item.source)
|
|
465
|
+
-- Checked inside the editor's callback: the text the write
|
|
466
|
+
-- replaces is the text the check saw.
|
|
467
|
+
ScriptEdit.write(target, function(current)
|
|
468
|
+
if typeof(item.revision) == "string" and ScriptEdit.revisionOf(target, current) ~= item.revision then
|
|
469
|
+
Dispatch.fail("STALE_SCRIPT", "changed in Studio since the last sync; that text was kept")
|
|
470
|
+
end
|
|
471
|
+
return source
|
|
472
|
+
end)
|
|
473
|
+
return ScriptEdit.revisionOf(target)
|
|
474
|
+
end)
|
|
475
|
+
if ok then
|
|
476
|
+
table.insert(results.writes, { ok = true, path = item.path, revision = err })
|
|
477
|
+
else
|
|
478
|
+
local code, message = reasonOf(err)
|
|
479
|
+
table.insert(results.writes, { ok = false, path = item.path, code = code, message = message })
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
return true
|
|
483
|
+
end)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
local counts = {}
|
|
487
|
+
for _, kind in { "writes", "creates", "deletes", "moves" } do
|
|
488
|
+
local done = 0
|
|
489
|
+
for _, result in (results :: any)[kind] do
|
|
490
|
+
if result.ok then
|
|
491
|
+
done += 1
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
if done > 0 then
|
|
495
|
+
table.insert(counts, string.format("%d %s", done, kind))
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
if #counts > 0 then
|
|
499
|
+
log("ok", "sync from files: " .. table.concat(counts, ", "), if recorded then "one undo step" else nil)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
writes = results.writes,
|
|
504
|
+
creates = results.creates,
|
|
505
|
+
deletes = results.deletes,
|
|
506
|
+
moves = results.moves,
|
|
507
|
+
undoStep = if recorded then "MCP sync" else nil,
|
|
508
|
+
}
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
-- Change tracking, for `watch` ----------------------------------------------
|
|
512
|
+
|
|
513
|
+
--[[
|
|
514
|
+
What changed since the server last asked, so `watch` does not fingerprint
|
|
515
|
+
every script in the place twice a second.
|
|
516
|
+
|
|
517
|
+
Edits in the script editor arrive through `TextDocumentDidChange`, and every
|
|
518
|
+
other write to `Source` through the property signal. Scripts appearing,
|
|
519
|
+
disappearing or being renamed mark the whole shape as changed, and the
|
|
520
|
+
server rescans. Renaming a FOLDER changes script paths with no event on the
|
|
521
|
+
scripts at all, which is why the server also rescans the shape on a slow
|
|
522
|
+
timer rather than trusting this alone.
|
|
523
|
+
|
|
524
|
+
Only runs while someone is asking: tracking starts on the first `changes`
|
|
525
|
+
call and stops by itself once nobody has asked for a minute, so a watch
|
|
526
|
+
that ended with its process leaves nothing connected behind.
|
|
527
|
+
]]
|
|
528
|
+
type Tracker = {
|
|
529
|
+
token: string,
|
|
530
|
+
dirty: { [Instance]: boolean },
|
|
531
|
+
structural: boolean,
|
|
532
|
+
connections: { RBXScriptConnection },
|
|
533
|
+
watched: { [Instance]: boolean },
|
|
534
|
+
lastPolled: number,
|
|
535
|
+
}
|
|
536
|
+
local tracker: Tracker? = nil
|
|
537
|
+
local IDLE_STOP = 60
|
|
538
|
+
|
|
539
|
+
local function stopTracking()
|
|
540
|
+
local current = tracker
|
|
541
|
+
if current == nil then
|
|
542
|
+
return
|
|
543
|
+
end
|
|
544
|
+
for _, connection in current.connections do
|
|
545
|
+
connection:Disconnect()
|
|
546
|
+
end
|
|
547
|
+
tracker = nil
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
local function watchScript(current: Tracker, instance: Instance)
|
|
551
|
+
if current.watched[instance] or not isScript(instance) then
|
|
552
|
+
return
|
|
553
|
+
end
|
|
554
|
+
current.watched[instance] = true
|
|
555
|
+
table.insert(current.connections, instance:GetPropertyChangedSignal("Source"):Connect(function()
|
|
556
|
+
current.dirty[instance] = true
|
|
557
|
+
end))
|
|
558
|
+
table.insert(current.connections, instance:GetPropertyChangedSignal("Name"):Connect(function()
|
|
559
|
+
current.structural = true
|
|
560
|
+
end))
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
local function startTracking(): Tracker
|
|
564
|
+
local current: Tracker = {
|
|
565
|
+
token = HttpService:GenerateGUID(false),
|
|
566
|
+
dirty = setmetatable({}, { __mode = "k" }) :: any,
|
|
567
|
+
structural = false,
|
|
568
|
+
connections = {},
|
|
569
|
+
watched = setmetatable({}, { __mode = "k" }) :: any,
|
|
570
|
+
lastPolled = os.clock(),
|
|
571
|
+
}
|
|
572
|
+
for _, root in rootsOf({}) do
|
|
573
|
+
for _, descendant in root:GetDescendants() do
|
|
574
|
+
watchScript(current, descendant)
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
table.insert(current.connections, game.DescendantAdded:Connect(function(instance)
|
|
578
|
+
if isScript(instance) then
|
|
579
|
+
current.structural = true
|
|
580
|
+
watchScript(current, instance)
|
|
581
|
+
end
|
|
582
|
+
end))
|
|
583
|
+
table.insert(current.connections, game.DescendantRemoving:Connect(function(instance)
|
|
584
|
+
if isScript(instance) then
|
|
585
|
+
current.structural = true
|
|
586
|
+
end
|
|
587
|
+
end))
|
|
588
|
+
pcall(function()
|
|
589
|
+
table.insert(current.connections, ScriptEditorService.TextDocumentDidChange:Connect(function(document)
|
|
590
|
+
local ok, target = pcall(function()
|
|
591
|
+
return document:GetScript()
|
|
592
|
+
end)
|
|
593
|
+
if ok and target ~= nil then
|
|
594
|
+
current.dirty[target] = true
|
|
595
|
+
end
|
|
596
|
+
end))
|
|
597
|
+
end)
|
|
598
|
+
tracker = current
|
|
599
|
+
|
|
600
|
+
task.spawn(function()
|
|
601
|
+
while tracker == current do
|
|
602
|
+
task.wait(IDLE_STOP / 2)
|
|
603
|
+
if tracker == current and os.clock() - current.lastPolled > IDLE_STOP then
|
|
604
|
+
stopTracking()
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end)
|
|
608
|
+
return current
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
--[[
|
|
612
|
+
Returns what changed since the previous call, and forgets it.
|
|
613
|
+
|
|
614
|
+
`token` names the tracking session. A token the server does not recognise
|
|
615
|
+
-- the first call, or the plugin reloaded in between and lost everything it
|
|
616
|
+
had noted -- comes back with `reset`, meaning "assume anything changed".
|
|
617
|
+
]]
|
|
618
|
+
function Sync.changes(params: { [string]: any }): { [string]: any }
|
|
619
|
+
local current = tracker
|
|
620
|
+
if current == nil or params.token ~= current.token then
|
|
621
|
+
current = current or startTracking()
|
|
622
|
+
current.lastPolled = os.clock()
|
|
623
|
+
current.dirty = setmetatable({}, { __mode = "k" }) :: any
|
|
624
|
+
current.structural = false
|
|
625
|
+
return { token = current.token, reset = true, dirty = {}, structural = true }
|
|
626
|
+
end
|
|
627
|
+
current.lastPolled = os.clock()
|
|
628
|
+
|
|
629
|
+
local dirty: { string } = {}
|
|
630
|
+
local memo: Paths.NameIndex = {}
|
|
631
|
+
for instance in current.dirty do
|
|
632
|
+
if instance.Parent ~= nil and not Scope.isNoisy(instance) then
|
|
633
|
+
table.insert(dirty, Paths.of(instance, memo))
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
local structural = current.structural
|
|
637
|
+
current.dirty = setmetatable({}, { __mode = "k" }) :: any
|
|
638
|
+
current.structural = false
|
|
639
|
+
return { token = current.token, reset = false, dirty = dirty, structural = structural }
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
function Sync.stop(_params: { [string]: any }): { [string]: any }
|
|
643
|
+
stopTracking()
|
|
644
|
+
return { stopped = true }
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
-- A line in the panel for work the server did that Studio cannot see, like
|
|
648
|
+
-- files written to disk. Quiet: logging the call as well would say it twice.
|
|
649
|
+
function Sync.log(params: { [string]: any }): { [string]: any }
|
|
650
|
+
local lines = params.lines
|
|
651
|
+
if typeof(lines) == "table" then
|
|
652
|
+
for _, line in lines do
|
|
653
|
+
if typeof(line) == "table" and typeof(line.message) == "string" then
|
|
654
|
+
log(
|
|
655
|
+
if typeof(line.level) == "string" then line.level else "dim",
|
|
656
|
+
line.message,
|
|
657
|
+
if typeof(line.detail) == "string" then line.detail else nil
|
|
658
|
+
)
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
return { logged = true }
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
-- Build files ---------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
-- Attribute types `create` can write back; the list its schema accepts.
|
|
668
|
+
local ATTRIBUTE_TYPES: { [string]: boolean } = {
|
|
669
|
+
BrickColor = true,
|
|
670
|
+
CFrame = true,
|
|
671
|
+
Color3 = true,
|
|
672
|
+
ColorSequence = true,
|
|
673
|
+
Font = true,
|
|
674
|
+
NumberRange = true,
|
|
675
|
+
NumberSequence = true,
|
|
676
|
+
Rect = true,
|
|
677
|
+
UDim = true,
|
|
678
|
+
UDim2 = true,
|
|
679
|
+
Vector2 = true,
|
|
680
|
+
Vector3 = true,
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
-- Classes in a subtree other than scripts, so the server can say which of their
|
|
684
|
+
-- properties are worth exporting.
|
|
685
|
+
function Sync.classes(params: { [string]: any }): { [string]: any }
|
|
686
|
+
local root = Paths.resolve(tostring(params.path))
|
|
687
|
+
if isScript(root) then
|
|
688
|
+
Dispatch.fail("IS_A_SCRIPT", "Scripts sync as files; export the instance tree that holds them.")
|
|
689
|
+
end
|
|
690
|
+
local seen: { [string]: boolean } = { [root.ClassName] = true }
|
|
691
|
+
for _, descendant in root:GetDescendants() do
|
|
692
|
+
if not isScript(descendant) then
|
|
693
|
+
seen[descendant.ClassName] = true
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
local classes: { string } = {}
|
|
697
|
+
for name in seen do
|
|
698
|
+
table.insert(classes, name)
|
|
699
|
+
end
|
|
700
|
+
table.sort(classes)
|
|
701
|
+
-- The chain above the root, so the server can place the build file beside
|
|
702
|
+
-- the synced scripts of the same tree.
|
|
703
|
+
local chain: { { [string]: any } } = {}
|
|
704
|
+
local link: Instance? = root
|
|
705
|
+
while link ~= nil and link ~= game do
|
|
706
|
+
table.insert(chain, 1, {
|
|
707
|
+
name = (link :: Instance).Name,
|
|
708
|
+
className = (link :: Instance).ClassName,
|
|
709
|
+
path = Paths.of(link :: Instance),
|
|
710
|
+
ordinal = Paths.ordinal(link :: Instance),
|
|
711
|
+
})
|
|
712
|
+
link = (link :: Instance).Parent
|
|
713
|
+
end
|
|
714
|
+
return { path = Paths.of(root), classes = classes, chain = chain }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
--[[
|
|
718
|
+
An instance tree as the spec `create` takes: class, name, and every
|
|
719
|
+
property that differs from a fresh instance of the class.
|
|
720
|
+
|
|
721
|
+
Differs, because a build file is read and edited by people and agents:
|
|
722
|
+
three hundred default values per frame would bury the eight that make it
|
|
723
|
+
that frame. Each exported value is also parsed back before it is kept, so
|
|
724
|
+
a build file never contains something the build could not apply --
|
|
725
|
+
anything that does not survive is listed in `skipped` instead.
|
|
726
|
+
|
|
727
|
+
Scripts are not exported. They live in the sync folder as files, and a
|
|
728
|
+
rebuild carries the existing ones over (see `build`).
|
|
729
|
+
]]
|
|
730
|
+
function Sync.export(params: { [string]: any }): { [string]: any }
|
|
731
|
+
local root = Paths.resolve(tostring(params.path))
|
|
732
|
+
local propertyLists = if typeof(params.properties) == "table" then params.properties else {}
|
|
733
|
+
local defaults: { [string]: Instance | false } = {}
|
|
734
|
+
local skipped: { string } = {}
|
|
735
|
+
local scripts = 0
|
|
736
|
+
local count = 0
|
|
737
|
+
|
|
738
|
+
local function defaultOf(className: string): Instance?
|
|
739
|
+
local cached = defaults[className]
|
|
740
|
+
if cached == nil then
|
|
741
|
+
local ok, made = pcall(Instance.new, className)
|
|
742
|
+
cached = if ok then made else false
|
|
743
|
+
defaults[className] = cached
|
|
744
|
+
end
|
|
745
|
+
return if cached then cached :: Instance else nil
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
local function describe(instance: Instance, depth: number): { [string]: any }?
|
|
749
|
+
if isScript(instance) then
|
|
750
|
+
scripts += 1
|
|
751
|
+
return nil
|
|
752
|
+
end
|
|
753
|
+
count += 1
|
|
754
|
+
local spec: { [string]: any } = { className = instance.ClassName, name = instance.Name }
|
|
755
|
+
local default = defaultOf(instance.ClassName)
|
|
756
|
+
local properties: { [string]: any } = {}
|
|
757
|
+
local any = false
|
|
758
|
+
for _, entry in (propertyLists[instance.ClassName] or {}) :: { any } do
|
|
759
|
+
local name = tostring(entry.name)
|
|
760
|
+
local okRead, value = pcall(function()
|
|
761
|
+
return (instance :: any)[name]
|
|
762
|
+
end)
|
|
763
|
+
if not okRead then
|
|
764
|
+
continue
|
|
765
|
+
end
|
|
766
|
+
if default ~= nil then
|
|
767
|
+
local okDefault, fallback = pcall(function()
|
|
768
|
+
return (default :: any)[name]
|
|
769
|
+
end)
|
|
770
|
+
if okDefault and fallback == value then
|
|
771
|
+
continue
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
local text = Serialize.value(value)
|
|
775
|
+
if typeof(text) == "table" or text == nil then
|
|
776
|
+
table.insert(skipped, string.format("%s.%s", instance:GetFullName(), name))
|
|
777
|
+
continue
|
|
778
|
+
end
|
|
779
|
+
local parsed = Serialize.parse(text, tostring(entry.type))
|
|
780
|
+
if not parsed then
|
|
781
|
+
table.insert(skipped, string.format("%s.%s", instance:GetFullName(), name))
|
|
782
|
+
continue
|
|
783
|
+
end
|
|
784
|
+
properties[name] = text
|
|
785
|
+
any = true
|
|
786
|
+
end
|
|
787
|
+
if any then
|
|
788
|
+
spec.properties = properties
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
local attributes = instance:GetAttributes()
|
|
792
|
+
if next(attributes) ~= nil then
|
|
793
|
+
local exported: { [string]: any } = {}
|
|
794
|
+
local anyAttribute = false
|
|
795
|
+
for name, value in attributes do
|
|
796
|
+
local kind = typeof(value)
|
|
797
|
+
if kind == "string" or kind == "number" or kind == "boolean" then
|
|
798
|
+
exported[name] = value
|
|
799
|
+
anyAttribute = true
|
|
800
|
+
elseif ATTRIBUTE_TYPES[kind] then
|
|
801
|
+
exported[name] = { type = kind, value = Serialize.value(value) }
|
|
802
|
+
anyAttribute = true
|
|
803
|
+
else
|
|
804
|
+
-- `create` cannot type it, and one unreadable value would
|
|
805
|
+
-- stop the whole file from building.
|
|
806
|
+
table.insert(skipped, string.format("%s@%s (%s)", instance:GetFullName(), name, kind))
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
if anyAttribute then
|
|
810
|
+
spec.attributes = exported
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
local tags = CollectionService:GetTags(instance)
|
|
815
|
+
if #tags > 0 then
|
|
816
|
+
table.sort(tags)
|
|
817
|
+
spec.tags = { add = tags }
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
local children: { { [string]: any } } = {}
|
|
821
|
+
for _, child in instance:GetChildren() do
|
|
822
|
+
local described = describe(child, depth + 1)
|
|
823
|
+
if described ~= nil then
|
|
824
|
+
table.insert(children, described)
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
if #children > 0 then
|
|
828
|
+
-- By name, then class: a stable file, so an export of an unchanged
|
|
829
|
+
-- tree is byte-identical and shows up as nothing changed.
|
|
830
|
+
table.sort(children, function(a, b)
|
|
831
|
+
if a.name ~= b.name then
|
|
832
|
+
return a.name < b.name
|
|
833
|
+
end
|
|
834
|
+
return a.className < b.className
|
|
835
|
+
end)
|
|
836
|
+
spec.children = children
|
|
837
|
+
end
|
|
838
|
+
return spec
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
local spec = describe(root, 0)
|
|
842
|
+
for _, default in defaults do
|
|
843
|
+
if default then
|
|
844
|
+
(default :: Instance):Destroy()
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
if spec == nil then
|
|
848
|
+
Dispatch.fail("IS_A_SCRIPT", "Scripts sync as files; export the instance tree that holds them.")
|
|
849
|
+
end
|
|
850
|
+
return { path = Paths.of(root), spec = spec, instances = count, scripts = scripts, skipped = skipped }
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
--[[
|
|
854
|
+
Replaces an instance tree with a freshly built one, as one undo step.
|
|
855
|
+
|
|
856
|
+
The old tree is renamed out of the way, the new one created in its place,
|
|
857
|
+
and then the scripts are carried across: a build file holds no scripts
|
|
858
|
+
(they sync as files), so rebuilding a ScreenGui must not delete the
|
|
859
|
+
LocalScript that drives it. Each script goes back under the same chain of
|
|
860
|
+
names it sat under, or under the new root when that chain no longer exists
|
|
861
|
+
-- which is reported, since the script may now be looking in the wrong place.
|
|
862
|
+
]]
|
|
863
|
+
function Sync.build(params: { [string]: any }): { [string]: any }
|
|
864
|
+
local spec = params.spec
|
|
865
|
+
if typeof(spec) ~= "table" then
|
|
866
|
+
Dispatch.fail("BAD_PARAMS", "sync.build requires a `spec`.")
|
|
867
|
+
end
|
|
868
|
+
local parent = Paths.resolve(tostring(params.parent))
|
|
869
|
+
local name = tostring(spec.name or spec.className)
|
|
870
|
+
|
|
871
|
+
local carried: { { [string]: any } } = {}
|
|
872
|
+
local builtPath: string? = nil
|
|
873
|
+
local _, recorded = Undo.record("StudioMCP.Build", "MCP build", function()
|
|
874
|
+
-- The tree this file built last time, when its name changed in the file:
|
|
875
|
+
-- renaming it there must replace that tree, not add a second one.
|
|
876
|
+
local old: Instance? = nil
|
|
877
|
+
if typeof(params.replaces) == "string" then
|
|
878
|
+
local found, previous = pcall(Paths.resolve, params.replaces)
|
|
879
|
+
if found and previous.Parent == parent then
|
|
880
|
+
old = previous
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
old = old or parent:FindFirstChild(name)
|
|
884
|
+
if old ~= nil and isScript(old) then
|
|
885
|
+
Dispatch.fail("IS_A_SCRIPT", string.format("%s is a script; build files hold instance trees.", old:GetFullName()))
|
|
886
|
+
end
|
|
887
|
+
if old ~= nil then
|
|
888
|
+
old.Name = "__mcp_build_old"
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
local top = table.clone(spec) :: any
|
|
892
|
+
top.parent = Paths.of(parent)
|
|
893
|
+
local created = Instances.create({ instances = { top } })
|
|
894
|
+
local fresh = parent:FindFirstChild(name)
|
|
895
|
+
if fresh == nil then
|
|
896
|
+
Dispatch.fail("BUILD_FAILED", "The new tree was not created.")
|
|
897
|
+
end
|
|
898
|
+
builtPath = (created.items[1] or {}).path
|
|
899
|
+
|
|
900
|
+
if old ~= nil then
|
|
901
|
+
local previous = old :: Instance
|
|
902
|
+
for _, item in previous:GetDescendants() do
|
|
903
|
+
local descendant = item :: Instance
|
|
904
|
+
if not isScript(descendant) then
|
|
905
|
+
continue
|
|
906
|
+
end
|
|
907
|
+
-- Only the outermost scripts: a script's own children move with it.
|
|
908
|
+
local outer = true
|
|
909
|
+
local above = descendant.Parent
|
|
910
|
+
while above ~= nil and above ~= previous do
|
|
911
|
+
if isScript(above) then
|
|
912
|
+
outer = false
|
|
913
|
+
break
|
|
914
|
+
end
|
|
915
|
+
above = above.Parent
|
|
916
|
+
end
|
|
917
|
+
if not outer then
|
|
918
|
+
continue
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
local chain: { string } = {}
|
|
922
|
+
local link = descendant.Parent
|
|
923
|
+
while link ~= nil and link ~= previous do
|
|
924
|
+
table.insert(chain, 1, link.Name)
|
|
925
|
+
link = link.Parent
|
|
926
|
+
end
|
|
927
|
+
local destination: Instance = fresh :: Instance
|
|
928
|
+
local exact = true
|
|
929
|
+
for _, step in chain do
|
|
930
|
+
local next = destination:FindFirstChild(step)
|
|
931
|
+
if next == nil then
|
|
932
|
+
exact = false
|
|
933
|
+
break
|
|
934
|
+
end
|
|
935
|
+
destination = next
|
|
936
|
+
end
|
|
937
|
+
if not exact then
|
|
938
|
+
destination = fresh :: Instance
|
|
939
|
+
end
|
|
940
|
+
descendant.Parent = destination
|
|
941
|
+
table.insert(carried, { path = Paths.of(descendant), exact = exact })
|
|
942
|
+
end
|
|
943
|
+
previous:Destroy()
|
|
944
|
+
end
|
|
945
|
+
return true
|
|
946
|
+
end)
|
|
947
|
+
|
|
948
|
+
log("ok", string.format("built %s from a build file", builtPath or name), if recorded then "one undo step" else nil)
|
|
949
|
+
return { path = builtPath, carried = carried, undoStep = if recorded then "MCP build" else nil }
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
function Sync.register()
|
|
953
|
+
Dispatch.registerAll("sync", {
|
|
954
|
+
scan = Sync.scan,
|
|
955
|
+
shape = Sync.shape,
|
|
956
|
+
revisions = Sync.revisions,
|
|
957
|
+
read = Sync.read,
|
|
958
|
+
apply = Sync.apply,
|
|
959
|
+
changes = Sync.changes,
|
|
960
|
+
stop = Sync.stop,
|
|
961
|
+
log = Sync.log,
|
|
962
|
+
classes = Sync.classes,
|
|
963
|
+
export = Sync.export,
|
|
964
|
+
build = Sync.build,
|
|
965
|
+
})
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
return Sync
|