@bitmagic/cli 0.1.71-dev.0 → 0.1.71-dev.3
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 +15 -6
- package/assets/roblox-port/README.md +19 -0
- package/assets/roblox-port/SKILL.md +154 -0
- package/assets/roblox-port/build-studio-update.mjs +41 -0
- package/assets/roblox-port/export-packed-mesh.mjs +166 -0
- package/assets/roblox-port/lib.mjs +193 -0
- package/assets/roblox-port/port-state.mjs +129 -0
- package/assets/roblox-port/references/assets.md +100 -0
- package/assets/roblox-port/references/source-and-runtime.md +68 -0
- package/assets/roblox-port/references/state-contracts.md +129 -0
- package/assets/roblox-port/references/studio-and-updates.md +119 -0
- package/assets/roblox-port/references/verification.md +63 -0
- package/assets/roblox-port/studio-runtime.luau +347 -0
- package/assets/roblox-port/tests/mesh.test.mjs +81 -0
- package/assets/roblox-port/tests/state.test.mjs +148 -0
- package/assets/roblox-port/tests/studio-mock.luau +109 -0
- package/assets/roblox-port/tests/studio-runtime.test.mjs +309 -0
- package/assets/skills/publishing-to-poki/SKILL.md +14 -5
- package/dist/commands/forge.js +10 -2
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.js +12 -0
- package/dist/commands/generate.js.map +1 -1
- package/dist/forge/edit-target.d.ts +3 -0
- package/dist/forge/edit-target.js +14 -2
- package/dist/forge/edit-target.js.map +1 -1
- package/dist/forge/run-pipeline.js +22 -11
- package/dist/forge/run-pipeline.js.map +1 -1
- package/dist/generate/model.d.ts +2 -0
- package/dist/generate/model.js +1 -0
- package/dist/generate/model.js.map +1 -1
- package/dist/generate/prop.d.ts +7 -0
- package/dist/generate/prop.js +2 -0
- package/dist/generate/prop.js.map +1 -1
- package/dist/poki/assets.js +6 -2
- package/dist/poki/assets.js.map +1 -1
- package/dist/poki/check.js +29 -0
- package/dist/poki/check.js.map +1 -1
- package/dist/poki/handoff.js +4 -1
- package/dist/poki/handoff.js.map +1 -1
- package/dist/poki/publish.js +9 -2
- package/dist/poki/publish.js.map +1 -1
- package/dist/poki/service-urls.d.ts +4 -0
- package/dist/poki/service-urls.js +38 -0
- package/dist/poki/service-urls.js.map +1 -0
- package/dist/poki/source-diagnostics.d.ts +3 -0
- package/dist/poki/source-diagnostics.js +32 -3
- package/dist/poki/source-diagnostics.js.map +1 -1
- package/dist/scaffold/project-files.js +1 -1
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +2 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/roblox-skill-files.d.ts +2 -0
- package/dist/scaffold/roblox-skill-files.js +16 -0
- package/dist/scaffold/roblox-skill-files.js.map +1 -0
- package/dist/scaffold/skill-files.js +2 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/tools/bundles.js +6 -0
- package/dist/tools/bundles.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
-- Included by build-studio-update.mjs. The caller must explicitly select the Studio session.
|
|
2
|
+
local Http = game:GetService("HttpService")
|
|
3
|
+
local Run = game:GetService("RunService")
|
|
4
|
+
local Editor = game:GetService("ScriptEditorService")
|
|
5
|
+
local NULL = {}
|
|
6
|
+
local project = request.project
|
|
7
|
+
local function editOnly()
|
|
8
|
+
assert(not Run:IsRunning(), "PLAY_ACTIVE: return to Edit before this operation")
|
|
9
|
+
end
|
|
10
|
+
local function nullable(value)
|
|
11
|
+
if value == nil then return NULL end
|
|
12
|
+
return value
|
|
13
|
+
end
|
|
14
|
+
local function encode(value)
|
|
15
|
+
if value == NULL or value == nil then return "null" end
|
|
16
|
+
if type(value) ~= "table" then return Http:JSONEncode(value) end
|
|
17
|
+
local parts = {}
|
|
18
|
+
if #value > 0 or next(value) == nil then
|
|
19
|
+
for _, child in ipairs(value) do table.insert(parts, encode(child)) end
|
|
20
|
+
return "[" .. table.concat(parts, ",") .. "]"
|
|
21
|
+
end
|
|
22
|
+
for key, child in pairs(value) do table.insert(parts, Http:JSONEncode(key) .. ":" .. encode(child)) end
|
|
23
|
+
return "{" .. table.concat(parts, ",") .. "}"
|
|
24
|
+
end
|
|
25
|
+
local function samePath(a, b)
|
|
26
|
+
if #a ~= #b then return false end
|
|
27
|
+
for i, part in ipairs(a) do if b[i] ~= part then return false end end
|
|
28
|
+
return true
|
|
29
|
+
end
|
|
30
|
+
local function getPath(object)
|
|
31
|
+
local parts = {}
|
|
32
|
+
while object and object ~= game do
|
|
33
|
+
table.insert(parts, 1, object.Name)
|
|
34
|
+
object = object.Parent
|
|
35
|
+
end
|
|
36
|
+
assert(object == game, "Instance is outside the DataModel")
|
|
37
|
+
return parts
|
|
38
|
+
end
|
|
39
|
+
local function childNamed(parent, name)
|
|
40
|
+
local found = nil
|
|
41
|
+
for _, child in ipairs(parent:GetChildren()) do
|
|
42
|
+
if child.Name == name then
|
|
43
|
+
assert(found == nil, "Ambiguous instance name: " .. name)
|
|
44
|
+
found = child
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
return found
|
|
48
|
+
end
|
|
49
|
+
local function atPath(parts)
|
|
50
|
+
local current = game:GetService(parts[1])
|
|
51
|
+
for i = 2, #parts do
|
|
52
|
+
current = childNamed(current, parts[i])
|
|
53
|
+
if not current then return nil end
|
|
54
|
+
end
|
|
55
|
+
return current
|
|
56
|
+
end
|
|
57
|
+
local function editorSource(object)
|
|
58
|
+
-- This reads the open editor buffer too. Lack of permission is a blocker, not a reason to
|
|
59
|
+
-- fall back to potentially stale Source and overwrite unsaved user work.
|
|
60
|
+
local ok, source = pcall(function() return Editor:GetEditorSource(object) end)
|
|
61
|
+
assert(ok and type(source) == "string", "Editor-aware read unavailable: " .. object:GetFullName())
|
|
62
|
+
return source
|
|
63
|
+
end
|
|
64
|
+
local function enabled(object)
|
|
65
|
+
if object:IsA("BaseScript") then return object.Enabled end
|
|
66
|
+
return nil
|
|
67
|
+
end
|
|
68
|
+
local function target()
|
|
69
|
+
return {
|
|
70
|
+
projectId = nullable(game:GetAttribute("BitmagicProjectId")),
|
|
71
|
+
targetId = nullable(game:GetAttribute("BitmagicTargetId")),
|
|
72
|
+
placeId = tostring(game.PlaceId), universeId = tostring(game.GameId),
|
|
73
|
+
creatorId = tostring(game.CreatorId), creatorType = game.CreatorType.Name,
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
local function snapshot()
|
|
77
|
+
editOnly()
|
|
78
|
+
local scripts, selected = {}, {}
|
|
79
|
+
for _, parts in ipairs(request.paths) do
|
|
80
|
+
local object = atPath(parts)
|
|
81
|
+
if object then
|
|
82
|
+
assert(object:IsA("LuaSourceContainer"), "Requested script path occupied by " .. object.ClassName)
|
|
83
|
+
selected[object] = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
for _, object in ipairs(game:GetDescendants()) do
|
|
87
|
+
if object:GetAttribute("BitmagicTargetId") == project.targetId and object:IsA("LuaSourceContainer") then selected[object] = true end
|
|
88
|
+
end
|
|
89
|
+
for object in pairs(selected) do
|
|
90
|
+
table.insert(scripts, {
|
|
91
|
+
id = nullable(object:GetAttribute("BitmagicSourceId")),
|
|
92
|
+
owner = nullable(object:GetAttribute("BitmagicTargetId")),
|
|
93
|
+
runId = nullable(object:GetAttribute("BitmagicRunId")),
|
|
94
|
+
path = getPath(object), className = object.ClassName,
|
|
95
|
+
source = editorSource(object), enabled = nullable(enabled(object)),
|
|
96
|
+
})
|
|
97
|
+
assert(#scripts <= 256, "Managed snapshot exceeds 256 scripts")
|
|
98
|
+
end
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion = 1, mode = "Edit", target = target(), scripts = scripts,
|
|
101
|
+
buildId = nullable(game:GetAttribute("BitmagicBuildId")),
|
|
102
|
+
pendingRun = nullable(game:GetAttribute("BitmagicPendingRun")),
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
local function findId(id)
|
|
106
|
+
local found = nil
|
|
107
|
+
for _, object in ipairs(game:GetDescendants()) do
|
|
108
|
+
if object:GetAttribute("BitmagicTargetId") == project.targetId and object:GetAttribute("BitmagicSourceId") == id then
|
|
109
|
+
assert(found == nil, "Duplicate managed ID: " .. id)
|
|
110
|
+
found = object
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
return found
|
|
114
|
+
end
|
|
115
|
+
local function matches(object, value, ownership)
|
|
116
|
+
if not object or object.ClassName ~= value.className then return false end
|
|
117
|
+
if not samePath(getPath(object), value.path) or editorSource(object) ~= value.source or enabled(object) ~= value.enabled then return false end
|
|
118
|
+
if ownership then return object:GetAttribute("BitmagicSourceId") == value.id and object:GetAttribute("BitmagicTargetId") == project.targetId end
|
|
119
|
+
return object:GetAttribute("BitmagicSourceId") == value.id and object:GetAttribute("BitmagicTargetId") == value.owner and object:GetAttribute("BitmagicRunId") == value.runId
|
|
120
|
+
end
|
|
121
|
+
editOnly()
|
|
122
|
+
if request.action == "inspect" then return encode(snapshot()) end
|
|
123
|
+
local plan = request.plan
|
|
124
|
+
local rollbackPending = "rollback:" .. plan.runId
|
|
125
|
+
local function checkIdentity()
|
|
126
|
+
editOnly()
|
|
127
|
+
local current = target()
|
|
128
|
+
for _, key in ipairs({"placeId", "universeId", "creatorId", "creatorType"}) do
|
|
129
|
+
assert(current[key] == plan.targetBefore[key], "TARGET_MISMATCH: " .. key)
|
|
130
|
+
end
|
|
131
|
+
local pending = game:GetAttribute("BitmagicPendingRun")
|
|
132
|
+
local ownRun = pending == plan.runId or pending == rollbackPending or game:GetAttribute("BitmagicBuildId") == plan.planHash
|
|
133
|
+
local expectedProject = plan.targetBefore.projectId
|
|
134
|
+
local expectedTarget = plan.targetBefore.targetId
|
|
135
|
+
if ownRun then expectedProject = project.projectId; expectedTarget = project.targetId end
|
|
136
|
+
local matchesBinding = game:GetAttribute("BitmagicProjectId") == expectedProject and game:GetAttribute("BitmagicTargetId") == expectedTarget
|
|
137
|
+
-- Recovery can also be interrupted before a first apply, or after restoring the old binding.
|
|
138
|
+
local restoredBinding = pending == rollbackPending and game:GetAttribute("BitmagicProjectId") == plan.targetBefore.projectId and game:GetAttribute("BitmagicTargetId") == plan.targetBefore.targetId
|
|
139
|
+
assert(matchesBinding or restoredBinding, "TARGET_MISMATCH: binding")
|
|
140
|
+
assert(pending == nil or pending == plan.runId or pending == rollbackPending, "Another run is incomplete")
|
|
141
|
+
local build = game:GetAttribute("BitmagicBuildId")
|
|
142
|
+
assert(build == plan.buildBefore or build == plan.planHash, "Build advanced since planning")
|
|
143
|
+
end
|
|
144
|
+
local function currentFor(operation)
|
|
145
|
+
return findId(operation.id) or atPath(operation.before and operation.before.path or operation.after.path)
|
|
146
|
+
end
|
|
147
|
+
local completedAtStart = game:GetAttribute("BitmagicBuildId") == plan.planHash
|
|
148
|
+
local function ownedIntermediate(object, operation, restoring)
|
|
149
|
+
if not object or object:GetAttribute("BitmagicRunId") ~= plan.runId then return false end
|
|
150
|
+
if object:GetAttribute("BitmagicTargetId") ~= project.targetId or object:GetAttribute("BitmagicSourceId") ~= operation.id then return false end
|
|
151
|
+
-- Once committed, old/staged values are later edits, not evidence of an interrupted apply.
|
|
152
|
+
if completedAtStart and not restoring and object:GetAttribute("BitmagicRollbackRun") ~= plan.runId then return matches(object, operation.after, true) end
|
|
153
|
+
local before, after = operation.before, operation.after
|
|
154
|
+
if object.ClassName ~= after.className then return false end
|
|
155
|
+
local source = editorSource(object)
|
|
156
|
+
if source ~= after.source and source ~= (before and before.source or "") then return false end
|
|
157
|
+
if not samePath(getPath(object), after.path) and not (before and samePath(getPath(object), before.path)) then return false end
|
|
158
|
+
local state = enabled(object)
|
|
159
|
+
return state == after.enabled or state == false or (before and state == before.enabled)
|
|
160
|
+
end
|
|
161
|
+
local function setSource(object, source, expected)
|
|
162
|
+
editOnly()
|
|
163
|
+
Editor:UpdateSourceAsync(object, function(current)
|
|
164
|
+
-- The callback can be retried by Studio. It has no other side effects.
|
|
165
|
+
assert(current == expected, "EDIT_CONFLICT: buffer changed during write")
|
|
166
|
+
return source
|
|
167
|
+
end)
|
|
168
|
+
assert(editorSource(object) == source, "Source readback failed")
|
|
169
|
+
end
|
|
170
|
+
local function parentFor(parts)
|
|
171
|
+
local parent = game:GetService(parts[1])
|
|
172
|
+
for i = 2, #parts - 1 do
|
|
173
|
+
local child = childNamed(parent, parts[i])
|
|
174
|
+
if not child then
|
|
175
|
+
child = Instance.new("Folder")
|
|
176
|
+
child.Name = parts[i]
|
|
177
|
+
child:SetAttribute("BitmagicFolderRun", plan.runId)
|
|
178
|
+
local prefix = {}
|
|
179
|
+
for j = 1, i do table.insert(prefix, parts[j]) end
|
|
180
|
+
child:SetAttribute("BitmagicFolderPath", encode(prefix))
|
|
181
|
+
child.Parent = parent
|
|
182
|
+
end
|
|
183
|
+
assert(child:IsA("Folder") or child:IsA("Model") or child.ClassName == "StarterPlayerScripts" or child.ClassName == "StarterCharacterScripts", "Unsupported source container: " .. child.ClassName)
|
|
184
|
+
parent = child
|
|
185
|
+
end
|
|
186
|
+
return parent
|
|
187
|
+
end
|
|
188
|
+
local function assertPristineCreated(object, attributes)
|
|
189
|
+
assert(#object:GetChildren() == 0, "New instance has children; preserve and review")
|
|
190
|
+
for key, value in pairs(object:GetAttributes()) do
|
|
191
|
+
assert(attributes[key] == value, "Creator attribute preserved: " .. key)
|
|
192
|
+
end
|
|
193
|
+
for key, value in pairs(attributes) do assert(object:GetAttribute(key) == value, "Creation metadata changed: " .. key) end
|
|
194
|
+
assert(#object:GetTags() == 0, "Creator tags preserved")
|
|
195
|
+
-- Compare native properties that the source updater never owns to an unparented default.
|
|
196
|
+
-- If a property cannot be read, preserve the instance and report the error, never delete it.
|
|
197
|
+
local pristine = Instance.new(object.ClassName)
|
|
198
|
+
local ok, why = pcall(function()
|
|
199
|
+
assert(object.Archivable == pristine.Archivable, "Creator Archivable setting preserved")
|
|
200
|
+
if object:IsA("BaseScript") then assert(object.RunContext == pristine.RunContext, "Creator RunContext preserved") end
|
|
201
|
+
if object:IsA("LuaSourceContainer") then assert(object.LinkedSource == pristine.LinkedSource, "Creator LinkedSource preserved") end
|
|
202
|
+
end)
|
|
203
|
+
pristine:Destroy()
|
|
204
|
+
assert(ok, tostring(why))
|
|
205
|
+
end
|
|
206
|
+
checkIdentity()
|
|
207
|
+
if request.action == "rollback" then
|
|
208
|
+
-- Set direction before any restoration. New plans and forward replay must not adopt a
|
|
209
|
+
-- partially reverted build, including after cancellation or a conflict on a later script.
|
|
210
|
+
game:SetAttribute("BitmagicPendingRun", rollbackPending)
|
|
211
|
+
local failures = {}
|
|
212
|
+
for i = #plan.operations, 1, -1 do
|
|
213
|
+
local operation = plan.operations[i]
|
|
214
|
+
local ok, why = pcall(function()
|
|
215
|
+
editOnly()
|
|
216
|
+
local object = currentFor(operation)
|
|
217
|
+
if operation.before and object and matches(object, operation.before, false) then
|
|
218
|
+
if object:GetAttribute("BitmagicRollbackRun") == plan.runId then object:SetAttribute("BitmagicRollbackRun", nil) end
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
if not object and not operation.before then return end
|
|
222
|
+
assert(ownedIntermediate(object, operation), "Later edit preserved: " .. operation.id)
|
|
223
|
+
local before = operation.before
|
|
224
|
+
if before then
|
|
225
|
+
local occupant = atPath(before.path)
|
|
226
|
+
assert(not occupant or occupant == object, "Rollback destination occupied")
|
|
227
|
+
local currentSource = editorSource(object)
|
|
228
|
+
assert(currentSource == before.source or currentSource == operation.after.source, "Later source edit preserved")
|
|
229
|
+
local recovering = object:GetAttribute("BitmagicRollbackRun")
|
|
230
|
+
assert(recovering == nil or recovering == plan.runId, "Another rollback owns this script")
|
|
231
|
+
-- Persist intent before the yielding source write so the same rollback can
|
|
232
|
+
-- resume a completed write without relaxing guards on untouched scripts.
|
|
233
|
+
object:SetAttribute("BitmagicRollbackRun", plan.runId)
|
|
234
|
+
setSource(object, before.source, currentSource)
|
|
235
|
+
assert(ownedIntermediate(object, operation, true), "Later instance edit preserved")
|
|
236
|
+
occupant = atPath(before.path)
|
|
237
|
+
assert(not occupant or occupant == object, "Rollback destination occupied during source write")
|
|
238
|
+
object.Parent = parentFor(before.path); object.Name = before.path[#before.path]
|
|
239
|
+
if object:IsA("BaseScript") then object.Enabled = before.enabled end
|
|
240
|
+
object:SetAttribute("BitmagicSourceId", before.id)
|
|
241
|
+
object:SetAttribute("BitmagicTargetId", before.owner)
|
|
242
|
+
object:SetAttribute("BitmagicRunId", before.runId)
|
|
243
|
+
object:SetAttribute("BitmagicRollbackRun", nil)
|
|
244
|
+
else
|
|
245
|
+
assertPristineCreated(object, { BitmagicSourceId = operation.id, BitmagicTargetId = project.targetId, BitmagicRunId = plan.runId })
|
|
246
|
+
object:Destroy()
|
|
247
|
+
end
|
|
248
|
+
end)
|
|
249
|
+
if not ok then table.insert(failures, tostring(why)) end
|
|
250
|
+
end
|
|
251
|
+
-- Only unchanged, empty folders created by this run can be removed, deepest first.
|
|
252
|
+
local descendants = game:GetDescendants()
|
|
253
|
+
table.sort(descendants, function(a, b) return #getPath(a) > #getPath(b) end)
|
|
254
|
+
for _, object in ipairs(descendants) do
|
|
255
|
+
if object:IsA("Folder") and object:GetAttribute("BitmagicFolderRun") == plan.runId and #object:GetChildren() == 0 then
|
|
256
|
+
local ok, why = pcall(function()
|
|
257
|
+
editOnly()
|
|
258
|
+
assertPristineCreated(object, { BitmagicFolderRun = plan.runId, BitmagicFolderPath = encode(getPath(object)) })
|
|
259
|
+
object:Destroy()
|
|
260
|
+
end)
|
|
261
|
+
if not ok then table.insert(failures, tostring(why)) end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
if #failures == 0 then
|
|
265
|
+
game:SetAttribute("BitmagicProjectId", plan.targetBefore.projectId)
|
|
266
|
+
game:SetAttribute("BitmagicTargetId", plan.targetBefore.targetId)
|
|
267
|
+
game:SetAttribute("BitmagicBuildId", plan.buildBefore)
|
|
268
|
+
game:SetAttribute("BitmagicPendingRun", nil)
|
|
269
|
+
end
|
|
270
|
+
return encode({ schemaVersion = 1, planHash = plan.planHash, stage = #failures == 0 and "rolled-back" or "rollback-incomplete", failures = failures, snapshot = snapshot() })
|
|
271
|
+
end
|
|
272
|
+
assert(request.action == "apply", "Unknown action")
|
|
273
|
+
assert(game:GetAttribute("BitmagicPendingRun") ~= rollbackPending, "ROLLBACK_PENDING: finish rollback before applying")
|
|
274
|
+
-- All known preconditions are checked before binding or writing. The editor callback rechecks
|
|
275
|
+
-- buffers during writes. Studio offers no whole-place transaction; the durable plan is recovery.
|
|
276
|
+
for _, operation in ipairs(plan.operations) do
|
|
277
|
+
local object = currentFor(operation)
|
|
278
|
+
if operation.before then
|
|
279
|
+
assert((object and matches(object, operation.before, false)) or ownedIntermediate(object, operation), "EDIT_CONFLICT: " .. operation.id)
|
|
280
|
+
else
|
|
281
|
+
assert(not object or ownedIntermediate(object, operation), "New path occupied: " .. operation.id)
|
|
282
|
+
end
|
|
283
|
+
local occupant = atPath(operation.after.path)
|
|
284
|
+
assert(not occupant or occupant == object, "Destination occupied: " .. operation.id)
|
|
285
|
+
end
|
|
286
|
+
local changed = {}
|
|
287
|
+
for _, operation in ipairs(plan.operations) do changed[operation.id] = true end
|
|
288
|
+
local expectedIds = {}
|
|
289
|
+
for _, expected in ipairs(plan.desired) do expectedIds[expected.id] = true end
|
|
290
|
+
for _, object in ipairs(game:GetDescendants()) do
|
|
291
|
+
if object:IsA("LuaSourceContainer") and object:GetAttribute("BitmagicTargetId") == project.targetId then
|
|
292
|
+
assert(expectedIds[object:GetAttribute("BitmagicSourceId")], "Managed script set changed since planning")
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
for _, expected in ipairs(plan.desired) do
|
|
296
|
+
if not changed[expected.id] then assert(matches(findId(expected.id), expected, true), "Unchanged dependency edited: " .. expected.id) end
|
|
297
|
+
end
|
|
298
|
+
if completedAtStart then
|
|
299
|
+
for _, expected in ipairs(plan.desired) do assert(matches(findId(expected.id), expected, true), "Committed build was edited: " .. expected.id) end
|
|
300
|
+
-- A prior apply may have been interrupted after committing the build but before clearing
|
|
301
|
+
-- its pending marker. The rollback-direction guard above prevents clearing a rollback here.
|
|
302
|
+
game:SetAttribute("BitmagicPendingRun", nil)
|
|
303
|
+
return encode(snapshot())
|
|
304
|
+
end
|
|
305
|
+
game:SetAttribute("BitmagicProjectId", project.projectId)
|
|
306
|
+
game:SetAttribute("BitmagicTargetId", project.targetId)
|
|
307
|
+
game:SetAttribute("BitmagicPendingRun", plan.runId)
|
|
308
|
+
for _, operation in ipairs(plan.operations) do
|
|
309
|
+
editOnly()
|
|
310
|
+
local after = operation.after
|
|
311
|
+
local object = currentFor(operation)
|
|
312
|
+
if not object then
|
|
313
|
+
local parent = parentFor(after.path)
|
|
314
|
+
object = Instance.new(after.className)
|
|
315
|
+
if object:IsA("BaseScript") then object.Enabled = false end
|
|
316
|
+
object.Name = after.path[#after.path]
|
|
317
|
+
object:SetAttribute("BitmagicSourceId", after.id)
|
|
318
|
+
object:SetAttribute("BitmagicTargetId", project.targetId)
|
|
319
|
+
object:SetAttribute("BitmagicRunId", plan.runId)
|
|
320
|
+
object.Parent = parent
|
|
321
|
+
else
|
|
322
|
+
assert((operation.before and matches(object, operation.before, false)) or ownedIntermediate(object, operation), "Edit changed before apply")
|
|
323
|
+
object:SetAttribute("BitmagicSourceId", after.id)
|
|
324
|
+
object:SetAttribute("BitmagicTargetId", project.targetId)
|
|
325
|
+
object:SetAttribute("BitmagicRunId", plan.runId)
|
|
326
|
+
if object:IsA("BaseScript") then object.Enabled = false end
|
|
327
|
+
end
|
|
328
|
+
local currentSource = editorSource(object)
|
|
329
|
+
assert(currentSource == after.source or currentSource == (operation.before and operation.before.source or ""), "EDIT_CONFLICT before source write")
|
|
330
|
+
setSource(object, after.source, currentSource)
|
|
331
|
+
assert(ownedIntermediate(object, operation), "EDIT_CONFLICT before moving script")
|
|
332
|
+
local occupant = atPath(after.path)
|
|
333
|
+
assert(not occupant or occupant == object, "Destination occupied before move")
|
|
334
|
+
object.Parent = parentFor(after.path); object.Name = after.path[#after.path]
|
|
335
|
+
end
|
|
336
|
+
-- Activate after the full source set exists. Fresh Play is still required for runtime verification.
|
|
337
|
+
for _, operation in ipairs(plan.operations) do
|
|
338
|
+
editOnly()
|
|
339
|
+
local object = findId(operation.id)
|
|
340
|
+
assert(object and editorSource(object) == operation.after.source, "Source changed before activation")
|
|
341
|
+
if object:IsA("BaseScript") then object.Enabled = operation.after.enabled end
|
|
342
|
+
end
|
|
343
|
+
for _, expected in ipairs(plan.desired) do assert(matches(findId(expected.id), expected, true), "Final readback differs: " .. expected.id) end
|
|
344
|
+
editOnly()
|
|
345
|
+
game:SetAttribute("BitmagicBuildId", plan.planHash)
|
|
346
|
+
game:SetAttribute("BitmagicPendingRun", nil)
|
|
347
|
+
return encode(snapshot())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { exportKit } from '../export-packed-mesh.mjs';
|
|
4
|
+
|
|
5
|
+
function pack(values, kind = 'f32') {
|
|
6
|
+
const widths = { f32: 4, i16: 2, u16: 2, i8: 1, u8: 1 };
|
|
7
|
+
const methods = { f32: 'writeFloatLE', i16: 'writeInt16LE', u16: 'writeUInt16LE', i8: 'writeInt8', u8: 'writeUInt8' };
|
|
8
|
+
const buffer = Buffer.alloc(values.length * widths[kind]);
|
|
9
|
+
values.forEach((v, i) => buffer[methods[kind]](v, i * widths[kind])); return buffer.toString('base64');
|
|
10
|
+
}
|
|
11
|
+
function fixture(version = 1) {
|
|
12
|
+
const mesh = version === 1 ? { position: pack([0,0,0, 1,0,0, 0,1,0]), normal: pack([0,0,1, 0,0,1, 0,0,1]), color: pack([1,0,0, 0,1,0, 0,0,1]), triangles: 1, height: 1, muzzleHeight: 0.5 } : { position: pack([0,0,0, 2,0,0, 0,2,0], 'i16'), normal: pack([0,0,127, 0,0,127, 0,0,127], 'i8'), color: pack([255,0,0, 0,255,0, 0,0,255], 'u8'), index: pack([0,1,2], 'u16'), quantum: 0.5, groups: [[0,3,0]], pivot: [1,2,3], triangles: 1 };
|
|
13
|
+
const kit = { version, meshes: { body: mesh } };
|
|
14
|
+
const recipe = { schemaVersion: 1, format: `bitmagic-packed-v${version}`, meshIds: ['body'], basis: [1,0,0, 0,1,0, 0,0,1], scale: 2, materials: [{ pbrMetallicRoughness: { baseColorFactor: [1,1,1,1], metallicFactor: 0, roughnessFactor: 0.8 } }] };
|
|
15
|
+
return { kit, recipe };
|
|
16
|
+
}
|
|
17
|
+
function parse(glb) {
|
|
18
|
+
assert.equal(glb.readUInt32LE(0), 0x46546c67); assert.equal(glb.readUInt32LE(4), 2); assert.equal(glb.readUInt32LE(8), glb.length);
|
|
19
|
+
const length = glb.readUInt32LE(12); assert.equal(glb.readUInt32LE(16), 0x4e4f534a);
|
|
20
|
+
const json = JSON.parse(glb.subarray(20, 20 + length).toString());
|
|
21
|
+
assert.equal(glb.readUInt32LE(24 + length), 0x004e4942);
|
|
22
|
+
const binary = glb.subarray(28 + length);
|
|
23
|
+
assert.equal(binary.length, json.buffers[0].byteLength);
|
|
24
|
+
function values(id) {
|
|
25
|
+
const a = json.accessors[id], view = json.bufferViews[a.bufferView];
|
|
26
|
+
const bytes = binary.subarray(view.byteOffset, view.byteOffset + view.byteLength), result = [];
|
|
27
|
+
for (let i = 0; i < bytes.length; i += 4) result.push(a.componentType === 5125 ? bytes.readUInt32LE(i) : bytes.readFloatLE(i));
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
return { json, values };
|
|
31
|
+
}
|
|
32
|
+
for (const version of [1, 2]) test(`layout v${version}: deterministic GLB preserves triangles, colors and pivots`, () => {
|
|
33
|
+
const { kit, recipe } = fixture(version), before = JSON.stringify(kit);
|
|
34
|
+
const a = exportKit(kit, recipe), b = exportKit(kit, recipe);
|
|
35
|
+
assert.deepEqual(a.glb, b.glb); assert.equal(JSON.stringify(kit), before);
|
|
36
|
+
const { json, values } = parse(a.glb), primitive = json.meshes[0].primitives[0];
|
|
37
|
+
assert.deepEqual(values(primitive.attributes.POSITION), [0,0,0, 2,0,0, 0,2,0]);
|
|
38
|
+
assert.deepEqual(values(primitive.attributes.COLOR_0), [1,0,0, 0,1,0, 0,0,1]);
|
|
39
|
+
assert.deepEqual(values(primitive.indices), [0,1,2]);
|
|
40
|
+
assert.deepEqual(json.nodes[0].translation, version === 2 ? [2,4,6] : [0,0,0]);
|
|
41
|
+
assert.equal(a.report.studioValidated, false);
|
|
42
|
+
if (version === 1) assert.equal(json.nodes[0].extras.muzzleHeight, 1);
|
|
43
|
+
});
|
|
44
|
+
test('reflected basis reverses winding and transforms normals', () => {
|
|
45
|
+
const { kit, recipe } = fixture(); recipe.basis = [-1,0,0, 0,1,0, 0,0,1];
|
|
46
|
+
const { json, values } = parse(exportKit(kit, recipe).glb), primitive = json.meshes[0].primitives[0];
|
|
47
|
+
assert.deepEqual(values(primitive.indices), [0,2,1]);
|
|
48
|
+
assert.equal(values(primitive.attributes.POSITION)[3], -2);
|
|
49
|
+
assert.equal(values(primitive.attributes.NORMAL)[2], 1);
|
|
50
|
+
});
|
|
51
|
+
test('zero-area faces require explicit cleanup and retain material groups and real geometry', () => {
|
|
52
|
+
const { kit, recipe } = fixture(2), mesh = kit.meshes.body;
|
|
53
|
+
mesh.position = pack([0,0,0, 2,0,0, 0,2,0, 0,0,0], 'i16');
|
|
54
|
+
mesh.normal = pack([0,0,127, 0,0,127, 0,0,127, 0,0,0], 'i8');
|
|
55
|
+
mesh.color = pack([255,0,0, 0,255,0, 0,0,255, 255,255,255], 'u8');
|
|
56
|
+
mesh.index = pack([3,3,3, 0,1,2], 'u16'); mesh.triangles = 2;
|
|
57
|
+
mesh.groups = [[0,3,0], [3,3,1]];
|
|
58
|
+
recipe.materials.push(structuredClone(recipe.materials[0]));
|
|
59
|
+
assert.throws(() => exportKit(kit, recipe), /Zero-area/);
|
|
60
|
+
recipe.degenerateTriangles = 'drop';
|
|
61
|
+
const { glb, report } = exportKit(kit, recipe), { json, values } = parse(glb);
|
|
62
|
+
assert.deepEqual(report.meshes.map(m => [m.sourceTriangles, m.triangles, m.droppedTriangles, m.vertices]), [[2,1,1,3]]);
|
|
63
|
+
assert.equal(json.meshes[0].primitives.length, 1);
|
|
64
|
+
assert.equal(json.meshes[0].primitives[0].material, 1);
|
|
65
|
+
assert.deepEqual(values(json.meshes[0].primitives[0].indices), [0,1,2]);
|
|
66
|
+
mesh.normal = pack([0,0,0, 0,0,127, 0,0,127, 0,0,0], 'i8');
|
|
67
|
+
assert.throws(() => exportKit(kit, recipe), /retained surface/);
|
|
68
|
+
});
|
|
69
|
+
test('malformed/unknown data and unmapped materials fail instead of losing content', () => {
|
|
70
|
+
for (const mutate of [
|
|
71
|
+
f => { f.kit.version = 3; },
|
|
72
|
+
f => { f.kit.meshes.body.uv = 'unsupported'; },
|
|
73
|
+
f => { f.kit.meshes.body.position = '???'; },
|
|
74
|
+
f => { f.kit.meshes.body.triangles = 99; },
|
|
75
|
+
f => { f.kit.meshes.body.groups = [[0,3,2]]; },
|
|
76
|
+
f => { f.kit.meshes.body.groups = [[3,3,0]]; },
|
|
77
|
+
f => { f.kit.meshes.body.index = pack([0,1,5], 'u16'); },
|
|
78
|
+
f => { f.recipe.basis[0] = 2; },
|
|
79
|
+
f => { f.recipe.meshIds = ['unknown']; },
|
|
80
|
+
]) { const f = fixture(2); mutate(f); assert.throws(() => exportKit(f.kit, f.recipe)); }
|
|
81
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { init, prepare, accept, acceptRollback, recordSave, status } from '../port-state.mjs';
|
|
7
|
+
import { loadProject, readJson, writeJson, readBase, materialize, reconcile } from '../lib.mjs';
|
|
8
|
+
import { build, longString } from '../build-studio-update.mjs';
|
|
9
|
+
|
|
10
|
+
function fixture(t) {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'roblox-state-'));
|
|
12
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
13
|
+
const source = path.join(dir, 'source'), root = path.join(dir, 'port');
|
|
14
|
+
fs.mkdirSync(source); writeJson(path.join(source, 'bitmagic.json'), { gameId: 'fixture', engineVersion: 'v-test', environment: 'dev' });
|
|
15
|
+
fs.writeFileSync(path.join(source, 'Game.ts'), 'export const damage = 10;\n');
|
|
16
|
+
init(source, root);
|
|
17
|
+
fs.writeFileSync(path.join(root, 'generated/shared/Rules.luau'), 'return { damage = 10 }\n');
|
|
18
|
+
writeJson(path.join(root, 'incoming.json'), { schemaVersion: 1, sourceFiles: ['bitmagic.json', 'Game.ts'], scripts: [{ id: 'rules', path: ['ReplicatedStorage', 'Port', 'Rules'], className: 'ModuleScript', file: 'generated/shared/Rules.luau' }] });
|
|
19
|
+
const project = loadProject(root);
|
|
20
|
+
const live = { schemaVersion: 1, mode: 'Edit', target: { projectId: null, targetId: null, placeId: '0', universeId: '0', creatorId: '42', creatorType: 'User' }, buildId: null, pendingRun: null, scripts: [] };
|
|
21
|
+
return { dir, root, source, project, live, run: path.join(root, '.bitmagic/roblox/runs/first') };
|
|
22
|
+
}
|
|
23
|
+
function applied(plan) {
|
|
24
|
+
return { schemaVersion: 1, mode: 'Edit', target: { ...plan.targetBefore, projectId: plan.project.projectId, targetId: plan.project.targetId }, buildId: plan.planHash, pendingRun: null, scripts: plan.desired.map(s => ({ ...s, owner: plan.project.targetId, runId: plan.runId })) };
|
|
25
|
+
}
|
|
26
|
+
test('separate workspace, source freshness, exact readback and unchanged rerun', t => {
|
|
27
|
+
const f = fixture(t), sourceBefore = fs.readFileSync(path.join(f.source, 'Game.ts'));
|
|
28
|
+
assert.throws(() => init(f.source, path.join(f.source, 'port')), /outside/);
|
|
29
|
+
assert.throws(() => init(f.source, f.root), /exists/);
|
|
30
|
+
assert.throws(() => prepare(f.root, f.live, f.run), /TARGET_UNBOUND/);
|
|
31
|
+
const plan = prepare(f.root, f.live, f.run, { bind: true });
|
|
32
|
+
assert.equal(plan.operations.length, 1); assert.equal(plan.conflicts.length, 0);
|
|
33
|
+
const live = applied(plan);
|
|
34
|
+
const bad = structuredClone(live); bad.scripts[0].source = 'wrong';
|
|
35
|
+
assert.throws(() => accept(f.root, f.run, bad), /READBACK_MISMATCH/);
|
|
36
|
+
assert.equal(readBase(f.root), null);
|
|
37
|
+
assert.equal(accept(f.root, f.run, live).verified, false);
|
|
38
|
+
const next = prepare(f.root, live, path.join(f.dir, 'second'));
|
|
39
|
+
assert.deepEqual(next.operations, []);
|
|
40
|
+
assert.deepEqual(next.conflicts, []);
|
|
41
|
+
assert.deepEqual(fs.readFileSync(path.join(f.source, 'Game.ts')), sourceBefore);
|
|
42
|
+
});
|
|
43
|
+
test('kept live edits stay overrides on subsequent exports; divergent edits conflict', t => {
|
|
44
|
+
const f = fixture(t), first = prepare(f.root, f.live, f.run, { bind: true });
|
|
45
|
+
const live = applied(first); accept(f.root, f.run, live);
|
|
46
|
+
live.scripts[0].source = 'return { damage = 15 }\n';
|
|
47
|
+
const secondDir = path.join(f.dir, 'second'), second = prepare(f.root, live, secondDir);
|
|
48
|
+
assert.equal(second.desired[0].source, live.scripts[0].source);
|
|
49
|
+
accept(f.root, secondDir, applied(second));
|
|
50
|
+
assert.equal(readBase(f.root).generated[0].source, first.generated[0].source);
|
|
51
|
+
assert.equal(readBase(f.root).applied[0].source, live.scripts[0].source);
|
|
52
|
+
const unchanged = prepare(f.root, applied(second), path.join(f.dir, 'third'));
|
|
53
|
+
assert.equal(unchanged.operations.length, 0);
|
|
54
|
+
fs.writeFileSync(path.join(f.root, 'generated/shared/Rules.luau'), 'return { damage = 12 }\n');
|
|
55
|
+
const changed = prepare(f.root, applied(second), path.join(f.dir, 'fourth'));
|
|
56
|
+
assert.match(changed.conflicts[0].reason, /EDIT_CONFLICT: source/);
|
|
57
|
+
assert.throws(() => build('apply', { root: f.root, run: path.join(f.dir, 'fourth') }), /EDIT_CONFLICT/);
|
|
58
|
+
});
|
|
59
|
+
test('independent rename and source changes merge per field', t => {
|
|
60
|
+
const f = fixture(t), first = prepare(f.root, f.live, f.run, { bind: true });
|
|
61
|
+
const live = applied(first); accept(f.root, f.run, live);
|
|
62
|
+
live.scripts[0].path = ['ReplicatedStorage', "Creator's Folder", 'Combat Rules — sääntö'];
|
|
63
|
+
fs.writeFileSync(path.join(f.root, 'generated/shared/Rules.luau'), 'return { damage = 12 }\n');
|
|
64
|
+
const next = prepare(f.root, live, path.join(f.dir, 'second'));
|
|
65
|
+
assert.equal(next.conflicts.length, 0);
|
|
66
|
+
assert.deepEqual(next.desired[0].path, live.scripts[0].path);
|
|
67
|
+
assert.match(next.desired[0].source, /12/);
|
|
68
|
+
});
|
|
69
|
+
test('unowned scripts require deliberate exact adoption', t => {
|
|
70
|
+
const f = fixture(t), incoming = materialize(f.root, f.project).scripts;
|
|
71
|
+
f.live.scripts.push({ ...incoming[0], id: null, owner: null, runId: null });
|
|
72
|
+
assert.match(reconcile(f.project, incoming, null, f.live).conflicts[0].reason, /Unowned/);
|
|
73
|
+
assert.equal(reconcile(f.project, incoming, null, f.live, true).conflicts.length, 0);
|
|
74
|
+
f.live.scripts[0].source = 'creator code';
|
|
75
|
+
assert.match(reconcile(f.project, incoming, null, f.live, true).conflicts[0].reason, /exact matching/);
|
|
76
|
+
});
|
|
77
|
+
test('wrong target, pending run, source drift and changed plans cannot be applied', t => {
|
|
78
|
+
const f = fixture(t);
|
|
79
|
+
const wrong = structuredClone(f.live); wrong.target.targetId = 'other';
|
|
80
|
+
assert.throws(() => prepare(f.root, wrong, f.run, { bind: true }), /TARGET_MISMATCH/);
|
|
81
|
+
const pending = structuredClone(f.live); pending.pendingRun = 'interrupted';
|
|
82
|
+
assert.throws(() => prepare(f.root, pending, f.run, { bind: true }), /PARTIAL_APPLY/);
|
|
83
|
+
const plan = prepare(f.root, f.live, f.run, { bind: true });
|
|
84
|
+
fs.writeFileSync(path.join(f.source, 'Game.ts'), 'export const damage = 12;');
|
|
85
|
+
assert.throws(() => build('apply', { root: f.root, run: f.run }), /SOURCE_CHANGED/);
|
|
86
|
+
assert.ok(build('rollback', { root: f.root, run: f.run }).includes('rollback'));
|
|
87
|
+
plan.desired[0].source = 'tampered'; writeJson(path.join(f.run, 'plan.json'), plan);
|
|
88
|
+
assert.throws(() => build('rollback', { root: f.root, run: f.run }), /hash/);
|
|
89
|
+
});
|
|
90
|
+
test('deletions, duplicate IDs and escaping paths are refused', t => {
|
|
91
|
+
const f = fixture(t), first = prepare(f.root, f.live, f.run, { bind: true });
|
|
92
|
+
const live = applied(first); accept(f.root, f.run, live);
|
|
93
|
+
live.scripts = [];
|
|
94
|
+
assert.match(prepare(f.root, live, path.join(f.dir, 'missing')).conflicts[0].reason, /missing/);
|
|
95
|
+
const base = readBase(f.root);
|
|
96
|
+
assert.match(reconcile(f.project, [], base, applied(first)).conflicts[0].reason, /retirement/);
|
|
97
|
+
const file = path.join(f.root, 'incoming.json'), incoming = readJson(file);
|
|
98
|
+
incoming.scripts.push(incoming.scripts[0]); writeJson(file, incoming);
|
|
99
|
+
assert.throws(() => materialize(f.root, f.project), /Duplicate/);
|
|
100
|
+
incoming.scripts.pop(); incoming.scripts[0].file = '../source/Game.ts'; writeJson(file, incoming);
|
|
101
|
+
assert.throws(() => materialize(f.root, f.project), /traversal/);
|
|
102
|
+
fs.symlinkSync(path.join(f.source, 'Game.ts'), path.join(f.root, 'escape.luau'));
|
|
103
|
+
incoming.scripts[0].file = 'escape.luau'; writeJson(file, incoming);
|
|
104
|
+
assert.throws(() => materialize(f.root, f.project), /escapes/);
|
|
105
|
+
});
|
|
106
|
+
test('recovery inspection uses the durable plan after conversion inputs disappear', t => {
|
|
107
|
+
const f = fixture(t), plan = prepare(f.root, f.live, f.run, { bind: true });
|
|
108
|
+
fs.unlinkSync(path.join(f.root, 'generated/shared/Rules.luau'));
|
|
109
|
+
fs.writeFileSync(path.join(f.root, 'incoming.json'), 'incomplete manifest');
|
|
110
|
+
fs.rmSync(f.source, { recursive: true });
|
|
111
|
+
const code = build('inspect', { root: f.root, run: f.run });
|
|
112
|
+
const json = code.match(/JSONDecode\(\[=\[([^]*?)\]=\]\)/)[1];
|
|
113
|
+
const request = JSON.parse(json);
|
|
114
|
+
assert.equal(request.action, 'inspect');
|
|
115
|
+
assert.deepEqual(request.project, plan.project);
|
|
116
|
+
assert.ok(request.paths.some(p => JSON.stringify(p) === JSON.stringify(plan.generated[0].path)));
|
|
117
|
+
plan.generated[0].source = 'tampered'; writeJson(path.join(f.run, 'plan.json'), plan);
|
|
118
|
+
assert.throws(() => build('inspect', { root: f.root, run: f.run }), /hash/);
|
|
119
|
+
});
|
|
120
|
+
test('rollback acceptance restores the exact durable baseline and preserves later builds', t => {
|
|
121
|
+
const f = fixture(t), plan = prepare(f.root, f.live, f.run, { bind: true });
|
|
122
|
+
accept(f.root, f.run, applied(plan));
|
|
123
|
+
const receipt = { stage: 'rolled-back', planHash: plan.planHash, failures: [], snapshot: f.live };
|
|
124
|
+
assert.equal(acceptRollback(f.root, f.run, receipt).buildId, null);
|
|
125
|
+
assert.equal(readBase(f.root), null);
|
|
126
|
+
const partial = { ...receipt, failures: ['human edit'], stage: 'rollback-incomplete' };
|
|
127
|
+
assert.throws(() => acceptRollback(f.root, f.run, partial), /incomplete/);
|
|
128
|
+
writeJson(path.join(f.root, 'state/base.json'), { planHash: 'newer' });
|
|
129
|
+
assert.throws(() => acceptRollback(f.root, f.run, receipt), /later baseline/);
|
|
130
|
+
});
|
|
131
|
+
test('save evidence is separate from apply and becomes stale after local changes', t => {
|
|
132
|
+
const f = fixture(t), plan = prepare(f.root, f.live, f.run, { bind: true });
|
|
133
|
+
accept(f.root, f.run, applied(plan));
|
|
134
|
+
assert.equal(status(f.root).saveStatus, 'pending');
|
|
135
|
+
assert.throws(() => recordSave(f.root, { buildId: plan.planHash, status: 'saved' }), /destination/);
|
|
136
|
+
const receipt = { buildId: plan.planHash, status: 'saved', destination: '/test/port.rbxl', evidence: 'test fixture receipt', published: false };
|
|
137
|
+
recordSave(f.root, receipt); assert.equal(status(f.root).saveStatus, 'saved');
|
|
138
|
+
assert.throws(() => recordSave(f.root, { ...receipt, published: true }), /non-publishing/);
|
|
139
|
+
fs.writeFileSync(path.join(f.source, 'Game.ts'), 'changed');
|
|
140
|
+
assert.equal(status(f.root).saveStatus, 'pending');
|
|
141
|
+
});
|
|
142
|
+
test('Luau long-string delimiter cannot be escaped by source content', () => {
|
|
143
|
+
const value = 'return "]]=] ]=] ]==]"\n-- arbitrary source';
|
|
144
|
+
const literal = longString(value), match = /^\[(=+)\[/.exec(literal);
|
|
145
|
+
assert.ok(match); const closing = `]${match[1]}]`;
|
|
146
|
+
assert.ok(!value.includes(closing));
|
|
147
|
+
assert.equal(literal.slice(match[0].length, -closing.length), value);
|
|
148
|
+
});
|