explodorama / files / tools / balance / structure.ts
structure.ts
d3e4ed26 2026-10-25 Pavel Safronov | 1 | import { ACTIONS } from '../../src/sim/actions' |
| 2 | import { step } from '../../src/sim/turn' | |
| 3 | import { cloneState } from '../../src/sim/state' | |
| 4 | import type { Action, GameState } from '../../src/sim/types' | |
| 5 | import { canSurvive, candidates, makeBot } from './bot' | |
| 6 | import type { Setup } from './play' | |
| 7 | import { 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 | */ | |
| 14 | export 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`. */ | |
| 27 | export 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 | ||
| 39 | const LOOK = 5 | |
| 40 | ||
| 41 | /** The ways of moving the player has: every action but those that put down or fire a bomb. */ | |
| 42 | const MOVES = new Set(['move', 'run', 'teleport', 'diagonal', 'slide', 'kick', 'wait']) | |
| 43 | const ways = (s: GameState): Action[] => candidates(s).filter((a) => MOVES.has(a.id)) | |
| 44 | ||
| 45 | function 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 | ||
| 53 | export 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 | } |