fuzzyworld

explodorama / files / src / sim / danger.ts

danger.ts

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

5f066960 2026-10-26 Pavel Safronov1import { collapse, detonate, proximityAlert, tickBombs } from './explosions'
5551eb37 2026-10-20 Pavel Safronov2import { cloneState } from './state'
e3d8dc49 2026-10-27 Pavel Safronov3import { step } from './turn'
4import type { Action, GameEvent, GameState } from './types'
5551eb37 2026-10-20 Pavel Safronov5
6/** Tile indices that will burn at the end of this turn, and at the end of the next one. */
7export interface Danger {
8 now: Set<number>
9 next: Set<number>
5f066960 2026-10-26 Pavel Safronov10 /**
11 * What the bombs on the board, and ceilings falling, will hit on any later turn, until the last
12 * goes off, if the player waits (user request, 2026-10-25: a bomb with a long fuse showed nothing
13 * until its last two turns). Not in `now` or `next`.
14 */
15 later: Set<number>
5551eb37 2026-10-20 Pavel Safronov16}
17
5f066960 2026-10-26 Pavel Safronov18/** How many turns past the next `later` looks ahead: more than the longest fuse or ceiling countdown. */
19const LATER_TURNS = 12
20
5551eb37 2026-10-20 Pavel Safronov21/**
22 * The danger overlay (PLAN.md §4), assuming the player waits. It runs the same countdown and
82adee18 2026-10-20 Pavel Safronov23 * chain code as `step()`, so it can't disagree with what actually happens. Flame lingers one
24 * extra turn, so "now" includes what is still burning from last turn, and "next" includes what
5f066960 2026-10-26 Pavel Safronov25 * explodes now. Raised spikes and a falling ceiling are danger too.
5551eb37 2026-10-20 Pavel Safronov26 */
27export function predictDanger(state: GameState): Danger {
28 const s = cloneState(state)
29 const ignored: GameEvent[] = []
7f242d36 2026-10-25 Pavel Safronov30 // Proximity bombs: set off by where the enemies will be after their announced moves (frozen
31 // ones stay put), this turn and the next.
32 const enemiesAfter = (k: number) => state.enemies.map((e) => (state.frozen > k ? e.pos : (e.plan[k] ?? e.plan[e.plan.length - 1] ?? e.pos)))
5f066960 2026-10-26 Pavel Safronov33 // Spikes up, and ceilings falling, this turn and the next (PLAN.md §19.3, §19.4).
34 const hazards = (turn: number, k: number) => [
35 ...state.spikes.filter((sp) => (turn + sp.phase) % 2 === 0).map((sp) => sp.cell),
36 ...state.collapsing.filter((c) => c.turns === k + 1).flatMap((c) => c.cells),
37 ]
7f242d36 2026-10-25 Pavel Safronov38 proximityAlert(s, ignored, enemiesAfter(0))
5551eb37 2026-10-20 Pavel Safronov39 tickBombs(s, ignored)
5f066960 2026-10-26 Pavel Safronov40 collapse(s, ignored)
82adee18 2026-10-20 Pavel Safronov41 const exploding = detonate(s, ignored)
5551eb37 2026-10-20 Pavel Safronov42 s.turn++
7f242d36 2026-10-25 Pavel Safronov43 proximityAlert(s, ignored, enemiesAfter(1))
5551eb37 2026-10-20 Pavel Safronov44 tickBombs(s, ignored)
5f066960 2026-10-26 Pavel Safronov45 collapse(s, ignored)
46 const following = detonate(s, ignored)
47 const now = new Set([...exploding, ...state.burning, ...hazards(state.turn, 0)])
48 const next = new Set([...following, ...exploding, ...hazards(state.turn + 1, 1)])
49 // Then on, until nothing is left to go off: what each bomb (and the chains and spreading fire it
50 // sets off) burns, and where a ceiling comes down. Enemies are left where they are.
51 const later = new Set<number>()
52 for (let k = 0; k < LATER_TURNS && (s.bombs.length || s.collapsing.length || s.smoldering.length); k++) {
53 s.turn++
54 tickBombs(s, ignored)
55 for (const i of collapse(s, ignored)) later.add(i)
56 for (const i of detonate(s, ignored)) later.add(i)
57 }
58 for (const i of [...now, ...next]) later.delete(i)
59 return { now, next, later }
5551eb37 2026-10-20 Pavel Safronov60}
e3d8dc49 2026-10-27 Pavel Safronov61
62/**
63 * Whether `action` costs the player a life this turn (user request, 2026-10-26: a tap in front
64 * of an enemy, or into flame, did). It is played on a copy with `step()`, so it can't disagree
65 * with what happens: a Shield or Flame Pass that takes the hit, or an Immortal run, means it
66 * doesn't. False while no turn can be taken (an offer open, the level over).
67 */
68export function isDeadly(s: GameState, action: Action): boolean {
69 if (s.status !== 'playing' || s.offer || s.chargeOffer) return false
70 return step(s, action).events.some((e) => e.type === 'playerDied')
71}