explodorama / files / tools / balance / bot.ts
bot.ts
d3e4ed26 2026-10-25 Pavel Safronov | 1 | import { ACTIONS, isAvailable } from '../../src/sim/actions' |
| 2 | import { predictDanger } from '../../src/sim/danger' | |
| 3 | import { PLAN_AHEAD } from '../../src/sim/enemies' | |
| 4 | import { blastTiles } from '../../src/sim/explosions' | |
| 5 | import { Rng } from '../../src/sim/rng' | |
| 6 | import { add, cellAt, exitOpen, idx, inBounds, playerCanPass } from '../../src/sim/state' | |
| 7 | import { step } from '../../src/sim/turn' | |
| 8 | import { DIRS, DIR_LIST, type Action, type ActionId, type GameEvent, type GameState, type Vec } from '../../src/sim/types' | |
| 9 | ||
| 10 | /** | |
| 11 | * The Careful bot (PLAN.md §19.1): a cautious player that never takes a turn it can't survive. | |
| 12 | * Every action it could take is tried on the real rules (`step()`); one that leaves no way to | |
| 13 | * stay alive for the next few turns (by moving and waiting) is out. Of the rest it takes the one | |
| 14 | * that scores best: kills, crates burned and pickups now, a bomb that will catch an enemy or | |
| 15 | * crates, and getting closer to what it is after (the open exit, an uncovered gem, a drop, then | |
| 16 | * enemies and crates). It only uses what a player can see: not where the exit or the gem hide. | |
| 17 | */ | |
| 18 | export interface Bot { | |
| 19 | name: string | |
| 20 | act(s: GameState): Action | |
| 21 | } | |
| 22 | ||
| 23 | export const DEBUG = { on: false } | |
| 24 | ||
| 25 | /** Turns the survival check looks ahead, at most (a bomb's fuse plus its lingering flame). */ | |
| 26 | const MAX_LOOKAHEAD = 6 | |
| 27 | ||
| 28 | /** | |
| 29 | * How good a player the bot is. `lookahead`: the most turns it checks it can survive (the | |
| 30 | * Careful bot looks as far as the enemies' plans and the bombs' fuses; fewer turns make it walk | |
| 31 | * into traps it didn't see). `slip`: the share of turns it takes some other action that doesn't | |
| 32 | * kill it at once, as a person might misjudge. | |
| 33 | */ | |
| 34 | export interface Skill { | |
| 35 | name: string | |
| 36 | lookahead: number | |
| 37 | slip: number | |
| 38 | } | |
| 39 | ||
| 40 | export const SKILLS: Record<string, Skill> = { | |
| 41 | careful: { name: 'careful', lookahead: MAX_LOOKAHEAD, slip: 0 }, | |
| 42 | average: { name: 'average', lookahead: 2, slip: 0.03 }, | |
| 43 | sloppy: { name: 'sloppy', lookahead: 1, slip: 0.08 }, | |
| 44 | } | |
| 45 | ||
| 46 | export function makeBot(seed: number, skill: Skill = SKILLS.careful!): Bot { | |
| 47 | const rng = new Rng(seed) | |
| 48 | const memory: Memory = { attempt: '', visits: new Map() } | |
| 49 | return { name: skill.name, act: (s) => decide(s, rng, memory, skill) } | |
| 50 | } | |
| 51 | ||
| 52 | export function carefulBot(seed: number): Bot { | |
| 53 | return makeBot(seed) | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * How often it has stood on each tile in this attempt at the level. Standing somewhere again | |
| 58 | * costs a little, so a bot with nothing useful to do where it is moves on instead of waiting | |
| 59 | * for ever. | |
| 60 | */ | |
| 61 | interface Memory { | |
| 62 | attempt: string | |
| 63 | visits: Map<number, number> | |
| 64 | } | |
| 65 | ||
| 66 | function decide(s: GameState, rng: Rng, memory: Memory, skill: Skill): Action { | |
| 67 | // A gem's offer: the first thing on it (the offer's order comes from the seed). | |
| 68 | if (s.offer) return { id: 'choose', kind: s.offer[0]! } | |
| 69 | // A second charge: keep the one held (sell the new one) — the simplest rule. | |
| 70 | if (s.chargeOffer) return { id: 'sellCharge' } | |
| 71 | if (s.status !== 'playing') return { id: 'continue' } | |
| 72 | ||
| 73 | const attempt = `${s.level}:${s.deaths}` | |
| 74 | if (memory.attempt !== attempt) { | |
| 75 | memory.attempt = attempt | |
| 76 | memory.visits.clear() | |
| 77 | } | |
| 78 | const here = idx(s, s.player) | |
| 79 | memory.visits.set(here, (memory.visits.get(here) ?? 0) + 1) | |
| 80 | const goals = goalDistances(s) | |
| 81 | const all = candidates(s) | |
| 82 | // A slip: any action that doesn't end the turn dead. | |
| 83 | if (skill.slip > 0 && rng.next() < skill.slip) { | |
| 84 | const alive = all.filter((a) => { | |
| 85 | try { | |
| 86 | return step(s, a).state.status === 'playing' | |
| 87 | } catch { | |
| 88 | return false | |
| 89 | } | |
| 90 | }) | |
| 91 | if (alive.length) return alive[rng.int(alive.length)]! | |
| 92 | } | |
| 93 | let best: { action: Action; score: number } | null = null | |
| 94 | for (const action of all) { | |
| 95 | let result: ReturnType<typeof step> | |
| 96 | try { | |
| 97 | result = step(s, action) | |
| 98 | } catch { | |
| 99 | continue | |
| 100 | } | |
| 101 | const score = scoreOf(s, result.state, result.events, goals, skill) - (memory.visits.get(idx(s, result.state.player)) ?? 0) + rng.next() * 0.5 | |
| 102 | if (DEBUG.on) console.log(JSON.stringify(action), score.toFixed(1)) | |
| 103 | if (!best || score > best.score) best = { action, score } | |
| 104 | } | |
| 105 | return best?.action ?? { id: 'wait' } | |
| 106 | } | |
| 107 | ||
| 108 | /** Every action the player could take now, with every target it allows. */ | |
| 109 | export function candidates(s: GameState): Action[] { | |
| 110 | const out: Action[] = [] | |
| 111 | for (const id of Object.keys(ACTIONS) as ActionId[]) { | |
| 112 | if (!isAvailable(s, id)) continue | |
| 113 | const def = ACTIONS[id] | |
| 114 | if (!def.needsTarget) out.push({ id }) | |
| 115 | else for (const target of def.validTargets(s)) out.push({ id, target }) | |
| 116 | } | |
| 117 | return out | |
| 118 | } | |
| 119 | ||
| 120 | /** The plain moves (and Wait) that the survival check tries: what any player has. */ | |
| 121 | export function escapes(s: GameState): Action[] { | |
| 122 | return [{ id: 'wait' }, ...ACTIONS.move.validTargets(s).map((target): Action => ({ id: 'move', target }))] | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Whether some run of moves and waits (or, with `ways`, other actions) keeps the player alive for | |
| 127 | * `turns` more turns (or to the end of the level). Positions already tried at the same depth | |
| 128 | * aren't tried again. | |
| 129 | */ | |
| 130 | export function canSurvive(s: GameState, turns: number, seen = new Set<string>(), ways = escapes): boolean { | |
| 131 | if (s.status === 'cleared' || s.status === 'won') return true | |
| 132 | if (s.status !== 'playing') return false | |
| 133 | if (turns <= 0) return true | |
| 134 | // A gem or a second charge just taken: its choice first (it takes no turn). | |
| 135 | if (s.offer) s = step(s, { id: 'choose', kind: s.offer[0]! }).state | |
| 136 | if (s.chargeOffer) s = step(s, { id: 'sellCharge' }).state | |
| 137 | const key = `${turns}:${s.player.x},${s.player.y}` | |
| 138 | if (seen.has(key)) return false | |
| 139 | seen.add(key) | |
| 140 | for (const a of ways(s)) { | |
| 141 | let next: GameState | |
| 142 | try { | |
| 143 | next = step(s, a).state | |
| 144 | } catch { | |
| 145 | continue | |
| 146 | } | |
| 147 | if (canSurvive(next, turns - 1, seen, ways)) return true | |
| 148 | } | |
| 149 | return false | |
| 150 | } | |
| 151 | ||
| 152 | /** How far ahead to look: as far as the enemies have announced, and until every bomb has gone off and its flame died. */ | |
| 153 | function lookahead(s: GameState): number { | |
| 154 | const fuse = Math.max(0, ...s.bombs.map((b) => b.timer)) | |
| 155 | return Math.min(MAX_LOOKAHEAD, Math.max(PLAN_AHEAD, fuse + 2)) | |
| 156 | } | |
| 157 | ||
| 158 | function scoreOf(s: GameState, next: GameState, events: GameEvent[], goals: Int32Array, skill: Skill): number { | |
| 159 | if (next.status === 'cleared' || next.status === 'won') return 1e6 | |
| 160 | if (next.status !== 'playing') return -1e9 | |
| 161 | if (!canSurvive(next, Math.min(skill.lookahead, lookahead(next)))) return -1e8 | |
| 162 | let score = 0 | |
| 163 | for (const e of events) { | |
| 164 | if (e.type === 'enemyDied') score += 200 | |
6c3fcc31 2026-10-26 Pavel Safronov | 165 | else if (e.type === 'blockDestroyed' || e.type === 'barrelLit') score += 15 |
d3e4ed26 2026-10-25 Pavel Safronov | 166 | else if (e.type === 'pickedUp') score += e.drop.kind === 'coins' ? 25 : 60 |
| 167 | else if (e.type === 'powerUpTaken') score += 300 | |
| 168 | else if (e.type === 'enemiesSpawned') score -= 400 | |
| 169 | } | |
| 170 | // A new bomb: what its blast will catch. | |
| 171 | const placed = next.bombs.filter((b) => !s.bombs.some((o) => o.pos.x === b.pos.x && o.pos.y === b.pos.y)) | |
| 172 | for (const b of placed) score += bombValue(next, b) | |
| 173 | // Closer to what it is after; standing still is fine when a bomb is ticking nearby. | |
| 174 | const d = goals[idx(s, next.player)] ?? -1 | |
| 175 | if (d >= 0) score -= 4 * d | |
| 176 | // Out of the flame's way where it can be. | |
| 177 | if (predictDanger(next).now.has(idx(next, next.player))) score -= 3 | |
| 178 | return score | |
| 179 | } | |
| 180 | ||
| 181 | /** A bomb's worth: enemies whose plan passes through its blast before it goes off, crates in reach. */ | |
| 182 | function bombValue(s: GameState, bomb: GameState['bombs'][number]): number { | |
| 183 | const tiles = blastTiles(s, bomb) | |
| 184 | const has = (p: Vec) => tiles.some((t) => t.x === p.x && t.y === p.y) | |
| 185 | let value = -10 | |
| 186 | for (const e of s.enemies) { | |
| 187 | const route = [e.pos, ...e.plan.slice(0, bomb.timer)] | |
| 188 | if (route.some(has)) value += 60 | |
| 189 | } | |
6c3fcc31 2026-10-26 Pavel Safronov | 190 | // Crates, and oil barrels (2026-10-25: the bot never burst one, and stalled where barrels shut |
| 191 | // in the exit or the last enemy). | |
| 192 | for (const t of tiles) if (cellAt(s, t) === 'block' || cellAt(s, t) === 'barrel') value += 12 | |
d3e4ed26 2026-10-25 Pavel Safronov | 193 | // Never blast the uncovered exit or gem: that lets enemies out. |
| 194 | if (cellAt(s, s.exit) === 'floor' && has(s.exit)) value -= 500 | |
| 195 | for (const g of s.gems) if (cellAt(s, g.pos) === 'floor' && has(g.pos)) value -= 500 | |
| 196 | return value | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * Steps from every tile to the nearest thing worth going to, over tiles the player can walk: | |
| 201 | * the open exit; else an uncovered gem or a drop; else a place to bomb from — a tile next to | |
| 202 | * one where a bomb would reach a crate or an enemy's route, with another way out. | |
| 203 | */ | |
| 204 | function goalDistances(s: GameState): Int32Array { | |
| 205 | const dist = new Int32Array(s.width * s.height).fill(-1) | |
| 206 | const goals: Vec[] = [] | |
| 207 | if (exitOpen(s)) goals.push(s.exit) | |
| 208 | else { | |
| 209 | for (const g of s.gems) if (cellAt(s, g.pos) === 'floor') goals.push(g.pos) | |
| 210 | for (const d of s.drops) goals.push(d.pos) | |
| 211 | if (!goals.length) { | |
| 212 | const near = (p: Vec) => DIR_LIST.map((d) => add(p, DIRS[d])).filter((q) => inBounds(s, q) && playerCanPass(s, q)) | |
| 213 | const routes = s.enemies.flatMap((e) => [e.pos, ...e.plan]) | |
| 214 | // The next bomb as it would be: a Pierce, Power or Nuke one while those last. | |
| 215 | // A plain bomb: since one charge at a time (2026-10-24) a charge is only used on purpose. | |
| 216 | const kind = { fire: s.stats.fire } | |
| 217 | for (let i = 0; i < s.cells.length; i++) { | |
| 218 | const spot = { x: i % s.width, y: Math.floor(i / s.width) } | |
| 219 | if (s.cells[i] !== 'floor' || s.bombs.some((b) => b.pos.x === spot.x && b.pos.y === spot.y)) continue | |
| 220 | const reach = blastTiles(s, { pos: spot, ...kind }) | |
6c3fcc31 2026-10-26 Pavel Safronov | 221 | const worth = reach.some((t) => cellAt(s, t) === 'block' || cellAt(s, t) === 'barrel' || routes.some((r) => r.x === t.x && r.y === t.y)) |
d3e4ed26 2026-10-25 Pavel Safronov | 222 | if (!worth) continue |
6c3fcc31 2026-10-26 Pavel Safronov | 223 | // Somewhere to stand next to the bomb with a walk out of the blast before it goes off; with |
| 224 | // the lab's NES rule (`underfoot`), the bomb's own tile. | |
| 225 | if (s.options.lab?.underfoot) { | |
| 226 | if (canGetClear(s, spot, spot, reach)) goals.push(spot) | |
| 227 | } else for (const from of near(spot)) if (canGetClear(s, from, spot, reach)) goals.push(from) | |
d3e4ed26 2026-10-25 Pavel Safronov | 228 | } |
| 229 | } | |
| 230 | } | |
| 231 | const queue: Vec[] = [] | |
| 232 | for (const g of goals) { | |
| 233 | const i = idx(s, g) | |
| 234 | if (dist[i] === -1) { | |
| 235 | dist[i] = 0 | |
| 236 | queue.push(g) | |
| 237 | } | |
| 238 | } | |
| 239 | for (let k = 0; k < queue.length; k++) { | |
| 240 | const p = queue[k]! | |
| 241 | for (const d of DIR_LIST) { | |
| 242 | const q = add(p, DIRS[d]) | |
| 243 | if (!inBounds(s, q) || !playerCanPass(s, q)) continue | |
| 244 | const i = idx(s, q) | |
| 245 | if (dist[i] !== -1) continue | |
| 246 | dist[i] = dist[idx(s, p)]! + 1 | |
| 247 | queue.push(q) | |
| 248 | } | |
| 249 | } | |
| 250 | return dist | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * Whether, with a bomb on `bomb`, there is a walk from `from` (not through the bomb) to a tile out | |
| 255 | * of `reach` in fewer steps than the fuse. Enemies are left out: a quick guess for choosing where to | |
| 256 | * go; the real check is `canSurvive`. | |
| 257 | */ | |
| 258 | function canGetClear(s: GameState, from: Vec, bomb: Vec, reach: Vec[]): boolean { | |
| 259 | const inReach = (p: Vec) => reach.some((t) => t.x === p.x && t.y === p.y) | |
| 260 | const seen = new Set([idx(s, from), idx(s, bomb)]) | |
| 261 | let frontier = [from] | |
| 262 | for (let steps = 0; steps < s.stats.fuse; steps++) { | |
| 263 | const next: Vec[] = [] | |
| 264 | for (const p of frontier) { | |
| 265 | if (!inReach(p)) return true | |
| 266 | for (const d of DIR_LIST) { | |
| 267 | const q = add(p, DIRS[d]) | |
| 268 | if (!inBounds(s, q) || seen.has(idx(s, q)) || !playerCanPass(s, q)) continue | |
| 269 | seen.add(idx(s, q)) | |
| 270 | next.push(q) | |
| 271 | } | |
| 272 | } | |
| 273 | frontier = next | |
| 274 | } | |
| 275 | return frontier.some((p) => !inReach(p)) | |
| 276 | } |