fuzzyworld

explodorama / files / tools / balance / bot.ts

bot.ts

Blame: the commit that last changed each line · file · history

d3e4ed26 2026-10-25 Pavel Safronov1import { ACTIONS, isAvailable } from '../../src/sim/actions'
2import { predictDanger } from '../../src/sim/danger'
3import { PLAN_AHEAD } from '../../src/sim/enemies'
4import { blastTiles } from '../../src/sim/explosions'
5import { Rng } from '../../src/sim/rng'
6import { add, cellAt, exitOpen, idx, inBounds, playerCanPass } from '../../src/sim/state'
7import { step } from '../../src/sim/turn'
8import { 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 */
18export interface Bot {
19 name: string
20 act(s: GameState): Action
21}
22
23export const DEBUG = { on: false }
24
25/** Turns the survival check looks ahead, at most (a bomb's fuse plus its lingering flame). */
26const 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 */
34export interface Skill {
35 name: string
36 lookahead: number
37 slip: number
38}
39
40export 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
46export 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
52export 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 */
61interface Memory {
62 attempt: string
63 visits: Map<number, number>
64}
65
66function 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. */
109export 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. */
121export 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 */
130export 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. */
153function 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
158function 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 Safronov165 else if (e.type === 'blockDestroyed' || e.type === 'barrelLit') score += 15
d3e4ed26 2026-10-25 Pavel Safronov166 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. */
182function 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 Safronov190 // 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 Safronov193 // 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 */
204function 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 Safronov221 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 Safronov222 if (!worth) continue
6c3fcc31 2026-10-26 Pavel Safronov223 // 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 Safronov228 }
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 */
258function 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}