755 lines
40 KiB
JavaScript
755 lines
40 KiB
JavaScript
/**
|
||
* 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);
|