@bitmagic/cli 0.1.70 → 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.
- 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/dist/commands/generate.js +12 -0
- package/dist/commands/generate.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/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,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
|
});
|