orbit/dev/classes.test.mjs

259 lines
14 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.

/**
* Class layer test (dev tool, run with Node — no browser):
*
* node dev/classes.test.mjs (or: node dev/run-all.mjs classes)
*
* Covers the class layer's PURE half — everything except the window:
* - data/classes.json is a real config file (listed in data/manifest.json,
* so the browser loads it) and normalizes into the roster the UI reads;
* - js/player/ClassModel.js: the roster (Vanguard + Wraith in data order),
* the accents/insignia frames, the perk records + their numbers, the
* door level read from data/shipScreen.json → level.classAt (one source),
* the template fills the confirm/toast print, and the junk handling
* (an unknown id is null, a malformed roster entry is dropped);
* - js/player/ClassState.js: the ledger — choose, the IDEMPOTENT initial
* bonus (a repeat grants nothing), the different-class refusal, the
* skill-point bank's spend/award guards;
* - js/save/SaveData.js: record.playerClass round-trips (id + perks +
* skill points survive capture → load) and an old save with no field
* loads as "no class on file".
*
* All numbers come from the real data/*.json, not a private copy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
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 dataDir = join(__dirname, '../data');
const readJson = (p) => JSON.parse(fs.readFileSync(join(dataDir, p), 'utf8'));
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = readJson(f);
}
config.init(configData);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// ------------------------------------------------------- the shipped roster
const model = await import(file('player/ClassModel.js'));
const { ClassState } = await import(file('player/ClassState.js'));
{
const manifest = readJson('manifest.json');
check(
'data/classes.json is listed in data/manifest.json (the browser loads it)',
manifest.files.includes('classes.json'),
);
check('JSON parses as a config section', !!config.section('classes', null));
}
{
const list = model.classList();
check('the roster offers two classes', list.length === 2);
check('in data order: vanguard, wraith', list[0]?.id === 'vanguard' && list[1]?.id === 'wraith');
const vg = model.classDef('vanguard');
const wr = model.classDef('wraith');
check('Vanguard label + frame 0 (classes.png)', vg.label === 'VANGUARD' && vg.frame === 0);
check('Wraith label + frame 1 (classes.png)', wr.label === 'WRAITH' && wr.frame === 1);
// The theme: Vanguard reads RED, Wraith reads PURPLE (the brief).
const rgb = (n) => [ (n >> 16) & 255, (n >> 8) & 255, n & 255 ];
const [vr, vg2, vb] = rgb(vg.accent);
const [ur, ug, ub] = rgb(wr.accent);
check('Vanguard accent is a red (R leads)', vr > 160 && vr > vg2 + 40 && vr > vb + 40);
check('Wraith accent is a purple (R + B lead, G lowest)', ur > 90 && ub > 160 && ug < Math.min(ur, ub));
check('both accents carry a css twin for Phaser text', /^#[0-9a-fA-F]{6}$/.test(vg.accentCss) && /^#[0-9a-fA-F]{6}$/.test(wr.accentCss));
check('copy is authored in data, not code (summary + overview)', vg.summary.length > 10 && vg.overview.length >= 2);
check('every class banks 5 skill points', vg.skillPoints === 5 && wr.skillPoints === 5);
check('Vanguard grants the dual cannon', vg.perks.map((p) => p.id).join() === 'dualCannon');
check('Wraith grants the targeting computer', wr.perks.map((p) => p.id).join() === 'targetingComputer');
check('a perk record carries its label + note', !!vg.perks[0].label && vg.perks[0].note.length > 10);
// The perk's MECHANICS numbers live in the data (the combat code reads them).
const dual = model.perkDef('dualCannon');
const tc = model.perkDef('targetingComputer');
check('dualCannon: two barrels, offset off the centreline', dual.barrels === 2 && dual.barrelOffset > 0);
check('targetingComputer: a lead cap is configured', Number(tc.maxLead) > 0);
check('perkDef(unknown) is null', model.perkDef('not-a-perk') === null);
// The door level has ONE source (data/shipScreen.json → level.classAt).
const shipLevel = config.section('shipScreen.level', {});
check('classAt reads shipScreen.level.classAt', model.classAt() === shipLevel.classAt && model.classAt() === 5);
// The insignia sheet: assets/images/classes.png, 128×128 frames.
const sheet = model.iconSheet();
check('the insignia sheet is classes.png at 128×128', /classes\.png$/.test(sheet.texture) && sheet.frameWidth === 128 && sheet.frameHeight === 128);
check('iconFrame mirrors the roster', model.iconFrame('wraith') === 1 && model.iconFrame('nope') === null);
// Case-insensitive lookup (the save's id / a URL param both land here).
check('lookup is case- and space-tolerant', model.classDef(' Vanguard ')?.id === 'vanguard');
check('unknown class → null', model.classDef('pirate') === null && model.classDef(null) === null);
// The confirm/toast templates resolve with no leftover placeholders.
const conf = model.confirmSpec('vanguard');
check('confirm title names the class', conf.title.includes('VANGUARD'));
check('confirm line1 lists the perk + skill points', conf.line1.includes('DUAL CANNON') && conf.line1.includes('+5'));
check('confirm warns about the later price', /later/i.test(conf.line2) && /price/i.test(conf.line2));
check('no unresolved placeholder in the confirm copy', !/[{]\w+[}]/.test(`${conf.title}${conf.line1}${conf.line2}`));
const msg = model.successMessage('wraith');
check('the lock-in toast reads the class + its bonus', msg.includes('WRAITH') && msg.includes('TARGETING COMPUTER') && !/[{]\w+[}]/.test(msg));
// The briefing (Trestle's copy + his clip) is data too.
const intro = model.introSpec();
check('the brief names the speaker', intro.speaker === 'TRESTLE');
check('the brief text is Trestle’s level-5 speech', /level 5 is no joke/i.test(intro.text) && /change your class later for a price/i.test(intro.text));
check('the brief points at assets/speech/trestle/classes.mp3', intro.audio === 'assets/speech/trestle/classes.mp3');
check(`the brief’s clip exists (${intro.audio})`, fs.existsSync(join(__dirname, '..', intro.audio)));
const win = model.windowSpec();
check('the feed is classes.mp4', /classes\.mp4$/.test(win.video.file));
const clip = join(__dirname, '..', 'assets/videos', win.video.file.split('/').pop());
check(`the feed clip exists (${win.video.file})`, fs.existsSync(clip));
}
// ------------------------------------------------- malformed data degrades
{
const pristine = configData.classes;
config.init({
...configData,
classes: {
enabled: true,
classes: [
{ label: 'NO ID' }, // no id → dropped (never a dead tab)
{ id: ' ', label: 'BLANK' }, // blank id → dropped
{ id: 'solo', label: 'Solo', bonuses: { skillPoints: 'nope', perks: ['ghost', 'dualCannon'] } },
],
perks: pristine.perks,
},
});
const list = model.classList();
check('a roster entry with no usable id is dropped', list.length === 1 && list[0].id === 'solo');
check('a junk skillPoints reads 0 (never NaN)', list[0].skillPoints === 0);
check('an unknown perk drops, a known one stays', list[0].perks.map((p) => p.id).join() === 'dualCannon');
config.init({ ...configData, classes: { ...pristine, enabled: false } });
check('classes.enabled:false empties the roster (the layer is off)', model.classList().length === 0);
config.init(configData); // restore the shipped data for the rest of the run
}
// --------------------------------------------------------- the run ledger
{
const s = new ClassState();
check('fresh ledger: no class, no perks, empty bank', !s.hasClass && s.perks.size === 0 && s.skillPoints === 0);
check('fresh ledger: no perk is owned', s.hasPerk('dualCannon') === false);
check('label/frame read empty without a class', s.label === '' && s.frame === null);
const r1 = s.choose('vanguard');
check('choose(vanguard) succeeds', r1.ok === true && r1.id === 'vanguard');
check('the dual cannon is owned', s.hasPerk('dualCannon') === true);
check('5 skill points are banked', s.skillPoints === 5);
check('the ledger reads its label + insignia frame', s.label === 'VANGUARD' && s.frame === 0);
check('stats() names the class for the consoles', s.stats().name === 'VANGUARD');
// THE GRANT IS IDEMPOTENT — a repeat pays nothing.
const r2 = s.choose('vanguard');
check('re-choosing the same class is ok, but grants nothing', r2.ok === true && r2.alreadyGranted === true);
check('the bank did not double', s.skillPoints === 5);
check('the perk set did not grow', s.perks.size === 1);
const r3 = s.choose('vanguard');
check('idempotent a third time too', s.skillPoints === 5 && r3.alreadyGranted === true);
// A DIFFERENT class is refused (changing class is a later layer with a price).
const r4 = s.choose('wraith');
check('a different class is refused with reason "taken"', r4.ok === false && r4.reason === 'taken' && s.id === 'vanguard');
const r5 = s.choose('bogus');
check('an unknown class is refused with reason "unknown"', r5.ok === false && r5.reason === 'unknown');
check('refusals moved nothing', s.skillPoints === 5 && s.hasPerk('targetingComputer') === false);
// Case/spaces still resolve (a URL param, a hand-written save).
const s2 = new ClassState();
check('choose is case- and space-tolerant', s2.choose(' WRAITH ').ok === true && s2.hasPerk('targetingComputer'));
check('the wraith banked its own 5 points', s2.skillPoints === 5);
// The bank's guards.
check('spend within the bank pays', s.spendSkillPoints(2) === true && s.skillPoints === 3);
check('spend beyond the bank is refused', s.spendSkillPoints(99) === false && s.skillPoints === 3);
check('award() tops the bank up', s.awardSkillPoints(4) === 7 && s.skillPoints === 7);
check('a junk award is a no-op', s.awardSkillPoints('nope') === 7 && s.spendSkillPoints(-3) === true && s.skillPoints === 7);
const s3 = new ClassState();
check('with no class on file the bank cannot be spent', s3.spendSkillPoints(1) === false);
}
// --------------------------------------------------------- the save seam
{
const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js'));
const { captureState, prepareLoad, consumeRestore, resetRunState } = await import(file('save/SaveData.js'));
const { Galaxy } = await import(file('galaxy/Galaxy.js'));
const SEED = 'CLASSTEST';
const galaxy = Galaxy.create(SEED, { systemCount: 8 });
const system = galaxy.byId.get(galaxy.currentSystemId);
const mkRegistry = () => {
const map = new Map();
return { map, set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
};
const classState = ClassState.fromJSON({ id: 'wraith', perks: ['targetingComputer'], skillPoints: 5, granted: true });
const scene = {
registry: mkRegistry(),
galaxy,
systemRecord: { name: system.name },
ship: { x: 10, y: 20, rotation: 0, minerals: 0, credits: 0, stats: {} },
discovery: null,
classState,
playTimeMs: 1000,
};
const rec = captureState(scene);
check('capture writes record.playerClass', !!rec.playerClass && rec.playerClass.id === 'wraith');
check('the record is plain JSON (perks as an array)', Array.isArray(rec.playerClass.perks) && JSON.stringify(rec.playerClass).includes('targetingComputer'));
check('validateRecord accepts the class field', SaveManager.validateRecord(rec) === null);
const reg2 = mkRegistry();
prepareLoad(reg2, rec);
const loaded = reg2.get('playerClass');
check('load restores the class id', loaded instanceof ClassState && loaded.id === 'wraith');
check('load restores the owned perks', loaded.hasPerk('targetingComputer') === true && loaded.hasPerk('dualCannon') === false);
check('load restores the skill-point bank', loaded.skillPoints === 5);
check('load keeps the grant witness (no double bonus after a load)', loaded.granted === true && loaded.choose('wraith').alreadyGranted === true && loaded.skillPoints === 5);
consumeRestore(reg2);
// An OLD save (predating the class layer) still loads — no class on file.
const legacy = { ...rec };
delete legacy.playerClass;
const reg3 = mkRegistry();
prepareLoad(reg3, legacy);
check('a save with no playerClass field stages nothing (old saves load)', reg3.get('playerClass') === null);
// A hand-edited record degrades instead of handing out unknown perks.
const bogus = { ...rec, playerClass: { id: 'pirate-lord', perks: ['dualCannon', 'godmode'], skillPoints: 9999, granted: true } };
const reg4 = mkRegistry();
prepareLoad(reg4, bogus);
check('an unknown class id drops the whole class', reg4.get('playerClass').hasClass === false);
const mixed = { ...rec, playerClass: { id: 'wraith', perks: ['dualCannon'], skillPoints: -7, granted: true } };
const reg5 = mkRegistry();
prepareLoad(reg5, mixed);
const strict = reg5.get('playerClass');
check('a perk the class does not own is dropped on load', strict.hasClass && strict.hasPerk('dualCannon') === false);
check('a negative bank clamps to 0', strict.skillPoints === 0);
// New Game clears it.
resetRunState(reg2);
check('resetRunState clears the class ledger', reg2.get('playerClass') === null);
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nclasses: all checks passed');
process.exit(failures ? 1 : 0);