366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
/**
|
||
* 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');
|