orbit/dev/combat.test.mjs

496 lines
22 KiB
JavaScript

/**
* Combat rules test (dev tool, run with Node — no browser needed):
*
* node dev/combat.test.mjs
*
* Stubs just enough of Phaser + a scene to run the REAL combat modules
* from js/combat/ — the pure rules (Combat.js: armor damage, render
* heading, orbit-ring step), the hostile craft (EnemyShip.js: config
* scaling + north-facing art offset, shields→hull damage, the
* standoff-ring AI, the lance decision), the tracer (Bullet.js:
* velocity from angle/speed, world radius, one-shot consume, life
* expiry), and the system (CombatSystem.js: the pack's lifecycle,
* the auto-fire, swept hits, rams, the player's armor + respawn,
* the energy) — then asserts the behavior the range
* (js/combat/CombatScene.js) and the main game (GameScene) are built on.
*
* All numbers come from the real data/*.json (data/combat.json included),
* so the assertions track the shipped config, not a private copy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const 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 } },
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;
}
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),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
};
}
// The real data/*.json (combat numbers come from data/combat.json).
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { applyDamage, headingOf, orbitStep, handleFeel } = await import(
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
);
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const {
COMBAT_SFX, combatSfx, combatSfxEnemyHit,
} = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSfx.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;
const E = (k, d) => config.get(`combat.enemy.${k}`, d);
// ------------------------------------------------------------------ armor
{
const armor = { shields: 40, hull: 100 };
const r1 = applyDamage(armor, 30);
check('damage: 30 hits shields only (40→10, hull 100)', armor.shields === 10 && armor.hull === 100 && !r1.dead && r1.shieldHit === 30);
const r2 = applyDamage(armor, 50);
check('damage: 50 drains shields then hull (0/60)', armor.shields === 0 && armor.hull === 60 && !r2.dead);
const r3 = applyDamage(armor, 999);
check('damage: overkill kills (hull 0, dead)', armor.hull === 0 && r3.dead);
applyDamage(armor, 5);
check('damage: dead armor takes no more', armor.hull === 0 && armor.shields === 0);
const a2 = { shields: 0, hull: 25 };
applyDamage(a2, 9);
check('damage: zero shields → hull straight', a2.hull === 16 && !a2.dead);
const a3 = { shields: 10, hull: 10 };
const r4 = applyDamage(a3, 'nope');
check('damage: non-numeric = 0', a3.shields === 10 && a3.hull === 10 && !r4.dead);
}
{
check('headingOf: rotation minus artOffset', approx(headingOf({ rotation: Math.PI / 2, artOffset: Math.PI / 2 }), 0));
check('headingOf: no offset', approx(headingOf({ rotation: 1.2 }), 1.2));
}
{
// player at origin, orbiting at radius 100
const t = orbitStep(0, 0, 0, 1, 1, 1, 100);
check('orbitStep: angle advances dir*speed*dt', approx(t.angle, 1));
check('orbitStep: target on the ring', approx(t.x, Math.cos(1) * 100) && approx(t.y, Math.sin(1) * 100));
const t2 = orbitStep(0, 0, 0, -1, 2, 0.5, 100);
check('orbitStep: counter-direction', approx(t2.angle, -1));
}
// ------------------------------------------------------------------ feel
{
const P = (k, d) => config.get(`combat.player.${k}`, d);
const cfg = {
rotSpeed: P('rotSpeed', 5.6),
rotApproach: P('rotApproach', 16),
thrustRamp: P('thrustRamp', 14),
thrustDecay: P('thrustDecay', 26),
brake: P('brake', 0.8),
};
check('feel cfg ships: all five knobs present + sane',
cfg.rotSpeed > 0 && cfg.rotApproach > 0 && cfg.thrustRamp > 0 &&
cfg.thrustDecay > 0 && cfg.brake > 0 && cfg.brake <= 1);
// Turn: a key BUILDS the turn rate (a tap is a nudge, a hold a spin) —
// no instant full rate on the first frame, and it settles at ±rotSpeed.
const st = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(st, { right: true }, 1 / 60, cfg);
check('feel: first frame builds turn (no instant full rate)',
st.turnVel > 0 && st.turnVel < cfg.rotSpeed && st.rot > 0);
for (let i = 0; i < 60; i++) handleFeel(st, { right: true }, 1 / 60, cfg);
check('feel: turn settles at rotSpeed (no overshoot)',
st.turnVel <= cfg.rotSpeed && Math.abs(st.turnVel - cfg.rotSpeed) < 0.05);
const sFlip = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
for (let i = 0; i < 3; i++) handleFeel(sFlip, { left: true }, 1 / 60, cfg);
check('feel: left flips the turn (eases toward -rotSpeed)', sFlip.turnVel < 0);
const s0 = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
for (let i = 0; i < 120; i++) handleFeel(s0, {}, 1 / 60, cfg);
check('feel: released turn decays to 0', s0.turnVel === 0);
// Throttle: ramps up toward 1 (bounded), cuts to 0 when released.
const s1 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
for (let i = 0; i < 30; i++) handleFeel(s1, { up: true }, 1 / 60, cfg);
check('feel: throttle ramps up, bounded by 1', s1.throttle > 0.5 && s1.throttle <= 1);
for (let i = 0; i < 60; i++) handleFeel(s1, {}, 1 / 60, cfg);
check('feel: throttle cuts to 0 on release', s1.throttle === 0);
// Brake: instant while down (a fraction of full thrust), gone on release.
const s2 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(s2, { down: true }, 1 / 60, cfg);
check('feel: brake is instant (no ramp)', s2.brake === cfg.brake);
handleFeel(s2, {}, 1 / 60, cfg);
check('feel: brake drops when released', s2.brake === 0);
// Frame-rate independence: two half-steps ≈ one full step.
const a = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(a, { right: true, up: true }, 1 / 60, cfg);
const b = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
check('feel: frame-rate independent (≈)',
Math.abs(a.rot - b.rot) < 1e-3 && Math.abs(a.throttle - b.throttle) < 1e-3);
// dt clamp: a backgrounded tab (dt = 5 s) can't fast-forward the feel.
const c = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(c, { right: true, up: true }, 5, cfg);
const d = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(d, { right: true, up: true }, 0.1, cfg);
check('feel: dt clamped at 0.1 s (no tab-switch jump)',
Math.abs(c.rot - d.rot) < 1e-9 && Math.abs(c.throttle - d.throttle) < 1e-9);
}
// ---------------------------------------------------------------- enemy
{
const scene = makeScene({ withSheet: true });
const e = new EnemyShip(scene, 200, 360, 0);
const fw = E('frameWidth', 128);
check('enemy: scale = size / frameWidth', approx(e.scale, E('size', 54) / fw));
check('enemy: north-facing art → offset +π/2', approx(e.artOffset, Math.PI / 2));
check('enemy: hitRadius from config', e.hitRadius === E('hitRadius', 20));
check('enemy: armor from config', e.maxHull === E('hull', 60) && e.maxShields === E('shields', 0));
// Damage model (shields → hull → dead).
e.shields = 30;
check('enemy.damage: shields first', e.damage(40) === false && e.shields === 0 && e.hull === E('hull', 60) - 10);
check('enemy.damage: killing blow', e.damage(999) === true && e.dead && e.hull === 0);
check('enemy.damage: dead is a wall', e.damage(10) === false);
e.revive();
check('enemy: revive restores armor + liveness', !e.dead && e.hull === e.maxHull && e.shields === e.maxShields);
// The AI steers toward its point on the standoff ring around the player.
const player = { x: 640, y: 360 };
e.x = 200; e.y = 360;
e.body.velocity.set(0, 0);
e.orbitAngle = Math.PI; // "behind" the enemy relative to the player (west side)
e.update(0, 16, player);
check('enemy AI: velocity heads for the ring (toward the player side)', e.body.velocity.x > 0 && Math.abs(e.body.velocity.y) < Math.abs(e.body.velocity.x));
// The lance: in range + aimed + off cooldown → fires once.
e.nextFireAt = 0;
e.x = 840; e.y = 360; // 200 px EAST of the player (range is 560)
const toPlayer = Math.PI; // facing west, straight at the player
e.rotation = toPlayer + e.artOffset;
const fired = e.update(0, 16, player);
check('enemy AI: fires when in range + aimed + armed', fired === true);
check('enemy AI: cooldown set after firing', e.nextFireAt > 0);
const firedAgain = e.update(0, 16, player);
check('enemy AI: no instant re-fire', firedAgain === false);
// Out of range → no fire (even aimed + armed).
e.nextFireAt = 0;
e.x = 1500; e.y = 360;
e.rotation = Math.atan2(360 - 360, 640 - 1500) + e.artOffset;
check('enemy AI: out of range, no fire', e.update(0, 16, player) === false);
// Player down → no fire, keeps orbiting the last position.
e.nextFireAt = 0;
e.x = 840; e.y = 360;
e.rotation = toPlayer + e.artOffset;
check('enemy AI: player down, no fire', e.update(0, 16, null) === false);
}
{
// No spritesheet → the procedural wedge (east-facing, offset 0).
const scene = makeScene({ withSheet: false });
const e = new EnemyShip(scene, 100, 100, 1);
check('enemy fallback: east-facing wedge, offset 0', e.artOffset === 0);
check('enemy fallback: scale from the wedge width', approx(e.scale, E('size', 54) / 76));
}
// ---------------------------------------------------------------- bullet
{
const scene = makeScene({ withSheet: true });
const b = new Bullet(scene, { x: 100, y: 100, angle: 0, speed: 680, damage: 10, life: 1.2, color: '#00e5ff', size: 16, owner: 'player' });
check('bullet: velocity = speed along angle', approx(b.body.velocity.x, 680) && approx(b.body.velocity.y, 0));
check('bullet: world radius > 0', b.radius > 0 && b.radius < 32);
check('bullet: owner + damage carried', b.owner === 'player' && b.damage === 10);
check('bullet: consume is one-shot', b.consume() === true && b.consume() === false);
check('bullet: consume destroys', b.active === false);
const b2 = new Bullet(scene, { x: 0, y: 0, angle: Math.PI / 2, speed: 330, damage: 8, life: 1.0, color: '#ff2d6f', size: 18, owner: 'enemy' });
check('bullet: down-angle velocity', approx(b2.body.velocity.x, 0) && approx(b2.body.velocity.y, 330));
b2.update(1000);
check('bullet: life expiry consumes', b2.dead === true);
}
// ------------------------------------------------------------- combat system
{
const scene = makeScene({ withSheet: true });
// A stand-in player ship: position + radius + body (the system reads
// exactly that — Ship's full API isn't needed for the rules).
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {}; // Ship API the system leans on (brake + hide on death)
const events = { down: 0, up: 0, kills: 0, active: [] };
const combat = new CombatSystem(scene, {
ship,
onPlayerDown: () => events.down++,
onPlayerUp: () => events.up++,
onKill: () => events.kills++,
onActiveChange: (a) => events.active.push(a),
});
check('system: starts with full armor + full energy',
combat.hull === 100 && combat.shields === 40 && combat.energy === 100 && combat.maxEnergy === 100);
// The pack: game.spawnCount hostiles around the ship, combat live.
combat.spawnPack(0, 0);
check('system: pack spawns (game.spawnCount = 3)', combat.enemies.length === 3);
check('system: combat is active with the pack up', combat.active === true);
check('system: onActiveChange fired true', events.active[0] === true);
// AUTO-FIRE: the nearest hostile is within game.autoFire.range → a
// player tracer is born (the scene's world steps it in the real game).
let t = 1000;
combat.update(t, 16);
const playerShots = combat.bullets.filter((b) => b.owner === 'player');
check('system: auto-fires at a hostile in range', playerShots.length >= 1);
t += 16;
const before = playerShots.length;
combat.update(t, 16);
check('system: auto-fire honors the weapon cooldown',
combat.bullets.filter((b) => b.owner === 'player').length === before);
// Out of range → no auto-fire (the cannon waits for contact).
for (const e of combat.enemies) { e.x = 5000; e.y = 5000; }
combat.nextFireAt = 0;
combat.update(t + 16, 16);
check('system: no auto-fire out of range',
combat.bullets.filter((b) => b.owner === 'player').length === before);
for (const e of combat.enemies) { e.x = 100; e.y = 0; }
// Swept hit: a player tracer on the enemy's segment kills it (damage
// over the enemy's hull) → the kill bookkeeping + kill event fire.
const target = combat.nearestEnemy(ship.x, ship.y);
combat.spawnBullet(target.x - 18, 0, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(t + 32, 16);
check('system: player tracer kills (swept hit)', target.dead === true && combat.kills === 1 && events.kills === 1);
// The enemy's respawn (enemy.respawn = true): down, then back.
check('system: killed enemy is on its respawn clock', target._respawnPending === true && target.respawnAt > 0);
combat.update(target.respawnAt + 10, 16);
check('system: enemy reforms (armor full, alive)', !target.dead && target.hull === target.maxHull);
// RAM: the ship inside the enemy's combined radius → both take damage.
const ram = combat.enemies[0];
ship.x = 0; ship.y = 0;
ram.x = 20; ram.y = 0;
const armorBefore = combat.hull + combat.shields;
combat.update(t + 64, 16);
check('system: ram damages the player (shields first)',
combat.hull + combat.shields < armorBefore);
// PLAYER DEATH + RESPAWN (playerShip.respawn = true, 2 s).
combat.damagePlayer(9999);
check('system: hull broke → player down', combat.playerDown === true && events.down === 1);
const respawnAt = combat.respawnAt;
combat.update(respawnAt + 10, 16);
check('system: player reformed (full armor)', !combat.playerDown && combat.hull === 100 && combat.shields === 40 && events.up === 1);
// cast(): the skill stub — no crash, no energy change (skills land later).
const energyBefore = combat.energy;
combat.cast(3);
check('system: cast is a safe no-op for now', combat.energy === energyBefore);
// Active flips off when the pack is gone (respawn clocks cleared).
for (const e of combat.enemies) { e.dead = true; e._respawnPending = false; e.respawnAt = 0; }
combat._setActive();
check('system: combat over → active off + event', combat.active === false && events.active[events.active.length - 1] === false);
// destroy(): the pack + tracers are gone.
combat.destroy();
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
}
// ------------------------------------------------------------------ SFX
{
// A scene with just enough of a sound manager for the shared guards to
// pass (js/utils/Sfx.js) — every play lands in `played`.
const makeSfxScene = () => {
const played = [];
return {
played,
sound: { play: (key, o) => played.push({ key, volume: o.volume }) },
cache: { audio: { has: () => true } },
};
};
const sfxNames = config.get('sfx') ?? {};
check('sfx: the seven combat events, each mapped',
Object.keys(COMBAT_SFX).sort().join(',') ===
'down,fire,hull,impact,kill,respawn,shield');
check('sfx: every mapped voice exists in data/sfx.json',
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));
const sets = Object.values(COMBAT_SFX).map((ns) => new Set(ns));
check('sfx: the events\u2019 voices are pairwise distinct',
sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((n) => b.has(n)))));
const s1 = makeSfxScene();
check('sfx: fire → the scan ping, base volume',
combatSfx(s1, 'fire') === 'fire' &&
s1.played.length === 1 &&
s1.played[0].key === 'sfx_scan' &&
s1.played[0].volume === Number(config.get('sfx.volume', 0.55)));
const s2 = makeSfxScene();
combatSfx(s2, 'fire', { vol: 0.45 }); // enemy lances play quieter
check('sfx: vol scales the volume',
Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9);
const killKeys = new Set();
for (let i = 0; i < 60; i++) {
const sc = makeSfxScene();
combatSfx(sc, 'kill');
killKeys.add(sc.played[0]?.key);
}
check('sfx: kill picks one of the thunder set',
killKeys.size >= 2 && [...killKeys].every((k) => /^sfx_thunder_0[1-4]$/.test(k)));
const sU = makeSfxScene();
check('sfx: unknown event is a no-op', combatSfx(sU, 'nope') === null && sU.played.length === 0);
// The shield/hull decision (one shared helper, both contexts):
// in front of the hit the craft has `preShields` shield points.
const hit = (pre, dmg) => {
const sc = makeSfxScene();
combatSfxEnemyHit(sc, pre, dmg);
return sc.played.map((p) => p.key.replace('sfx_', ''));
};
check('sfx: full shields → the shield voice only',
JSON.stringify(hit(40, 10)) === JSON.stringify(['solar_buzz']));
check('sfx: no shields → the hull voice only',
JSON.stringify(hit(0, 10)) === JSON.stringify(['mining_split']));
check('sfx: shield breaks through → BOTH (the pop reads)',
JSON.stringify(hit(5, 10)) === JSON.stringify(['solar_buzz', 'mining_split']));
// The dev log: every call is remembered (capped) + mirrored to window.
combatSfx(makeSfxScene(), 'down');
const after = window.__COMBAT_SFX__ ?? null;
check('sfx: dev log records the call (window.__COMBAT_SFX__)',
after && after.last === 'down' &&
after.log.length >= 1 && after.log.length <= 64 &&
after.log[after.log.length - 1].event === 'down');
// The master switch: off → nothing plays, but the log still records
// the call (headless verification depends on it).
const saved = config.data.sfx.enabled;
config.data.sfx.enabled = false;
const sOff = makeSfxScene();
combatSfx(sOff, 'kill');
config.data.sfx.enabled = saved;
check('sfx: master switch off → silent, still logged',
sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill'));
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);