/** * Combat XP test (dev tool, run with Node — no browser needed): * * node dev/xp.test.mjs * * Exercises the combat level + experience bank (js/combat/XpManager.js — * the pure rules: the curve from data/progression.json, the grants, the * multi-level bursts, the cap's banked overflow, the EVENT seam the skill * layer will subscribe to, the save round-trip) — then the system's * wiring (js/combat/CombatSystem.js: a kill grants the hostile's type's * own `xp` (data/combat.json → enemy.xp / enemyTypes.bomber.xp), a * crossed level's beat fires the armor bump (data/progression.json → * reward) + the onLevelUp callback, and a RESTORED ledger's level applies * the bump on build with no events). * * All numbers come from the real data/*.json (data/progression.json + * data/combat.json included), so the assertions track the shipped config, * not a private copy. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fs = await import('node:fs'); // ------------------------------------------------------------------ stubs // The minimum of Phaser + a scene for the REAL combat modules (the same // seams dev/combat.test.mjs exercises — the manager itself needs none). const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); 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); console.log('config loaded from data/:', Object.keys(configData).join(', ')); const { XpManager } = await import( pathToFileURL(join(__dirname, '../js/combat/XpManager.js')).href ); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; // ------------------------------------------------------------------- curve { const m = new XpManager(); const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10; // The curve (data/progression.json — base × n^power, shipped: 50 × n²): check('curve: level 1 = 50 XP (the data, not a private copy)', m.xpForLevel(1) === 50); check('curve: level 1 = five base kills (the barometer)', m.xpForLevel(1) === 5 * baseXp); check('curve: level 2 = 200 XP', m.xpForLevel(2) === 200); check('curve: level 3 = 450 XP', m.xpForLevel(3) === 450); check('curve: level 80 = 320,000 XP', m.xpForLevel(80) === 320000); check('curve: quadratic (n²) — level n = n² × level 1', m.xpForLevel(5) === 25 * m.xpForLevel(1)); check('curve: the relative step shrinks (2→3 is a smaller × than 1→2)', m.xpForLevel(3) / m.xpForLevel(2) < m.xpForLevel(2) / m.xpForLevel(1)); check('curve: the cap is the level (80), not the formula', m.maxLevel === 80); check('curve: xpForLevel(<1) = 0', m.xpForLevel(0) === 0 && m.xpForLevel(-4) === 0); check('curve: fresh ledger (level 0, 0 xp, not max)', m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 50); } // ------------------------------------------------------------- the grants { const m = new XpManager(); const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10; // The barometer, as a player would see it: five kills of the base craft. let levelUps = 0; const sub = m.on('levelup', () => levelUps++); for (let i = 0; i < 5; i++) m.grant(baseXp, 'kill'); check('grant: five base kills → level 1 (xp 0, the bank is spent)', m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 200); sub(); m.grant(999); check('grant: the unsubscribe stops the listener (no more levelup events)', levelUps === 1); // A BIG grant crosses several levels — one 'levelup' event EACH (an // "unlocks exactly at level N" rule can't miss its level). 10,000 buys // 50+200+450+800+1250+1800+2450 = 7,000 → level 7, 3,000 banked toward 8. const m2 = new XpManager(); const crossed = []; m2.on('levelup', (ev) => crossed.push(ev.to)); const r = m2.grant(10000, 'dev'); // → level 7 check("grant: a big grant crosses to level 7 (the curve's math)", r.level === 7 && r.levelsGained === 7 && r.leveledUp === true && m2.xp === 3000); check('grant: one levelup event PER level crossed (1,2,3,4,5,6,7)', JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6, 7])); // The source tag rides the events (the ledger's audit trail). const m3 = new XpManager(); const srcs = []; m3.on('grant', (ev) => srcs.push(ev.source)); m3.grant(10, 'kill'); m3.grant(50, 'quest'); check('grant: the source tag rides each event', JSON.stringify(srcs) === JSON.stringify(['kill', 'quest'])); // grant(0) / junk: safe no-ops, no events. const m4 = new XpManager(); let grants = 0; m4.on('grant', () => grants++); const r0 = m4.grant(0); const rJunk = m4.grant('junk'); check('grant: 0 / junk amounts are no-ops (no events, no state)', grants === 0 && r0.gained === 0 && rJunk.gained === 0 && m4.xp === 0); // A throwing listener can't wedge the bank. const m5 = new XpManager(); m5.on('levelup', () => { throw new Error('a bad subscriber'); }); let threw = false; try { m5.grant(100, 'kill'); } catch { threw = true; } check('events: a throwing listener is caught (the grant still lands)', threw === false && m5.level === 1); } // ---------------------------------------------------------------- the cap { const m = new XpManager(); const max = m.maxLevel; const maxEvents = []; const levelEvents = []; m.on('max', (ev) => maxEvents.push(ev)); m.on('levelup', (ev) => levelEvents.push(ev.to)); // A grant bigger than the WHOLE curve (the sum of levels 1..80 = // 8,694,000): the level caps, the overflow is BANKED (harmless — the // post-cap currency will be skills, not XP). const totalToCap = (50 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 50 const huge = totalToCap + 12345; const r = m.grant(huge, 'dev'); check('cap: the level holds at the cap (80)', r.level === max && m.isMax === true); check('cap: the overflow is banked (xp keeps counting)', m.xp === 12345); check('cap: the "max" event fires ONCE, with the cap level', maxEvents.length === 1 && maxEvents[0].level === max); check('cap: all 80 levels crossed, one event each', levelEvents.length === max && levelEvents[max - 1] === max); // More grants at the cap: the bank grows, nothing else does. const xpBefore = m.xp; m.grant(500, 'kill'); check('cap: grants at the cap just bank (no events, the level holds)', m.xp === xpBefore + 500 && m.level === max && maxEvents.length === 1); // The banked bank survives the save round-trip (level + xp, verbatim). const json = m.toJSON(); check('cap: the save is tiny ({level, xp})', json.level === max && json.xp === xpBefore + 500); const m2 = XpManager.fromJSON(json); let events = 0; m2.on('levelup', () => events++); m2.on('grant', () => events++); check('cap: the restore is silent (a load is not a level-up)', events === 0 && m2.level === max && m2.xp === json.xp && m2.isMax === true); check('cap: at the cap, xpForNext is null + progress is full', m2.xpForNext() === null && m2.stats().progress === 1); } // --------------------------------------------------- events / save / reset { const m = new XpManager(); m.grant(550, 'kill'); // 550 = 50 + 200 + 300 → level 2, 300 banked check('state: level 2 with 300 banked toward level 3', m.level === 2 && m.xp === 300); const json = m.toJSON(); check('save: the record is {level, xp} — the curve stays in data', json.level === 2 && json.xp === 300 && Object.keys(json).length === 2); const m2 = XpManager.fromJSON(json); check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 300); // A restore clamps junk (a hand-edited record can't break the run). const m3 = XpManager.fromJSON({ level: 999, xp: -50 }); check('restore: level clamps to the cap, xp floors at 0', m3.level === m3.maxLevel && m3.xp === 0); const m4 = XpManager.fromJSON(null); check('restore: a null state is the fresh ledger', m4.level === 0 && m4.xp === 0); // reset: the new run's ledger (a menu action, not a save load). m2.reset(); check('reset: back to the fresh ledger (level 0, 0 xp)', m2.level === 0 && m2.xp === 0); // stats(): the bar's snapshot (progress 0..1, the next level's cost). const m5 = new XpManager(); m5.grant(25, 'kill'); const s = m5.stats(); check('stats: level 0, 25/50 toward level 1', s.level === 0 && s.xp === 25 && s.xpForNext === 50 && approx(s.progress, 0.5) && s.isMax === false); m5.grant(60, 'kill'); const s2 = m5.stats(); check('stats: after crossing, 35/200 toward level 2', s2.level === 1 && s2.xp === 35 && s2.xpForNext === 200 && approx(s2.progress, 0.175)); } // --------------------------------------------------- the system's wiring { // The REAL CombatSystem (a headless scene — the same seams // dev/combat.test.mjs exercises). A kill grants the hostile's TYPE's // own `xp` (data/combat.json), and a crossed level fires the beat: // the armor bump (data/progression.json → reward) + the callback. class V2 { constructor(x = 0, y = 0) { this.x = x; this.y = y; } set(x, y) { this.x = x; this.y = y; return this; } length() { return Math.hypot(this.x, this.y); } } class Sprite { constructor(scene, x, y, key, frame) { this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame; this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true; this.active = true; this.body = null; } setScale(s) { this.scale = s; return this; } setTint() { return this; } setBlendMode() { return this; } setDepth() { return this; } setOrigin() { return this; } setAlpha(a) { this.alpha = a; return this; } setVisible(v) { this.visible = v; return this; } destroy() { this.active = false; this.destroyed = true; } } const PhaserStub = { Physics: { Arcade: { Sprite } }, Math: { Angle: { Wrap: (a) => { const min = -Math.PI; const r = Math.PI * 2; return (((a - min) % r) + r) % r + min; } } }, Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } }, BlendModes: { ADD: 'ADD' }, }; globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this const makeImg = () => ({ x: 0, y: 0, scale: 1, alpha: 1, visible: true, active: true, setScale() { return this; }, setAlpha() { return this; }, setTint() { return this; }, setBlendMode() { return this; }, setDepth() { return this; }, setAngle() { return this; }, setVisible(v) { this.visible = v; return this; }, destroy() { this.active = false; }, }); const makeGraphics = () => ({ clear() { return this; }, lineStyle() { return this; }, fillStyle() { return this; }, lineBetween() { return this; }, fillCircle() { return this; }, fillRoundedRect() { return this; }, strokeRoundedRect() { return this; }, setScrollFactor() { return this; }, setDepth() { return this; }, setVisible(v) { this.visible = v; return this; }, destroy() { this.destroyed = true; }, }); const makeTextObj = () => ({ text: '', alpha: 0, visible: true, setText(t) { this.text = String(t); return this; }, setAlpha() { return this; }, setVisible(v) { this.visible = v; return this; }, setOrigin() { return this; }, setScrollFactor() { return this; }, setDepth() { return this; }, destroy() { this.destroyed = true; }, }); const makeScene = () => ({ textures: { exists: () => true }, make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) }, add: { existing: (o) => o, image: () => makeImg(), graphics: makeGraphics, text: makeTextObj, rectangle: () => ({ setInteractive() { return { on() {} }; }, setScrollFactor() { return this; }, setDepth() { return this; } }), }, physics: { add: { existing: (o) => { o.body = { velocity: new V2(), setCircle() {}, setAllowGravity() {} }; return o; } } }, scale: { width: 1280, height: 720 }, time: { now: 0 }, }); const { CombatSystem } = await import( pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href ); const scene = makeScene(); const ship = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0); scene.physics.add.existing(ship); ship.radius = 12; ship.stop = () => {}; const levels = []; const combat = new CombatSystem(scene, { ship, onLevelUp: (level) => levels.push(level), }); // Level 0: the base armor (playerShip's), no bump yet. check('system: level 0 = the base armor (no bump yet)', combat.maxHull === 100 && combat.maxShields === 40 && combat.hull === 100 && combat.shields === 40); // The roster's XP values (data/combat.json — the base's + the bomber's). const raider = combat.spawnEnemy(200, 0, 0, 'raider'); const bomber = combat.spawnEnemy(400, 0, 1, 'bomber'); const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10; check("system: the raider carries the base type's xp (data)", raider.xp === baseXp); check('system: the bomber carries its own xp (data: 25)', bomber.xp === 25); // A kill = one grant of the hostile's type's xp (the kill path is the // single seam — pack kills, wave kills, and rolling replacements alike). raider.damage(999); combat.killEnemy(raider, 1000); check("system: a kill grants the raider's xp (10, level still 0)", combat.xp.xp === 10 && combat.xp.level === 0 && levels.length === 0); // Four more base kills → the level lands: the armor bump + the beat. for (let i = 0; i < 4; i++) { const e = combat.spawnEnemy(200 + i * 40, 0, 10 + i); e.damage(999); combat.killEnemy(e, 2000 + i); } const hullPerLevel = Number(config.get('progression.reward.hullPerLevel', 3)) || 0; const shieldPerLevel = Number(config.get('progression.reward.shieldPerLevel', 2)) || 0; check('system: five base kills → level 1 (the barometer, through the system)', combat.xp.level === 1 && levels.length === 1 && levels[0] === 1); check("system: the level's armor bump stacks on the base (max armor up)", combat.maxHull === 100 + hullPerLevel && combat.maxShields === 40 + shieldPerLevel); check('system: the level-up refills the armor (the full-heal beat)', combat.hull === combat.maxHull && combat.shields === combat.maxShields); check("system: stats() carries the bank (the bar's LV chip reads it)", combat.stats().xp !== null && combat.stats().xp.level === 1); // The bomber's kill banks ITS value (the roster sets XP-per-threat). bomber.damage(999); combat.killEnemy(bomber, 3000); check("system: the bomber's kill banks 25 (toward level 2's 200)", combat.xp.xp === 25 && combat.xp.level === 1 && levels.length === 1); // A RESTORED ledger: the level's armor applies on build — with no // events (a load is not a level-up) and a full ship. const saved = XpManager.fromJSON({ level: 3, xp: 500 }); const ship2 = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0); scene.physics.add.existing(ship2); ship2.radius = 12; ship2.stop = () => {}; const levels2 = []; const combat2 = new CombatSystem(scene, { ship: ship2, xp: saved, onLevelUp: (level) => levels2.push(level), }); check("system: a restored ledger's level applies its armor on build", combat2.maxHull === 100 + 3 * hullPerLevel && combat2.maxShields === 40 + 3 * shieldPerLevel); check('system: the restore is silent (no level-up beat on a load)', levels2.length === 0 && combat2.hull === combat2.maxHull); combat2.destroy(); combat.destroy(); } // ------------------------------------------------------------------ report if (failures > 0) { console.error(`\n✘ ${failures} check(s) failed`); process.exit(1); } console.log('\n✔ all combat XP checks passed');