fuzzyworld

explodorama / files / tools / balance / lab.ts

lab.ts

14.7 KB · history · blame

1import { mkdirSync, writeFileSync } from 'node:fs'
2import { availableParallelism } from 'node:os'
3import { isMainThread, parentPort, Worker } from 'node:worker_threads'
4import { LAST_LEVEL } from '../../src/sim/levelgen'
5import { SIM_VERSION } from '../../src/sim/record'
6import { newRun } from '../../src/sim/state'
7import { applyPowerUp, POWER_UP_WEIGHTS, step } from '../../src/sim/turn'
8import { CHARGE_IDS, type Action, type PowerUpKind } from '../../src/sim/types'
9import { CHARGE_AMOUNT } from '../../src/sim/charges'
10import { DEBUG, makeBot, SKILLS } from './bot'
11import { playRun, type RunStats, type Setup } from './play'
12import { show } from './show'
13import { estimate, signed } from './stats'
14import { 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. */
30type 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). */
33function 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 */
52function 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
60function 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
71const 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
79interface Task {
80 experiment: string
81 arm: string
82 seed: number
83 skill: string
84 maxLevels: number
85}
86
87function 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. */
93function 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
100if (!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
106function 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
111async 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. */
142async 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. */
173function 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
186function 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. */
240function 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. */
258async 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 */
306function 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}