@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
|
@@ -41,6 +41,8 @@ local MAX_CONTEXT = 1000
|
|
|
41
41
|
|
|
42
42
|
export type Entry = {
|
|
43
43
|
sequence: number?,
|
|
44
|
+
id: number?,
|
|
45
|
+
updated: boolean?,
|
|
44
46
|
level: string,
|
|
45
47
|
message: string,
|
|
46
48
|
timestamp: number,
|
|
@@ -101,6 +103,10 @@ local collecting: { string }? = nil
|
|
|
101
103
|
local function pushEntry(entry: Entry)
|
|
102
104
|
nextSequence += 1
|
|
103
105
|
entry.sequence = nextSequence
|
|
106
|
+
entry.id = nextSequence
|
|
107
|
+
entry.message = string.sub(entry.message, 1, 2000)
|
|
108
|
+
if entry.stack then entry.stack = string.sub(entry.stack, 1, 4000) end
|
|
109
|
+
if entry.source then entry.source = string.sub(entry.source, 1, 500) end
|
|
104
110
|
table.insert(entries, entry)
|
|
105
111
|
|
|
106
112
|
if #entries > CAPACITY + SLACK then
|
|
@@ -133,18 +139,24 @@ function LogBuffer.pushClient(player: Player, payload: any)
|
|
|
133
139
|
if payload.kind == "ready" then buffer.ready = true; return end
|
|
134
140
|
if payload.kind == "trace" then
|
|
135
141
|
if typeof(payload.message) ~= "string" or typeof(payload.stack) ~= "string" then return end
|
|
142
|
+
local traceMessage = string.sub(payload.message, 1, 2000)
|
|
136
143
|
for index = #buffer.entries, math.max(1, #buffer.entries - CORRELATION_WINDOW + 1), -1 do
|
|
137
144
|
local item = buffer.entries[index]
|
|
138
|
-
if item.level == "error" and item.message == payload.message and item.stack == nil then
|
|
145
|
+
if item.level == "error" and item.message == string.sub(payload.message, 1, 2000) and item.stack == nil then
|
|
139
146
|
item.stack = string.sub(payload.stack, 1, 4000)
|
|
140
147
|
item.source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil
|
|
148
|
+
buffer.sequence += 1
|
|
149
|
+
item.sequence = buffer.sequence
|
|
150
|
+
item.updated = true
|
|
151
|
+
table.remove(buffer.entries, index)
|
|
152
|
+
table.insert(buffer.entries, item)
|
|
141
153
|
return
|
|
142
154
|
end
|
|
143
155
|
end
|
|
144
156
|
local orphanCount = 0
|
|
145
157
|
for _ in buffer.orphan do orphanCount += 1 end
|
|
146
|
-
if orphanCount < 20 or buffer.orphan[
|
|
147
|
-
buffer.orphan[
|
|
158
|
+
if orphanCount < 20 or buffer.orphan[traceMessage] ~= nil then
|
|
159
|
+
buffer.orphan[traceMessage] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
|
|
148
160
|
end
|
|
149
161
|
return
|
|
150
162
|
end
|
|
@@ -154,6 +166,7 @@ function LogBuffer.pushClient(player: Player, payload: any)
|
|
|
154
166
|
buffer.sequence += 1
|
|
155
167
|
local entry: Entry = {
|
|
156
168
|
sequence = buffer.sequence,
|
|
169
|
+
id = buffer.sequence,
|
|
157
170
|
level = level,
|
|
158
171
|
message = string.sub(payload.message, 1, 2000),
|
|
159
172
|
timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
|
|
@@ -202,17 +215,20 @@ end
|
|
|
202
215
|
]]
|
|
203
216
|
-- JSONEncode writes a Vector3 or an Instance as `null` without complaint, so
|
|
204
217
|
-- anything JSON has no shape for is turned into its text first.
|
|
205
|
-
local function plain(value: any, depth: number): any
|
|
218
|
+
local function plain(value: any, depth: number, budget: { nodes: number }): any
|
|
219
|
+
budget.nodes -= 1
|
|
220
|
+
if budget.nodes <= 0 then return "<truncated>" end
|
|
206
221
|
local kind = typeof(value)
|
|
207
222
|
if kind == "string" or kind == "number" or kind == "boolean" then
|
|
208
|
-
return value
|
|
223
|
+
return if kind == "string" then string.sub(value, 1, MAX_CONTEXT) else value
|
|
209
224
|
elseif kind == "table" then
|
|
210
225
|
if depth >= 4 then
|
|
211
226
|
return "<table>"
|
|
212
227
|
end
|
|
213
228
|
local copy = {}
|
|
214
229
|
for key, item in value do
|
|
215
|
-
|
|
230
|
+
if budget.nodes <= 0 then copy["..."] = "<truncated>"; break end
|
|
231
|
+
copy[if typeof(key) == "number" then key else string.sub(tostring(key), 1, 100)] = plain(item, depth + 1, budget)
|
|
216
232
|
end
|
|
217
233
|
return copy
|
|
218
234
|
elseif kind == "Instance" then
|
|
@@ -229,15 +245,17 @@ function LogBuffer.encodeContext(context: any): string?
|
|
|
229
245
|
return nil
|
|
230
246
|
end
|
|
231
247
|
local ok, encoded = pcall(function()
|
|
232
|
-
return HttpService:JSONEncode(plain(context, 0))
|
|
248
|
+
return HttpService:JSONEncode(plain(context, 0, {nodes = 50}))
|
|
233
249
|
end)
|
|
234
250
|
if not ok then
|
|
235
251
|
return nil
|
|
236
252
|
end
|
|
237
|
-
return string.sub(encoded
|
|
253
|
+
if #encoded > MAX_CONTEXT then return HttpService:JSONEncode({truncated = string.sub(encoded, 1, 100)}) end
|
|
254
|
+
return encoded
|
|
238
255
|
end
|
|
239
256
|
|
|
240
257
|
local function push(message: string, messageType: Enum.MessageType, timestamp: number?, context: any?)
|
|
258
|
+
message = string.sub(message, 1, 2000)
|
|
241
259
|
local level = LEVELS[messageType.Value] or "print"
|
|
242
260
|
local entry: Entry = {
|
|
243
261
|
level = level,
|
|
@@ -270,12 +288,20 @@ end
|
|
|
270
288
|
two identical lines.
|
|
271
289
|
]]
|
|
272
290
|
local function attachStack(message: string, stack: string, source: string?)
|
|
291
|
+
message = string.sub(message, 1, 2000)
|
|
292
|
+
stack = string.sub(stack, 1, 4000)
|
|
293
|
+
if source then source = string.sub(source, 1, 500) end
|
|
273
294
|
local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
|
|
274
295
|
for index = #entries, first, -1 do
|
|
275
296
|
local entry = entries[index]
|
|
276
297
|
if entry.level == "error" and entry.message == message and entry.stack == nil then
|
|
277
298
|
entry.stack = stack
|
|
278
299
|
entry.source = source
|
|
300
|
+
nextSequence += 1
|
|
301
|
+
entry.sequence = nextSequence
|
|
302
|
+
entry.updated = true
|
|
303
|
+
table.remove(entries, index)
|
|
304
|
+
table.insert(entries, entry)
|
|
279
305
|
return
|
|
280
306
|
end
|
|
281
307
|
end
|
|
@@ -336,7 +362,10 @@ function LogBuffer.start()
|
|
|
336
362
|
local existing = recent.stack
|
|
337
363
|
local existingFrames = if existing then #string.split(existing, "\n") else 0
|
|
338
364
|
if existing == nil or #buffered > existingFrames then
|
|
339
|
-
recent.stack = table.concat(buffered, "\n")
|
|
365
|
+
recent.stack = string.sub(table.concat(buffered, "\n"), 1, 4000)
|
|
366
|
+
nextSequence += 1
|
|
367
|
+
recent.sequence = nextSequence
|
|
368
|
+
recent.updated = true
|
|
340
369
|
end
|
|
341
370
|
end
|
|
342
371
|
end
|
package/plugin/src/Paths.luau
CHANGED
|
@@ -67,6 +67,40 @@ local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: {
|
|
|
67
67
|
table.insert(list, child)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
--[[
|
|
71
|
+
Same-named siblings put into a fixed order.
|
|
72
|
+
|
|
73
|
+
`GetChildren` does not return a stable order (see `stableOrder` in
|
|
74
|
+
Discover), so `Part[2]` could name a different part on the call that
|
|
75
|
+
reads the path back than on the one that printed it. The engine's debug
|
|
76
|
+
id is fixed for an instance's lifetime, which makes it the tiebreak.
|
|
77
|
+
|
|
78
|
+
Guarded, because this module also runs in the client relay, where
|
|
79
|
+
`GetDebugId` is not callable -- there the order is simply the engine's,
|
|
80
|
+
as it always was, rather than an error on every path.
|
|
81
|
+
]]
|
|
82
|
+
for _, list in byName do
|
|
83
|
+
if #list > 1 then
|
|
84
|
+
local ids: { [Instance]: string } = {}
|
|
85
|
+
local stable = true
|
|
86
|
+
for _, child in list do
|
|
87
|
+
local ok, id = pcall(function()
|
|
88
|
+
return child:GetDebugId()
|
|
89
|
+
end)
|
|
90
|
+
if not ok then
|
|
91
|
+
stable = false
|
|
92
|
+
break
|
|
93
|
+
end
|
|
94
|
+
ids[child] = id
|
|
95
|
+
end
|
|
96
|
+
if stable then
|
|
97
|
+
table.sort(list, function(a: Instance, b: Instance): boolean
|
|
98
|
+
return ids[a] < ids[b]
|
|
99
|
+
end)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
70
104
|
if memo then
|
|
71
105
|
memo[parent] = byName
|
|
72
106
|
end
|
|
@@ -315,6 +349,14 @@ end
|
|
|
315
349
|
The index is positional, so it shifts if same-named siblings are inserted or
|
|
316
350
|
removed between calls. Read a fresh path after any structural change.
|
|
317
351
|
]]
|
|
352
|
+
--[[
|
|
353
|
+
The position among same-named siblings, or nil when the name is unique --
|
|
354
|
+
the `n` in `Name[n]`. Exposed for `sync`, which names files after it.
|
|
355
|
+
]]
|
|
356
|
+
function Paths.ordinal(instance: Instance, memo: NameIndex?): number?
|
|
357
|
+
return siblingIndex(instance, memo)
|
|
358
|
+
end
|
|
359
|
+
|
|
318
360
|
function Paths.of(instance: Instance, memo: NameIndex?): string
|
|
319
361
|
--[[
|
|
320
362
|
Refuses anything that is not an Instance, rather than duck-typing it.
|
package/plugin/src/Phrase.luau
CHANGED
|
@@ -91,6 +91,42 @@ type Describer = (params: { [string]: any }) -> string
|
|
|
91
91
|
playtest is about a mode, a find is about what was searched for.
|
|
92
92
|
]]
|
|
93
93
|
local DESCRIBERS: { [string]: Describer } = {
|
|
94
|
+
["sync.scan"] = function(params)
|
|
95
|
+
return if params.revisions == false then "Check script layout for sync" else "Scan scripts for sync"
|
|
96
|
+
end,
|
|
97
|
+
["sync.read"] = function(params)
|
|
98
|
+
return "Read " .. subjectOf(params.paths, "script") .. " for sync"
|
|
99
|
+
end,
|
|
100
|
+
["sync.apply"] = function(params)
|
|
101
|
+
local total = count(params.writes) + count(params.creates) + count(params.deletes) + count(params.moves)
|
|
102
|
+
return string.format("Sync %d change%s from files", total, if total == 1 then "" else "s")
|
|
103
|
+
end,
|
|
104
|
+
["sync.shape"] = function()
|
|
105
|
+
return "Check script layout for sync"
|
|
106
|
+
end,
|
|
107
|
+
["sync.revisions"] = function()
|
|
108
|
+
return "Check script revisions"
|
|
109
|
+
end,
|
|
110
|
+
["sync.changes"] = function()
|
|
111
|
+
return "Check for script changes"
|
|
112
|
+
end,
|
|
113
|
+
["sync.stop"] = function()
|
|
114
|
+
return "Stop sync tracking"
|
|
115
|
+
end,
|
|
116
|
+
["sync.log"] = function()
|
|
117
|
+
return "Sync note"
|
|
118
|
+
end,
|
|
119
|
+
["sync.classes"] = function(params)
|
|
120
|
+
return "List classes in " .. (leaf(params.path) or "tree")
|
|
121
|
+
end,
|
|
122
|
+
["sync.export"] = function(params)
|
|
123
|
+
return "Export " .. (leaf(params.path) or "tree") .. " to a build file"
|
|
124
|
+
end,
|
|
125
|
+
["sync.build"] = function(params)
|
|
126
|
+
local spec = params.spec
|
|
127
|
+
local name = if typeof(spec) == "table" then spec.name or spec.className else nil
|
|
128
|
+
return "Build " .. tostring(name or "tree") .. " from a build file"
|
|
129
|
+
end,
|
|
94
130
|
["script.read"] = function(params)
|
|
95
131
|
return "Read " .. subjectOf(params.paths, "script")
|
|
96
132
|
end,
|
|
@@ -756,6 +792,8 @@ local KINDS: { [string]: string } = {
|
|
|
756
792
|
terrain = "write",
|
|
757
793
|
generate = "write",
|
|
758
794
|
data = "write",
|
|
795
|
+
-- Applies files to the place; the reads inside the group are carved out below.
|
|
796
|
+
sync = "write",
|
|
759
797
|
}
|
|
760
798
|
|
|
761
799
|
function Phrase.kindOf(op: string): string
|
|
@@ -770,6 +808,9 @@ function Phrase.kindOf(op: string): string
|
|
|
770
808
|
if op == "audio.inspect" then
|
|
771
809
|
return "read"
|
|
772
810
|
end
|
|
811
|
+
if op == "sync.scan" or op == "sync.shape" or op == "sync.revisions" or op == "sync.read" or op == "sync.changes" or op == "sync.classes" or op == "sync.export" then
|
|
812
|
+
return "read"
|
|
813
|
+
end
|
|
773
814
|
return KINDS[group] or "read"
|
|
774
815
|
end
|
|
775
816
|
|
|
@@ -38,6 +38,70 @@ function ScriptEdit.read(target: LuaSourceContainer): string
|
|
|
38
38
|
return (target :: SourceContainer).Source
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
--[[
|
|
42
|
+
A short fingerprint of a script's source, for detecting that it moved.
|
|
43
|
+
|
|
44
|
+
Handed out by `script_read` and passed back to `script_edit`, and kept by
|
|
45
|
+
`sync` to tell which side of a synced script changed. `edit`, which refuses to write when
|
|
46
|
+
the live source no longer matches. That is the only thing standing between
|
|
47
|
+
two agents on one place and a silent overwrite: a line-range edit computed
|
|
48
|
+
against source somebody has since changed still applies cleanly, it just
|
|
49
|
+
applies to the wrong lines, and nothing anywhere reports it.
|
|
50
|
+
|
|
51
|
+
FNV-1a over the whole string, with the length appended. Not a security
|
|
52
|
+
hash and does not need to be -- it is guarding against ordinary concurrent
|
|
53
|
+
editing, not against someone constructing a collision. The length is there
|
|
54
|
+
because it is free and rules out the whole class of same-length accidents.
|
|
55
|
+
|
|
56
|
+
The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
|
|
57
|
+
a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
|
|
58
|
+
so the low bits -- the ones that carry the mixing -- would be quietly
|
|
59
|
+
rounded away.
|
|
60
|
+
]]
|
|
61
|
+
function ScriptEdit.fingerprint(source: string): string
|
|
62
|
+
local hash = 2166136261
|
|
63
|
+
local length = #source
|
|
64
|
+
local index = 1
|
|
65
|
+
while index <= length do
|
|
66
|
+
local last = math.min(index + 511, length)
|
|
67
|
+
local chunk = { string.byte(source, index, last) }
|
|
68
|
+
for _, byte in chunk do
|
|
69
|
+
hash = bit32.bxor(hash, byte)
|
|
70
|
+
local low = bit32.band(hash, 0xFFFF)
|
|
71
|
+
local high = bit32.rshift(hash, 16)
|
|
72
|
+
-- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
|
|
73
|
+
hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
|
|
74
|
+
end
|
|
75
|
+
index = last + 1
|
|
76
|
+
end
|
|
77
|
+
return string.format("%08x-%x", hash, length)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
--[[
|
|
81
|
+
A script's revision, computed once per distinct text.
|
|
82
|
+
|
|
83
|
+
Fingerprinting walks every byte in Luau, and `sync` asks for the revision of
|
|
84
|
+
every script in a place on each run -- so a place with megabytes of code paid
|
|
85
|
+
for hashing all of it after every save, to learn that one file changed. The
|
|
86
|
+
text is kept beside its revision per script, and a lookup whose text is the
|
|
87
|
+
same string skips the hash: Luau interns strings, so that comparison is a
|
|
88
|
+
pointer check, not a second pass over the source.
|
|
89
|
+
|
|
90
|
+
Weak keys, so a deleted script's entry goes with it.
|
|
91
|
+
]]
|
|
92
|
+
local revisions: { [Instance]: { source: string, revision: string } } = setmetatable({}, { __mode = "k" }) :: any
|
|
93
|
+
|
|
94
|
+
function ScriptEdit.revisionOf(target: LuaSourceContainer, source: string?): string
|
|
95
|
+
local text = source or ScriptEdit.read(target)
|
|
96
|
+
local cached = revisions[target]
|
|
97
|
+
if cached ~= nil and cached.source == text then
|
|
98
|
+
return cached.revision
|
|
99
|
+
end
|
|
100
|
+
local revision = ScriptEdit.fingerprint(text)
|
|
101
|
+
revisions[target] = { source = text, revision = revision }
|
|
102
|
+
return revision
|
|
103
|
+
end
|
|
104
|
+
|
|
41
105
|
--[[
|
|
42
106
|
Makes sure a write that reported success actually stuck.
|
|
43
107
|
|
|
@@ -59,11 +123,16 @@ end
|
|
|
59
123
|
caused this is only reachable when the USER opens one in the same moment we
|
|
60
124
|
write to it -- rare, and cheap enough to cover anyway. One settle, one read,
|
|
61
125
|
and a rewrite if it did not take.
|
|
126
|
+
|
|
127
|
+
A rewrite only ever replaces `original`, the text our write replaced. That
|
|
128
|
+
is what a finishing load puts back. Anything else in the buffer is somebody
|
|
129
|
+
typing after the write landed, and rewriting it "to make ours stick" would
|
|
130
|
+
delete their work -- so that is reported instead, and their text is kept.
|
|
62
131
|
]]
|
|
63
132
|
local SETTLE = 0.2
|
|
64
133
|
|
|
65
|
-
local function confirm(target: LuaSourceContainer, produced: string?)
|
|
66
|
-
if produced == nil then
|
|
134
|
+
local function confirm(target: LuaSourceContainer, produced: string?, original: string?)
|
|
135
|
+
if produced == nil or produced == original then
|
|
67
136
|
return
|
|
68
137
|
end
|
|
69
138
|
|
|
@@ -86,20 +155,35 @@ local function confirm(target: LuaSourceContainer, produced: string?)
|
|
|
86
155
|
-- repeating will not fix.
|
|
87
156
|
for _ = 1, 3 do
|
|
88
157
|
task.wait(SETTLE)
|
|
89
|
-
|
|
158
|
+
local current = ScriptEdit.read(target)
|
|
159
|
+
if current == produced then
|
|
90
160
|
return
|
|
91
161
|
end
|
|
162
|
+
if current ~= original then
|
|
163
|
+
break
|
|
164
|
+
end
|
|
92
165
|
pcall(function()
|
|
93
|
-
ScriptEditorService:UpdateSourceAsync(target, function()
|
|
94
|
-
|
|
166
|
+
ScriptEditorService:UpdateSourceAsync(target, function(source)
|
|
167
|
+
-- Checked again at commit time: the user may have typed since the read.
|
|
168
|
+
return if source == original then produced :: string else source
|
|
95
169
|
end)
|
|
96
170
|
end)
|
|
97
171
|
end
|
|
172
|
+
|
|
173
|
+
if ScriptEdit.read(target) ~= produced then
|
|
174
|
+
Dispatch.fail(
|
|
175
|
+
"VERIFY_FAILED",
|
|
176
|
+
string.format("%s changed while the write was being confirmed.", target:GetFullName()),
|
|
177
|
+
"The newer text was kept, not overwritten. Read the script again before retrying."
|
|
178
|
+
)
|
|
179
|
+
end
|
|
98
180
|
end
|
|
99
181
|
|
|
100
182
|
--[[
|
|
101
|
-
Replaces source through the editor. `transform` receives the current text
|
|
102
|
-
|
|
183
|
+
Replaces source through the editor. `transform` receives the current text --
|
|
184
|
+
read at commit time, so it is where a "has this changed since I read it"
|
|
185
|
+
check belongs -- and returns the replacement; returning the input unchanged
|
|
186
|
+
is a no-op.
|
|
103
187
|
|
|
104
188
|
`transform` is allowed to raise a structured Dispatch failure -- a bad line
|
|
105
189
|
range is only discoverable once the authoritative source is in hand. Such a
|
|
@@ -115,10 +199,12 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
|
|
|
115
199
|
local pending: any = nil
|
|
116
200
|
|
|
117
201
|
local produced: string? = nil
|
|
202
|
+
local original: string? = nil
|
|
118
203
|
local ok, err = pcall(function()
|
|
119
204
|
ScriptEditorService:UpdateSourceAsync(target, function(source)
|
|
120
205
|
local applied, result = pcall(transform, source)
|
|
121
206
|
if applied then
|
|
207
|
+
original = source
|
|
122
208
|
produced = result
|
|
123
209
|
return result
|
|
124
210
|
end
|
|
@@ -131,7 +217,7 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
|
|
|
131
217
|
error(pending, 0)
|
|
132
218
|
end
|
|
133
219
|
if ok then
|
|
134
|
-
confirm(target, produced)
|
|
220
|
+
confirm(target, produced, original)
|
|
135
221
|
return
|
|
136
222
|
end
|
|
137
223
|
|
|
@@ -413,7 +413,22 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
|
|
|
413
413
|
end
|
|
414
414
|
-- 0-255 is a common mistake and unambiguous to detect, so accept it.
|
|
415
415
|
local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
|
|
416
|
-
|
|
416
|
+
--[[
|
|
417
|
+
Snapped back to an exact 1/255 step when the text is that step
|
|
418
|
+
rounded to PRECISION places.
|
|
419
|
+
|
|
420
|
+
Part colours are stored as bytes. "0.7686" is how 196/255 prints,
|
|
421
|
+
but it is a hair under it, and the engine truncates on the way in
|
|
422
|
+
-- so a colour read and written back came out as 195, one step
|
|
423
|
+
darker each round trip. A value genuinely between steps, like 0.5,
|
|
424
|
+
is further than half a printed digit from one and is left alone.
|
|
425
|
+
]]
|
|
426
|
+
local function component(value: number): number
|
|
427
|
+
local unit = value / scale
|
|
428
|
+
local step = math.round(unit * 255) / 255
|
|
429
|
+
return if math.abs(unit - step) <= 0.5 * 10 ^ -PRECISION then step else unit
|
|
430
|
+
end
|
|
431
|
+
return true, Color3.new(component(numbers[1]), component(numbers[2]), component(numbers[3])), nil
|
|
417
432
|
end
|
|
418
433
|
if valueType == "BrickColor" then
|
|
419
434
|
-- Cast because the API definitions type this overload as a literal union
|
|
@@ -26,7 +26,9 @@ local Transport = {}
|
|
|
26
26
|
export type Status = "disconnected" | "connecting" | "connected"
|
|
27
27
|
|
|
28
28
|
type Callbacks = {
|
|
29
|
-
|
|
29
|
+
-- `deadlineMs` is when the server stops waiting for the reply, on this
|
|
30
|
+
-- machine's clock: the server sends a duration, never a time (see Command.timeoutMs).
|
|
31
|
+
onCommand: (id: string, op: string, params: { [string]: any }?, deadlineMs: number?) -> (),
|
|
30
32
|
onStatus: (status: Status, detail: string?) -> (),
|
|
31
33
|
--[[
|
|
32
34
|
Anything the bridge wants to tell this plugin that is not a command.
|
|
@@ -243,7 +245,8 @@ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
|
|
|
243
245
|
local id = frame.id
|
|
244
246
|
local op = frame.op
|
|
245
247
|
if typeof(id) == "string" and typeof(op) == "string" then
|
|
246
|
-
|
|
248
|
+
local deadline = if typeof(frame.timeoutMs) == "number" then DateTime.now().UnixTimestampMillis + frame.timeoutMs else nil
|
|
249
|
+
callbacks.onCommand(id, op, frame.params, deadline)
|
|
247
250
|
return
|
|
248
251
|
end
|
|
249
252
|
if typeof(frame.event) == "string" then
|
package/plugin/src/Undo.luau
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
20
20
|
|
|
21
|
+
local Dispatch = require(script.Parent.Dispatch)
|
|
22
|
+
|
|
21
23
|
local Undo = {}
|
|
22
24
|
|
|
23
25
|
--[[
|
|
@@ -132,13 +134,57 @@ end
|
|
|
132
134
|
is something tools tell the agent, and therefore the user, so it has to be
|
|
133
135
|
observed rather than assumed: a claim of "one Ctrl+Z" that silently degrades
|
|
134
136
|
to no undo entry at all is worse than no claim.
|
|
137
|
+
|
|
138
|
+
Refusing instead is not an option either: Studio opens no recording at all
|
|
139
|
+
inside a running playtest (measured), so a refusal would make every write
|
|
140
|
+
tool unusable against the runtime session.
|
|
141
|
+
]]
|
|
142
|
+
|
|
143
|
+
-- Opens a recording, or nil. Guarded because an engine error here must not
|
|
144
|
+
-- escape with the lock held.
|
|
145
|
+
local function begin(name: string, displayName: string): string?
|
|
146
|
+
local ok, identifier = pcall(function()
|
|
147
|
+
return ChangeHistoryService:TryBeginRecording(name, displayName)
|
|
148
|
+
end)
|
|
149
|
+
return if ok then identifier else nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[
|
|
153
|
+
Runs `body` after the lock is ours, unless the request expired while it
|
|
154
|
+
waited. The server stops waiting at its timeout, so work that starts later
|
|
155
|
+
would be reported to nobody -- and an agent told "timed out" is likely to
|
|
156
|
+
retry it.
|
|
135
157
|
]]
|
|
158
|
+
local function run<T>(body: () -> T): (boolean, any)
|
|
159
|
+
return pcall(function()
|
|
160
|
+
Dispatch.checkDeadline()
|
|
161
|
+
return body()
|
|
162
|
+
end)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
-- Closes a recording. A failure here is reported, with the lock released, as
|
|
166
|
+
-- an outcome the agent must check rather than assume.
|
|
167
|
+
local function finish(identifier: string, operation: Enum.FinishRecordingOperation, acquired: boolean)
|
|
168
|
+
local ok, err = pcall(function()
|
|
169
|
+
ChangeHistoryService:FinishRecording(identifier, operation)
|
|
170
|
+
end)
|
|
171
|
+
if ok then
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
release(acquired)
|
|
175
|
+
Dispatch.fail(
|
|
176
|
+
"UNDO_FAILED",
|
|
177
|
+
string.format("Studio could not close the undo recording: %s", tostring(err)),
|
|
178
|
+
"The change may or may not have applied. Inspect the affected instances before retrying."
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
136
182
|
function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, boolean)
|
|
137
183
|
local acquired = acquire()
|
|
138
184
|
|
|
139
|
-
local identifier =
|
|
185
|
+
local identifier = begin(name, displayName)
|
|
140
186
|
if not identifier then
|
|
141
|
-
local ok, result =
|
|
187
|
+
local ok, result = run(body)
|
|
142
188
|
release(acquired)
|
|
143
189
|
if not ok then
|
|
144
190
|
error(result, 0)
|
|
@@ -146,15 +192,15 @@ function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, b
|
|
|
146
192
|
return result, false
|
|
147
193
|
end
|
|
148
194
|
|
|
149
|
-
local ok, result =
|
|
195
|
+
local ok, result = run(body)
|
|
150
196
|
if ok then
|
|
151
|
-
|
|
197
|
+
finish(identifier, Enum.FinishRecordingOperation.Commit, acquired)
|
|
152
198
|
release(acquired)
|
|
153
199
|
return result, true
|
|
154
200
|
end
|
|
155
201
|
|
|
156
|
-
|
|
157
|
-
dropRedo
|
|
202
|
+
finish(identifier, Enum.FinishRecordingOperation.Cancel, acquired)
|
|
203
|
+
pcall(dropRedo)
|
|
158
204
|
release(acquired)
|
|
159
205
|
error(result, 0)
|
|
160
206
|
end
|
|
@@ -167,9 +213,9 @@ end
|
|
|
167
213
|
function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T): (T, boolean)
|
|
168
214
|
local acquired = acquire()
|
|
169
215
|
|
|
170
|
-
local identifier =
|
|
216
|
+
local identifier = begin(name, displayName)
|
|
171
217
|
if not identifier then
|
|
172
|
-
local ok, result =
|
|
218
|
+
local ok, result = run(body)
|
|
173
219
|
release(acquired)
|
|
174
220
|
if not ok then
|
|
175
221
|
error(result, 0)
|
|
@@ -177,8 +223,8 @@ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T)
|
|
|
177
223
|
return result, false
|
|
178
224
|
end
|
|
179
225
|
|
|
180
|
-
local ok, result =
|
|
181
|
-
|
|
226
|
+
local ok, result = run(body)
|
|
227
|
+
finish(identifier, Enum.FinishRecordingOperation.Commit, acquired)
|
|
182
228
|
release(acquired)
|
|
183
229
|
if not ok then
|
|
184
230
|
error(result, 0)
|
|
@@ -186,4 +232,22 @@ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T)
|
|
|
186
232
|
return result, true
|
|
187
233
|
end
|
|
188
234
|
|
|
235
|
+
--[[
|
|
236
|
+
Holds the mutation lock without opening a recording.
|
|
237
|
+
|
|
238
|
+
For script source, whose undo is the script editor's own, per document --
|
|
239
|
+
a recording would add an empty step, not make the edit undoable. The lock
|
|
240
|
+
is what matters: two batches writing the same scripts must not interleave
|
|
241
|
+
their writes and their rollbacks.
|
|
242
|
+
]]
|
|
243
|
+
function Undo.exclusive<T>(body: () -> T): T
|
|
244
|
+
local acquired = acquire()
|
|
245
|
+
local ok, result = run(body)
|
|
246
|
+
release(acquired)
|
|
247
|
+
if not ok then
|
|
248
|
+
error(result, 0)
|
|
249
|
+
end
|
|
250
|
+
return result
|
|
251
|
+
end
|
|
252
|
+
|
|
189
253
|
return Undo
|