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

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"'));
});