explodorama / files / tools / balance / lab.ts
lab.ts
| 1 | import { mkdirSync, writeFileSync } from 'node:fs' |
| 2 | import { availableParallelism } from 'node:os' |
| 3 | import { isMainThread, parentPort, Worker } from 'node:worker_threads' |
| 4 | import { LAST_LEVEL } from '../../src/sim/levelgen' |
| 5 | import { SIM_VERSION } from '../../src/sim/record' |
| 6 | import { newRun } from '../../src/sim/state' |
| 7 | import { applyPowerUp, POWER_UP_WEIGHTS, step } from '../../src/sim/turn' |
| 8 | import { CHARGE_IDS, type Action, type PowerUpKind } from '../../src/sim/types' |
| 9 | import { CHARGE_AMOUNT } from '../../src/sim/charges' |
| 10 | import { DEBUG, makeBot, SKILLS } from './bot' |
| 11 | import { playRun, type RunStats, type Setup } from './play' |
| 12 | import { show } from './show' |
| 13 | import { estimate, signed } from './stats' |
| 14 | import { measureShape, samplePositions, type Shape } from './structure' |
| 15 | |
| 16 | /** |
| 17 | * The balance lab (PLAN.md §19.1). Bots play the game on many seeds; an experiment plays the same |
| 18 | * seeds once per arm (the baseline, then each change), so every difference is measured on the |
| 19 | * same boards. The runs are spread over the CPU's cores. |
| 20 | * |
| 21 | * npm run balance -- powers [--seeds 200] [--skill careful|sloppy] each power-up held from level 1 |
| 22 | * npm run balance -- diagonal [--seeds 200] [--skill …] Diagonal now, and with the old squeeze |
| 23 | * npm run balance -- underfoot [--seeds 200] [--skill …] bombs next to the player, or under them (NES) |
| 24 | * npm run balance -- structure [--seeds 60] what each power-up changes on real positions |
| 25 | * npm run balance -- runs [--seeds 20] [--skill …] the baseline, run by run |
| 26 | * npm run balance -- trace <seed> <level> [turns] one level, board by board |
| 27 | */ |
| 28 | |
| 29 | /** An experiment's arms: a name and what it changes at the start of a run. */ |
| 30 | type Arms = Record<string, Setup> |
| 31 | |
| 32 | /** Every power-up held from level 1, and every charge carried from it (Coin bag left out: the bots don't shop). */ |
| 33 | function powerArms(): Arms { |
| 34 | const arms: Arms = { baseline: () => {} } |
| 35 | for (const [kind] of POWER_UP_WEIGHTS) { |
| 36 | if (kind === 'coinBag') continue |
| 37 | arms[kind] = (s) => applyPowerUp(s, kind as PowerUpKind) |
| 38 | } |
| 39 | // Each charge carried from level 1 (one pickup's worth), since charges are used on purpose. |
| 40 | for (const kind of CHARGE_IDS) arms[`${kind} (charge)`] = (s) => void (s.charge = { kind, left: CHARGE_AMOUNT[kind] }) |
| 41 | return arms |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Diagonal as it is (no squeezing, since 2026-10-25) against the old rule that let it step between |
| 46 | * two blocked tiles: without it, with it, and with the squeeze back. |
| 47 | */ |
| 48 | /** |
| 49 | * Where a bomb goes (user question, 2026-10-25): next to the player, as now, against the NES rule, |
| 50 | * on the player's own tile. |
| 51 | */ |
| 52 | function underfootArms(): Arms { |
| 53 | const underfoot: Setup = (s) => void (s.options = { ...s.options, lab: { underfoot: true } }) |
| 54 | return { |
| 55 | baseline: () => {}, |
| 56 | 'underfoot (NES rule)': underfoot, |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | function diagonalArms(): Arms { |
| 61 | return { |
| 62 | baseline: () => {}, |
| 63 | diagonal: (s) => applyPowerUp(s, 'diagonal'), |
| 64 | 'diagonal, with the old squeeze': (s) => { |
| 65 | applyPowerUp(s, 'diagonal') |
| 66 | s.options = { ...s.options, lab: { squeeze: true } } |
| 67 | }, |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | const EXPERIMENTS: Record<string, () => Arms> = { |
| 72 | powers: powerArms, |
| 73 | diagonal: diagonalArms, |
| 74 | underfoot: underfootArms, |
| 75 | // The baseline alone, or with one power-up or rule variant (`--arm <name>`), run by run. |
| 76 | runs: () => ({ baseline: { ...powerArms(), ...underfootArms() }[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }), |
| 77 | } |
| 78 | |
| 79 | interface Task { |
| 80 | experiment: string |
| 81 | arm: string |
| 82 | seed: number |
| 83 | skill: string |
| 84 | maxLevels: number |
| 85 | } |
| 86 | |
| 87 | function runTask(t: Task): RunStats { |
| 88 | const arms = EXPERIMENTS[t.experiment]!() |
| 89 | return playRun(t.seed, makeBot(t.seed, SKILLS[t.skill]), arms[t.arm]!, t.maxLevels) |
| 90 | } |
| 91 | |
| 92 | /** The structural measures of one seed's positions, for every arm: arm → one Shape per position. */ |
| 93 | function shapeTask(seed: number): Record<string, Shape[]> { |
| 94 | const positions = samplePositions(seed) |
| 95 | const out: Record<string, Shape[]> = {} |
| 96 | for (const [arm, setup] of Object.entries(powerArms())) out[arm] = positions.map((p) => measureShape(p, setup)) |
| 97 | return out |
| 98 | } |
| 99 | |
| 100 | if (!isMainThread) { |
| 101 | parentPort!.on('message', (m: { task?: Task; shapes?: number }) => parentPort!.postMessage(m.task ? runTask(m.task) : shapeTask(m.shapes!))) |
| 102 | } else { |
| 103 | await main() |
| 104 | } |
| 105 | |
| 106 | function option(args: string[], name: string, fallback: string): string { |
| 107 | const i = args.indexOf(`--${name}`) |
| 108 | return i >= 0 && args[i + 1] ? args[i + 1]! : fallback |
| 109 | } |
| 110 | |
| 111 | async function main(): Promise<void> { |
| 112 | const [command = 'help', ...args] = process.argv.slice(2) |
| 113 | if (command === 'trace') return trace(args.map(Number)) |
| 114 | if (command === 'structure') return structure(Number(option(args, 'seeds', '60'))) |
| 115 | if (command === 'record-until') return recordUntil(args[0] ?? 'chargeOffered') |
| 116 | if (!EXPERIMENTS[command]) { |
| 117 | console.log('Commands: powers, diagonal, underfoot, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.') |
| 118 | return |
| 119 | } |
| 120 | const seeds = Number(option(args, 'seeds', command === 'runs' ? '20' : '200')) |
| 121 | const skill = option(args, 'skill', 'careful') |
| 122 | process.env.BALANCE_ARM = option(args, 'arm', 'baseline') |
| 123 | const maxLevels = Number(option(args, 'levels', String(LAST_LEVEL))) |
| 124 | if (!SKILLS[skill]) throw new Error(`no skill ${skill}`) |
| 125 | const arms = Object.keys(EXPERIMENTS[command]!()) |
| 126 | const tasks: Task[] = arms.flatMap((arm) => Array.from({ length: seeds }, (_, i) => ({ experiment: command, arm, seed: i + 1, skill, maxLevels }))) |
| 127 | const t0 = performance.now() |
| 128 | const results = await runAll<RunStats>(tasks.map((task) => ({ task }))) |
| 129 | const seconds = (performance.now() - t0) / 1000 |
| 130 | const byArm = new Map<string, RunStats[]>() |
| 131 | tasks.forEach((t, i) => byArm.set(t.arm, [...(byArm.get(t.arm) ?? []), results[i]!])) |
| 132 | if (command === 'runs') { |
| 133 | for (const r of byArm.get('baseline')!) { |
| 134 | console.log(r.seed, r.levelsCleared, r.won ? 'won' : r.stalled ? 'stalled' : 'over', JSON.stringify(r.deaths), r.levels.map((l) => `${l.turns}${l.deaths ? '/' + l.deaths + 'd' : ''}`).join(' ')) |
| 135 | } |
| 136 | } |
| 137 | const report = writeReport(command, skill, seeds, byArm, seconds) |
| 138 | console.log(report) |
| 139 | } |
| 140 | |
| 141 | /** Every message on a pool of worker threads, one per core but one; results in the messages' order. */ |
| 142 | async function runAll<R>(tasks: unknown[]): Promise<R[]> { |
| 143 | const results: R[] = new Array(tasks.length) |
| 144 | const workers = Math.max(1, Math.min(tasks.length, availableParallelism() - 1)) |
| 145 | let next = 0 |
| 146 | let done = 0 |
| 147 | await Promise.all( |
| 148 | Array.from({ length: workers }, () => new Promise<void>((resolve, reject) => { |
| 149 | const worker = new Worker(new URL(import.meta.url)) |
| 150 | const give = () => { |
| 151 | if (next >= tasks.length) { |
| 152 | void worker.terminate() |
| 153 | resolve() |
| 154 | return |
| 155 | } |
| 156 | const i = next++ |
| 157 | worker.once('message', (r: R) => { |
| 158 | results[i] = r |
| 159 | if (++done % 10 === 0 || done === tasks.length) process.stderr.write(`\r${done} / ${tasks.length}`) |
| 160 | give() |
| 161 | }) |
| 162 | worker.postMessage(tasks[i]) |
| 163 | } |
| 164 | worker.on('error', reject) |
| 165 | give() |
| 166 | })), |
| 167 | ) |
| 168 | process.stderr.write('\n') |
| 169 | return results |
| 170 | } |
| 171 | |
| 172 | /** A run's numbers that the report compares. */ |
| 173 | function measures(r: RunStats) { |
| 174 | const attempted = Math.max(1, r.levels.length) |
| 175 | const deaths = Object.values(r.deaths).reduce((a, b) => a + (b ?? 0), 0) |
| 176 | return { |
| 177 | levels: r.levelsCleared, |
| 178 | won: r.won ? 1 : 0, |
| 179 | deaths, |
| 180 | turnsPerLevel: r.turns / attempted, |
| 181 | danger: r.turns ? (100 * r.dangerTurns) / r.turns : 0, |
| 182 | stalled: r.stalled ? 1 : 0, |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | function writeReport(experiment: string, skill: string, seeds: number, byArm: Map<string, RunStats[]>, seconds: number): string { |
| 187 | const base = byArm.get('baseline')!.map(measures) |
| 188 | const rows: { arm: string; line: string; delta: number }[] = [] |
| 189 | for (const [arm, runs] of byArm) { |
| 190 | const m = runs.map(measures) |
| 191 | const diff = (k: keyof ReturnType<typeof measures>) => estimate(m.map((x, i) => x[k] - base[i]![k])) |
| 192 | const plain = (k: keyof ReturnType<typeof measures>) => estimate(m.map((x) => x[k])) |
| 193 | const levels = plain('levels') |
| 194 | const line = arm === 'baseline' |
| 195 | ? `| **baseline** | ${levels.mean.toFixed(2)} | ${(100 * plain('won').mean).toFixed(0)}% | ${plain('deaths').mean.toFixed(2)} | ${plain('turnsPerLevel').mean.toFixed(1)} | ${plain('danger').mean.toFixed(1)}% | ${(100 * plain('stalled').mean).toFixed(0)}% |` |
| 196 | : `| ${arm} | ${signed(diff('levels'))} | ${signed({ ...diff('won'), mean: 100 * diff('won').mean, ci: 100 * diff('won').ci }, 0)}% | ${signed(diff('deaths'))} | ${signed(diff('turnsPerLevel'), 1)} | ${signed(diff('danger'), 1)} | ${(100 * plain('stalled').mean).toFixed(0)}% |` |
| 197 | rows.push({ arm, line, delta: arm === 'baseline' ? Infinity : diff('levels').mean - diff('deaths').mean / 10 }) |
| 198 | } |
| 199 | rows.sort((a, b) => b.delta - a.delta) |
| 200 | // The baseline's difficulty curve: how many runs reach each level, and deaths there. |
| 201 | const baseRuns = byArm.get('baseline')! |
| 202 | const curve: string[] = [] |
| 203 | for (let level = 1; level <= LAST_LEVEL; level++) { |
| 204 | const at = baseRuns.flatMap((r) => r.levels.filter((l) => l.level === level)) |
| 205 | if (!at.length) break |
| 206 | const deaths = at.reduce((a, l) => a + l.deaths, 0) |
| 207 | const turns = at.reduce((a, l) => a + l.turns, 0) / at.length |
| 208 | curve.push(`| ${level} | ${((100 * at.length) / baseRuns.length).toFixed(0)}% | ${(deaths / at.length).toFixed(2)} | ${turns.toFixed(0)} | ${((100 * at.filter((l) => l.underPar).length) / at.length).toFixed(0)}% |`) |
| 209 | } |
| 210 | const causes = new Map<string, number>() |
| 211 | for (const r of baseRuns) for (const [c, n] of Object.entries(r.deaths)) causes.set(c, (causes.get(c) ?? 0) + (n ?? 0)) |
| 212 | const text = [ |
| 213 | `# Balance lab: ${experiment} (${skill} bot)`, |
| 214 | '', |
| 215 | `${new Date().toISOString().slice(0, 10)} · rules version ${SIM_VERSION} · ${seeds} seeds per arm · ${seconds.toFixed(0)} s.`, |
| 216 | '', |
| 217 | 'Each arm plays the same seeds as the baseline; its columns are the difference from the baseline,', |
| 218 | 'per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels', |
| 219 | 'gained. The bots answer a gem by taking the first thing offered and never shop.', |
| 220 | '', |
| 221 | '| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled |', |
| 222 | '|---|---|---|---|---|---|---|', |
| 223 | ...rows.map((r) => r.line), |
| 224 | '', |
| 225 | '## The baseline, level by level', |
| 226 | '', |
| 227 | '| Level | Runs reaching it | Lives lost there | Turns there | Within par |', |
| 228 | '|---|---|---|---|---|', |
| 229 | ...curve, |
| 230 | '', |
| 231 | `Lives lost in the baseline, by cause: ${[...causes].map(([c, n]) => `${c} ${n}`).join(', ') || 'none'}.`, |
| 232 | '', |
| 233 | ].join('\n') |
| 234 | mkdirSync('docs/balance', { recursive: true }) |
| 235 | writeFileSync(`docs/balance/${experiment}-${skill}.md`, text) |
| 236 | return text |
| 237 | } |
| 238 | |
| 239 | /** One level of one seed, as the Careful bot plays it, board by board. */ |
| 240 | function trace([seed = 1, level = 1, turns = 60]: number[]): void { |
| 241 | let s = newRun(seed) |
| 242 | ;({ ...powerArms(), ...underfootArms() })[process.env.BALANCE_ARM ?? 'baseline']?.(s) |
| 243 | const bot = makeBot(seed) |
| 244 | let shown = 0 |
| 245 | while (s.status !== 'gameOver' && s.status !== 'won' && shown < turns) { |
| 246 | DEBUG.on = process.env.DEBUG_TURN === String(s.turn) && s.level === level |
| 247 | const a = bot.act(s) |
| 248 | const r = step(s, a) |
| 249 | s = r.state |
| 250 | if (s.level !== level) continue |
| 251 | shown++ |
| 252 | const notes = r.events.filter((e) => ['playerDied', 'enemyDied', 'exploded', 'levelRestarted'].includes(e.type)).map((e) => e.type) |
| 253 | console.log(`turn ${s.turn} ${JSON.stringify(a)} ${notes.join(' ')} ${s.status} fire ${s.stats.fire} fuse ${s.stats.fuse} charge ${JSON.stringify(s.charge)}\n${show(s)}`) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /** The structural report: each power-up's effect on escapes, reach and safe actions, on the same positions. */ |
| 258 | async function structure(seeds: number): Promise<void> { |
| 259 | const t0 = performance.now() |
| 260 | const results = await runAll<Record<string, Shape[]>>(Array.from({ length: seeds }, (_, i) => ({ shapes: i + 1 }))) |
| 261 | const arms = Object.keys(results[0] ?? {}) |
| 262 | const pooled = (arm: string) => results.flatMap((r) => r[arm] ?? []) |
| 263 | const base = pooled('baseline') |
| 264 | const rows = arms.map((arm) => { |
| 265 | const m = pooled(arm) |
| 266 | // Pairs where either side has no value (no bomb could go down) are left out. |
| 267 | const diff = (k: keyof Shape, scale = 1) => |
| 268 | estimate(m.flatMap((x, i) => (x[k] === null || base[i]![k] === null ? [] : [scale * (x[k] - base[i]![k])]))) |
| 269 | const mean = (k: keyof Shape, scale = 1) => estimate(m.flatMap((x) => (x[k] === null ? [] : [scale * x[k]]))).mean |
| 270 | return { |
| 271 | arm, |
| 272 | key: arm === 'baseline' ? Infinity : diff('escape').mean + diff('reach').mean / 10, |
| 273 | line: arm === 'baseline' |
| 274 | ? `| **baseline** | ${mean('escape', 100).toFixed(0)}% | ${mean('reach').toFixed(1)} | ${mean('safe').toFixed(1)} |` |
| 275 | : `| ${arm} | ${signed(diff('escape', 100), 0)} | ${signed(diff('reach'), 1)} | ${signed(diff('safe'), 1)} |`, |
| 276 | } |
| 277 | }) |
| 278 | rows.sort((a, b) => b.key - a.key) |
| 279 | const text = [ |
| 280 | '# Balance lab: structure', |
| 281 | '', |
| 282 | `${new Date().toISOString().slice(0, 10)} · rules version ${SIM_VERSION} · ${base.length} positions from ${seeds} seeds · ${((performance.now() - t0) / 1000).toFixed(0)} s.`, |
| 283 | '', |
| 284 | 'Positions from the Careful bot\'s games (every 10th turn), measured as they are and with each', |
| 285 | 'power-up added; the columns are the difference from the baseline, with a 95% interval (✱: real).', |
| 286 | '', |
| 287 | '- **Escapable bombs:** of the tiles a bomb can go on, the share the player can survive after,', |
| 288 | ' getting away by any of their moves (positions where no bomb can go down are left out).', |
| 289 | '- **Reach in 3 turns:** distinct tiles the player can be on after up to 3 turns, by any actions.', |
| 290 | '- **Safe actions:** actions now that the player can survive after.', |
| 291 | '', |
| 292 | '| Arm | Escapable bombs | Reach in 3 turns | Safe actions |', |
| 293 | '|---|---|---|---|', |
| 294 | ...rows.map((r) => r.line), |
| 295 | '', |
| 296 | ].join('\n') |
| 297 | mkdirSync('docs/balance', { recursive: true }) |
| 298 | writeFileSync('docs/balance/structure.md', text) |
| 299 | console.log(text) |
| 300 | } |
| 301 | |
| 302 | /** |
| 303 | * The first run record (Careful bot, seeds 1 on) that reaches an event of `type`, printed as JSON: |
| 304 | * put in the game's save, it reopens the game at that moment (for screenshots of rare dialogs). |
| 305 | */ |
| 306 | function recordUntil(type: string): void { |
| 307 | for (let seed = 1; seed <= 200; seed++) { |
| 308 | let s = newRun(seed) |
| 309 | const bot = makeBot(seed) |
| 310 | const actions: Action[] = [] |
| 311 | for (let n = 0; n < 3000 && s.status !== 'gameOver' && s.status !== 'won'; n++) { |
| 312 | const a = bot.act(s) |
| 313 | actions.push(a) |
| 314 | const r = step(s, a) |
| 315 | s = r.state |
| 316 | if (r.events.some((e) => e.type === type)) { |
| 317 | console.log(JSON.stringify({ simVersion: SIM_VERSION, seed, startLevel: 1, startCharge: null, options: s.options, actions })) |
| 318 | return |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | } |