/** * Save system test (dev tool, run with Node — no browser): * * node dev/saves.test.mjs * * Covers the storage seam + state capture (js/save/): * - SaveManager: the 10-slot localStorage bank — list/get/put/clear, * hasAny/filledCount, corrupt-bank recovery, failed-write surfacing, * and exportAll (the bulk download payload); * - SaveManager.validateRecord: the shape a save must have; * - SaveData.captureState: a live scene → a record (round-trips); * - SaveData.prepareLoad/consumeRestore/resetRunState: the registry * hand-off the two scenes use (galaxy from seed, discovery restored, * the pending restore consumed exactly once). */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href; // --- Load the real config (data/*.json) into the config singleton -------- const { config } = await import(file('config/Config.js')); const fs = await import('node:fs'); const dataDir = join(__dirname, '../data'); const configData = {}; for (const f of fs.readdirSync(dataDir)) { if (!f.endsWith('.json') || f === 'manifest.json') continue; configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); } config.init(configData); const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js')); const { captureState, prepareLoad, consumeRestore, resetRunState, PENDING_RESTORE_KEY, } = await import(file('save/SaveData.js')); const { Galaxy } = await import(file('galaxy/Galaxy.js')); const { Discovery } = await import(file('galaxy/Discovery.js')); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // A small deterministic galaxy (fast) + a fake "scene" for captureState. const SEED = 'SAVETEST'; const galaxy = Galaxy.create(SEED, { systemCount: 8 }); const system = galaxy.byId.get(galaxy.currentSystemId); // the starting system const fakeScene = () => ({ registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }, galaxy, systemRecord: { name: system.name }, ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 }, discovery: new Discovery(540), tetherField: { tethers: [ { id: 'home', x: 0, y: 0, level: 1, label: 'Terra' }, { id: 'st:42', x: 300, y: 250, level: 2, label: 'Outpost' }, ], }, playTimeMs: 123456, }); const makeRec = (over = {}) => ({ app: 'orbit', format: SAVE_FORMAT, // a record of the CURRENT format (older builds' saves are rejected) savedAt: '2026-07-15T00:00:00.000Z', seed: SEED, galaxyName: galaxy.name, currentSystemId: system.id, systemName: system.name, ship: { x: 123.5, y: -77, heading: 0.72 }, discovery: { distance: 540, bySystem: {} }, tethers: [{ id: 'home', x: 0, y: 0, level: 1, label: 'Terra' }], playTimeMs: 123456, ...over, }); // --- a fake localStorage (in-memory, with failure modes) ------------------- const makeStorage = (fail = false) => { const m = new Map(); return { map: m, fail, getItem(k) { return m.has(k) ? m.get(k) : null; }, setItem(k, v) { if (this.fail) throw new Error('QuotaExceeded'); m.set(k, String(v)); }, removeItem(k) { m.delete(k); }, }; }; // --------------------------------------------------------------------------- // SaveManager — the bank // --------------------------------------------------------------------------- { const storage = makeStorage(); const sm = new SaveManager(storage); check('fresh bank: hasAny() is false', sm.hasAny() === false); check('fresh bank: filledCount() is 0', sm.filledCount() === 0); const list0 = sm.listSlots(); check('listSlots(): 10 slots, all empty', list0.length === 10 && list0.every((s) => s.record === null)); check('listSlots(): sequential ids 1..10', list0[0].slot === 1 && list0[9].slot === 10); const rec = makeRec(); sm.put(3, rec); check('put(3) → get(3) round-trips', JSON.stringify(sm.get(3)) === JSON.stringify(rec)); check('put(3) → hasAny()', sm.hasAny() === true); check('put(3) → filledCount() 1', sm.filledCount() === 1); check('put(3) → the slot shows in listSlots', JSON.stringify(sm.listSlots()[2].record) === JSON.stringify(rec)); const rec2 = makeRec({ savedAt: '2026-07-16T00:00:00.000Z' }); sm.put(7, rec2); check('put(7) → filledCount() 2', sm.filledCount() === 2); sm.put(3, makeRec({ seed: SEED, ship: { x: 1, y: 2, heading: 0 } })); check('put(3) again overwrites the slot', sm.get(3).ship.x === 1 && sm.filledCount() === 2); sm.clear(7); check('clear(7) empties the slot', sm.get(7) === null && sm.filledCount() === 1); // The bank lives in storage — a second manager on the same storage sees it. const sm2 = new SaveManager(storage); check('the bank persists in storage (new manager sees slot 3)', sm2.get(3).seed === SEED); // Corrupt bank → treated as empty (the UI then offers an overwrite). storage.setItem(sm.key, '{definitely not json'); const sm3 = new SaveManager(storage); check('corrupt bank → readBank() is empty', sm3.hasAny() === false && sm3.filledCount() === 0); sm3.put(1, rec); check('writing into a corrupt bank recovers it', sm3.get(1).seed === SEED && sm3.get(3) === null); // Failed write (quota) → surfaced, the old value untouched. storage.fail = true; let threw = false; try { sm3.put(2, rec); } catch { threw = true; } check('failed write throws (the UI toasts it)', threw === true); storage.fail = false; // exportAll — the bulk download payload: every filled slot, nothing else. sm3.put(10, rec2); const json = sm3.exportAll(); let parsed = null; try { parsed = JSON.parse(json); } catch { /* checked below */ } check('exportAll(): parses as JSON', parsed !== null); check('exportAll(): app/format present', parsed?.app === 'orbit' && parsed.format === SAVE_FORMAT); check('exportAll(): exactly the filled slots', parsed && Object.keys(parsed.slots).length === 2); check('exportAll(): slot records intact', parsed?.slots['1']?.seed === SEED && parsed?.slots['10']?.seed === SEED); // latest() — the "Continue" target: the newest save by savedAt, not by slot. { const s = makeStorage(); const m = new SaveManager(s); check('latest(): empty bank → null', m.latest() === null); const older = makeRec({ savedAt: '2026-07-01T00:00:00.000Z' }); const newer = makeRec({ savedAt: '2026-07-02T00:00:00.000Z' }); m.put(9, older); check('latest(): single save → that slot/record', m.latest()?.slot === 9 && JSON.stringify(m.latest()?.record) === JSON.stringify(older)); m.put(2, newer); // a NEWER save in a LOWER slot number check('latest(): newest savedAt wins over slot order', m.latest()?.slot === 2 && JSON.stringify(m.latest()?.record) === JSON.stringify(newer)); m.clear(2); m.put(5, makeRec({ savedAt: 'garbage' })); // unparseable timestamp check('latest(): unparseable savedAt loses to any valid one', m.latest()?.slot === 9); m.clear(9); m.put(8, makeRec({ savedAt: 'garbage' })); // same (missing) timestamp → later write check('latest(): missing-timestamp tie breaks to the higher slot', m.latest()?.slot === 8); } // validateRecord — the shape contract. check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null); check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null); check('validateRecord: bad ship rejected', SaveManager.validateRecord(makeRec({ ship: { x: 1 } })) !== null); check('validateRecord: good record accepted', SaveManager.validateRecord(rec) === null); check('validateRecord: tolerates missing tethers/discovery', SaveManager.validateRecord(makeRec({ tethers: undefined, discovery: undefined })) === null); // The galaxy-redesign gate: an old build's save (format 1) references a // world that no longer exists — rejected with a clear, toast-able message. const legacy = makeRec({ format: 1 }); const legacyErr = SaveManager.validateRecord(legacy); check('validateRecord: a legacy (format 1) save is rejected', typeof legacyErr === 'string' && legacyErr.length > 0); check('validateRecord: the legacy message names the format gap (the UI toasts it)', typeof legacyErr === 'string' && legacyErr.includes('1') && legacyErr.includes(String(SAVE_FORMAT))); } // --------------------------------------------------------------------------- // SaveData — capture → (travel) → prepare/consume, over a fake registry // --------------------------------------------------------------------------- { const scene = fakeScene(); // Discover one object so the round-trip is non-trivial. scene.discovery.check(system.id, 0, 0, [{ id: 'pl:0', x: 500, y: 0, radius: 12 }]); check('setup: discovery has a hit', scene.discovery.isDiscovered(system.id, 'pl:0') === true); const rec = captureState(scene); check('capture: seed carried', rec.seed === SEED); check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name); check('capture: ship carried', rec.ship.x === 123.5 && rec.ship.y === -77 && rec.ship.heading === 0.72); check('capture: the hold is carried (ship.minerals)', rec.ship.minerals === 37); check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0')); check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost'); check('capture: playtime carried', rec.playTimeMs === 123456); // A FRESH registry = a new browser session: prepareLoad rebuilds the // galaxy from the seed and restores the discovery state. const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(reg, rec); const staged = reg.get(PENDING_RESTORE_KEY); check('prepare: galaxy rebuilt from the seed', reg.get('galaxy') instanceof Galaxy && reg.get('galaxy').seed === SEED); check('prepare: the rebuilt galaxy is the SAME galaxy', reg.get('galaxy').name === galaxy.name && reg.get('galaxy').byId.get(system.id)?.name === system.name); check('prepare: discovery restored', reg.get('discovery').isDiscovered(system.id, 'pl:0') === true); check('prepare: a NEW undiscovered object stays undiscovered', reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false); check('prepare: the live state is staged under the pending key', staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456); check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37); // A LEGACY record (pre-minerals) still loads — no field, the restore // side's `typeof === 'number'` guard keeps the ship at 0. const regLegacy = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(regLegacy, makeRec()); // makeRec's ship has no minerals check('prepare: a legacy record (no minerals) still loads, field absent', regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined); // BUILDS state: an in-flight build (remaining time on the loop clock) // rides the pending restore, like research. const buildsRec = { built: { Terra: ['tether-l1'] }, active: { planet: 'Keth', build: 'tether-l2', startedAt: 1000, durationMs: 20000, remainingMs: 15000 }, }; const regBuilds = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(regBuilds, makeRec({ builds: buildsRec })); check('prepare: builds staged (built records + the in-flight build)', regBuilds.get(PENDING_RESTORE_KEY).builds?.built?.Terra?.includes('tether-l1') === true && regBuilds.get(PENDING_RESTORE_KEY).builds?.active?.remainingMs === 15000); const regNoBuilds = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(regNoBuilds, makeRec()); // pre-build-system save check('prepare: a legacy record (no builds field) stages null builds', regNoBuilds.get(PENDING_RESTORE_KEY).builds === null); // consumeRestore: exactly once. const first = consumeRestore(reg); const second = consumeRestore(reg); check('consume: first read returns the staged state', first !== null && first.ship.y === -77); check('consume: second read returns null (consumed)', second === null); // resetRunState: New Game clears the run-state seams. reg.set('discovery', reg.get('discovery')); reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } }); resetRunState(reg); check('reset: discovery cleared (fresh run)', reg.get('discovery') === null); check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null); } // prepareLoad must reject a record it can't trust. { const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; let threw = false; try { prepareLoad(reg, makeRec({ seed: null })); } catch { threw = true; } check('prepare: invalid record throws', threw === true); } if (failures > 0) { console.error(`\n${failures} save-system test(s) FAILED`); process.exit(1); } console.log('\nsave-system: all checks passed');