orbit/dev/faction-battle-sim.mjs

471 lines
20 KiB
JavaScript

/**
* 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'));