orbit/dev/reputation.test.mjs

307 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 placeholder: standingFor() resolves
* home → stored → owner/faction → neutral, with factionStanding()
* stubbed (factions land later) and settlements' `owner` null today;
* - 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 `<sysId>-p<ordinal>`,
* settlements `<sysId>-s<n>`, 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 (placeholder) — standingFor()'s resolution order
// ----------------------------------------------------------------------
{
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);
// Owner set (the factions layer will populate this): the check consults
// factionStanding() — stubbed to null today, so neutral again…
const owned = { id: 'S000008-s2', kind: 'deepSpaceStation', owner: 'pirates' };
check('…an owned place with no stored standing still reads neutral (stub)',
rep.standingFor(owned) === NEUTRAL);
check('…but a STORED standing beats the (stub) faction check — stored first',
(rep.set('S000008-s2', -3), rep.standingFor(owned)) === -3);
// And a stub that "has" standing (simulating the factions layer landing)
// would hand it straight to standingFor() — the seam is real:
const rep2 = new Reputation();
const standIn = (id) => (id === 'pirates' ? 15 : null);
rep2.factionStanding = standIn; // monkey-patch: pretend the factions exist
check('when factionStanding() answers, standingFor() uses it for owned places',
rep2.standingFor({ id: 'S000009-s1', owner: 'pirates' }) === 15
&& rep2.standingFor({ id: 'S000010-s1', owner: null }) === 0
&& rep2.standingFor({ id: 'S000011-s1' }) === 0);
check('the home world beats everything (stored, owned, faction)',
rep2.standingFor({ id: HOME_KEY, owner: 'pirates' }) === rep2.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);
const json = rep.toJSON();
check('toJSON() keeps the scale + the stored standings',
json.min === MIN && json.max === MAX && json.places['S000001-p2'] === 12 && json.places['S000002-s3'] === -8);
const back = Reputation.fromJSON(JSON.parse(JSON.stringify(json)));
check('fromJSON() round-trips every standing',
back.get('S000001-p2') === 12 && back.get('S000002-s3') === -8 && back.get('S000003-p1') === NEUTRAL);
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('null / missing records load as a fresh, all-neutral standing',
Reputation.fromJSON(null).get('anywhere') === NEUTRAL
&& Reputation.fromJSON(undefined).home === HOME);
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 <systemId>-p<ordinal> and every settlement id <systemId>-s<n>', 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);
}
// ----------------------------------------------------------------------
// ----------------------------------------------------------------------
// 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);
}
if (failures > 0) {
console.error(`\n${failures} reputation test(s) FAILED`);
process.exit(1);
}
console.log(`\nAll reputation tests passed (${pass} checks).`);