1// Headless balance harness (v2): pit the archetype bots across seeds.2// npx tsx scripts/balance.mjs [seedCount] [maxTicks]3// A tuning instrument, not a test. Interpret with judgment.4import { createWorld } from '../packages/engine/src/index.ts';5import {6 createScriptHost,7 homesteaderSource,8 bulwarkSource,9 warlordSource,10 magnateSource,11} from '../packages/script-host/src/index.ts';12import { LIMITS, mapSizeForTribes } from '../packages/protocol/src/index.ts';1314const SEEDS = Number(process.argv[2] ?? 5);15const MAX_TICKS = Number(process.argv[3] ?? 10_000);1617const BOTS = {18 homesteader: homesteaderSource,19 bulwark: bulwarkSource,20 warlord: warlordSource,21 magnate: magnateSource,22};2324const MATCHUPS = [25 ['homesteader', 'warlord'],26 ['bulwark', 'warlord'],27 ['magnate', 'warlord'],28 ['homesteader', 'bulwark'],29 ['homesteader', 'magnate'],30 ['homesteader', 'bulwark', 'warlord', 'magnate'],31];3233async function runMatch(names, seed) {34 const host = await createScriptHost();35 const world = createWorld({36 seed: String(seed),37 tribeCount: names.length,38 mapSize: mapSizeForTribes(names.length),39 maxTicks: MAX_TICKS,40 });41 const vms = names.map((n, slot) =>42 host.createTribeVm(BOTS[n], {43 gasPerTick: LIMITS.GAS_PER_TICK,44 memoryLimitBytes: LIMITS.VM_MEMORY_LIMIT,45 rngSeed: `${seed}:${slot}`,46 }),47 );48 const overruns = names.map(() => 0);49 while (!world.isOver()) {50 const orders = new Map();51 const alive = new Set(world.result().tribes.filter((t) => t.alive).map((t) => t.tribeId));52 for (let slot = 0; slot < vms.length; slot++) {53 if (!alive.has(slot)) continue;54 const out = vms[slot].runTick(world.viewFor(slot));55 if (out.error && world.tick < 3) console.error(` !! ${names[slot]} t${world.tick}: ${out.error}`);56 if (out.overBudget) {57 if (++overruns[slot] >= LIMITS.MAX_CONSECUTIVE_OVERRUNS) world.forfeit(slot);58 } else overruns[slot] = 0;59 orders.set(slot, out.orders);60 }61 world.step(orders);62 }63 const result = world.result();64 const metrics = world.latestMetrics();65 vms.forEach((vm) => vm.dispose());66 host.dispose();67 return { result, metrics };68}6970for (const names of MATCHUPS) {71 const wins = Object.fromEntries(names.map((n) => [n, 0]));72 let ties = 0;73 let elims = 0;74 let lastStanding = 0;75 let battles = 0;76 let avgPop = 0;77 for (let seed = 1; seed <= SEEDS; seed++) {78 const { result, metrics } = await runMatch(names, seed * 1000 + names.length);79 if (result.winner === null) ties++;80 else wins[names[result.winner]] = (wins[names[result.winner]] ?? 0) + 1;81 elims += result.tribes.filter((t) => !t.alive).length;82 if (result.reason === 'last_standing') lastStanding++;83 if (metrics) {84 battles += metrics.tribes.reduce((s, t) => s + t.battlesWon + t.battlesLost, 0) / 2;85 avgPop += metrics.tribes.reduce((s, t) => s + t.pop, 0) / metrics.tribes.length;86 }87 }88 console.log(89 `${names.join(' vs ').padEnd(46)} wins=${JSON.stringify(wins)} ties=${ties} ` +90 `elims=${elims}/${SEEDS * names.length} lastStanding=${lastStanding}/${SEEDS} ` +91 `avgBattles=${(battles / SEEDS).toFixed(0)} avgEndPop=${(avgPop / SEEDS).toFixed(0)}`,92 );93}