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:
Axel Meyer
2026-05-21 22:40:50 +02:00
parent b64e3e87a9
commit 83499850fb
3 changed files with 153 additions and 0 deletions

69
src/lock.js Normal file
View 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
View 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
View 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"'));
});