@bitmagic/cli 0.1.71-dev.0 → 0.1.71-dev.1

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 (36) hide show
  1. package/assets/roblox-port/README.md +19 -0
  2. package/assets/roblox-port/SKILL.md +154 -0
  3. package/assets/roblox-port/build-studio-update.mjs +41 -0
  4. package/assets/roblox-port/export-packed-mesh.mjs +166 -0
  5. package/assets/roblox-port/lib.mjs +193 -0
  6. package/assets/roblox-port/port-state.mjs +129 -0
  7. package/assets/roblox-port/references/assets.md +100 -0
  8. package/assets/roblox-port/references/source-and-runtime.md +68 -0
  9. package/assets/roblox-port/references/state-contracts.md +129 -0
  10. package/assets/roblox-port/references/studio-and-updates.md +119 -0
  11. package/assets/roblox-port/references/verification.md +63 -0
  12. package/assets/roblox-port/studio-runtime.luau +347 -0
  13. package/assets/roblox-port/tests/mesh.test.mjs +81 -0
  14. package/assets/roblox-port/tests/state.test.mjs +148 -0
  15. package/assets/roblox-port/tests/studio-mock.luau +109 -0
  16. package/assets/roblox-port/tests/studio-runtime.test.mjs +309 -0
  17. package/dist/commands/generate.js +12 -0
  18. package/dist/commands/generate.js.map +1 -1
  19. package/dist/generate/model.d.ts +2 -0
  20. package/dist/generate/model.js +1 -0
  21. package/dist/generate/model.js.map +1 -1
  22. package/dist/generate/prop.d.ts +7 -0
  23. package/dist/generate/prop.js +2 -0
  24. package/dist/generate/prop.js.map +1 -1
  25. package/dist/scaffold/project-files.js +1 -1
  26. package/dist/scaffold/project-files.js.map +1 -1
  27. package/dist/scaffold/project.js +2 -0
  28. package/dist/scaffold/project.js.map +1 -1
  29. package/dist/scaffold/roblox-skill-files.d.ts +2 -0
  30. package/dist/scaffold/roblox-skill-files.js +16 -0
  31. package/dist/scaffold/roblox-skill-files.js.map +1 -0
  32. package/dist/scaffold/skill-files.js +2 -1
  33. package/dist/scaffold/skill-files.js.map +1 -1
  34. package/dist/tools/bundles.js +6 -0
  35. package/dist/tools/bundles.js.map +1 -1
  36. package/package.json +4 -4
