orbit/dev/skills-combat.test.mjs

755 lines
40 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.

/**
* SKILL COMBAT test (dev tool, run with Node — no browser needed):
*
* node dev/skills-combat.test.mjs
*
* The skill layer's RUNTIME rules, against the real js/combat/CombatSystem.js
* and the shipped data/*.json: the energy economy (data/combat.json → energy),
* the cast pipeline (seat → skill, every refusal a reason, the cost paid once,
* one recharge clock per skill ticked by update()), Ramming Charge's dash
* (speed × duration, the aim point PAST the hostile, the flight controls locked
* for the charge) and arrival blast (radius + damage multiples), and Reinforced
* Plating's hull pool (maxHull = base × (1 + 0.05·rank), rolled from BASE so a
* rank-up, a level-up armor bump, a combat restart and a save/load can never
* stack it).
*
* Every number asserted is READ from data/skills.json / data/combat.json — the
* test restates none of them (the same rule dev/skills.test.mjs holds to).
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TAU = Math.PI * 2;
// Mirror of Phaser.Math.Angle.Wrap — the vendored build's Angle.Wrap is
// Wrap(value, -π, π): ((v - min) % range + range) % range + min.
const wrap = (a) => {
const min = -Math.PI;
const range = Math.PI * 2;
return (((a - min) % range) + range) % range + min;
};
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); }
scale(s) { this.x *= s; this.y *= s; return this; }
}
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; }
}
function makeBody() {
return {
velocity: new V2(),
acceleration: new V2(),
enable: true,
setCircle() { return this; },
setAllowGravity() { return this; },
setVelocity(x, y) { this.velocity.set(x, y); return this; },
};
}
const PhaserStub = {
Physics: { Arcade: { Sprite } },
GameObjects: { Sprite },
Math: {
Angle: {
Wrap: wrap,
RotateTo: (cur, tgt, step) => (Math.abs(wrap(tgt - cur)) <= step ? tgt : cur + Math.sign(wrap(tgt - cur)) * step),
},
},
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } },
BlendModes: { ADD: 'ADD' },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
function makeImg(x = 0, y = 0, key = '__dot') {
const o = {
x, y, key, scale: 1, alpha: 1, visible: true, active: true,
setScale(s) { this.scale = s; return this; },
setAlpha(a) { this.alpha = a; 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; this.destroyed = true; },
};
return o;
}
// The readability layer's seams (js/combat/CombatHud.js) — just enough
// of the display list for it to run headless and be asserted against.
function makeGraphics() {
return {
visible: true,
clear() { return this; },
lineStyle() { return this; },
fillStyle() { return this; },
lineBetween() { return this; },
fillCircle() { return this; },
fillTriangle() { return this; },
setBlendMode() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.destroyed = true; },
};
}
function makeTextObj() {
return {
text: '', alpha: 0, visible: true,
setText(t) { this.text = String(t); return this; },
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeRectObj(x = 0, y = 0, w = 0, h = 0, alpha = 0) {
return {
x, y, width: w, height: h, alpha, visible: true,
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeScene({ withSheet = true } = {}) {
return {
textures: { exists: () => withSheet },
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
add: {
existing: (o) => o,
image: (x, y, key) => makeImg(x, y, key),
graphics: () => makeGraphics(),
text: (x, y, s) => makeTextObj(),
rectangle: (x, y, w, h, c, a) => makeRectObj(x, y, w, h, a ?? 0),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
};
}
// The real data/*.json (the skill + combat numbers under test are the shipped
// ones — this file restates none of them).
const fs = await import('node:fs');
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);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const { SkillState } = await import(pathToFileURL(join(__dirname, '../js/skills/SkillState.js')).href);
const { ClassState } = await import(pathToFileURL(join(__dirname, '../js/player/ClassState.js')).href);
const model = await import(pathToFileURL(join(__dirname, '../js/skills/SkillModel.js')).href);
const effects = await import(pathToFileURL(join(__dirname, '../js/skills/SkillEffects.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;
// ---------------------------------------------------------------- the numbers
const RAM = model.skillDef('ramming_charge', 'vanguard');
const PLATE = model.skillDef('reinforced_plating', 'vanguard');
const EFF = RAM.effect;
const BOLA = model.skillDef('boleadoras', 'wraith');
const HOP = model.skillDef('blink', 'wraith');
const BEFF = BOLA.effect;
const HEFF = HOP.effect;
const BASE_DAMAGE = effects.baseWeaponDamage(); // data/combat.json → player.weapon.damage
const MAX_ENERGY = Number(config.get('combat.energy.max', 0));
const REGEN = Number(config.get('combat.energy.regen', 0));
const SHIP_MAX_SPEED = Number(config.get('ship.maxSpeed', 480)) || 480;
const BASE_HULL = Number(config.get('combat.playerShip.hull', 0));
const HULL_PER_LEVEL = Number(config.get('progression.reward.hullPerLevel', 0));
console.log(
`data: ${RAM.label} ${RAM.energy}e/${RAM.cooldown}s ×${EFF.damageMul}+${EFF.damageMulPerRank}/pt ` +
`${EFF.speedMul}x ${EFF.durationMs}ms r${EFF.blastRadiusPx} · ${PLATE.label} +${PLATE.effect.hullBonusPerRank * 100}% ` +
`· weapon ${BASE_DAMAGE} · energy ${MAX_ENERGY}@${REGEN}/s`,
);
/** A Vanguard at play: a scene, a ship, the ledger (ranked as asked) and a fight. */
function makeFight({ points = 99, ranks = {}, classId = 'vanguard' } = {}) {
const scene = makeScene();
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 300, 360, '__ship', 0));
ship.maxSpeed = SHIP_MAX_SPEED;
const cls = new ClassState();
if (classId) cls.choose(classId);
cls.skillPoints = points;
const skills = new SkillState(cls);
skills.applyDefaultSeats();
for (const [id, n] of Object.entries(ranks)) {
for (let i = 0; i < n; i++) skills.rankUp(id);
}
const kills = [];
const combat = new CombatSystem(scene, { ship, onKill: (e) => kills.push(e) });
// The cannon is not what these tests are measuring: auto-fire stays off so a
// blast's number is the blast's number.
combat.autoFire = { ...combat.autoFire, enabled: false };
combat.skillState = skills;
combat.classState = cls;
combat.refreshSkillPassives();
return { scene, ship, cls, skills, combat, kills };
}
/** Run the fight's clock to `ms` in `step` ms frames (a dash step needs dt). */
function tick(combat, ms, step = 16, t0 = 1000) {
let t = t0;
for (let spent = 0; spent < ms; spent += step) {
t += step;
combat.update(t, step);
}
return t;
}
/** An immortal target so a damage assertion reads the number, not the corpse. */
function dummy(combat, x, y) {
const e = combat.spawnEnemy(x, y, 0);
e.maxHull = 1e6;
e.hull = 1e6;
e.maxShields = 0;
e.shields = 0;
return e;
}
// ============================================================ energy economy
{
const f = makeFight({ ranks: { ramming_charge: 1 } });
check('energy: the orb is data/combat.json → energy.max', f.combat.maxEnergy === MAX_ENERGY && f.combat.energy === MAX_ENERGY);
check(`energy: regen is data-driven and ON (${REGEN}/s)`, f.combat.energyRegen === REGEN && REGEN > 0);
f.combat.energy = 0;
tick(f.combat, 16);
check('energy: one frame recovers exactly regen × dt', approx(f.combat.energy, REGEN * 0.016, 1e-9));
f.combat.energy = MAX_ENERGY - RAM.energy;
const t = tick(f.combat, Math.ceil((RAM.energy / REGEN) * 1000), 16);
check(`energy: a spent ${RAM.energy}-point charge is affordable again in ~${Math.ceil(RAM.energy / REGEN)}s`, f.combat.energy >= RAM.energy && t - 1000 <= (RAM.energy / REGEN) * 1000 + 32);
const down = makeFight({ ranks: { ramming_charge: 1 } });
down.combat.playerDown = true;
down.combat.energy = 0;
tick(down.combat, 16);
check('energy: a broken hull does not regen (and the seat refuses)', down.combat.energy === 0 && down.combat.cast(0).reason === 'down');
}
// ============================================================== cast pipeline
{
const f = makeFight(); // Vanguard, seats seeded, nothing ranked
check('cast: no ledger means the seats are inert', (f.combat.skillState = null, f.combat.cast(0).reason === 'noLedger'));
f.combat.skillState = f.skills;
check('cast: an empty seat says so (seat 8 has nothing bound)', f.combat.cast(7).reason === 'empty' && f.combat.cast(7).seat === 8);
const inert = f.combat.cast(0);
check(`cast: rank 0 is INERT (${inert.label}) — a seat is not a skill`, inert.reason === 'inert' && inert.id === RAM.id && inert.rank === 0);
f.skills.rankUp(RAM.id);
const fired = f.combat.cast(0);
check(`cast: rank ≥1 fires and pays ${RAM.energy} energy`, fired.ok && f.combat.energy <= MAX_ENERGY - RAM.energy && fired.cost === RAM.energy);
check(`cast: the skill's own ${RAM.cooldown}s recharge starts`, f.combat.skillCooldowns.get(RAM.id) > RAM.cooldown - 0.1);
const twice = f.combat.cast(0);
check('cast: a second press inside the recharge is refused (with seconds left)', twice.reason === 'cooldown' && twice.cooldownLeft > 0);
// The recharge is per SKILL, not per seat: moving it does not hand the wait back.
f.skills.bindSeat(RAM.id, 4);
check('cast: a moved skill keeps its recharge (seats are addresses, not owners)', f.combat.cast(3).reason === 'cooldown' && f.combat.cast(0).reason === 'empty');
// Energy gate.
const broke = makeFight({ points: 99, ranks: { ramming_charge: 1 } });
broke.combat.energy = RAM.energy - 1;
const noEnergy = broke.combat.cast(0);
check('cast: a thin orb refuses (reason energy, cost reported)', noEnergy.reason === 'energy' && noEnergy.cost === RAM.energy && broke.combat.energy === RAM.energy - 1);
// A passive never fires — not even if a hand-edited save parked one on a seat.
const seeded = makeFight({ points: 0 });
seeded.skills.seats[2] = PLATE.id;
const passive = seeded.combat.cast(2);
check(`cast: a passive on a seat is refused (${passive.reason}) — it is always on`, passive.reason === 'passive' && passive.id === PLATE.id);
// The recharge runs on the fight clock and clears.
const slow = makeFight({ ranks: { ramming_charge: 1 } });
slow.combat.energy = RAM.energy * 2; // two charges' worth: the wait is the only wall
check('cast: first press lands', slow.combat.cast(0).ok === true);
tick(slow.combat, RAM.cooldown * 1000 - 100, 16);
check(`cast: still recharging just before ${RAM.cooldown}s`, slow.combat.skillCooldowns.get(RAM.id) > 0 && slow.combat.cast(0).reason === 'cooldown');
tick(slow.combat, 200, 16);
check(`cast: the recharge is done a moment later and the seat fires again`, slow.combat.skillCooldowns.size === 0 && slow.combat.cast(0).ok === true);
// An effect kind with no handler is refused, never silently "cast".
const typo = makeFight({ ranks: { ramming_charge: 1 } });
const rawEffect = config.get('skills.classes.vanguard.skills', [])[0].effect;
const keptKind = rawEffect.kind;
rawEffect.kind = 'notAWiredEffect';
model.invalidateSkillCache();
const bad = typo.combat.cast(0);
check('cast: an unknown effect kind refuses with unhandled and spends nothing', bad.reason === 'unhandled' && typo.combat.energy === MAX_ENERGY && typo.combat.skillCooldowns.size === 0);
rawEffect.kind = keptKind;
model.invalidateSkillCache();
// Disabled layer = the seats go back to being decoration.
const off = config.get('skills.enabled');
config.get('skills').enabled = false;
check('cast: data/skills.json → enabled:false closes the whole layer', makeFight({ ranks: { ramming_charge: 1 } }).combat.cast(0).reason === 'disabled');
config.get('skills').enabled = off;
// One charge at a time — and the reason names the REAL wall, so the seat is
// resolved before the "busy" answer (an empty seat still says EMPTY; the
// skill mid-charge says RECHARGE, since its clock started when it fired).
const busy = makeFight({ ranks: { ramming_charge: 1 } });
check('cast: a charge starts', busy.combat.cast(0).ok === true);
check('cast: the charging skill answers with its own recharge, not a generic busy', busy.combat.cast(0).reason === 'cooldown');
check('cast: an empty seat mid-charge still reads empty', busy.combat.cast(6).reason === 'empty');
}
// ============================================================ Ramming Charge
{
const f = makeFight({ ranks: { ramming_charge: 1 } });
const foe = dummy(f.combat, 700, 360);
const res = f.combat.cast(0);
const gap = Math.hypot(700 - f.ship.x, 360 - f.ship.y);
check(`dash: the aim point is PAST the hostile (+${EFF.overshootPx}px overshoot, never inside it)`, approx(res.dash.toX, 700 + EFF.overshootPx, 1e-6) && approx(res.dash.toY, 360, 1e-6));
check('dash: the flight distance is the gap plus the overshoot', approx(res.dash.distance, gap + EFF.overshootPx, 1e-6));
check(`dash: speed is ${EFF.speedMul}× the ship's own max`, approx(res.dash.speed, SHIP_MAX_SPEED * EFF.speedMul, 1e-6));
check('dash: the flight is time-boxed to the data duration', res.dash.durationMs === EFF.durationMs);
// The controls are HELD OFF for the charge (a mid-dash click does not queue).
f.ship.target = { x: 0, y: 700 };
f.ship.thrustDir = { x: -1, y: 0 };
tick(f.combat, 32);
check('dash: the player cannot steer mid-charge (target + throttle are dropped every frame)', f.ship.target === null && f.ship.thrustDir === null);
// It covers the ground at the data speed (whole frames of it, so the window is
// exact: n frames × 16 ms × speed).
const before = { x: f.ship.x, y: f.ship.y };
const FRAMES = 6;
tick(f.combat, FRAMES * 16);
const moved = Math.hypot(f.ship.x - before.x, f.ship.y - before.y);
check(`dash: it moves the hull at ${EFF.speedMul}× max speed (${Math.round(moved)}px in ${FRAMES} frames)`, approx(moved, SHIP_MAX_SPEED * EFF.speedMul * FRAMES * 0.016, 1e-6));
}
{
// Damage at 1/5, 2/5 and 5/5 — the multiples the brief states (4x, then +2x).
for (const rank of [1, 2, 5]) {
const f = makeFight({ ranks: { ramming_charge: rank } });
const foe = dummy(f.combat, 620, 360);
const hp = foe.hull;
f.combat.cast(0);
tick(f.combat, EFF.durationMs + 120);
const dealt = hp - foe.hull;
const expected = BASE_DAMAGE * (EFF.damageMul + EFF.damageMulPerRank * (rank - 1));
check(`${RAM.label} ${rank}/5 deals ${expected} (${EFF.damageMul + EFF.damageMulPerRank * (rank - 1)}× the cannon's ${BASE_DAMAGE})`, approx(dealt, expected, 1e-6));
check(`${RAM.label} ${rank}/5: the charge ends and the hull stops dead`, f.combat.dash === null && Math.hypot(f.ship.body.velocity.x, f.ship.body.velocity.y) === 0);
check(`${RAM.label} ${rank}/5: the ship lands PAST the hostile it charged`, f.ship.x > foe.x);
// The blast is ONE hit per hostile (not one per frame of the shock).
const hp2 = foe.hull;
tick(f.combat, 400);
check(`${RAM.label} ${rank}/5: the blast lands once (no repeat damage after arrival)`, foe.hull === hp2);
}
}
{
// The radius is the data's, and it reaches everything inside it at once.
const f = makeFight({ ranks: { ramming_charge: 1 } });
const near = dummy(f.combat, 600, 360);
const inBlast = dummy(f.combat, 640, 300);
const farAway = dummy(f.combat, 620 + EFF.blastRadiusPx + 120, 360);
const before = [near.hull, inBlast.hull, farAway.hull];
f.combat.cast(0);
tick(f.combat, EFF.durationMs + 140);
const expected = BASE_DAMAGE * EFF.damageMul;
check('blast: every hostile inside the data radius takes the hit', near.hull === before[0] - expected && inBlast.hull === before[1] - expected);
check(`blast: a hostile outside the ${EFF.blastRadiusPx}px shock is untouched`, farAway.hull === before[2]);
}
{
// Lethality runs through the real kill path (a normal hull does not survive).
const f = makeFight({ ranks: { ramming_charge: RAM.maxPoints } });
const foe = f.combat.spawnEnemy(f.ship.x + 240, f.ship.y, 0);
const total = foe.hull + foe.shields;
check(`a live hostile's armor (${total}) is under the ${RAM.maxPoints}/${RAM.maxPoints} blast`, total < BASE_DAMAGE * (EFF.damageMul + EFF.damageMulPerRank * (RAM.maxPoints - 1)));
f.combat.cast(0);
tick(f.combat, EFF.durationMs + 140);
check('blast: the kill runs the real death path (the pack thins, onKill fires)', foe.dead === true && f.kills.length === 1);
}
{
// The clock, not only arrival: a hostile far out still ends the charge.
const f = makeFight({ ranks: { ramming_charge: 1 } });
dummy(f.combat, f.ship.x + SHIP_MAX_SPEED * EFF.speedMul * 6, 360); // unreachable in the window
const startX = f.ship.x;
f.combat.cast(0);
tick(f.combat, EFF.durationMs - 60, 16);
const stillGoing = f.combat.dash !== null;
tick(f.combat, 120, 16);
const traveled = f.ship.x - startX;
const cap = SHIP_MAX_SPEED * EFF.speedMul * (EFF.durationMs / 1000);
check('dash: still flying just before the duration is up', stillGoing === true);
check(`dash: the clock ends it even without a target (traveled ${Math.round(traveled)} ≤ ${Math.round(cap)}px = speed × duration)`, f.combat.dash === null && traveled <= cap + 1e-6 && traveled > cap * 0.95);
}
// ========================================================= Reinforced Plating
{
const maxAt = (rank) => Math.round(BASE_HULL * (1 + PLATE.effect.hullBonusPerRank * rank));
for (const rank of [0, 1, 2, 3, 4, 5]) {
const f = makeFight({ ranks: { reinforced_plating: rank } });
check(`plating ${rank}/5: max hull is ${maxAt(rank)} (${BASE_HULL} × 1 + ${PLATE.effect.hullBonusPerRank * 100}% × ${rank})`, f.combat.maxHull === maxAt(rank));
check(`plating ${rank}/5: the bar's orbs read the grown pool (hull starts full)`, f.combat.stats().maxHull === maxAt(rank) && f.combat.hull === maxAt(rank) && approx(f.combat.hullBonus, PLATE.effect.hullBonusPerRank * rank, 1e-9));
}
const f = makeFight({ ranks: { reinforced_plating: 0 } });
f.combat.hull = Math.round(f.combat.maxHull / 2);
const beforeMax = f.combat.maxHull;
const beforeHull = f.combat.hull;
f.skills.rankUp(PLATE.id);
f.combat.refreshSkillPassives();
const grew = f.combat.maxHull - beforeMax;
check(`plating: a rank landed mid-fight adds the hull it promises (${beforeMax}→${f.combat.maxHull})`, grew === maxAt(1) - beforeMax);
check('plating: it is NOT a free heal (the damage already taken stays taken)', f.combat.hull === beforeHull + grew && f.combat.hull < f.combat.maxHull);
const before = { max: f.combat.maxHull, hull: f.combat.hull };
for (let i = 0; i < 6; i++) f.combat.refreshSkillPassives();
check('plating: refreshing the passives is idempotent (rolled from base, never compounded)', f.combat.maxHull === before.max && f.combat.hull === before.hull);
// A level-up bumps the base and re-rolls — the two multipliers stack once.
const lvl = makeFight({ ranks: { reinforced_plating: PLATE.maxPoints } });
lvl.combat.xp.grant(100000, 'test');
const LEVELS = lvl.combat.xp.level;
const expectLevelMax = Math.round((BASE_HULL + LEVELS * HULL_PER_LEVEL) * (1 + PLATE.effect.hullBonusPerRank * PLATE.maxPoints));
check(`plating at combat level ${LEVELS}: max hull is (${BASE_HULL} + ${LEVELS}×${HULL_PER_LEVEL}) × ${(1 + PLATE.effect.hullBonusPerRank * PLATE.maxPoints).toFixed(2)} = ${expectLevelMax}`, lvl.combat.maxHull === expectLevelMax && LEVELS > 0);
check('plating: the level-up still heals (the beat is a full heal, the passive only sizes the pool)', lvl.combat.hull === expectLevelMax);
// A fresh fight reads the ledger again — same number, no compounding.
const shared = makeFight({ ranks: { reinforced_plating: PLATE.maxPoints } });
const firstMax = shared.combat.maxHull;
const secondScene = makeScene();
const ship2 = secondScene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(secondScene, 0, 0, '__ship', 0));
ship2.maxSpeed = SHIP_MAX_SPEED;
const restart = new CombatSystem(secondScene, { ship: ship2 });
restart.skillState = shared.skills;
restart.refreshSkillPassives();
check('plating: a combat restart reads the same pool (idempotent across fights)', restart.maxHull === firstMax);
// …and so does a save/load of the ledger.
const loadedCls = ClassState.fromJSON(shared.cls.toJSON());
const loaded = SkillState.fromJSON(JSON.parse(JSON.stringify(shared.skills.toJSON())), loadedCls);
const thirdScene = makeScene();
const ship3 = thirdScene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(thirdScene, 0, 0, '__ship', 0));
ship3.maxSpeed = SHIP_MAX_SPEED;
const reloaded = new CombatSystem(thirdScene, { ship: ship3 });
reloaded.skillState = loaded;
reloaded.refreshSkillPassives();
check('plating: a save/load restores the pool exactly (ranks survive, multipliers do not stack)', reloaded.maxHull === firstMax && loaded.rank(PLATE.id) === PLATE.maxPoints);
// A ship with no class on file keeps its plain armor.
const plain = new CombatSystem(makeScene(), { ship: makeScene().physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(makeScene(), 0, 0, '__ship', 0)) });
check('plating: no ledger means the base armor is exactly the data\'s', plain.maxHull === BASE_HULL && plain.hullBonus === 0);
}
// ========================================================= Boleadoras (the Wraith)
{
// The stun formula, straight off the data (0.5s base + 0.5s per point).
check(`bola: the stun is the data's (${BEFF.disableBaseMs} + ${BEFF.disablePerRankMs} × rank)`,
effects.stunMs(BEFF, 1) === BEFF.disableBaseMs + BEFF.disablePerRankMs &&
effects.stunMs(BEFF, BOLA.maxPoints) === BEFF.disableBaseMs + BEFF.disablePerRankMs * BOLA.maxPoints);
const f = makeFight({ classId: 'wraith', ranks: { boleadoras: 1 } });
// The dummy's own lances are not what these tests measure.
f.combat.maxHull = 1e6; f.combat.hull = 1e6; f.combat.maxShields = 1e6; f.combat.shields = 1e6;
const foe = dummy(f.combat, 700, 360);
const cast = f.combat.cast(0); // seat 1 — the Wraith's data default binding
check(`bola: the cast pays ${BOLA.energy} energy and starts the ${BOLA.cooldown}s recharge`, cast.ok === true && cast.cost === BOLA.energy && f.combat.skillCooldowns.get(BOLA.id) > BOLA.cooldown - 0.1);
check('bola: one set launches per nearest hostile (one hostile here), at the data speed', cast.bolas.count === 1 && f.combat.bolas.length === 1 && f.combat.bolas[0].speed === BEFF.speed);
// Fly the set out (a few hundred px at the data speed) and note the frame it lands on.
let t = 1000;
for (let spent = 0; spent < 4000 && f.combat.bolas.length > 0; spent += 16) { t += 16; f.combat.update(t, 16); }
check('bola: the set lands (the flight roster is spent)', f.combat.bolas.length === 0);
const tLand = t; // the frame the snag landed on
const stun = effects.stunMs(BEFF, 1);
check(`bola: the snag stamps the data's stun on the caught hostile (${stun}ms at 1/5)`, foe.stunUntil === tLand + stun);
// The stun's EFFECT: the strung craft does not fly and does not shoot.
// (The headless stub has no physics world, so "flies" is read off the
// craft's own velocity — the combat gate zeroes it every strung frame.)
const bolts = () => f.combat.bullets.filter((b) => b.owner === 'enemy').length;
const b0 = bolts();
f.combat.update(tLand + 16, 16);
check('stun: the strung craft does not fly (velocity held at zero)', foe.body.velocity.length() === 0);
check('stun: the strung craft does not shoot', bolts() === b0);
// THE STRUNG READ: the hull DIMS to the data's fx.stunAlpha (the
// disabled read) and the crackling discharge is live on the combat fx.
check('stun: the strung craft is dimmed (its alpha sinks while the snare holds)', foe.alpha < 0.9);
check(`stun: the dim is the data's (fx.stunAlpha ${BEFF.fx.stunAlpha})`, Math.abs(foe.alpha - BEFF.fx.stunAlpha) < 0.01);
check('stun: the crackling discharge is live (a stunArms item on the combat fx)',
f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === foe));
// …and it is released exactly when the data's clock runs out.
f.combat.update(tLand + stun - 16, 16);
check('stun: still strung one frame before the data clock runs out', foe.body.velocity.length() === 0);
f.combat.update(tLand + stun + 32, 16);
check('stun: released once the data clock runs out (the AI runs again)', foe.body.velocity.length() > 0);
// …and the strung read settles with it: the hull's alpha comes back,
// the discharge is gone.
check('stun: the release restores the hull (alpha back, the discharge settled)',
foe.alpha >= 0.99 && !f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === foe));
}
{
// THREE sets, at the three NEAREST hostiles — the furthest one is left out.
const f = makeFight({ classId: 'wraith', ranks: { boleadoras: 3 } });
f.combat.maxHull = 1e6; f.combat.hull = 1e6; f.combat.maxShields = 1e6; f.combat.shields = 1e6;
const near1 = dummy(f.combat, 420, 360);
const near2 = dummy(f.combat, 300, 180);
const near3 = dummy(f.combat, 180, 360);
const far = dummy(f.combat, -320, 360);
const cast = f.combat.cast(0);
check(`bola: exactly the data's ${BEFF.targetCount} sets launch (the nearest ${BEFF.targetCount} of 4 hostiles)`, cast.bolas.count === BEFF.targetCount && f.combat.bolas.length === BEFF.targetCount);
let t = 1000;
for (let spent = 0; spent < 4000 && f.combat.bolas.length > 0; spent += 16) { t += 16; f.combat.update(t, 16); }
check('bola: all three nearest are strung', [near1, near2, near3].every((e) => (e.stunUntil || 0) > t));
check('bola: the furthest hostile (no set assigned) is not strung', (far.stunUntil || 0) <= t);
check('stun: every strung craft is dimmed with its discharge live',
[near1, near2, near3].every((e) => e.alpha < 0.9 && f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === e)));
check('stun: the unstrung hostile is neither dimmed nor arced',
far.alpha >= 0.99 && !f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === far));
// The stun is the data's at THIS rank — the caught crafts all wear the same number.
const stun = effects.stunMs(BEFF, 3);
check(`bola: the strung wear rank 3/5's stun (${stun}ms)`, [near1, near2, near3].every((e) => e.stunUntil <= t + stun && e.stunUntil >= 1016 + stun));
}
// ============================================================== Blink (the Wraith)
{
check(`blink: the decoy's stand is the data's (${HEFF.decoyBaseMs} + ${HEFF.decoyPerRankMs} × rank)`,
effects.decoyMs(HEFF, 1) === HEFF.decoyBaseMs + HEFF.decoyPerRankMs &&
effects.decoyMs(HEFF, HOP.maxPoints) === HEFF.decoyBaseMs + HEFF.decoyPerRankMs * HOP.maxPoints);
const f = makeFight({ classId: 'wraith', ranks: { blink: 1 } });
f.combat.maxHull = 1e6; f.combat.hull = 1e6; f.combat.maxShields = 1e6; f.combat.shields = 1e6;
const foe = dummy(f.combat, 700, 360);
const from = { x: f.ship.x, y: f.ship.y };
const cast = f.combat.cast(1); // seat 2 — the Wraith's data default binding
check(`blink: the cast pays ${HOP.energy} energy and starts the ${HOP.cooldown}s recharge`, cast.ok === true && cast.cost === HOP.energy && f.combat.skillCooldowns.get(HOP.id) > HOP.cooldown - 0.1);
check(`blink: the hull hops at least the data's ${HEFF.minDistancePx}px from the old position`, Math.hypot(f.ship.x - from.x, f.ship.y - from.y) >= HEFF.minDistancePx);
check('blink: the decoy stands at the OLD position (a copy of the player hull)',
f.combat.decoys.length === 1 && f.combat.decoys[0].x === from.x && f.combat.decoys[0].y === from.y &&
f.combat.decoys[0].sprite.key === '__ship' && f.combat.decoys[0].sprite.frame === HEFF.decoyFrame - 1);
const decoy = f.combat.decoys[0];
check(`blink: the decoy's stand is the data's clock (${effects.decoyMs(HEFF, 1)}ms at 1/5)`, decoy.until === effects.decoyMs(HEFF, 1));
// THE AGGRO: the pack aims at the decoy, not at the hull.
check("aggro: a pirate's target is the decoy while it stands", f.combat.pickEnemyTarget(foe, f.ship) === decoy.sprite);
// Their lances × the decoy: the bolt that reaches it fizzles on the shell.
const hp = f.combat.hull;
const bolt = {
dead: false, owner: 'enemy', radius: 4, angle0: 0,
x: decoy.x - 12, y: decoy.y,
body: { velocity: { x: 600, y: 0 } },
consume: () => true,
};
f.combat.bullets.push(bolt);
f.combat.collide(16, f.ship);
check('aggro: an enemy bolt that reaches the decoy fizzles (it is consumed, the hull takes none)',
!f.combat.bullets.includes(bolt) && f.combat.hull === hp);
// The stand ends on the data's clock — the decoy is collected, the aggro is back.
let t = 1000;
f.combat.update(t, 16); // the decoy's 0.8s stand is over long before the scene clock catches it
check('blink: the decoy is gone when its stand runs out', f.combat.decoys.length === 0 && decoy.dead === true && decoy.sprite.destroyed === true);
check('blink: the aggro returns to the hull once the decoy is gone', f.combat.pickEnemyTarget(foe, f.ship) === f.ship);
// A fresh rank reads the data again — 5/5 stands five times as long as 1/5.
const maxed = makeFight({ classId: 'wraith', ranks: { blink: HOP.maxPoints } });
const hop5 = maxed.combat.blink(HOP, HOP.maxPoints);
check(`blink: the 5/5 stand is the data's (${hop5.decoyMs}ms)`, hop5.decoyMs === effects.decoyMs(HEFF, HOP.maxPoints));
}
// ================================================================== the Inferno
// THE VANGUARD'S FIRE (data/skills.json → 'inferno', effect.kind 'flameArc'):
// one wide arc of flame per nearest hostile (2, or 3 from the 4th point on),
// each homing on its craft and, on arrival, igniting whatever its splash
// touches — the burn then ticks on the combat loop's own clock: the rank's
// damage every effect.burnTickMs for the rank's duration, starting at t=0
// (a 0.5s burn at rank 1 deals exactly two ticks: 0s and 0.5s).
const INF = model.skillDef('inferno', 'vanguard');
const INFEFF = INF.effect;
const tickDmg = (rank) => (INFEFF.burnDamageMul || 0) * BASE_DAMAGE + (INFEFF.burnDamagePerRank || 0) * (rank - 1) * BASE_DAMAGE;
const burnFor = (rank) => (INFEFF.burnBaseMs || 0) + (INFEFF.burnPerRankMs || 0) * (rank - 1);
const stillDummy = (combat, x, y) => {
const e = dummy(combat, x, y);
e.speed = 0; // a dummy that holds its station (no orbit drift under the burn)
return e;
};
const waitLand = (combat) => {
for (let t = 1016; t <= 9000; t += 16) {
combat.update(t, 16);
if (combat.flames.length === 0) return t;
}
return null;
};
const infernoSeat = (f) => f.combat.skillSeats().findIndex((s) => s.id === INF.id);
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 1 } });
const d = stillDummy(f.combat, 620, 360);
const seat = infernoSeat(f);
check(`inferno: the cast spends ${INF.energy} energy and starts the ${INF.cooldown}s recharge`,
f.combat.cast(seat).ok === true && f.combat.energy === MAX_ENERGY - INF.energy &&
Math.abs(f.combat.skillCooldowns.get(INF.id) - INF.cooldown) < 1e-9);
check('inferno: one arc per nearest hostile (one here)', f.combat.flames.length === 1);
const tLand = waitLand(f.combat);
check('inferno: the arc reached its craft', tLand !== null);
check('inferno: the impact ticks at once (the t=0 rule)', tLand !== null && approx(d.maxHull - d.hull, tickDmg(1)));
f.combat.update(tLand + 250, 16);
check('inferno: no tick mid-interval', approx(d.maxHull - d.hull, tickDmg(1)));
f.combat.update(tLand + 500, 16);
check('inferno: the second tick lands at 0.5s (two ticks in a 0.5s burn)', approx(d.maxHull - d.hull, tickDmg(1) * 2));
f.combat.update(tLand + 501, 16);
f.combat.update(tLand + 1500, 16);
check('inferno: the burn holds 0.5s — no third tick after', approx(d.maxHull - d.hull, tickDmg(1) * 2) && d.burnUntil === 0);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 5 } });
const d = stillDummy(f.combat, 620, 360);
check('inferno: the 5/5 cast still arcs its one nearest hostile', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 1);
const tLand = waitLand(f.combat);
f.combat.update(tLand + burnFor(5), 16);
check('inferno: 5/5 — six ticks of 1.4x across the 2.5s burn (0s … 2.5s)',
tLand !== null && approx(d.maxHull - d.hull, tickDmg(5) * 6));
f.combat.update(tLand + burnFor(5) + 1, 16);
f.combat.update(tLand + burnFor(5) + 1500, 16);
check('inferno: 5/5 — no seventh tick after the burn ends', approx(d.maxHull - d.hull, tickDmg(5) * 6) && d.burnUntil === 0);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 1 } });
const d1 = stillDummy(f.combat, 560, 260);
const d2 = stillDummy(f.combat, 640, 360);
const d3 = stillDummy(f.combat, 720, 460);
check('inferno: the cast arcs the nearest 2 (not the third)', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 2);
const tLand = waitLand(f.combat);
check('inferno: both nearest craft are alight', tLand !== null && d1.burnUntil > 0 && d2.burnUntil > 0);
check('inferno: the third enemy is untouched', d3.burnUntil === undefined && d3.hull === d3.maxHull);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 4 } });
const d1 = stillDummy(f.combat, 560, 260);
const d2 = stillDummy(f.combat, 640, 360);
const d3 = stillDummy(f.combat, 720, 460);
check('inferno: the 4th point arcs the third nearest', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 3);
const tLand = waitLand(f.combat);
check('inferno: all three are alight', tLand !== null && d1.burnUntil > 0 && d2.burnUntil > 0 && d3.burnUntil > 0);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 1 } });
check('inferno: a cast with no hostiles arcs nothing', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 0);
}
// ================================================================== the Nanobots
// THE HULL THAT MENDS ITSELF (data/skills.json → 'nanobots', effect.kind
// 'hullRegen'): a passive — no cast, no seat — on the combat clock, every
// effect.intervalMs (2s) the hull regrows the ledger's share of its MAX
// (1% per point, 5% at 5/5), a full hull banking nothing.
{
const f = makeFight({ ranks: { ramming_charge: 1, reinforced_plating: 3, nanobots: 1 } });
const maxHull = f.combat.maxHull;
const perTick = 0.01 * maxHull;
f.combat._nanobotAccumMs = 0;
f.combat.hull = maxHull - 100;
tick(f.combat, 1984); // just short of the first mend
check('nanobots: no mend before the 2s clock turns', f.combat.hull === maxHull - 100);
tick(f.combat, 16); // the 2000 ms mark
check('nanobots: 1/5 mends 1% of max hull per tick', approx(f.combat.hull, maxHull - 100 + perTick));
tick(f.combat, 1983); // 16 + 1983 = 1999 ms: still one frame short of the second mend
check('nanobots: no second mend early', approx(f.combat.hull, maxHull - 100 + perTick));
tick(f.combat, 1);
check('nanobots: the second tick mends again', approx(f.combat.hull, maxHull - 100 + perTick * 2));
}
{
const f = makeFight({ ranks: { ramming_charge: 1, reinforced_plating: 3, nanobots: 5 } });
const maxHull = f.combat.maxHull;
f.combat._nanobotAccumMs = 0;
f.combat.hull = maxHull; // a full hull banks nothing
tick(f.combat, 5000);
check('nanobots: a full hull does not overfill or bank mends', f.combat.hull === maxHull);
f.combat.hull = maxHull - 50;
tick(f.combat, 2000);
check('nanobots: 5/5 mends 5% of max hull per tick (no banked extra)', approx(f.combat.hull, maxHull - 50 + 0.05 * maxHull));
}
{
const f = makeFight({ ranks: { ramming_charge: 1, reinforced_plating: 3 } });
f.combat._nanobotAccumMs = 0;
f.combat.hull = f.combat.maxHull - 50;
tick(f.combat, 5000);
check('nanobots: nothing mends the hull without the passive', f.combat.hull === f.combat.maxHull - 50);
}
// ================================================================= the bar's row
{
const f = makeFight({ ranks: { ramming_charge: RAM.maxPoints } });
let seats = f.combat.skillSeats();
check('bar: eight seats, one per key (data/combat.json → bar.slot.count)', seats.length === Number(config.get('combat.bar.slot.count', 8)));
check(`bar: seat 1 carries ${RAM.label} ${RAM.maxPoints}/${RAM.maxPoints} off the data's sheet`, seats[0].id === RAM.id && seats[0].rank === RAM.maxPoints && seats[0].frame === RAM.frame && seats[0].icon === model.iconSheet().texture);
check('bar: a ready seat is ready (full charge, full orb)', seats[0].ready === true && seats[0].cooldownFrac === 1 && seats[0].affordable === true);
f.combat.cast(0);
seats = f.combat.skillSeats();
check('bar: the frame a charge goes off the seat reads spent (sweep empty, not ready)', seats[0].ready === false && seats[0].cooldownLeft === RAM.cooldown && seats[0].cooldownFrac === 0);
tick(f.combat, 2000);
seats = f.combat.skillSeats();
check('bar: mid-recharge the sweep climbs with the clock (cooldownFrac grows, still not ready)', seats[0].ready === false && seats[0].cooldownLeft > 0 && seats[0].cooldownFrac > 0 && seats[0].cooldownFrac < 1);
f.combat.energy = RAM.energy - 1;
check('bar: a thin orb shows it (affordable false, ready false)', f.combat.skillSeats()[0].affordable === false && f.combat.skillSeats()[0].ready === false);
const fresh = makeFight();
const freshSeats = fresh.combat.skillSeats();
check('bar: the Vanguard ships two seats — the ram and the fire (both inert at rank 0)',
freshSeats[0].id === RAM.id && freshSeats[0].inert === true && freshSeats[0].ready === false &&
freshSeats[1].id === 'inferno' && freshSeats[1].inert === true && freshSeats[1].ready === false &&
freshSeats[2].id === null);
// The stats() row the bar polls every frame.
const st = f.combat.stats();
check('bar: stats() carries the seats, the hull bonus and the grown pool for the orbs', Array.isArray(st.seats) && st.seats.length === 8 && st.hullBonus === 0 && st.maxHull === BASE_HULL);
// A load with no ledger leaves the bar exactly as it was found: empty seats.
const bare = new CombatSystem(makeScene(), { ship: makeScene().physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(makeScene(), 0, 0, '__ship', 0)) });
check('bar: no ledger = the same eight inert seats as before the skill layer', bare.skillSeats().length === Number(config.get('combat.bar.slot.count', 8)) && bare.skillSeats().every((s) => s.id === null));
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nskills-combat: all checks passed');
process.exit(failures ? 1 : 0);