259 lines
14 KiB
JavaScript
259 lines
14 KiB
JavaScript
/**
|
||
* 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);
|