orbit/dev/xp.test.mjs

368 lines
16 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.

/**
* 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;
const bomberXp = Number(config.get('combat.enemyTypes.bomber.xp', baseXp)) || baseXp;
check("system: the raider carries the base type's xp (data)", raider.xp === baseXp);
check('system: the bomber carries its own xp (its data row, not the base)',
bomberXp !== baseXp && bomber.xp === bomberXp);
// 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 ITS value (toward level 2's 200)",
combat.xp.xp === bomberXp && 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');