@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.
Files changed (59) hide show
  1. package/README.md +15 -6
  2. package/assets/roblox-port/README.md +19 -0
  3. package/assets/roblox-port/SKILL.md +154 -0
  4. package/assets/roblox-port/build-studio-update.mjs +41 -0
  5. package/assets/roblox-port/export-packed-mesh.mjs +166 -0
  6. package/assets/roblox-port/lib.mjs +193 -0
  7. package/assets/roblox-port/port-state.mjs +129 -0
  8. package/assets/roblox-port/references/assets.md +100 -0
  9. package/assets/roblox-port/references/source-and-runtime.md +68 -0
  10. package/assets/roblox-port/references/state-contracts.md +129 -0
  11. package/assets/roblox-port/references/studio-and-updates.md +119 -0
  12. package/assets/roblox-port/references/verification.md +63 -0
  13. package/assets/roblox-port/studio-runtime.luau +347 -0
  14. package/assets/roblox-port/tests/mesh.test.mjs +81 -0
  15. package/assets/roblox-port/tests/state.test.mjs +148 -0
  16. package/assets/roblox-port/tests/studio-mock.luau +109 -0
  17. package/assets/roblox-port/tests/studio-runtime.test.mjs +309 -0
  18. package/assets/skills/publishing-to-poki/SKILL.md +14 -5
  19. package/dist/commands/forge.js +10 -2
  20. package/dist/commands/forge.js.map +1 -1
  21. package/dist/commands/generate.js +12 -0
  22. package/dist/commands/generate.js.map +1 -1
  23. package/dist/forge/edit-target.d.ts +3 -0
  24. package/dist/forge/edit-target.js +14 -2
  25. package/dist/forge/edit-target.js.map +1 -1
  26. package/dist/forge/run-pipeline.js +22 -11
  27. package/dist/forge/run-pipeline.js.map +1 -1
  28. package/dist/generate/model.d.ts +2 -0
  29. package/dist/generate/model.js +1 -0
  30. package/dist/generate/model.js.map +1 -1
  31. package/dist/generate/prop.d.ts +7 -0
  32. package/dist/generate/prop.js +2 -0
  33. package/dist/generate/prop.js.map +1 -1
  34. package/dist/poki/assets.js +6 -2
  35. package/dist/poki/assets.js.map +1 -1
  36. package/dist/poki/check.js +29 -0
  37. package/dist/poki/check.js.map +1 -1
  38. package/dist/poki/handoff.js +4 -1
  39. package/dist/poki/handoff.js.map +1 -1
  40. package/dist/poki/publish.js +9 -2
  41. package/dist/poki/publish.js.map +1 -1
  42. package/dist/poki/service-urls.d.ts +4 -0
  43. package/dist/poki/service-urls.js +38 -0
  44. package/dist/poki/service-urls.js.map +1 -0
  45. package/dist/poki/source-diagnostics.d.ts +3 -0
  46. package/dist/poki/source-diagnostics.js +32 -3
  47. package/dist/poki/source-diagnostics.js.map +1 -1
  48. package/dist/scaffold/project-files.js +1 -1
  49. package/dist/scaffold/project-files.js.map +1 -1
  50. package/dist/scaffold/project.js +2 -0
  51. package/dist/scaffold/project.js.map +1 -1
  52. package/dist/scaffold/roblox-skill-files.d.ts +2 -0
  53. package/dist/scaffold/roblox-skill-files.js +16 -0
  54. package/dist/scaffold/roblox-skill-files.js.map +1 -0
  55. package/dist/scaffold/skill-files.js +2 -1
  56. package/dist/scaffold/skill-files.js.map +1 -1
  57. package/dist/tools/bundles.js +6 -0
  58. package/dist/tools/bundles.js.map +1 -1
  59. 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
+ });