explodorama / files / tools / balance / play.ts
play.ts
d3e4ed26 2026-10-25 Pavel Safronov | 1 | import { predictDanger } from '../../src/sim/danger' |
| 2 | import { LAST_LEVEL } from '../../src/sim/levelgen' | |
| 3 | import { idx, newRun } from '../../src/sim/state' | |
| 4 | import { step } from '../../src/sim/turn' | |
| 5 | import type { Death, GameState } from '../../src/sim/types' | |
| 6 | import type { Bot } from './bot' | |
| 7 | ||
| 8 | /** A level taking longer than this is given up: the bot is stuck, and the run counts as ended there. */ | |
| 9 | const STALL_TURNS = 400 | |
| 10 | ||
| 11 | export interface LevelStats { | |
| 12 | level: number | |
| 13 | /** Turns over every attempt at it. */ | |
| 14 | turns: number | |
| 15 | deaths: number | |
| 16 | cleared: boolean | |
| 17 | underPar: boolean | |
| 18 | } | |
| 19 | ||
| 20 | export interface RunStats { | |
| 21 | seed: number | |
| 22 | levelsCleared: number | |
| 23 | won: boolean | |
| 24 | /** Ended because a level took over STALL_TURNS turns. */ | |
| 25 | stalled: boolean | |
| 26 | turns: number | |
| 27 | kills: number | |
| 28 | coins: number | |
| 29 | deaths: Partial<Record<Death['cause'], number>> | |
| 30 | /** Turns ended standing on a tile that burns this turn or the next (the danger overlay). */ | |
| 31 | dangerTurns: number | |
| 32 | /** Out of a blast's reach on the turn it went off. */ | |
| 33 | closeCalls: number | |
| 34 | levels: LevelStats[] | |
| 35 | } | |
| 36 | ||
| 37 | /** What an experiment changes at the start of a run (a power-up held, a stat raised…). */ | |
| 38 | export type Setup = (s: GameState) => void | |
| 39 | ||
| 40 | /** One run with `bot`, from level 1 until game over, the win, a stall, or `maxLevels` cleared. */ | |
| 41 | export function playRun(seed: number, bot: Bot, setup: Setup, maxLevels = LAST_LEVEL): RunStats { | |
| 42 | let s = newRun(seed) | |
| 43 | setup(s) | |
| 44 | const stats: RunStats = { | |
| 45 | seed, levelsCleared: 0, won: false, stalled: false, turns: 0, kills: 0, coins: 0, deaths: {}, | |
| 46 | dangerTurns: 0, closeCalls: 0, levels: [], | |
| 47 | } | |
| 48 | let level: LevelStats = { level: s.level, turns: 0, deaths: 0, cleared: false, underPar: false } | |
| 49 | stats.levels.push(level) | |
| 50 | for (;;) { | |
| 51 | if (s.status === 'gameOver' || s.status === 'won') break | |
| 52 | if (s.status === 'cleared' && stats.levelsCleared >= maxLevels) break | |
| 53 | if (level.turns > STALL_TURNS) { | |
| 54 | stats.stalled = true | |
| 55 | break | |
| 56 | } | |
| 57 | const prev = s | |
| 58 | const action = bot.act(s) | |
| 59 | const { state, events } = step(s, action) | |
| 60 | s = state | |
| 61 | // Whatever the bot did with the turn: what it was worth. | |
| 62 | if (prev.status === 'playing' && !prev.offer && !prev.chargeOffer && action.id !== 'choose') { | |
| 63 | level.turns++ | |
| 64 | stats.turns++ | |
| 65 | if (s.status === 'playing' && predictDanger(s).now.has(idx(s, s.player))) stats.dangerTurns++ | |
| 66 | const wasAt = prev.player | |
| 67 | if (s.status === 'playing' && events.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === wasAt.x && t.y === wasAt.y))) stats.closeCalls++ | |
| 68 | } | |
| 69 | for (const e of events) { | |
| 70 | if (e.type === 'enemyDied') stats.kills++ | |
| 71 | else if (e.type === 'playerDied') { | |
| 72 | level.deaths++ | |
| 73 | const cause = s.death?.cause | |
| 74 | if (cause) stats.deaths[cause] = (stats.deaths[cause] ?? 0) + 1 | |
| 75 | } else if (e.type === 'levelCleared') { | |
| 76 | level.cleared = true | |
| 77 | level.underPar = e.parBonus > 0 | |
| 78 | stats.levelsCleared++ | |
| 79 | } else if (e.type === 'levelStarted') { | |
| 80 | level = { level: e.level, turns: 0, deaths: 0, cleared: false, underPar: false } | |
| 81 | stats.levels.push(level) | |
| 82 | } | |
| 83 | } | |
| 84 | if (s.status === 'won') stats.won = true | |
| 85 | } | |
| 86 | stats.coins = s.coins | |
| 87 | return stats | |
| 88 | } |