orbit/dev/neutral.test.mjs

504 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Neutral-escort test (dev tool — run in Node, no browser):
*
* node dev/neutral.test.mjs
*
* The player-side half of the combat layer, against a stub scene:
* the escort's spawn (the allies roster, side 'player', frames 20–27),
* the escort/engage/break state machine (the 10–15 s window → break-off),
* the player-safety rules (never fires at the player, excluded from
* auto-fire, bolts don't hit either way), the mixed fight (they lance
* the nearest pirate — the kill still banks the pirate's XP; a pirate's
* bolt can drop them, and that loss banks none), and the pirate-target
* generalization (player preferred in range, nearest in-range escort
* otherwise, classic chase unchanged).
*
* Every number comes 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));
// A mirror of the vendored build's Angle.Wrap (Wrap(v, -π, π)).
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') {
return {
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; },
};
}
function makeGraphics() {
return {
visible: true,
clear() { return this; },
lineStyle() { return this; },
fillStyle() { return this; },
lineBetween() { return this; },
fillCircle() { return this; },
fillTriangle() { 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 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 { neutralTypeConfig, neutralTypeIds } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const { firePattern, missilesHitPlayer } = await import(
pathToFileURL(join(__dirname, '../js/combat/Weapons.js')).href
);
const { CombatSystem } = await import(
pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// A fresh harness per block: stub scene + player + real CombatSystem.
function harness(o = {}) {
const scene = makeScene();
const ship = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0);
scene.physics.add.existing(ship);
ship.radius = 12;
ship.stop = () => {};
const activeFlips = [];
const combat = new CombatSystem(scene, {
ship,
onActiveChange: (a) => activeFlips.push(a),
onComm: typeof o.onComm === 'function' ? o.onComm : null,
});
return { scene, ship, combat, activeFlips };
}
const ids = neutralTypeIds();
// ---------------------------------------------------------------- roster + escort
{
const { ship, combat, activeFlips } = harness();
const base = config.section('combat.neutral', {});
check('config: the escort window is data (neutral.escortMin/escortMax = 10–15 s)',
base.escortMin === 10 && base.escortMax === 15);
const a0 = combat.spawnAlly(300, 0, 0, 'courier', { escortMs: 5000, viewFn: () => null });
check("spawn: lands in the system's allies roster (not in enemies)",
combat.allies.length === 1 && combat.enemies.length === 0 && combat.allies[0] === a0);
check('spawn: player-side (side "player" — the roster contract)', a0.side === 'player' && !a0.dead);
check('spawn: frame within 20–27 (the neutral-ship sheet)', a0.frame >= 20 && a0.frame <= 27);
check('active: a live escort keeps combat lit', combat.active === true);
const a1 = combat.spawnAlly(-300, 0, 1, 'runner', { viewFn: () => null });
check('spawn: per-ship escort window rolls inside the data band (10–15 s)',
a1.escortMs >= base.escortMin * 1000 - 1 && a1.escortMs <= base.escortMax * 1000 + 1);
// Escort (no pirates): never fires, never aims at the player.
a0.x = 80; a0.y = 0;
let fired = false;
for (let t = 1000; t <= 4000; t += 16) {
a0.body.velocity.set(0, 0);
const r = a0.update(t, 16, ship, []);
if (r.wantsFire) fired = true;
}
check('escort: never aims at the player (3 s window — no volley, no target)',
!fired && a0.target === null && a0.state === 'escort');
check('escort: no "ally" bolts ever leave the barrel', combat.bullets.length === 0);
// Break-off: window elapses → peel away from the player.
a0.x = 120; a0.y = 0;
a0.state = 'escort';
a0.escortMs = 1000;
a0.escortStart = 0;
a0.update(2000, 16, ship, []);
check('break: the escort window elapses into the break-off', a0.state === 'break');
a0.body.velocity.set(0, 0);
a0.update(2016, 16, ship, []);
check('break: flies OFF (away from the player, not back to the ring)', a0.body.velocity.x > 0);
// Despawn: broken escorts fully off-screen are collected; when the
// last one is gone, combat is over.
a0.gone = true;
a1.gone = true;
combat.update(90000, 16);
check('despawn: broken escorts off-screen are collected (encounter ends)',
combat.allies.length === 0);
check('active: over when the last escort is off-screen', combat.active === false);
// The seam the scene hangs the deck/soundtrack swap on (GameScene
// _setCombatBar — combat bar out, normal deck + deep-space music back)
// must fire on the break-off cull, not only on a kill.
check('seam: onActiveChange flips true→false when the last escort leaves the screen',
JSON.stringify(activeFlips) === '[true,false]');
}
// ---------------------------------------------------------------- the scan comms
{
// The NEUTRAL PILOT's comms (data/comms.json → neutralScan /
// neutralScanComplete): armed by the system, delivered through the
// onComm seam — once per encounter, and only while the roster is
// neutral-only (zero hostiles).
const comms = [];
const { ship, combat } = harness({ onComm: (t) => comms.push(t) });
const a = combat.spawnAlly(300, 0, 0, 'courier', { escortMs: 60000, viewFn: () => null });
check('seam: a neutral-only encounter stands (active + the neutral flag)',
combat.active === true && combat._neutral === true && combat._neutralOnly() === true);
a.state = 'escort'; // skip the stream-in (approach)
a.x = 80; a.y = 0;
combat.update(1000, 16);
check('scan: the first on-screen moment arms the scan comm (once)',
JSON.stringify(comms) === '["neutralScan"]');
combat.update(1016, 16); // no barrage on the next frames
check('scan: the mark holds — no repeat arm', JSON.stringify(comms) === '["neutralScan"]');
// The break-off (escort window lapsed) arms the scan-complete comm.
a.escortMs = 1000; a.escortStart = 0;
combat.update(2000, 16);
check('break: window elapse → break-off', a.state === 'break');
check('scan-complete: the break-off arms the completion comm (once)',
JSON.stringify(comms) === '["neutralScan","neutralScanComplete"]');
// A live hostile anywhere keeps the roster MIXED: no neutral flag, no
// scan comm (the pilot's line is a neutral-only beat).
const comms2 = [];
const h2 = harness({ onComm: (t) => comms2.push(t) });
const b = h2.combat.spawnEnemy(400, 0, 0, 'raider');
b.respawn = false; b.nextFireAt = Infinity; b.comms = null; // hold it + no pirate line
const a2 = h2.combat.spawnAlly(300, 0, 1, 'runner', { escortMs: 60000, viewFn: () => null });
a2.state = 'escort'; a2.x = 80; a2.y = 0;
h2.combat.update(1000, 16);
check('mixed: a live hostile keeps the neutral flag down + the scan comm silent',
h2.combat._neutral === false && comms2.length === 0);
// …and the moment the last hostile goes down (escorts still escorting)
// the roster RE-RESOLVES to neutral: the seam re-fires with the flag.
b.damage(999); // the real path is a tracer sweep → damage() → killEnemy
h2.combat.killEnemy(b, 1000);
check('mixed→neutral: the last kill re-fires the seam with neutral up',
h2.combat.active === true && h2.combat._neutral === true);
}
// ---------------------------------------------------------------- engage
{
const { ship, combat } = harness();
const b = combat.spawnEnemy(200, 60, 0, 'raider');
const c = combat.spawnEnemy(300, 150, 1, 'raider');
b.respawn = false; c.respawn = false;
b.nextFireAt = Infinity; c.nextFireAt = Infinity; // hold them for the test window
const a = combat.spawnAlly(100, 0, 2, 'runner', { escortMs: 60000, viewFn: () => null });
a.state = 'engage'; // skip the stream-in (approach is covered above)
// Run the system (on-screen gate + shared dispatch) until the escort
// has locked the nearest pirate and fired.
let fired = false;
for (let t = 1000; t <= 30000; t += 16) {
combat.update(t, 16);
if (a.target === b && combat.bullets.some((x) => x.owner === 'ally')) { fired = true; break; }
}
check('engage: lances the NEAREST pirate (an "ally" bolt leaves the barrel)',
fired && a.target === b);
check('no friendly fire: the player never takes escort bolts',
combat.hull === combat.maxHull && combat.shields === combat.maxShields);
// The direct seam: an ally bolt crossing the player's seat. (Headless
// has no physics world, so the test advances the tracer itself.)
combat.bullets.length = 0;
const ab = combat.spawnBullet(-30, 0, 0, { bulletSpeed: 300, damage: 9, bulletLife: 5, color: '#37e0a1' }, 'ally');
for (let i = 0; i < 30 && !ab.dead; i++) { ab.x += ab.body.velocity.x * 0.016; combat.update(31000 + i * 16, 16); }
check('no friendly fire: the collide pass skips "ally" bolts at the player',
combat.hull === combat.maxHull && combat.shields === combat.maxShields);
}
// ---------------------------------------------------------------- auto-fire
{
const { ship, combat } = harness();
const a = combat.spawnAlly(0, 100, 0, 'sloop', { escortMs: 60000, viewFn: () => null });
a.state = 'escort';
a.x = 0; a.y = 100;
check('auto-fire: the escort is outside the cannon target pool (no lock when escort-only)',
combat.currentTarget() === null);
const b = combat.spawnEnemy(200, 60, 1, 'raider');
b.respawn = false;
b.nextFireAt = Infinity;
check('auto-fire: the lock sits on the pirate, never the escort', combat.currentTarget() === b);
// The player's bolts pass straight through the escort (no hit). (The
// headless harness advances the tracer by its own velocity.)
const pb = combat.spawnBullet(0, 140, -Math.PI / 2, { bulletSpeed: 300, damage: 20, bulletLife: 5, color: '#00e5ff' }, 'player');
for (let i = 0; i < 30; i++) {
if (pb.dead) break;
pb.x += pb.body.velocity.x * 0.016;
pb.y += pb.body.velocity.y * 0.016;
combat.update(2000 + i * 16, 16);
}
check('no friendly fire: player bolts pass through the escort (no damage)',
a.hull === a.maxHull && a.shields === a.maxShields);
}
// ---------------------------------------------------------------- pirate targeting
{
const { ship, combat } = harness();
const a = combat.spawnAlly(150, 0, 0, 'sentinel', { escortMs: 60000, viewFn: () => null });
a.state = 'escort';
const d = combat.spawnEnemy(300, 0, 1, 'raider');
d.respawn = false;
d.nextFireAt = Infinity;
// The player is within the pirate's weapon range — the player is
// preferred, even with a nearer escort.
check('pirate targeting: player preferred in range (even with a nearer escort)',
combat.pickEnemyTarget(d, ship) === ship);
// The player is out of range; the escort is in range → the escort
// draws aggro.
ship.x = 1000; ship.y = 0;
a.x = 250; a.y = 0;
check('pirate targeting: out of player range, the nearest in-range escort draws aggro',
combat.pickEnemyTarget(d, ship) === a);
// No escort in reach → the classic player chase (pirates-only
// unchanged).
const saved = combat.allies;
combat.allies = [];
check('pirate targeting: with no escort in reach, the classic chase stands',
combat.pickEnemyTarget(d, ship) === ship);
combat.allies = saved;
// The pirate AI actually fires at the escort (the same gate that
// fires at the player — in range, aimed, off cooldown).
ship.x = 0; ship.y = 0;
d.x = 300; d.y = 0;
d.rotation = -Math.PI / 2; // heading = rotation - artOffset → facing west (art faces north)
d.nextFireAt = 0;
const w = d.update(1000, 16, a);
check('pirate targeting: aimed + in range, the pirate lances the escort', w === true);
// And the escort takes the hit (the new collide pass — the headless
// harness advances the tracer by its own velocity).
a.x = 0; a.y = 100;
const eb = combat.spawnBullet(-40, 100, 0, { bulletSpeed: 300, damage: 9, bulletLife: 5, color: '#ff2d6f' }, 'enemy');
const shB = a.shields, huB = a.hull;
for (let i = 0; i < 30; i++) { if (eb.dead) break; eb.x += eb.body.velocity.x * 0.016; combat.update(2000 + i * 16, 16); }
check('mixed: a pirate bolt that strikes the escort lands damage',
a.shields < shB || a.hull < huB || a.dead);
}
// ---------------------------------------------------------------- escort's loss
{
const { ship, combat } = harness();
const a = combat.spawnAlly(0, 100, 0, 'sally', { escortMs: 60000, viewFn: () => null });
a.state = 'escort';
a.x = 0; a.y = 100;
const xpB = combat.xp.xp;
// A fatal pirate bolt: the escort is pulled out of the world — and
// that loss banks no XP (the bounty is the pirate's).
const eb = combat.spawnBullet(-40, 100, 0, { bulletSpeed: 300, damage: 999, bulletLife: 5, color: '#ff2d6f' }, 'enemy');
for (let i = 0; i < 20; i++) { if (eb.dead) break; eb.x += eb.body.velocity.x * 0.016; combat.update(1000 + i * 16, 16); }
combat.update(1000 + 20 * 16 + 16, 16); // flush the cull
check('mixed: a shot-down escort is collected (out of the allies roster)',
a.dead && !combat.allies.includes(a));
check('mixed: a shot-down escort banks no XP (the bounty is the pirate)', combat.xp.xp === xpB);
}
{
const { ship, combat } = harness();
const d = combat.spawnEnemy(400, 0, 0, 'raider');
d.respawn = false;
d.nextFireAt = Infinity;
const xpB = combat.xp.xp;
// An escort bolt across the pirate: one kill path — the XP still
// lands.
const ab = combat.spawnBullet(380, 0, 0, { bulletSpeed: 300, damage: 999, bulletLife: 5, color: '#37e0a1' }, 'ally');
for (let i = 0; i < 20; i++) { if (ab.dead) break; ab.x += ab.body.velocity.x * 0.016; combat.update(1000 + i * 16, 16); }
const gained = Number(config.get('combat.enemy.xp', 0)) || 10;
check('mixed: an escort kill still banks the pirate XP (one grant)',
d.dead === true && combat.xp.xp - xpB >= gained);
}
// ---------------------------------------------------------------- seekers
{
const { ship, combat } = harness();
const heraldId = neutralTypeIds().find((id) => neutralTypeConfig(id).weapon.pattern === 'missile');
check('roster: the neutral roster carries a missile-pattern craft', !!heraldId);
const a = combat.spawnAlly(0, 0, 0, heraldId, { escortMs: 60000, viewFn: () => null });
a.state = 'engage';
const d = combat.spawnEnemy(300, 0, 1, 'raider');
d.respawn = false;
d.nextFireAt = Infinity;
// Fire the seeker (the herald's gate is the same honesty as the rest).
a.rotation = Math.PI / 2; // heading = rotation - artOffset → facing east (art faces north)
a.nextFireAt = 0;
const w = a.update(1000, 16, ship, combat.enemies);
check('escort seeker: engaged + aimed, the herald lances the pirate',
w.wantsFire === true && w.target === d);
combat.fireAlly(a, d, 1000);
const m = combat.missiles.find((x) => x.owner === 'ally');
check('escort seeker: the seeker carries the "ally" owner + its victim lock',
!!m && m.owner === 'ally' && m.targetRef === d);
// Homing: the lock moves → the seeker curves to the new bearing.
d.x = 400; d.y = 120;
const h0 = m.heading;
for (let i = 0; i < 12 && !m.dead; i++) m.update(16, d);
check('escort seeker: the seeker homes on the (moved) pirate', Math.abs(m.heading - h0) > 0.1);
// No friendly fire: an escort seeker crossing the player's seat
// misses.
m.dead = false;
m.x = ship.x + 4; m.y = ship.y;
m.heading = 0;
const hullB = combat.hull, shB = combat.shields;
missilesHitPlayer(combat, ship, 12, 16);
check('escort seeker: "ally" seekers never strike the player',
m.dead === false && combat.hull === hullB && combat.shields === shB);
// A hostile seeker can drop the escort (the mixed-encounter threat).
const hunter = config.section('combat.enemyTypes.hunter', {});
const fake = { x: -100, y: 0, rotation: 0 };
firePattern(combat, fake, a, { ...hunter.weapon });
const em = combat.missiles.find((x) => x.owner === 'enemy');
check('hostile seeker: the pirate fires one too (the shared dispatch)', !!em);
if (em) {
// Keep the escort OFF the player's seat (the hostile seeker's first
// test in the collide order is the player's).
a.x = 200; a.y = 60;
em.dead = false;
em.x = a.x + 2; em.y = a.y;
const aShB = a.shields, aHuB = a.hull;
combat.update(3000, 16);
check('hostile seeker: a pirate seeker can drop the escort (new pass)',
a.shields < aShB || a.hull < aHuB || a.dead);
}
}
// ---------------------------------------------------------------- verdict
console.log('');
if (failures === 0) console.log('✔ ALL NEUTRAL-ESCORT TESTS PASSED');
else {
console.log(`✘ ${failures} NEUTRAL-ESCORT TEST(S) FAILED`);
process.exitCode = 1;
}