@@ -0,0 +1,109 @@
1
+ -- Local behavioral model only: this does not replace a native Studio integration test.
2
+ local function newStudio()
3
+ local methods = {}
4
+ local objects = {}
5
+ local model = { running = false, writes = 0, failWrite = nil, beforeWrite = nil }
6
+ local function newObject(className)
7
+ local object = { _props = { ClassName = className, Name = className, Source = "", Enabled = true, Archivable = true, RunContext = "Legacy", LinkedSource = "" }, _attributes = {}, _tags = {} }
8
+ setmetatable(object, {
9
+ __index = function(self, key) return methods[key] or self._props[key] end,
10
+ __newindex = function(self, key, value) self._props[key] = value end,
11
+ })
12
+ table.insert(objects, object)
13
+ return object
14
+ end
15
+ function methods:IsA(className)
16
+ if className == self.ClassName then return true end
17
+ local script = self.ClassName == "Script" or self.ClassName == "LocalScript"
18
+ return (className == "BaseScript" and script) or (className == "LuaSourceContainer" and (script or self.ClassName == "ModuleScript"))
19
+ end
20
+ function methods:GetChildren()
21
+ local children = {}
22
+ for _, object in ipairs(objects) do if object.Parent == self then table.insert(children, object) end end
23
+ return children
24
+ end
25
+ function methods:GetDescendants()
26
+ local result = {}
27
+ local function visit(parent)
28
+ for _, child in ipairs(parent:GetChildren()) do table.insert(result, child); visit(child) end
29
+ end
30
+ visit(self)
31
+ return result
32
+ end
33
+ function methods:SetAttribute(key, value) self._attributes[key] = value end
34
+ function methods:GetAttribute(key) return self._attributes[key] end
35
+ function methods:GetAttributes() return table.clone(self._attributes) end
36
+ function methods:AddTag(tag) self._tags[tag] = true end
37
+ function methods:RemoveTag(tag) self._tags[tag] = nil end
38
+ function methods:GetTags()
39
+ local tags = {}
40
+ for tag in pairs(self._tags) do table.insert(tags, tag) end
41
+ return tags
42
+ end
43
+ function methods:GetFullName()
44
+ return self.Parent and self.Parent:GetFullName() .. "." .. self.Name or self.Name
45
+ end
46
+ function methods:Destroy()
47
+ for _, child in ipairs(self:GetChildren()) do child:Destroy() end
48
+ self.Parent = nil
49
+ end
50
+ local root = newObject("DataModel")
51
+ root.PlaceId = 0; root.GameId = 0; root.CreatorId = 42; root.CreatorType = { Name = "User" }
52
+ local services = {}
53
+ local function encodePrimitive(value)
54
+ if type(value) ~= "string" then return tostring(value) end
55
+ local escaped = value:gsub('[%z\1-\31\\"]', function(character)
56
+ if character == '"' then return '\\"' end
57
+ if character == "\\" then return "\\\\" end
58
+ return string.format("\\u%04x", string.byte(character))
59
+ end)
60
+ return '"' .. escaped .. '"'
61
+ end
62
+ services.HttpService = { JSONEncode = function(_, value) return encodePrimitive(value) end }
63
+ services.RunService = { IsRunning = function() return model.running end }
64
+ services.ScriptEditorService = {
65
+ GetEditorSource = function(_, object) return object.Buffer or object.Source end,
66
+ UpdateSourceAsync = function(_, object, callback)
67
+ model.writes += 1
68
+ if model.writes == model.failWrite then error("Injected source failure") end
69
+ if model.beforeWrite then model.beforeWrite(object) end
70
+ local output = callback(object.Buffer or object.Source)
71
+ object.Source = output
72
+ if object.Buffer then object.Buffer = output end
73
+ if model.afterWrite then model.afterWrite(object) end
74
+ end,
75
+ }
76
+ function methods:GetService(name)
77
+ if not services[name] then
78
+ local object = newObject(name)
79
+ object.Parent = root
80
+ services[name] = object
81
+ end
82
+ return services[name]
83
+ end
84
+ function model:at(parts)
85
+ local object = root:GetService(parts[1])
86
+ for i = 2, #parts do
87
+ local nextObject = nil
88
+ for _, child in ipairs(object:GetChildren()) do if child.Name == parts[i] then nextObject = child end end
89
+ if not nextObject then return nil end
90
+ object = nextObject
91
+ end
92
+ return object
93
+ end
94
+ function model:add(className, parts, source)
95
+ local parent = root:GetService(parts[1])
96
+ for i = 2, #parts - 1 do
97
+ local prefix = {}; for j = 1, i do table.insert(prefix, parts[j]) end
98
+ local child = self:at(prefix)
99
+ if not child then child = newObject("Folder"); child.Name = parts[i]; child.Parent = parent end
100
+ parent = child
101
+ end
102
+ local object = newObject(className)
103
+ object.Name = parts[#parts]; object.Source = source or ""; object.Parent = parent
104
+ return object
105
+ end
106
+ model.game = root
107
+ model.Instance = { new = newObject }
108
+ return model
109
+ end
@@ -0,0 +1,309 @@
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 { spawnSync } from 'node:child_process';
7
+ import { init, prepare, accept, acceptRollback } from '../port-state.mjs';
8
+ import { loadProject, writeJson, validateSnapshot, verifyReadback } from '../lib.mjs';
9
+ import { longString } from '../build-studio-update.mjs';
10
+
11
+ // Opt in with an installed official interpreter. Tests never download or install tools.
12
+ const luau = process.env.LUAU_BIN;
13
+ const runtime = fs.readFileSync(new URL('../studio-runtime.luau', import.meta.url), 'utf8');
14
+ const mock = fs.readFileSync(new URL('./studio-mock.luau', import.meta.url), 'utf8');
15
+ function literal(value) {
16
+ if (value === null) return 'nil';
17
+ if (typeof value === 'string') return longString(value);
18
+ if (Array.isArray(value)) return `{${value.map(literal).join(',')}}`;
19
+ if (typeof value === 'object') return `{${Object.entries(value).map(([k, v]) => `[ ${literal(k)} ]=${literal(v)}`).join(',')}}`;
20
+ return String(value);
21
+ }
22
+ function fixture(t) {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'roblox-luau-'));
24
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
25
+ const source = path.join(dir, 'source'), root = path.join(dir, 'port');
26
+ fs.mkdirSync(source);
27
+ writeJson(path.join(source, 'bitmagic.json'), { gameId: 'local-test', engineVersion: 'test', environment: 'dev' });
28
+ init(source, root);
29
+ fs.writeFileSync(path.join(root, 'generated/shared/Rules.luau'), 'return { damage = 10 }\n');
30
+ fs.writeFileSync(path.join(root, 'generated/server/Main.luau'), 'print("fixture")\n');
31
+ writeJson(path.join(root, 'incoming.json'), { schemaVersion: 1, sourceFiles: ['bitmagic.json'], scripts: [
32
+ { id: 'rules', className: 'ModuleScript', path: ['ReplicatedStorage', 'Port', 'Rules'], file: 'generated/shared/Rules.luau' },
33
+ { id: 'main', className: 'Script', path: ['ServerScriptService', 'Port', 'Main'], file: 'generated/server/Main.luau', enabled: true },
34
+ ] });
35
+ const live = { schemaVersion: 1, mode: 'Edit', target: { projectId: null, targetId: null, placeId: '0', universeId: '0', creatorId: '42', creatorType: 'User' }, buildId: null, pendingRun: null, scripts: [] };
36
+ const run = path.join(dir, 'run'), plan = prepare(root, live, run, { bind: true });
37
+ return { dir, root, run, plan, live, project: loadProject(root) };
38
+ }
39
+ function execute(f, body) {
40
+ const file = path.join(f.dir, 'execution.luau');
41
+ const source = `${mock}\nlocal model = newStudio()\nlocal game, Instance = model.game, model.Instance\nlocal plan = ${literal(f.plan)}\nlocal project = plan.project\nlocal function operation(action, selectedPlan)\nlocal request = {action=action, project=project, plan=selectedPlan or plan, paths={}}\nfor _, s in ipairs(request.plan.generated) do table.insert(request.paths, s.path) end\nfor _, s in ipairs(request.plan.desired) do table.insert(request.paths, s.path) end\n${runtime}\nend\n${body}`;
42
+ fs.writeFileSync(file, source);
43
+ const result = spawnSync(luau, [file], { encoding: 'utf8', timeout: 15000 });
44
+ assert.equal(result.error, undefined);
45
+ assert.equal(result.status, 0, result.stderr);
46
+ return result.stdout.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
47
+ }
48
+ test('Luau applies exact sources, replays, preserves native content and rolls back', { skip: !luau }, t => {
49
+ const f = fixture(t);
50
+ const [before, applied, replayed, rolledBack] = execute(f, `
51
+ local native = model:add("Part", {"Workspace", "CreatorArt"}); native.Color = "purple"
52
+ print(operation("inspect"))
53
+ print(operation("apply"))
54
+ assert(model:at({"ServerScriptService", "Port", "Main"}).Enabled == true)
55
+ local writes = model.writes
56
+ print(operation("apply"))
57
+ assert(model.writes == writes)
58
+ print(operation("rollback"))
59
+ assert(native.Parent == game:GetService("Workspace") and native.Color == "purple")
60
+ assert(model:at({"ReplicatedStorage", "Port"}) == nil)
61
+ `);
62
+ assert.deepEqual(before, f.live);
63
+ validateSnapshot(applied); verifyReadback(f.plan, applied); verifyReadback(f.plan, replayed);
64
+ accept(f.root, f.run, applied);
65
+ acceptRollback(f.root, f.run, rolledBack);
66
+ });
67
+ test('Luau refuses Play, wrong targets and unowned collisions before mutation', { skip: !luau }, t => {
68
+ const f = fixture(t);
69
+ const [snapshot] = execute(f, `
70
+ model.running = true
71
+ local ok, err = pcall(function() operation("apply") end); assert(not ok and string.find(err, "PLAY_ACTIVE"))
72
+ model.running = false; game.PlaceId = 123
73
+ ok, err = pcall(function() operation("apply") end); assert(not ok and string.find(err, "TARGET_MISMATCH"))
74
+ game.PlaceId = 0
75
+ local other = model:add("ModuleScript", {"ReplicatedStorage", "Port", "Rules"}, "creator source")
76
+ ok = pcall(function() operation("apply") end); assert(not ok and other.Source == "creator source")
77
+ assert(game:GetAttribute("BitmagicPendingRun") == nil and model.writes == 0)
78
+ other:Destroy()
79
+ local extra = model:add("ModuleScript", {"ReplicatedStorage", "CreatorExtra"}, "new managed code")
80
+ extra:SetAttribute("BitmagicTargetId", project.targetId); extra:SetAttribute("BitmagicSourceId", "extra")
81
+ ok, err = pcall(function() operation("apply") end); assert(not ok and string.find(err, "script set changed"))
82
+ assert(model.writes == 0 and game:GetAttribute("BitmagicPendingRun") == nil)
83
+ extra:Destroy()
84
+ print(operation("inspect"))
85
+ `);
86
+ assert.deepEqual(snapshot, f.live);
87
+ });
88
+ test('forward replay finishes an interrupted build commit without rewriting sources', { skip: !luau }, t => {
89
+ const f = fixture(t);
90
+ const [replayed] = execute(f, `
91
+ operation("apply")
92
+ game:SetAttribute("BitmagicPendingRun", plan.runId)
93
+ local writes = model.writes
94
+ print(operation("apply"))
95
+ assert(model.writes == writes)
96
+ `);
97
+ verifyReadback(f.plan, replayed);
98
+ });
99
+ test('Luau resumes an interrupted source batch and can restore the original snapshot', { skip: !luau }, t => {
100
+ const f = fixture(t);
101
+ const [applied, rolledBack] = execute(f, `
102
+ model.failWrite = 2
103
+ local ok = pcall(function() operation("apply") end); assert(not ok)
104
+ assert(game:GetAttribute("BitmagicPendingRun") == plan.runId)
105
+ assert(model:at({"ServerScriptService", "Port", "Main"}).Enabled == false)
106
+ model.failWrite = nil
107
+ print(operation("apply"))
108
+ print(operation("rollback"))
109
+ `);
110
+ verifyReadback(f.plan, applied); acceptRollback(f.root, f.run, rolledBack);
111
+ });
112
+ test('Luau protects concurrent editor buffer changes and later rollback edits', { skip: !luau }, t => {
113
+ const f = fixture(t);
114
+ const [receipt] = execute(f, `
115
+ model.beforeWrite = function(object) object.Buffer = "human unsaved edit" end
116
+ local ok, err = pcall(function() operation("apply") end)
117
+ assert(not ok and string.find(err, "EDIT_CONFLICT"))
118
+ model.beforeWrite = nil
119
+ local object = model:at({"ReplicatedStorage", "Port", "Rules"})
120
+ assert(object.Buffer == "human unsaved edit")
121
+ print(operation("rollback"))
122
+ assert(object.Buffer == "human unsaved edit" and object.Parent ~= nil)
123
+ `);
124
+ assert.equal(receipt.stage, 'rollback-incomplete');
125
+ assert.ok(receipt.failures.length > 0);
126
+ assert.equal(receipt.snapshot.pendingRun, `rollback:${f.plan.runId}`);
127
+ assert.throws(() => acceptRollback(f.root, f.run, receipt), /incomplete/);
128
+ });
129
+ test('committed builds treat a later disable or source revert as a human edit', { skip: !luau }, t => {
130
+ const f = fixture(t);
131
+ const [receipt, retry] = execute(f, `
132
+ operation("apply")
133
+ local script = model:at({"ServerScriptService", "Port", "Main"})
134
+ script.Enabled = false
135
+ local module = model:at({"ReplicatedStorage", "Port", "Rules"})
136
+ module.Buffer = ""
137
+ local writes = model.writes
138
+ local ok = pcall(function() operation("apply") end)
139
+ assert(not ok and model.writes == writes)
140
+ print(operation("rollback"))
141
+ print(operation("rollback"))
142
+ assert(script.Parent ~= nil and script.Enabled == false and module.Buffer == "")
143
+ `);
144
+ assert.equal(receipt.stage, 'rollback-incomplete');
145
+ assert.equal(receipt.failures.length, 2);
146
+ assert.equal(retry.stage, 'rollback-incomplete');
147
+ assert.equal(retry.failures.length, 2);
148
+ });
149
+ test('Luau adoption uses editor buffers and restores original ownership', { skip: !luau }, t => {
150
+ const f = fixture(t);
151
+ const generated = f.plan.generated;
152
+ f.live.scripts = generated.map(s => ({ ...s, id: null, owner: null, runId: null }));
153
+ f.run = path.join(f.dir, 'adopt'); f.plan = prepare(f.root, f.live, f.run, { bind: true, adopt: true });
154
+ const [applied, rollback] = execute(f, `
155
+ for _, before in ipairs(plan.snapshot.scripts) do
156
+ local object = model:add(before.className, before.path, "stale persisted source")
157
+ object.Buffer = before.source
158
+ if object:IsA("BaseScript") then object.Enabled = before.enabled end
159
+ end
160
+ print(operation("apply"))
161
+ print(operation("rollback"))
162
+ `);
163
+ verifyReadback(f.plan, applied); acceptRollback(f.root, f.run, rollback);
164
+ });
165
+ test('Luau updates and renames managed code while retaining children and unchanged scripts', { skip: !luau }, t => {
166
+ const f = fixture(t);
167
+ const [first] = execute(f, 'print(operation("apply"))');
168
+ accept(f.root, f.run, first);
169
+ fs.writeFileSync(path.join(f.root, 'generated/shared/Rules.luau'), 'return { damage = 12 }\n');
170
+ const manifestFile = path.join(f.root, 'incoming.json');
171
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
172
+ manifest.scripts[0].path = ['ReplicatedStorage', 'Port', 'NewRules'];
173
+ writeJson(manifestFile, manifest);
174
+ f.run = path.join(f.dir, 'update'); f.plan = prepare(f.root, first, f.run);
175
+ const [applied, rollback] = execute(f, `
176
+ for key, value in pairs({BitmagicProjectId=plan.targetBefore.projectId, BitmagicTargetId=plan.targetBefore.targetId, BitmagicBuildId=plan.buildBefore}) do game:SetAttribute(key, value) end
177
+ for _, before in ipairs(plan.snapshot.scripts) do
178
+ local object = model:add(before.className, before.path, before.source)
179
+ object:SetAttribute("BitmagicSourceId", before.id); object:SetAttribute("BitmagicTargetId", before.owner); object:SetAttribute("BitmagicRunId", before.runId)
180
+ if object:IsA("BaseScript") then object.Enabled = before.enabled end
181
+ end
182
+ local native = model:add("StringValue", {"ReplicatedStorage", "Port", "Rules", "CreatorNote"})
183
+ print(operation("apply"))
184
+ assert(model.writes == 1 and native.Parent == model:at({"ReplicatedStorage", "Port", "NewRules"}))
185
+ print(operation("rollback"))
186
+ assert(native.Parent == model:at({"ReplicatedStorage", "Port", "Rules"}))
187
+ `);
188
+ verifyReadback(f.plan, applied); accept(f.root, f.run, applied); acceptRollback(f.root, f.run, rollback);
189
+ });
190
+
191
+ for (const [name, change, preserved] of [
192
+ ['attribute', 'script:SetAttribute("CreatorRewardId", "keep-me")', 'script:GetAttribute("CreatorRewardId") == "keep-me"'],
193
+ ['tag', 'script:AddTag("CreatorFeature")', '#script:GetTags() == 1'],
194
+ ['Archivable', 'script.Archivable = false', 'script.Archivable == false'],
195
+ ['RunContext', 'script.RunContext = "Client"', 'script.RunContext == "Client"'],
196
+ ['LinkedSource', 'script.LinkedSource = "rbxassetid://42"', 'script.LinkedSource == "rbxassetid://42"'],
197
+ ]) test(`rollback preserves a new script's creator ${name}`, { skip: !luau }, t => {
198
+ const f = fixture(t);
199
+ const [receipt, retry] = execute(f, `
200
+ operation("apply")
201
+ local script = model:at({"ServerScriptService", "Port", "Main"})
202
+ ${change}
203
+ print(operation("rollback"))
204
+ print(operation("rollback"))
205
+ assert(script.Parent ~= nil and ${preserved})
206
+ `);
207
+ for (const result of [receipt, retry]) {
208
+ assert.equal(result.stage, 'rollback-incomplete');
209
+ assert.equal(result.snapshot.pendingRun, `rollback:${f.plan.runId}`);
210
+ assert.equal(result.failures.length, 1);
211
+ }
212
+ });
213
+
214
+ for (const [name, change, preserved] of [
215
+ ['attribute', 'folder:SetAttribute("CreatorFolder", true)', 'folder:GetAttribute("CreatorFolder") == true'],
216
+ ['tag', 'folder:AddTag("KeepFolder")', '#folder:GetTags() == 1'],
217
+ ['Archivable', 'folder.Archivable = false', 'folder.Archivable == false'],
218
+ ['rename', 'model:at({"ServerScriptService", "Port", "Main"}):Destroy(); folder.Name = "CreatorFolder"', 'folder.Name == "CreatorFolder"'],
219
+ ]) test(`rollback preserves an empty created folder's creator ${name}`, { skip: !luau }, t => {
220
+ const f = fixture(t);
221
+ const [receipt] = execute(f, `
222
+ operation("apply")
223
+ local folder = model:at({"ServerScriptService", "Port"})
224
+ ${change}
225
+ print(operation("rollback"))
226
+ assert(folder.Parent ~= nil and #folder:GetChildren() == 0 and ${preserved})
227
+ `);
228
+ assert.equal(receipt.stage, 'rollback-incomplete');
229
+ assert.equal(receipt.failures.length, 1);
230
+ assert.equal(receipt.snapshot.pendingRun, `rollback:${f.plan.runId}`);
231
+ });
232
+
233
+ const seedBefore = `
234
+ for key, value in pairs({BitmagicProjectId=plan.targetBefore.projectId, BitmagicTargetId=plan.targetBefore.targetId, BitmagicBuildId=plan.buildBefore}) do game:SetAttribute(key, value) end
235
+ for _, before in ipairs(plan.snapshot.scripts) do
236
+ local object = model:add(before.className, before.path, before.source)
237
+ object:SetAttribute("BitmagicSourceId", before.id); object:SetAttribute("BitmagicTargetId", before.owner); object:SetAttribute("BitmagicRunId", before.runId)
238
+ if object:IsA("BaseScript") then object.Enabled = before.enabled end
239
+ end
240
+ `;
241
+ test('source updates and rollback preserve creator names with spaces and Unicode', { skip: !luau }, t => {
242
+ const f = fixture(t);
243
+ const [first] = execute(f, 'print(operation("apply"))');
244
+ accept(f.root, f.run, first);
245
+ first.scripts.find(s => s.id === 'rules').path = ['ReplicatedStorage', "Creator's Folder", 'Combat Rules — sääntö'];
246
+ fs.writeFileSync(path.join(f.root, 'generated/shared/Rules.luau'), 'return { damage = 12 }\n');
247
+ f.run = path.join(f.dir, 'renamed-update'); f.plan = prepare(f.root, first, f.run);
248
+ assert.equal(f.plan.conflicts.length, 0);
249
+ const [applied, restored] = execute(f, `${seedBefore}\nprint(operation("apply"))\nprint(operation("rollback"))`);
250
+ verifyReadback(f.plan, applied);
251
+ assert.deepEqual(applied.scripts.find(s => s.id === 'rules').path, first.scripts.find(s => s.id === 'rules').path);
252
+ accept(f.root, f.run, applied); acceptRollback(f.root, f.run, restored);
253
+ });
254
+ function updateFixture(t) {
255
+ const f = fixture(t);
256
+ const [first] = execute(f, 'print(operation("apply"))');
257
+ accept(f.root, f.run, first);
258
+ fs.writeFileSync(path.join(f.root, 'generated/shared/Rules.luau'), 'return { damage = 12 }\n');
259
+ fs.writeFileSync(path.join(f.root, 'generated/server/Main.luau'), 'print("new main")\n');
260
+ f.run = path.join(f.dir, 'update'); f.plan = prepare(f.root, first, f.run);
261
+ return f;
262
+ }
263
+ test('partial rollback blocks new plans and forward replay until recovery completes', { skip: !luau }, t => {
264
+ const f = updateFixture(t);
265
+ const [applied, partial, retry, restored] = execute(f, `${seedBefore}
266
+ print(operation("apply"))
267
+ local rules = model:at({"ReplicatedStorage", "Port", "Rules"})
268
+ rules.Buffer = "human customization"
269
+ print(operation("rollback"))
270
+ local oldMain
271
+ for _, before in ipairs(plan.snapshot.scripts) do if before.id == "main" then oldMain = before.source end end
272
+ assert(model:at({"ServerScriptService", "Port", "Main"}).Source == oldMain)
273
+ local writes = model.writes
274
+ local ok, err = pcall(function() operation("apply") end)
275
+ assert(not ok and string.find(err, "ROLLBACK_PENDING") and model.writes == writes)
276
+ print(operation("rollback"))
277
+ assert(rules.Buffer == "human customization")
278
+ -- Simulate explicit resolution by putting the conflicted buffer back to the applied source.
279
+ rules.Buffer = plan.generated[1].source
280
+ print(operation("rollback"))
281
+ `);
282
+ accept(f.root, f.run, applied);
283
+ for (const result of [partial, retry]) {
284
+ assert.equal(result.stage, 'rollback-incomplete');
285
+ assert.equal(result.snapshot.buildId, applied.buildId);
286
+ assert.equal(result.snapshot.pendingRun, `rollback:${f.plan.runId}`);
287
+ assert.throws(() => prepare(f.root, result.snapshot, path.join(f.dir, 'blocked-plan')), /PARTIAL_APPLY/);
288
+ assert.throws(() => acceptRollback(f.root, f.run, result), /incomplete/);
289
+ }
290
+ acceptRollback(f.root, f.run, restored);
291
+ });
292
+ test('rollback resumes a source write interrupted before ownership restoration', { skip: !luau }, t => {
293
+ const f = updateFixture(t);
294
+ const [applied, partial, restored] = execute(f, `${seedBefore}
295
+ print(operation("apply"))
296
+ model.afterWrite = function()
297
+ model.afterWrite = nil
298
+ error("Injected interruption after source restoration")
299
+ end
300
+ print(operation("rollback"))
301
+ assert(model:at({"ServerScriptService", "Port", "Main"}):GetAttribute("BitmagicRollbackRun") == plan.runId)
302
+ print(operation("rollback"))
303
+ assert(model:at({"ServerScriptService", "Port", "Main"}):GetAttribute("BitmagicRollbackRun") == nil)
304
+ `);
305
+ assert.equal(partial.stage, 'rollback-incomplete');
306
+ assert.equal(partial.snapshot.pendingRun, `rollback:${f.plan.runId}`);
307
+ accept(f.root, f.run, applied);
308
+ acceptRollback(f.root, f.run, restored);
309
+ });
@@ -896,6 +896,14 @@ function resolveKeepGlb(root, options, log) {
896
896
  log('Keeping the mesh: game.json artStyle is low-poly (pass --voxelize to bake it instead).');
897
897
  return true;
898
898
  }
