From 33f43a1d8e66353c89f895d97d15adaf671bda3a Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Tue, 16 Jun 2026 21:13:30 +0200 Subject: [PATCH] feat(lock-sprite): stable alias-set + source-file persistence across re-bakes --- src/lock-sprite.js | 49 ++++++++++++++++++++++++ tests/lock-sprite.test.js | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/lock-sprite.js create mode 100644 tests/lock-sprite.test.js diff --git a/src/lock-sprite.js b/src/lock-sprite.js new file mode 100644 index 0000000..1ba9c88 --- /dev/null +++ b/src/lock-sprite.js @@ -0,0 +1,49 @@ +// src/lock-sprite.js +// Sprite-Atlas-Lock: stable alias-set + source-file mapping across +// re-bakes. New aliases append; removed aliases surface in `removed[]` +// so the caller can loud-warn. + +const fs = require('node:fs'); + +function loadSpriteLock(lockPath) { + if (!lockPath || !fs.existsSync(lockPath)) return null; + const raw = fs.readFileSync(lockPath, 'utf8'); + return JSON.parse(raw); +} + +function mergeSpriteLock(existing, sources) { + const sourceMap = {}; + const currentAliases = []; + for (const s of sources) { + sourceMap[s.alias] = s.sourceFile; + currentAliases.push(s.alias); + } + let mergedAliases; + let removed = []; + if (existing) { + const incoming = new Set(currentAliases); + // Preserve existing order; append new + mergedAliases = existing.aliases.filter(a => incoming.has(a)); + for (const a of currentAliases) { + if (!existing.aliases.includes(a)) mergedAliases.push(a); + } + removed = existing.aliases.filter(a => !incoming.has(a)); + } else { + mergedAliases = currentAliases; + } + return { + merged: { + atlas_id: existing ? existing.atlas_id : null, + version: 1, + aliases: mergedAliases, + sources: sourceMap, + }, + removed, + }; +} + +function serializeSpriteLock(lock) { + return JSON.stringify(lock, null, 2) + '\n'; +} + +module.exports = { loadSpriteLock, mergeSpriteLock, serializeSpriteLock }; diff --git a/tests/lock-sprite.test.js b/tests/lock-sprite.test.js new file mode 100644 index 0000000..9a8f16c --- /dev/null +++ b/tests/lock-sprite.test.js @@ -0,0 +1,80 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { loadSpriteLock, mergeSpriteLock, serializeSpriteLock } = require('../src/lock-sprite'); + +test('loadSpriteLock: missing file -> null', () => { + const result = loadSpriteLock('/nonexistent/lock.json'); + assert.strictEqual(result, null); +}); + +test('loadSpriteLock: reads valid lock', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lock-test-')); + const lockPath = path.join(tmp, 'lock.json'); + fs.writeFileSync(lockPath, JSON.stringify({ + atlas_id: 'tcbasics', + version: 1, + aliases: ['bed1', 'bench1'], + sources: { bed1: 'Bed1.png', bench1: 'Bench1.png' }, + })); + const lock = loadSpriteLock(lockPath); + assert.strictEqual(lock.atlas_id, 'tcbasics'); + assert.deepStrictEqual(lock.aliases, ['bed1', 'bench1']); +}); + +test('mergeSpriteLock: no existing lock -> all new', () => { + const sources = [ + { alias: 'bed1', sourceFile: 'Bed1.png' }, + { alias: 'bench1',sourceFile: 'Bench1.png' }, + ]; + const { merged, removed } = mergeSpriteLock(null, sources); + assert.deepStrictEqual(merged.aliases, ['bed1', 'bench1']); + assert.deepStrictEqual(removed, []); +}); + +test('mergeSpriteLock: existing alias preserved; new appended', () => { + const existing = { + atlas_id: 'tcbasics', + version: 1, + aliases: ['bed1'], + sources: { bed1: 'Bed1.png' }, + }; + const sources = [ + { alias: 'bed1', sourceFile: 'Bed1.png' }, + { alias: 'bench1', sourceFile: 'Bench1.png' }, + ]; + const { merged, removed } = mergeSpriteLock(existing, sources); + assert.deepStrictEqual(merged.aliases, ['bed1', 'bench1']); + assert.deepStrictEqual(removed, []); +}); + +test('mergeSpriteLock: removed alias appears in `removed`', () => { + const existing = { + atlas_id: 'tcbasics', + version: 1, + aliases: ['bed1', 'bench1'], + sources: { bed1: 'Bed1.png', bench1: 'Bench1.png' }, + }; + const sources = [ + { alias: 'bed1', sourceFile: 'Bed1.png' }, + ]; + const { merged, removed } = mergeSpriteLock(existing, sources); + assert.deepStrictEqual(merged.aliases, ['bed1']); + assert.deepStrictEqual(removed, ['bench1']); +}); + +test('serializeSpriteLock: stable JSON shape', () => { + const lock = { + atlas_id: 'tcbasics', + version: 1, + aliases: ['bed1'], + sources: { bed1: 'Bed1.png' }, + }; + const json = serializeSpriteLock(lock); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed, lock); + // Trailing newline for POSIX-friendliness + assert.ok(json.endsWith('\n')); +});