504 lines
21 KiB
JavaScript
504 lines
21 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|