explodorama / files / src / sim / danger.ts
danger.ts
| 1 | import { collapse, detonate, proximityAlert, tickBombs } from './explosions' |
| 2 | import { cloneState } from './state' |
| 3 | import { step } from './turn' |
| 4 | import type { Action, GameEvent, GameState } from './types' |
| 5 | |
| 6 | /** Tile indices that will burn at the end of this turn, and at the end of the next one. */ |
| 7 | export interface Danger { |
| 8 | now: Set<number> |
| 9 | next: Set<number> |
| 10 | /** |
| 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> |
| 16 | } |
| 17 | |
| 18 | /** How many turns past the next `later` looks ahead: more than the longest fuse or ceiling countdown. */ |
| 19 | const LATER_TURNS = 12 |
| 20 | |
| 21 | /** |
| 22 | * The danger overlay (PLAN.md §4), assuming the player waits. It runs the same countdown and |
| 23 | * 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 |
| 25 | * explodes now. Raised spikes and a falling ceiling are danger too. |
| 26 | */ |
| 27 | export function predictDanger(state: GameState): Danger { |
| 28 | const s = cloneState(state) |
| 29 | const ignored: GameEvent[] = [] |
| 30 | // 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))) |
| 33 | // 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 | ] |
| 38 | proximityAlert(s, ignored, enemiesAfter(0)) |
| 39 | tickBombs(s, ignored) |
| 40 | collapse(s, ignored) |
| 41 | const exploding = detonate(s, ignored) |
| 42 | s.turn++ |
| 43 | proximityAlert(s, ignored, enemiesAfter(1)) |
| 44 | tickBombs(s, ignored) |
| 45 | 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 } |
| 60 | } |
| 61 | |
| 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 | */ |
| 68 | export 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 | } |