fuzzyworld

explodorama / files / tools / balance / structure.ts

structure.ts

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

d3e4ed26 2026-10-25 Pavel Safronov1import { ACTIONS } from '../../src/sim/actions'
2import { step } from '../../src/sim/turn'
3import { cloneState } from '../../src/sim/state'
4import type { Action, GameState } from '../../src/sim/types'
5import { canSurvive, candidates, makeBot } from './bot'
6import type { Setup } from './play'
7import { newRun } from '../../src/sim/state'
8
9/**
10 * Structural measures (PLAN.md §19.1): what a power-up changes outright on real positions, no
11 * judgement by a bot needed. The positions come from the Careful bot's own games (every 10th
12 * turn); each is measured as it is and with the power-up added.
13 */
14export interface Shape {
15 /**
16 * Of the tiles a bomb can go on, the share after which the player can still survive, getting away
17 * by any of their moves (a Run, a Teleport…); null where no bomb can go down.
18 */
19 escape: number | null
20 /** Distinct tiles the player can be on after up to 3 turns, by any actions. */
21 reach: number
22 /** Actions now that the player can survive after. */
23 safe: number
24}
25
26/** Positions to measure: every 10th turn of the Careful bot's play of `seed`, up to `max`. */
27export function samplePositions(seed: number, max = 30): GameState[] {
28 const out: GameState[] = []
29 let s = newRun(seed)
30 const bot = makeBot(seed)
31 for (let turn = 0; turn < 3000 && out.length < max; turn++) {
32 if (s.status === 'gameOver' || s.status === 'won') break
33 if (s.status === 'playing' && !s.offer && !s.chargeOffer && s.turn % 10 === 5) out.push(cloneState(s))
34 s = step(s, bot.act(s)).state
35 }
36 return out
37}
38
39const LOOK = 5
40
41/** The ways of moving the player has: every action but those that put down or fire a bomb. */
42const MOVES = new Set(['move', 'run', 'teleport', 'diagonal', 'slide', 'kick', 'wait'])
43const ways = (s: GameState): Action[] => candidates(s).filter((a) => MOVES.has(a.id))
44
45function survivable(s: GameState, a: Action): boolean {
46 try {
47 return canSurvive(step(s, a).state, LOOK, new Set(), ways)
48 } catch {
49 return false
50 }
51}
52
53export function measureShape(position: GameState, setup: Setup): Shape {
54 const s = cloneState(position)
55 setup(s)
56 const bombs = ACTIONS.bomb.validTargets(s).map((target): Action => ({ id: 'bomb', target }))
57 const escape = bombs.length ? bombs.filter((a) => survivable(s, a)).length / bombs.length : null
58 const all = candidates(s)
59 const safe = all.filter((a) => survivable(s, a)).length
60 // Breadth-first over whole turns, one state kept per tile reached.
61 const seen = new Set<string>([`${s.player.x},${s.player.y}`])
62 let frontier = [s]
63 for (let depth = 0; depth < 3; depth++) {
64 const next: GameState[] = []
65 for (const f of frontier) {
66 for (const a of candidates(f)) {
67 let r: GameState
68 try {
69 r = step(f, a).state
70 } catch {
71 continue
72 }
73 if (r.status !== 'playing') continue
74 const key = `${r.player.x},${r.player.y}`
75 if (seen.has(key)) continue
76 seen.add(key)
77 let settled = r.offer ? step(r, { id: 'choose', kind: r.offer[0]! }).state : r
78 if (settled.chargeOffer) settled = step(settled, { id: 'sellCharge' }).state
79 next.push(settled)
80 }
81 }
82 frontier = next
83 }
84 return { escape, reach: seen.size, safe }
85}