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