/** * 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);