/** * FACTION BATTLE SIM (diagnostic — throwaway, no game-code changes): * fights random compositions of two FACTIONS against each other with the * REAL combat stack (CombatSystem / EnemyShip / Weapons / EnemyAbilities, * all numbers from data/combat.json) running headless in Node, and prints * a win/loss + power report (factions and ships that read over/under). * * The scenario mirrors the in-game WAR-ZONE encounter * (GameScene._spawnFactionEncounter): both factions spawn off-screen on * opposite bearings, NEUTRAL to the player (the player observes — it is * untargetable, rams disarmed, bolts fizzle past it), one-shots (no * respawn). Every faction craft ALWAYS fights the rival faction * (CombatSystem.pickEnemyTarget's rival rule); the fight ends when one * faction is wiped out (or on the time cap — judged on armor remaining). * * The Phaser arcade world normally integrates body.velocity → position * (world pre-step, before the scene's update); headless we do the same * integration by hand each frame, in the same order. * * node dev/faction-battle-sim.mjs [BATTLES_PER_MATCHUP=40] [SEED=1] */ process.env.NODE_ENV = 'dev'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href; // ------------------------------------------------------ the Phaser stub // (the same minimal stand-in dev/combat.test.mjs runs the real combat // modules against — sprites, bodies, the scene's display seams.) const TAU = Math.PI * 2; 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 modules const { config } = await import(file('config/Config.js')); 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); const { CombatSystem } = await import(file('combat/CombatSystem.js')); const { factionTypeIds } = await import(file('combat/EnemyTypes.js')); // ------------------------------------------------------ reproducible rng function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const pick = (rng, arr) => arr[Math.floor(rng() * arr.length) | 0]; const sampleN = (rng, arr, n) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a.slice(0, n); }; // ------------------------------------------------------------------ one // battle: two factions, random (equal-size) compositions, neutral player. const TIME_CAP_MS = 90_000; // a fight this long is a stalemate const FRAME = 16; // ms — the tests' step const TRACE = !!process.env.TRACE; function runBattle(A, B, size, rng) { const scene = makeScene({ withSheet: true }); // no camera → the on-screen rule is a no-op const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0)); ship.radius = 12; ship.stop = () => {}; const combat = new CombatSystem(scene, { ship }); combat.damagePlayer = () => {}; // the observer is invulnerable (the rule already stands it off) // -- attribution seams (per-instance wraps; the game code is untouched) const origFire = combat.fireEnemy.bind(combat); combat.fireEnemy = (e, t, time) => { combat._shooter = e; return origFire(e, t, time); }; const origSpawnB = combat.spawnBullet.bind(combat); combat.spawnBullet = (x, y, a, wpn, owner) => { const b = origSpawnB(x, y, a, wpn, owner); if (owner === 'enemy' && combat._shooter) { b.shooter = combat._shooter; const rec = combat._shooter._rec; if (rec) { rec.boltsFired += 1; rec.dmgFired += Number(b.damage) || 0; } } return b; }; const origHS = combat.hitsSwept.bind(combat); combat.hitsSwept = (b, other, r, dt) => { const hit = origHS(b, other, r, dt); if (hit && other && typeof other.factionId === 'string') other._lastHit = b; // the killing shot's source return hit; }; // -- spawn (the GameScene._spawnFactionEncounter mirror, opposite bearings) const r = Math.max(380, combat._offscreenRadius()) * 1.0; const baseA = rng() * TAU; let serial = 0; const typesA = sampleN(rng, factionTypeIds(A), size); const typesB = sampleN(rng, factionTypeIds(B), size); const mk = (fid, types, off) => types.map((t, i) => { const n = types.length; const a = baseA + off + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (rng() - 0.5) * 0.4; const e = combat.spawnEnemy(ship.x + Math.cos(a) * r, ship.y + Math.sin(a) * r, serial++, t); e._typeId = t; // the registry id (kill-feed / report key) e.respawn = false; // one-shots: the fight ends with the sky clear e.factionId = fid; // the war-zone's side (pickEnemyTarget's rival rule) e.hostileToPlayer = false; // the player observes — guns/rams stand off e.ramDamage = 0; e.ramDamageSelf = 0; return e; }); const shipsA = mk(A, typesA, 0); const shipsB = mk(B, typesB, Math.PI); const ships = [...shipsA, ...shipsB]; for (const e of ships) { e._rec = { ship: e, faction: e.factionId, type: e._typeId ?? null, armor0: e.hull + (e.shields || 0), lastArmor: e.hull + (e.shields || 0), dmgTaken: 0, diedAt: null, killer: null, boltsFired: 0, dmgFired: 0, }; } // -- the loop: world pre-step (velocity → position), then the combat step const dts = FRAME / 1000; let t = 1000; let frames = 0; const aliveOf = (list) => list.filter((s) => !s.dead && !s._peelGone); while (t < TIME_CAP_MS && aliveOf(shipsA).length > 0 && aliveOf(shipsB).length > 0) { // pre-step: the arcade world would have moved every body on last frame's velocity for (const e of ships) { if (e.dead || !e.body) continue; e.x += e.body.velocity.x * dts; e.y += e.body.velocity.y * dts; } for (const b of combat.bullets) { if (b.dead || !b.body) continue; b.x += b.body.velocity.x * dts; b.y += b.body.velocity.y * dts; } for (const m of combat.missiles || []) { if (m.dead || !m.body) continue; m.x += m.body.velocity.x * dts; m.y += m.body.velocity.y * dts; } t += FRAME; combat.update(t, FRAME); frames++; if (TRACE && frames % 60 === 0) { const alive = (list) => list.filter((s) => !s.dead); const la = alive(shipsA); const lb = alive(shipsB); let minD = Infinity; for (const a of la) for (const b of lb) minD = Math.min(minD, Math.hypot(a.x - b.x, a.y - b.y)); const meanD = (list) => list.length ? list.reduce((acc, s) => acc + Math.hypot(s.x - ship.x, s.y - ship.y), 0) / list.length : 0; console.log(`t=${((t - 1000) / 1000).toFixed(0)}s A alive=${la.length} B alive=${lb.length} minDist=${minD.toFixed(0)} meanToPlayer A=${meanD(la).toFixed(0)} B=${meanD(lb).toFixed(0)} bullets=${combat.bullets.length} ms=${(combat.missiles || []).length}`); } // bookkeeping: armor delta = damage taken; the kill's attribution for (const rec of ships.map((s) => s._rec)) { const armor = rec.ship.hull + (rec.ship.shields || 0); if (armor < rec.lastArmor) rec.dmgTaken += rec.lastArmor - armor; rec.lastArmor = armor; if (rec.ship.dead && rec.diedAt == null) { rec.diedAt = t; const shot = rec.ship._lastHit; rec.killer = shot && shot.shooter ? shot.shooter : null; // bullets carry the shooter; seekers → faction-only } } } // -- the result const aAlive = aliveOf(shipsA).length; const bAlive = aliveOf(shipsB).length; let winner = 'draw'; if (aAlive === 0 && bAlive > 0) winner = B; else if (bAlive === 0 && aAlive > 0) winner = A; else if (aAlive === 0 && bAlive === 0) winner = 'mutual'; else { // the time cap: the side flying more armor (and ships) leads the stalemate const armor = (list) => list.reduce((acc, s) => acc + (s.dead ? 0 : s.hull + (s.shields || 0)), 0); const ha = armor(shipsA); const hb = armor(shipsB); winner = Math.abs(ha - hb) < 1 ? 'stalemate' : (ha > hb ? A : B); } return { winner, frames, durationMs: (t - 1000), aAlive, bAlive, ships: ships.map((s) => s._rec), dmgDealt: { [A]: shipsB.reduce((a, s) => a + s._rec.dmgTaken, 0), [B]: shipsA.reduce((a, s) => a + s._rec.dmgTaken, 0) }, }; } // ------------------------------------------------------------------ run const N = Number(process.argv[2] ?? 40); const SEED = Number(process.argv[3] ?? 1); const rng = mulberry32(SEED); const FACS = ['apex', 'mindweave', 'zenith']; const MATCHUPS = [ ['apex', 'mindweave'], ['apex', 'zenith'], ['mindweave', 'zenith'], ]; const SIZES = [3, 4, 5, 6, 8]; // same size both sides — faction strength, not ship-count, is under test const battles = []; const t0 = Date.now(); for (const [A, B] of MATCHUPS) { for (let i = 0; i < N; i++) { const size = pick(rng, SIZES); // The spawn order flips half the time (the first-spawned wing's bearing // and orbit alternation must not leak into the win-rate). const [X, Y] = rng() < 0.5 ? [A, B] : [B, A]; try { battles.push({ A, B, size, ...runBattle(X, Y, size, rng) }); } catch (err) { console.error(`battle ${A} vs ${B} (size ${size}) threw:`, err); process.exit(2); } } } const wallMs = Date.now() - t0; // ---------------------------------------------------------------- report const fmt = (n) => (Number.isFinite(n) ? String(Math.round(n)) : '—'); const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN); const pct = (x, y) => (y > 0 ? Math.round((100 * x) / y) + '%' : '—'); const lines = []; const L = (s = '') => lines.push(s); L(`FACTION BATTLE SIM — ${battles.length} battles (${N} per matchup), sizes ${SIZES.join('/')} (equal both sides)`); L(`seed=${SEED} · wall ${Math.round(wallMs / 100) / 10}s · real combat stack + data/combat.json, neutral player (observe-only)`); L(); // ---- per matchup L('── MATCHUP RESULTS ─────────────────────────────────────────────────'); L(` matchup wins avg duration avg ships left (winner) dmg dealt (win/loss side)`); for (const [A, B] of MATCHUPS) { const ms = battles.filter((b) => b.A === A && b.B === B); const wA = ms.filter((b) => b.winner === A).length; const wB = ms.filter((b) => b.winner === B).length; const dr = ms.length - wA - wB; const dur = avg(ms.map((b) => b.durationMs)) / 1000; const left = avg(ms.map((b) => (b.winner === A ? b.aAlive : b.winner === B ? b.bAlive : b.aAlive + b.bAlive))); const dA = avg(ms.map((b) => b.dmgDealt[A])); const dB = avg(ms.map((b) => b.dmgDealt[B])); L(` ${A} vs ${B}`.padEnd(25) + `${wA} / ${wB} (draw ${dr}) ${dur.toFixed(1)} s ${left.toFixed(1)} ${fmt(dA)} / ${fmt(dB)}`); } L(); // ---- per faction (aggregate) L('── FACTION LEDGER (aggregate over the two matchups each fights) ──────'); L(` faction battles win-rate avg dmg dealt avg dmg taken avg kills avg ships alive at end`); for (const f of FACS) { const ms = battles.filter((b) => b.A === f || b.B === f); const wins = ms.filter((b) => b.winner === f).length; const dealt = avg(ms.map((b) => b.dmgDealt[f])); const taken = avg(ms.map((b) => (b.A === f ? b.dmgDealt[b.B] : b.dmgDealt[b.A]))); const kills = avg(ms.map((b) => { const rivals = b.ships.filter((s) => s.faction !== f && s.diedAt != null); return rivals.length; })); const aliveEnd = avg(ms.map((b) => b.ships.filter((s) => s.faction === f && s.diedAt == null).length)); L(` ${f.padEnd(11)} ${String(ms.length).padEnd(9)} ${pct(wins, ms.length).padEnd(11)} ${fmt(dealt).padEnd(14)} ${fmt(taken).padEnd(13)} ${fmt(kills).padEnd(10)} ${aliveEnd.toFixed(1)}`); } L(); // ---- per ship type L('── SHIP TYPES (across every battle they flew in) ─────────────────────'); L(' kills = kills attributed to THIS craft (bullet kills; seeker kills count at faction level)'); L(); L(` faction · ship flew survived kills avg dmg taken avg armor lost avg dmg fired`); for (const f of FACS) { const recs = {}; for (const b of battles) for (const s of b.ships) { if (s.faction !== f) continue; const key = s.type || s.ship.name; recs[key] ??= { n: 0, surv: 0, kills: 0, taken: 0, lostPct: 0, fired: 0 }; const r = recs[key]; r.n++; r.surv += s.diedAt == null ? 1 : 0; r.kills += b.ships.filter((o) => o.faction !== f && o.killer === s.ship).length; r.taken += s.dmgTaken; r.lostPct += s.armor0 > 0 ? (s.dmgTaken / s.armor0) * 100 : 0; r.fired += s.dmgFired; } const rows = Object.entries(recs).sort((a, b) => b[1].n - a[1].n); for (const [type, r] of rows) { L(` ${f} · ${type}`.padEnd(30) + `${String(r.n).padEnd(6)} ${pct(r.surv, r.n).padEnd(9)} ${String(fmt(r.kills / r.n)).padEnd(6)} ${String(fmt(r.taken / r.n)).padEnd(14)} ${fmt(r.lostPct / r.n) + '%'.padEnd(17)} ${fmt(r.fired / r.n)}`); } L(); } // ---- quick read: the extremes L('── QUICK READ ────────────────────────────────────────────────────────'); { const fstat = {}; for (const f of FACS) { const ms = battles.filter((b) => b.A === f || b.B === f); fstat[f] = { win: ms.filter((b) => b.winner === f).length / ms.length, diff: avg(ms.map((b) => b.dmgDealt[f] - (b.A === f ? b.dmgDealt[b.B] : b.dmgDealt[b.A]))), }; } const by = (cmp) => FACS.reduce((a, b) => (cmp(fstat[a], fstat[b]) ? a : b)); const best = by((x, y) => (x.win === y.win ? x.diff > y.diff : x.win > y.win)); const worst = by((x, y) => (x.win === y.win ? x.diff < y.diff : x.win < y.win)); L(` strongest faction: ${best} (win ${pct(Math.round(fstat[best].win * 100), 100)}, avg dmg differential ${fmt(fstat[best].diff)})`); L(` weakest faction: ${worst} (win ${pct(Math.round(fstat[worst].win * 100), 100)}, avg dmg differential ${fmt(fstat[worst].diff)})`); // the ships that read over / under: survival + attributed kills per flight const agg = {}; for (const b of battles) { for (const s of b.ships) { const key = `${s.faction}/${s.type || s.ship.name}`; const r = (agg[key] ??= { n: 0, surv: 0, kills: 0 }); r.n++; r.surv += s.diedAt == null ? 1 : 0; r.kills += b.ships.filter((o) => o.faction !== s.faction && o.killer === s.ship).length; } } const stats = Object.entries(agg).map(([k, r]) => ({ k, ...r, score: r.surv / r.n + r.kills / r.n, })); const hot = [...stats].filter((s) => s.n >= 5).sort((a, b) => b.score - a.score).slice(0, 3); const cold = [...stats].filter((s) => s.n >= 5).sort((a, b) => a.score - b.score).slice(0, 3); L(' reading strong: ' + hot.map((s) => `${s.k} (survived ${pct(s.surv, s.n)}, ${(s.kills / s.n).toFixed(2)} kills/fight)`).join(' ')); L(' reading weak: ' + cold.map((s) => `${s.k} (survived ${pct(s.surv, s.n)}, ${(s.kills / s.n).toFixed(2)} kills/fight)`).join(' ')); } L(); L(`raw battles: ${battles.length}; timeouts=${battles.filter((b) => b.durationMs >= TIME_CAP_MS - 1).length}; mutual=${battles.filter((b) => b.winner === 'mutual').length}`); console.log(lines.join('\n'));