/** * 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);