286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
/**
|
|
* 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),
|
|
depletedClusters: new Set([`${system.id}:c-depleted`]), // a mined-out field
|
|
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);
|
|
check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
|
|
|
|
// 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: depleted fields restored (the field stays gone)',
|
|
reg.get('depletedClusters') instanceof Set && reg.get('depletedClusters').has(`${system.id}:c-depleted`));
|
|
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);
|
|
check('prepare: a legacy record (no depletedClusters) stages an empty set',
|
|
regLegacy.get('depletedClusters') instanceof Set && regLegacy.get('depletedClusters').size === 0);
|
|
|
|
// 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: depleted fields cleared (fresh run)', reg.get('depletedClusters') === 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');
|