307 lines
16 KiB
JavaScript
307 lines
16 KiB
JavaScript
/**
|
||
* 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).`);
|