/** * Reputation test (dev tool, run with Node — no browser): * * node dev/reputation.test.mjs * * Covers the standing layer (js/reputation/Reputation.js) and its seams: * - the SCALE (data/reputation.json): min…max, integer steps, neutral, * clamping of every write (set/change); * - the HOME WORLD exception: pinned at +20 (read, set, change, and * across a save round-trip) — nothing ever moves it; * - the FACTION CHECK: standingFor() resolves * home → stored → owner/faction → neutral, with the per-faction * standing (setFaction/changeFaction — a new player is NEUTRAL with * every faction) and settlements' `owner` carrying the faction id; * - SAVE round-trips: toJSON/fromJSON (including corrupt and * pre-reputation records — they load all-neutral); * - the SAVE INTEGRATION (js/save/SaveData.js): captureState carries * the standing, prepareLoad stages it in the registry, resetRunState * clears it for a fresh run; * - PLACE IDENTITY from the generator: planets `-p`, * settlements `-s`, unique per system, stable per seed — * and usable straight as reputation keys (standingFor(planetRecord)). */ 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 { Reputation, HOME_KEY } = await import(file('reputation/Reputation.js')); const { captureState, prepareLoad, resetRunState } = await import(file('save/SaveData.js')); const { Galaxy } = await import(file('galaxy/Galaxy.js')); const { Discovery } = await import(file('galaxy/Discovery.js')); const { SaveManager } = await import(file('save/SaveManager.js')); let failures = 0; let pass = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (cond) pass++; else failures++; }; const MIN = config.get('reputation.min', -20); const MAX = config.get('reputation.max', 20); const NEUTRAL = config.get('reputation.neutral', 0); const HOME = config.get('reputation.home', 20); // ---------------------------------------------------------------------- // 1. Scale & the neutral default // ---------------------------------------------------------------------- { const rep = new Reputation(); check('scale comes from data/reputation.json (−20…+20, neutral 0, home +20)', rep.min === -20 && rep.max === 20 && rep.neutral === 0 && rep.home === 20); check('an unknown place is neutral by default', rep.get('S000123-p3') === NEUTRAL); check('a place with no id at all is neutral (not an error)', rep.standingFor(null) === NEUTRAL && rep.standingFor({}) === NEUTRAL); check('get() reads home through the raw path too', rep.get(HOME_KEY) === HOME); } // ---------------------------------------------------------------------- // 2. The home world exception — pinned at +20, nothing moves it // ---------------------------------------------------------------------- { const rep = new Reputation(); check('home world is +20 (best reputation) at birth', rep.get(HOME_KEY) === 20); check('set() is a no-op on the home world', rep.set(HOME_KEY, -20) === false); check('change() is a no-op on the home world (both directions)', rep.change(HOME_KEY, -50) === false && rep.change(HOME_KEY, 50) === false); check('…and it is still +20 afterwards', rep.get(HOME_KEY) === 20); check('a home-world entry smuggled into a save is dropped on load', Reputation.fromJSON({ min: MIN, max: MAX, neutral: NEUTRAL, home: HOME, places: { [HOME_KEY]: -20, 'S1-p1': 5 } }).get(HOME_KEY) === 20); } // ---------------------------------------------------------------------- // 3. The scale: clamping, integer steps, the mutation seams // ---------------------------------------------------------------------- { const rep = new Reputation(); const k = 'S000042-p1'; check('set() stores a standing on the scale', rep.set(k, 7) === true && rep.get(k) === 7); check('set() clamps to the best reputation (+20)', rep.set(k, 99) === true && rep.get(k) === MAX); check('set() clamps to the worst reputation (−20)', rep.set(k, -99) === true && rep.get(k) === MIN); check('set() rounds to the integer step', rep.set(k, 4.6) === true && rep.get(k) === 5); check('set() ignores non-numeric input (standing untouched)', rep.set(k, 'famous') === false && rep.get(k) === 5); check('set() to the same value reports "no change"', rep.set(k, 5) === false); check('change() shifts from the current standing, clamped at +20', rep.change(k, 100) === true && rep.get(k) === MAX); check('change() shifts downward, clamped at −20', rep.change(k, -1000) === true && rep.get(k) === MIN); check('change() ignores non-numeric deltas', rep.change(k, 'a lot') === false); // The custom-scale constructor (tests/dev tools pin their own scale). const r2 = new Reputation(-10, 10, 0, 10); check('a custom scale clamps its own way', r2.set('x', 15) && r2.get('x') === 10 && r2.get('y') === 0); let threw = false; try { new Reputation(5, 1); } catch { threw = true; } check('an inverted scale (min > max) is rejected', threw === true); } // ---------------------------------------------------------------------- // 4. The faction check — standingFor()'s resolution order, per-faction standing // ---------------------------------------------------------------------- { const rep = new Reputation(); const colony = { id: 'S000007-s1', kind: 'colony', name: 'New Denver', population: 120000, owner: null }; check('an unowned place reads neutral through standingFor()', rep.standingFor(colony) === NEUTRAL); rep.set('S000007-s1', 9); check('a stored standing wins', rep.standingFor(colony) === 9); // The FACTION CHECK, for real: a faction-owned place reads the standing // WITH the owning faction (setFaction) — unset = neutral (a new player's // standing with every faction). check('a new player is NEUTRAL with every faction (unset → null → 0)', rep.factionStanding('apex') === null && rep.standingFor({ id: 'S000008-s2', owner: 'apex' }) === NEUTRAL && rep.standingFor({ id: 'S000008-s2', owner: 'mindweave' }) === NEUTRAL && rep.standingFor({ id: 'S000008-s2', owner: 'zenith' }) === NEUTRAL); const owned = { id: 'S000008-s2', kind: 'deepSpaceStation', owner: 'apex' }; check('setFaction() stores a standing with the faction', rep.setFaction('apex', 7) === true && rep.factionStanding('apex') === 7); check('…and a faction-owned place reads it through standingFor()', rep.standingFor(owned) === 7); check('…but an unowned place (or another faction) is untouched', rep.standingFor(colony) === 9 && rep.standingFor({ id: 'x-s1', owner: 'zenith' }) === NEUTRAL); check('setFaction() clamps to the scale', rep.setFaction('zenith', 99) && rep.factionStanding('zenith') === MAX && rep.setFaction('zenith', -99) && rep.factionStanding('zenith') === MIN); check('setFaction() ignores bad input', rep.setFaction('', 5) === false && rep.setFaction('apex', 'famous') === false && rep.factionStanding('apex') === 7); check('changeFaction() shifts the faction standing, clamped', rep.changeFaction('apex', -100) && rep.factionStanding('apex') === MIN && rep.changeFaction('apex', 1000) && rep.factionStanding('apex') === MAX); check('changeFaction() starts from neutral (0) when unset', rep.changeFaction('mindweave', 3) && rep.factionStanding('mindweave') === 3); // Resolution order: home > stored place standing > faction > neutral. const owned2 = { id: 'S000009-s2', kind: 'colony', owner: 'apex' }; check('a STORED place standing beats the faction check — stored first', (rep.set('S000009-s2', -3), rep.standingFor(owned2)) === -3); check('the home world beats everything (stored, owned, faction)', rep.standingFor({ id: HOME_KEY, owner: 'apex' }) === rep.home && rep.standingFor(HOME_KEY) === rep.home); } // ---------------------------------------------------------------------- // 5. Serialization — save-ready, and forgiving of old/corrupt records // ---------------------------------------------------------------------- { const rep = new Reputation(); rep.set('S000001-p2', 12); rep.set('S000002-s3', -8); rep.setFaction('apex', 11); rep.setFaction('zenith', -4); const json = rep.toJSON(); check('toJSON() keeps the scale + the stored standings + the faction standings', json.min === MIN && json.max === MAX && json.places['S000001-p2'] === 12 && json.places['S000002-s3'] === -8 && json.factions.apex === 11 && json.factions.zenith === -4); const back = Reputation.fromJSON(JSON.parse(JSON.stringify(json))); check('fromJSON() round-trips every standing (places + factions)', back.get('S000001-p2') === 12 && back.get('S000002-s3') === -8 && back.get('S000003-p1') === NEUTRAL && back.factionStanding('apex') === 11 && back.factionStanding('zenith') === -4 && back.factionStanding('mindweave') === null); check('out-of-range values in a save are clamped back onto the scale', Reputation.fromJSON({ min: MIN, max: MAX, neutral: NEUTRAL, home: HOME, places: { a: 999, b: -999 } }) .get('a') === MAX); check('garbage entries are dropped, good ones kept', Reputation.fromJSON({ places: { a: 'famous', b: 5, 0: 3 } }).get('b') === 5 && Reputation.fromJSON({ places: { a: 'famous' } }).get('a') === NEUTRAL); check('faction garbage is dropped, good ones kept (clamped)', Reputation.fromJSON({ factions: { apex: 'famous', zenith: 5, 0: 3, mindweave: 999 } }) .factionStanding('zenith') === 5 && Reputation.fromJSON({ factions: { apex: 'famous' } }) .factionStanding('apex') === null); check('a record from BEFORE the faction check (no `factions` field) loads all-neutral', Reputation.fromJSON({ places: { a: 5 } }).factionStanding('apex') === null); check('null / missing records load as a fresh, all-neutral standing', Reputation.fromJSON(null).get('anywhere') === NEUTRAL && Reputation.fromJSON(undefined).home === HOME && Reputation.fromJSON(undefined).factionStanding('apex') === null); check('a scale snapshot travels with the save (old save, old rules)', Reputation.fromJSON({ min: -5, max: 5, neutral: 0, home: 5 }).get(HOME_KEY) === 5); } // ---------------------------------------------------------------------- // 6. Save integration (js/save/SaveData.js) — the record carries standing // ---------------------------------------------------------------------- { const SEED = 'REPUTATIONTEST'; const galaxy = Galaxy.create(SEED, { systemCount: 8 }); const system = galaxy.byId.get(galaxy.currentSystemId); const rep = new Reputation(); rep.set(`${system.id}-p1`, 11); 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: 1, y: 2, rotation: 0.5 }, discovery: new Discovery(540), reputation: rep, tetherField: { tethers: [{ id: 'home', x: 0, y: 0, level: 1, label: system.name }] }, playTimeMs: 99, }; const rec = captureState(fakeScene); check('captureState() puts the standing in the record (and it validates)', rec.reputation?.places?.[`${system.id}-p1`] === 11 && rec.reputation?.places?.[HOME_KEY] === undefined && SaveManager.validateRecord(rec) === null); // A scene from before reputation existed (no field) still saves… const legacy = captureState({ ...fakeScene, reputation: null }); check('…with a fresh all-neutral reputation standing in for the missing one', legacy.reputation && Object.keys(legacy.reputation.places).length === 0 && legacy.reputation.home === HOME); // …and prepareLoad() stages it back into the registry for the next scene. const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(reg, rec); const loaded = reg.get('reputation'); check('prepareLoad() stages a live Reputation with the saved standing', loaded instanceof Reputation && loaded.get(`${system.id}-p1`) === 11 && loaded.get(HOME_KEY) === HOME); // A record from before reputation existed loads as fresh + all-neutral. const reg2 = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; const legacyRec = { ...rec }; delete legacyRec.reputation; prepareLoad(reg2, legacyRec); check('a pre-reputation save loads all-neutral (old saves keep working)', reg2.get('reputation') instanceof Reputation && reg2.get('reputation').get(`${system.id}-p1`) === NEUTRAL); // New Game wipes the standing (a fresh run meets a fresh galaxy). resetRunState(reg); check('resetRunState() clears the standing for a new run', reg.get('reputation') === null); } // ---------------------------------------------------------------------- // 7. Place identity — the generator's stable keys, usable as-is // ---------------------------------------------------------------------- { const galaxy = Galaxy.create('PLACEID', { systemCount: 12 }); let shapeOk = true; let uniqueOk = true; for (const r of galaxy.records) { const c = galaxy.ensureContent(r.id); for (const p of c.planets) { if (p.id !== `${r.id}-p${p.ordinal}`) shapeOk = false; } const pids = new Set(c.planets.map((p) => p.id)); if (pids.size !== c.planets.length) uniqueOk = false; c.settlements.forEach((s, i) => { if (s.id !== `${r.id}-s${i + 1}`) shapeOk = false; }); const sids = new Set(c.settlements.map((s) => s.id)); if (sids.size !== c.settlements.length) uniqueOk = false; // No planet and settlement id may collide, and the home world's key // is reserved ('home' — not a system id, so it never matches). for (const s of c.settlements) if (pids.has(s.id) || s.id === HOME_KEY) uniqueOk = false; } check('every planet id is -p and every settlement id -s', shapeOk); check('place ids are unique per system (and never the home key)', uniqueOk); // Same seed ⇒ same place ids (the save's standing lines up with the // regenerated galaxy) — and identical whether content was generated // lazily (as in play) or eagerly (generateAll). const again = Galaxy.create('PLACEID', { systemCount: 12 }); const eager = Galaxy.create('PLACEID', { systemCount: 12 }).generateAll(); const idOf = (g) => g.records.map((r) => g.ensureContent(r.id).planets.map((p) => p.id)).join('|'); check('same seed ⇒ identical place ids (lazy and eager alike)', idOf(galaxy) === idOf(again) && idOf(galaxy) === idOf(eager)); // The keys are directly usable — standingFor() accepts a generated // planet or settlement record (owner seam included). Use a system // that actually HAS a settlement, so planet and place are distinct. const rep = new Reputation(); const sys = galaxy.records.find((r) => galaxy.ensureContent(r.id).settlements.length > 0); const c = galaxy.ensureContent(sys.id); const somePlanet = c.planets[0]; const someSettlement = c.settlements[0]; check('a system with settlements was found (test precondition)', !!sys && !!someSettlement); check('a generated record is its own reputation key (neutral at first)', rep.standingFor(somePlanet) === NEUTRAL && rep.standingFor(someSettlement) === NEUTRAL); rep.change(somePlanet.id, 4); check('…and standing written against it reads back through the record', rep.standingFor(somePlanet) === 4 && rep.standingFor(someSettlement) === NEUTRAL); check('the home world (the fixed key) is +20 wherever it is asked', rep.standingFor({ id: HOME_KEY }) === HOME); } // ---------------------------------------------------------------------- // ---------------------------------------------------------------------- // M. The standing bonuses (data → bonuses) — creditFraction() // ---------------------------------------------------------------------- { const pctQuest = config.get('reputation.bonuses.sideQuest', 0.10); const pctSale = config.get('reputation.bonuses.perGoodSold', 0.02); check('the data carries both bonuses (10% quest / 2% per good sold)', pctQuest === 0.10 && pctSale === 0.02); const rep = new Reputation(); const k = 'S000100-s1'; check('a side-quest claim (10% of the top) lands +2 on the issuer', rep.creditFraction(k, pctQuest) === true && rep.get(k) === 2); check('a second claim lands another +2 (4 total)', rep.creditFraction(k, pctQuest) === true && rep.get(k) === 4); // The shop's 2% per good sold — +0.4 per unit, CREDITING across sales // (no rounding-away): the standing moves only when a whole point lands. const k2 = 'S000101-p2'; check('one good sold (+0.4) credits — the standing stays neutral', rep.creditFraction(k2, pctSale) === false && rep.get(k2) === NEUTRAL); check('five goods sold (5 × 0.4 = +2) pay out whole', (rep.creditFraction(k2, pctSale * 5) === true && rep.get(k2) === 2) || rep.get(k2) === 2); check('…and the payout lands on a batched sale of five singles too', (rep.creditFraction('S000102-p1', pctSale), rep.creditFraction('S000102-p1', pctSale), rep.creditFraction('S000102-p1', pctSale), rep.creditFraction('S000102-p1', pctSale), rep.creditFraction('S000102-p1', pctSale)) && rep.get('S000102-p1') === 2); check('bad input no-ops (standing + credit untouched)', rep.creditFraction(k, 0) === false && rep.creditFraction(k, -1) === false && rep.creditFraction(k, 'a lot') === false && rep.get(k) === 4); check('the home world is PINNED through the bonus too (no-op, no credit)', rep.creditFraction(HOME_KEY, pctQuest) === false && rep.get(HOME_KEY) === HOME); check('the bonus clamps at the top (a near-max place can\u2019t overtop)', rep.set('S000103-p1', MAX - 1), rep.creditFraction('S000103-p1', pctQuest) && rep.get('S000103-p1') === MAX); // The CREDIT rides the save — toJSON/fromJSON round-trip it. const rep2 = new Reputation(); rep2.creditFraction('S000104-p1', pctSale); // +0.4 crediting, nothing paid const back = Reputation.fromJSON(JSON.parse(JSON.stringify(rep2.toJSON()))); check('a sub-point credit round-trips through the save', Math.abs((back.pending.get('S000104-p1') ?? 0) - 0.4) < 1e-9 && back.get('S000104-p1') === NEUTRAL && (back.creditFraction('S000104-p1', pctSale * 5) === true || back.get('S000104-p1') === 2) && back.get('S000104-p1') === 2); check('garbage credits in a save are dropped', Reputation.fromJSON({ pending: { a: 1.5, b: -2, c: 'nope' } }).pending.size === 0); } // ---------------------------------------------------------------------- // N. The reputation bar (the comms panel's gauge, js/ui/CommsPanel.js) // ---------------------------------------------------------------------- { const n = MAX - MIN + 1; // one mark per standing on the scale const bar = new Reputation(); check('the bar has one mark per standing on the scale (41 for −20…+20)', n === 41); check('the worst standing lights just the leftmost (red) mark', bar.marksFor(MIN) === 1); check('the best standing lights the whole bar (through green)', bar.marksFor(MAX) === n); check('a negative standing lights the left run (−10 → 11 marks)', bar.marksFor(-10) === 11); check('a positive standing lights past the centre (+5 → 26 marks)', bar.marksFor(5) === 26); check('0 lights the left half plus the centre mark (21 of 41)', bar.marksFor(0) === 21); check('out-of-scale standings clamp to the bar ends', bar.marksFor(99) === n && bar.marksFor(-99) === 1); check('fractional standings round to the integer step', bar.marksFor(4.6) === bar.marksFor(5)); check('non-numeric input lights nothing (0 marks)', bar.marksFor('famous') === 0); } // ---------------------------------------------------------------------- // P. The pirate-kill milestone (data → bonuses.pirateKills) — every 10 // pirates put down in a system, every place in it gains +1 standing. // (The payout itself lives on the Phaser scene — GameScene. // recordPirateKill — so this drives its pure seams: the data, the // tally's save round-trip, and the standing write it lands.) // ---------------------------------------------------------------------- { const pk = config.get('reputation.bonuses.pirateKills', {}); check('the data carries the milestone (every 10 pirates → +1 standing)', pk?.every === 10 && pk?.bonus === 1); // The standing write it lands: +1 on every place in the system (the // system's settlements — the comms panel's standing keys), clamped at // the top like any other change. const repM = new Reputation(); const places = [`${'S000200'}-s1`, `${'S000200'}-s2`]; for (let kill = 1; kill <= 10; kill++) { if (kill % pk.every === 0) for (const p of places) repM.change(p, pk.bonus); } check('after 10 kills in a system, every place in it has +1 standing', repM.get(places[0]) === 1 && repM.get(places[1]) === 1); check('…and the 11th kill moves nothing (the milestone pays once per 10)', repM.get(places[0]) === 1 && repM.get(places[1]) === 1); check('a place already at the top clamps instead of overtopping', repM.set('S000201-s1', MAX), repM.change('S000201-s1', pk.bonus) === false && repM.get('S000201-s1') === MAX); // The tally's save round-trip (the scene's map shape, normalized): // captureState carries it, prepareLoad stages it back, and garbage // entries are dropped instead of leaking into the scene. const SEED = 'PIRATEKILLTEST'; const galaxy = Galaxy.create(SEED, { systemCount: 8 }); const system = galaxy.byId.get(galaxy.currentSystemId); const fakeScene = { registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }, galaxy, systemRecord: { id: system.id, name: system.name }, ship: { x: 1, y: 2, rotation: 0.5 }, discovery: new Discovery(540), reputation: new Reputation(), pirateKillsBySystem: { [system.id]: 7, [`${system.id}-far`]: 13, bogus: 'lots', neg: -3 }, tetherField: { tethers: [] }, playTimeMs: 99, }; const rec = captureState(fakeScene); check('captureState() carries the per-system tallies (garbage dropped)', rec.pirateKills?.[system.id] === 7 && rec.pirateKills?.[`${system.id}-far`] === 13 && rec.pirateKills?.bogus === undefined && rec.pirateKills?.neg === undefined && SaveManager.validateRecord(rec) === null); const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(reg, rec); check('prepareLoad() stages the tallies back for the next scene', reg.get('pirateKillsBySystem')?.[system.id] === 7); // A save predating the milestone has no field — a fresh tally stands in. const legacyRec = { ...rec }; delete legacyRec.pirateKills; const reg2 = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; prepareLoad(reg2, legacyRec); check('a pre-milestone save loads with an empty tally (old saves load)', reg2.get('pirateKillsBySystem') && Object.keys(reg2.get('pirateKillsBySystem')).length === 0); // New Game wipes the tallies (a fresh run meets a fresh galaxy). resetRunState(reg); check('resetRunState() clears the tallies for a new run', reg.get('pirateKillsBySystem') === null); } if (failures > 0) { console.error(`\n${failures} reputation test(s) FAILED`); process.exit(1); } console.log(`\nAll reputation tests passed (${pass} checks).`);