Add lock-file load and stable ID assignment
Lock-file determinism guarantees re-bakes do not renumber existing tiles. Atlas_id mismatch is a hard-fail to prevent silent corruption. Deleted tile IDs move to deleted[] and are not recycled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
src/lock.js
Normal file
69
src/lock.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/lock.js
|
||||
// Load + update + serialize the tiles.atlas.lock.json file.
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
function loadLock(lockPath) {
|
||||
if (!fs.existsSync(lockPath)) {
|
||||
return null; // signal: fresh bake
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
||||
if (typeof raw.atlas_id !== 'string') {
|
||||
throw new Error(`lock: missing atlas_id in ${lockPath}`);
|
||||
}
|
||||
return {
|
||||
atlas_id: raw.atlas_id,
|
||||
bindings: raw.bindings || {},
|
||||
deleted: raw.deleted || [],
|
||||
next_id: raw.next_id || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function assignIds(sources, existingLock, atlasId) {
|
||||
if (existingLock && existingLock.atlas_id !== atlasId) {
|
||||
throw new Error(
|
||||
`lock: atlas_id mismatch (lock has '${existingLock.atlas_id}', `
|
||||
+ `cli passed '${atlasId}'); refusing to bake`
|
||||
);
|
||||
}
|
||||
const bindings = existingLock ? { ...existingLock.bindings } : {};
|
||||
const deletedSet = new Set(existingLock ? existingLock.deleted : []);
|
||||
let nextId = existingLock ? existingLock.next_id : 1;
|
||||
|
||||
const presentNames = new Set(sources.map(s => s.name));
|
||||
const tiles = [];
|
||||
|
||||
// Assign IDs to all sources (existing → stable, new → next_id++)
|
||||
for (const source of sources) {
|
||||
let id = bindings[source.name];
|
||||
if (id === undefined) {
|
||||
id = nextId++;
|
||||
bindings[source.name] = id;
|
||||
}
|
||||
tiles.push({ ...source, id });
|
||||
}
|
||||
|
||||
// Detect deletions: previously-bound names not in sources
|
||||
for (const name of Object.keys(bindings)) {
|
||||
if (!presentNames.has(name)) {
|
||||
deletedSet.add(bindings[name]);
|
||||
delete bindings[name];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tiles,
|
||||
lock: {
|
||||
atlas_id: atlasId,
|
||||
bindings,
|
||||
deleted: [...deletedSet].sort((a, b) => a - b),
|
||||
next_id: nextId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serializeLock(lock) {
|
||||
return JSON.stringify(lock, null, 2);
|
||||
}
|
||||
|
||||
module.exports = { loadLock, assignIds, serializeLock };
|
||||
9
tests/fixtures/lock-existing.json
vendored
Normal file
9
tests/fixtures/lock-existing.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"atlas_id": "small_demo",
|
||||
"bindings": {
|
||||
"grass": 1,
|
||||
"stone": 2
|
||||
},
|
||||
"deleted": [],
|
||||
"next_id": 3
|
||||
}
|
||||
75
tests/lock.test.js
Normal file
75
tests/lock.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const path = require('node:path');
|
||||
const { loadLock, assignIds, serializeLock } = require('../src/lock');
|
||||
|
||||
const EXISTING_LOCK = path.resolve(__dirname, 'fixtures/lock-existing.json');
|
||||
|
||||
test('lock: load existing', () => {
|
||||
const lock = loadLock(EXISTING_LOCK);
|
||||
assert.strictEqual(lock.atlas_id, 'small_demo');
|
||||
assert.strictEqual(lock.bindings.grass, 1);
|
||||
assert.strictEqual(lock.bindings.stone, 2);
|
||||
assert.strictEqual(lock.next_id, 3);
|
||||
});
|
||||
|
||||
test('lock: load nonexistent returns null', () => {
|
||||
const lock = loadLock('/nonexistent/path.json');
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
test('lock: assign IDs to fresh sources', () => {
|
||||
const sources = [
|
||||
{ name: 'apple' },
|
||||
{ name: 'banana' },
|
||||
{ name: 'cherry' },
|
||||
];
|
||||
const { tiles, lock } = assignIds(sources, null, 'fresh_atlas');
|
||||
assert.strictEqual(tiles[0].id, 1);
|
||||
assert.strictEqual(tiles[1].id, 2);
|
||||
assert.strictEqual(tiles[2].id, 3);
|
||||
assert.strictEqual(lock.atlas_id, 'fresh_atlas');
|
||||
assert.strictEqual(lock.next_id, 4);
|
||||
assert.deepStrictEqual(lock.deleted, []);
|
||||
});
|
||||
|
||||
test('lock: preserve existing bindings on re-bake', () => {
|
||||
const existing = loadLock(EXISTING_LOCK);
|
||||
const sources = [
|
||||
{ name: 'grass' },
|
||||
{ name: 'stone' },
|
||||
{ name: 'water' }, // new
|
||||
];
|
||||
const { tiles, lock } = assignIds(sources, existing, 'small_demo');
|
||||
const idByName = Object.fromEntries(tiles.map(t => [t.name, t.id]));
|
||||
assert.strictEqual(idByName.grass, 1); // preserved
|
||||
assert.strictEqual(idByName.stone, 2); // preserved
|
||||
assert.strictEqual(idByName.water, 3); // new
|
||||
assert.strictEqual(lock.next_id, 4);
|
||||
});
|
||||
|
||||
test('lock: deleted tiles move to deleted[]', () => {
|
||||
const existing = loadLock(EXISTING_LOCK);
|
||||
const sources = [
|
||||
{ name: 'grass' },
|
||||
// stone removed
|
||||
];
|
||||
const { lock } = assignIds(sources, existing, 'small_demo');
|
||||
assert.deepStrictEqual(lock.deleted, [2]);
|
||||
assert.strictEqual(lock.bindings.stone, undefined);
|
||||
assert.strictEqual(lock.bindings.grass, 1);
|
||||
});
|
||||
|
||||
test('lock: atlas_id mismatch -> error', () => {
|
||||
const existing = loadLock(EXISTING_LOCK);
|
||||
assert.throws(
|
||||
() => assignIds([{ name: 'grass' }], existing, 'WRONG_ID'),
|
||||
/atlas_id mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test('lock: serialize is JSON with 2-space indent', () => {
|
||||
const lock = { atlas_id: 'x', bindings: { a: 1 }, deleted: [], next_id: 2 };
|
||||
const s = serializeLock(lock);
|
||||
assert.ok(s.includes(' "atlas_id"'));
|
||||
});
|
||||
Reference in New Issue
Block a user