From 83499850fb449cf53f4db809604686ab45aff721 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Thu, 21 May 2026 22:40:50 +0200 Subject: [PATCH] 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) --- src/lock.js | 69 ++++++++++++++++++++++++++++ tests/fixtures/lock-existing.json | 9 ++++ tests/lock.test.js | 75 +++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 src/lock.js create mode 100644 tests/fixtures/lock-existing.json create mode 100644 tests/lock.test.js diff --git a/src/lock.js b/src/lock.js new file mode 100644 index 0000000..09c4adc --- /dev/null +++ b/src/lock.js @@ -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 }; diff --git a/tests/fixtures/lock-existing.json b/tests/fixtures/lock-existing.json new file mode 100644 index 0000000..e69853a --- /dev/null +++ b/tests/fixtures/lock-existing.json @@ -0,0 +1,9 @@ +{ + "atlas_id": "small_demo", + "bindings": { + "grass": 1, + "stone": 2 + }, + "deleted": [], + "next_id": 3 +} diff --git a/tests/lock.test.js b/tests/lock.test.js new file mode 100644 index 0000000..a748f6f --- /dev/null +++ b/tests/lock.test.js @@ -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"')); +});