orbit/dev/combat.test.mjs

233 lines
9.8 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), and the tracer (Bullet.js:
* velocity from angle/speed, world radius, one-shot consume, life
* expiry) — then asserts the behavior the sandbox (js/combat/CombatScene.js)
* is 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 makeScene({ withSheet = true } = {}) {
return {
textures: { exists: () => withSheet },
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
add: { existing: (o) => o },
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 } = 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);
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));
}
// ---------------------------------------------------------------- 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);
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);