471 lines
20 KiB
JavaScript
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'));
|