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