899
+ /**
900
+ * A kept mesh in a low-poly project is forged low-poly (Rodin Gen-2), whether the style or
901
+ * `--keep-glb` kept it. `--keep-glb` in a voxel project keeps the default mesh. Quiet, because
902
+ * `resolveKeepGlb` has already said anything worth saying about the style.
903
+ */
904
+ function keptMeshArtStyle(root, keepGlb) {
905
+ return keepGlb && resolveArtStyle(root) === 'low-poly' ? 'low-poly' : undefined;
906
+ }
899
907
  /** The voxel path's `--glb-url`: a paid-for master to bake, instead of buying another. */
900
908
  const MASTER_URL_FLAG = {
901
909
  type: 'string',
@@ -923,6 +931,7 @@ export async function runPropGeneration(options) {
923
931
  assertOneRetrySource(options.glbUrl, options.masterUrl);
924
932
  const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
925
933
  const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
934
+ const meshArtStyle = keptMeshArtStyle(context.root, keepGlb);
926
935
  const result = await withJobRecord({
927
936
  root: context.root,
928
937
  kind: 'prop',
@@ -947,6 +956,7 @@ export async function runPropGeneration(options) {
947
956
  ...(options.smart !== undefined ? { smart: options.smart } : {}),
948
957
  ...(options.smartSpec !== undefined ? { smartSpec: options.smartSpec } : {}),
949
958
  ...(keepGlb ? { keepGlb: true } : {}),
959
+ ...(meshArtStyle !== undefined ? { meshArtStyle } : {}),
950
960
  ...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
951
961
  fetchImpl: runDeps.fetch,
952
962
  log,
@@ -1054,6 +1064,7 @@ export async function runModelGeneration(options) {
1054
1064
  assertSmartAllowed(options.smart, options.mesh === true);
1055
1065
  const { output, runDeps, context, environment, token } = await resolveRun(options.json, options.deps);
1056
1066
  const keepGlb = resolveKeepGlb(context.root, options, runDeps.log);
1067
+ const meshArtStyle = keptMeshArtStyle(context.root, keepGlb);
1057
1068
  if (keepGlb && options.smart !== undefined) {
1058
1069
  throw new CliError('--smart has no meaning for a kept mesh: the analysis reads the voxel master, and this project '
1059
1070
  + 'keeps meshes (game.json artStyle is low-poly, or --keep-glb). Pass --voxelize. world.json is unchanged.');
@@ -1089,6 +1100,7 @@ export async function runModelGeneration(options) {
1089
1100
  fetchImpl: runDeps.fetch,
1090
1101
  ...(runDeps.voxelize !== undefined ? { voxelize: runDeps.voxelize } : {}),
1091
1102
  ...(keepGlb ? { keepGlb: true } : {}),
1103
+ ...(meshArtStyle !== undefined ? { meshArtStyle } : {}),
1092
1104
  ...(runDeps.keepGlbUpload !== undefined ? { keepGlbUpload: runDeps.keepGlbUpload } : {}),
1093
1105
  log,
1094
1106
  });