fuzzyworld

explodorama / files / src / sim / types.ts

types.ts

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

5551eb37 2026-10-20 Pavel Safronov1/**
2 * The shapes of the game's state, actions and events. Everything in `sim/` is plain data and
3 * pure functions: no DOM, no LittleJS, no `Math.random()`.
4 */
5
6/** A tile position. x grows to the right, y grows downward; (0, 0) is the top-left corner. */
7export interface Vec {
8 x: number
9 y: number
10}
11
12export type Dir = 'up' | 'down' | 'left' | 'right'
13
14export const DIRS: Record<Dir, Vec> = {
15 up: { x: 0, y: -1 },
16 down: { x: 0, y: 1 },
17 left: { x: -1, y: 0 },
18 right: { x: 1, y: 0 },
19}
20
21export const DIR_LIST: readonly Dir[] = ['up', 'down', 'left', 'right']
22
5f066960 2026-10-26 Pavel Safronov23/**
24 * `wall` is a hard wall or pillar (indestructible); `block` is a soft block (flame destroys it).
25 * Since 2026-10-25 (PLAN.md §19.3, §19.4): `metal`, a metal crate (indestructible, anywhere);
26 * `barrel`, an oil barrel (blocks like a crate; flame lights it and it goes off the next turn);
27 * `rubble`, a fallen ceiling (indestructible, for good). Bushes, oil and spikes lie on floor.
28 */
29export type Cell = 'floor' | 'wall' | 'block' | 'metal' | 'barrel' | 'rubble'
30
31/** Cells nothing goes through or into, and flame stops before: walls, metal crates, rubble. */
32export function isSolid(cell: Cell): boolean {
33 return cell === 'wall' || cell === 'metal' || cell === 'rubble'
34}
35
36/** Spikes on a floor tile: up on the turns where (turn + phase) is even, and then they hurt. */
37export interface Spike {
38 cell: number
39 phase: number
40}
5551eb37 2026-10-20 Pavel Safronov41
42export interface Bomb {
43 pos: Vec
44 /** How many more player actions until it explodes (PLAN.md §1). */
45 timer: number
46 /** Blast radius in tiles, in each of the 4 directions. */
47 fire: number
48 /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */
49 placedTurn: number
52d68b59 2026-10-24 Pavel Safronov50 /** Pierce: its flame goes through soft blocks, burning them all. Set from the player's boosts when placed. */
51 pierce?: boolean
52 /** Power: its flame reaches to the walls. */
53 power?: boolean
54 /** Nuke: it also burns every tile within NUKE_RANGE steps, through anything. */
55 nuke?: boolean
7f242d36 2026-10-25 Pavel Safronov56 /** Proximity alert: it goes off the turn an enemy is in its reach (the power-up, when placed). */
57 proximity?: boolean
5f066960 2026-10-26 Pavel Safronov58 /** A lit oil barrel, not the player's: it doesn't count to their limit, nor can they set it off. */
59 barrel?: boolean
60 /** Acid (the charge, 2026-10-25): its flame also cracks each pillar it reaches. */
61 acid?: boolean
5551eb37 2026-10-20 Pavel Safronov62}
63
64export interface Stats {
65 /** Most of the player's own bombs on the board at once. */
66 capacity: number
67 fire: number
68 /** The timer a newly placed bomb starts at. */
69 fuse: number
70}
71
c37a2b0b 2026-10-20 Pavel Safronov72/**
73 * `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left.
74 * `cleared`: stepped onto the open exit; `continue` goes to the next level.
27f24fed 2026-10-20 Pavel Safronov75 * `won`: cleared the last level (PLAN.md §11); the run is over.
c37a2b0b 2026-10-20 Pavel Safronov76 */
27f24fed 2026-10-20 Pavel Safronov77export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won'
78
00ad7d0a 2026-10-24 Pavel Safronov79/**
80 * Chosen when a run starts, fixed for the run, part of its record. (A board size and Crumbling
81 * crates were options too until 2026-10-23: now there is one board, and crates always crumble.)
82 */
beb9f2b7 2026-10-21 Pavel Safronov83export interface RunOptions {
6e94f995 2026-10-24 Pavel Safronov84 /**
85 * Developer mode: an extra, rainbow gem lies right of the start, offering every power-up not
86 * held yet; the level keeps its own. Nothing else.
87 */
beb9f2b7 2026-10-21 Pavel Safronov88 dev: boolean
00ad7d0a 2026-10-24 Pavel Safronov89 /**
3cf3607b 2026-10-24 Pavel Safronov90 * Immortal: nothing can kill the player, so the run
00ad7d0a 2026-10-24 Pavel Safronov91 * can be played to the end — but it earns no achievements (when they exist: the victory
d603166d 2026-10-25 Pavel Safronov92 * certificate).
00ad7d0a 2026-10-24 Pavel Safronov93 */
94 immortal: boolean
a313eb7d 2026-10-25 Pavel Safronov95 /**
96 * Rule variants for the balance lab to compare (PLAN.md §19.1), never set in play. `squeeze`: the
97 * old Diagonal, which could step between two blocked tiles (the rule until 2026-10-25).
6c3fcc31 2026-10-26 Pavel Safronov98 * `underfoot`: the NES rule, a bomb goes on the player's own tile, not one next to them (to
99 * measure, 2026-10-25; the player walks off it, and nothing walks onto it).
a313eb7d 2026-10-25 Pavel Safronov100 */
6c3fcc31 2026-10-26 Pavel Safronov101 lab?: { squeeze?: boolean; underfoot?: boolean }
beb9f2b7 2026-10-21 Pavel Safronov102}
103
00ad7d0a 2026-10-24 Pavel Safronov104export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false }
beb9f2b7 2026-10-21 Pavel Safronov105
52d68b59 2026-10-24 Pavel Safronov106/** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */
27f24fed 2026-10-20 Pavel Safronov107export type PowerUpKind =
fcf44f32 2026-10-25 Pavel Safronov108 | 'bombPlus' | 'firePlus' | 'tnt' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'detonator'
109 | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'diagonal'
7f242d36 2026-10-25 Pavel Safronov110 | 'rpg' | 'proximity' | 'slide'
27f24fed 2026-10-20 Pavel Safronov111
6e94f995 2026-10-24 Pavel Safronov112/**
113 * A gem's colour, from the seed. Only how it looks: every level's gem offers the same kind of
114 * choice. Rainbow is only Developer mode's extra gem (user request, 2026-10-24).
115 */
116export type GemColor = 'blue' | 'green' | 'red' | 'yellow' | 'rainbow'
52d68b59 2026-10-24 Pavel Safronov117
118/**
119 * The level's gem, hidden one per level under a soft block. Taking it offers OFFER_SIZE
120 * power-ups to choose one from (user request, 2026-10-23: it used to be one fixed power-up).
121 */
27f24fed 2026-10-20 Pavel Safronov122export interface PowerUp {
123 pos: Vec
52d68b59 2026-10-24 Pavel Safronov124 color: GemColor
6e94f995 2026-10-24 Pavel Safronov125 /**
126 * Developer mode's extra gem, beside the start; the level keeps its own (user request,
127 * 2026-10-23). It is the rainbow one and offers every power-up not held yet (2026-10-24).
128 */
39c26813 2026-10-24 Pavel Safronov129 extra?: boolean
27f24fed 2026-10-20 Pavel Safronov130}
131
99869630 2026-10-23 Pavel Safronov132/**
52d68b59 2026-10-24 Pavel Safronov133 * Abilities from power-ups. Run, Detonate, Kick, Throw, Teleport and Diagonal are actions; the
134 * passes change where the player can go. All are kept through a death (since 2026-10-23).
99869630 2026-10-23 Pavel Safronov135 */
27f24fed 2026-10-20 Pavel Safronov136export interface Powers {
52d68b59 2026-10-24 Pavel Safronov137 /** The Run power-up (called Skates until 2026-10-22): 2 tiles in a straight line. */
27f24fed 2026-10-20 Pavel Safronov138 run: boolean
52d68b59 2026-10-24 Pavel Safronov139 /** Kick: walk into a bomb and it slides away. */
99869630 2026-10-23 Pavel Safronov140 kick: boolean
28670c3e 2026-10-24 Pavel Safronov141 /** Throw: a bomb 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar or wall). */
99869630 2026-10-23 Pavel Safronov142 throw: boolean
52d68b59 2026-10-24 Pavel Safronov143 /** Diagonal: Move can also step diagonally (a charge until 2026-10-23, user request). */
144 diagonal: boolean
145 /** Detonator: the Detonate action, set off one of your bombs now. */
27f24fed 2026-10-20 Pavel Safronov146 detonate: boolean
52d68b59 2026-10-24 Pavel Safronov147 /** Walk through soft blocks. */
27f24fed 2026-10-20 Pavel Safronov148 wallPass: boolean
52d68b59 2026-10-24 Pavel Safronov149 /** Walk over bombs. */
27f24fed 2026-10-20 Pavel Safronov150 bombPass: boolean
7f242d36 2026-10-25 Pavel Safronov151 /** RPG: Bomb can also fire a bomb in a straight line, as far as it flies (user request, 2026-10-24). */
152 rpg: boolean
153 /** Proximity alert: your bombs go off as soon as an enemy is in their reach. */
154 proximity: boolean
155 /** Slide: Move can also slide in a straight line until a wall, crate or bomb stops you. */
156 slide: boolean
27f24fed 2026-10-20 Pavel Safronov157}
c37a2b0b 2026-10-20 Pavel Safronov158
565a6873 2026-10-25 Pavel Safronov159/** The enemy types so far (PLAN.md §10); the Reaper is for later. */
160export type EnemyKind = 'blob' | 'seeker' | 'drifter' | 'sprinter' | 'ghost' | 'wisp' | 'hunter'
c37a2b0b 2026-10-20 Pavel Safronov161
162export interface Enemy {
163 /** Stable for the enemy's life, so the renderer can follow it between turns. */
164 id: number
165 kind: EnemyKind
166 pos: Vec
82adee18 2026-10-20 Pavel Safronov167 /** The way its last planned move goes. Blobs and Drifters keep going this way. */
c37a2b0b 2026-10-20 Pavel Safronov168 dir: Dir
82adee18 2026-10-20 Pavel Safronov169 /**
565a6873 2026-10-25 Pavel Safronov170 * Its next moves, up to PLAN_AHEAD of them, first one first (announced in phase 5, taken one
171 * per turn in phase 2): where it will be after each. Usually the next tile; the tile it is on
172 * for a rest (Ghost, Sprinter); 2 tiles on for a Sprinter's dash. Empty: it has nowhere to go
173 * and stays put.
82adee18 2026-10-20 Pavel Safronov174 */
175 plan: Vec[]
c37a2b0b 2026-10-20 Pavel Safronov176 /** Spawned as a punishment (the exit was flamed): drops nothing. */
177 spawned: boolean
52d68b59 2026-10-24 Pavel Safronov178 /**
179 * Just let out by a flamed exit or gem: the flame that let it out (still lingering next turn)
180 * can't hurt it; any new explosion can (user's choice, 2026-10-23: they used to die in it at once).
181 */
182 sheltered: boolean
c37a2b0b 2026-10-20 Pavel Safronov183}
184
99869630 2026-10-23 Pavel Safronov185/**
52d68b59 2026-10-24 Pavel Safronov186 * The charges dropped by enemies (PLAN.md §3.3). Since 2026-10-23 (user request) they aren't
187 * actions: each works the moment it is picked up, as a temporary change (`applyCharge`).
188 * Pierce, Power and Nuke change the next BOMB_BOOST bombs; Shield blocks the next hit; Freeze
189 * freezes every enemy; Decoy leaves a copy of the player on the pickup tile. (Line was a charge
190 * until then, and Diagonal, which became a power-up the same day; Kick, Throw and Teleport until
191 * 2026-10-22; Fuse ±1 was removed on 2026-10-22.)
99869630 2026-10-23 Pavel Safronov192 */
5f066960 2026-10-26 Pavel Safronov193export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' | 'flamePass' | 'teleport' | 'acid'
c37a2b0b 2026-10-20 Pavel Safronov194
fcf44f32 2026-10-25 Pavel Safronov195/** Flame Pass and Teleport were power-ups until 2026-10-25, when the balance lab found each won the game alone. */
5f066960 2026-10-26 Pavel Safronov196export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport', 'acid']
82adee18 2026-10-20 Pavel Safronov197
e3f29330 2026-10-24 Pavel Safronov198/**
199 * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them
200 * at once: user request, 2026-10-24). A chosen charge works as if picked up.
201 */
202export type OfferKind = PowerUpKind | ChargeId
203
204export function isCharge(kind: OfferKind): kind is ChargeId {
205 return (CHARGE_IDS as readonly OfferKind[]).includes(kind)
206}
207
52d68b59 2026-10-24 Pavel Safronov208/** What picked-up charges still have to give. Kept from level to level and through deaths. */
d3e4ed26 2026-10-25 Pavel Safronov209/**
210 * The one charge the player carries (PLAN.md §19.7; user request, 2026-10-24) and what is left
211 * of it: bombs for Pierce, Power and Nuke, launches for ICBM, 1 for the rest. Used deliberately,
212 * with the charge button (a Shield works by itself, when the player is hit).
213 */
214export interface HeldCharge {
215 kind: ChargeId
216 left: number
52d68b59 2026-10-24 Pavel Safronov217}
82adee18 2026-10-20 Pavel Safronov218
219/** A stand-in for the player that chasing enemies go after instead, for a few turns. */
d3e4ed26 2026-10-25 Pavel Safronov220/**
221 * A Decoy put down with the charge button: chasers head for it instead of the player. It lasts
222 * until the level ends, an enemy reaches it, or flame burns it (user request, 2026-10-25; it lasted
223 * 3 turns).
224 */
82adee18 2026-10-20 Pavel Safronov225export interface Decoy {
226 pos: Vec
227}
c37a2b0b 2026-10-20 Pavel Safronov228
229/** Something lying on a floor tile to walk over and collect: coins, or one action charge. */
fcf44f32 2026-10-25 Pavel Safronov230export type Drop =
231 | { pos: Vec; kind: 'coins'; amount: number }
232 /** `left`: what is left of it, for a charge dropped where the player died (full when absent). */
233 | { pos: Vec; kind: ChargeId; left?: number }
5551eb37 2026-10-20 Pavel Safronov234
235export interface GameState {
236 seed: number
beb9f2b7 2026-10-21 Pavel Safronov237 options: RunOptions
5551eb37 2026-10-20 Pavel Safronov238 level: number
239 /** Turns taken on this attempt at this level, starting at 0. */
240 turn: number
241 width: number
242 height: number
243 /** Row-major: the cell at (x, y) is `cells[y * width + x]`. */
244 cells: Cell[]
245 player: Vec
246 lives: number
247 stats: Stats
248 bombs: Bomb[]
249 status: Status
c37a2b0b 2026-10-20 Pavel Safronov250 enemies: Enemy[]
251 nextEnemyId: number
252 drops: Drop[]
253 /** Under a soft block until that block burns. Open (walkable to win) once no enemies are left. */
254 exit: Vec
255 /** Finishing the level in at most this many turns pays PAR_BONUS coins. */
256 par: number
257 coins: number
d3e4ed26 2026-10-25 Pavel Safronov258 /** The one charge carried, if any; kept to the next level and through a death. */
259 charge: HeldCharge | null
260 /**
261 * A charge just picked up while another is carried: the player sells it, or swaps it for the one
262 * held (which is sold). Nothing else happens until they choose; it takes no turn.
263 */
fcf44f32 2026-10-25 Pavel Safronov264 chargeOffer: HeldCharge | null
d3e4ed26 2026-10-25 Pavel Safronov265 /**
5f066960 2026-10-26 Pavel Safronov266 * The board's objects on floor (PLAN.md §19.3; 2026-10-25): bushes (walk through; they burn, and
267 * fire spreads to the bushes touching them a turn later), oil (flame on any of a spill burns all
268 * of it at once), spikes (up every other turn). Cell indices.
fcf44f32 2026-10-25 Pavel Safronov269 */
5f066960 2026-10-26 Pavel Safronov270 bushes: number[]
271 /** Bushes that catch fire next turn, from a bush that burned this turn. */
272 smoldering: number[]
273 oil: number[]
274 spikes: Spike[]
275 /** Where the enemies last saw the player: a bush hides them, and chasers head here instead. */
276 lastSeen: Vec
277 /** Acid (§19.4): pillars cracked by an acid bomb; a second blast breaks them. Cell indices. */
278 cracked: number[]
279 /** The ceiling round a broken pillar, coming down: these tiles, in this many turns. */
280 collapsing: { cells: number[]; turns: number }[]
99869630 2026-10-23 Pavel Safronov281 /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */
282 frozen: number
27f24fed 2026-10-20 Pavel Safronov283 powers: Powers
284 /** Turns played and lives lost over the whole run, for the ending. */
285 runTurns: number
0580e85e 2026-10-24 Pavel Safronov286 /** Enemies killed over the whole run, for the high-score board (2026-10-24). */
287 runKills: number
27f24fed 2026-10-20 Pavel Safronov288 deaths: number
289 /** Turns of Star invincibility left. */
290 star: number
39c26813 2026-10-24 Pavel Safronov291 /**
292 * This level's gems: hidden while its block stands, then lying in the open until taken; gone
293 * once taken or burned. One per level, and in Developer mode an extra one (since 2026-10-23).
294 */
295 gems: PowerUp[]
52d68b59 2026-10-24 Pavel Safronov296 /**
e3f29330 2026-10-24 Pavel Safronov297 * The power-ups (and, from the rainbow gem, charges) the gem just taken offers. While it is
298 * set, the only action is `choose`; a death (or game over) in the same turn drops it, and the
299 * retry has the gem back.
52d68b59 2026-10-24 Pavel Safronov300 */
e3f29330 2026-10-24 Pavel Safronov301 offer: OfferKind[] | null
8edb5cb9 2026-11-01 Pavel Safronov302 /** Gems taken on the same turn as the one on offer (a slide over two): each offers in turn, after the one before is answered. */
303 waitingGems: PowerUp[]
e78b7781 2026-10-25 Pavel Safronov304 /** The shop, open after a cleared level every few levels until the player goes on (sim/shop.ts). */
305 shop: Shop | null
82adee18 2026-10-20 Pavel Safronov306 /**
307 * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it
308 * kills creatures that are in it or walk into it this turn, and the player can't step or put
309 * a bomb there. It doesn't burn crates, drops or bombs a second time.
310 */
311 burning: number[]
0580e85e 2026-10-24 Pavel Safronov312 /** How the player last died on this attempt, for the banner; null while alive. */
313 death: Death | null
00ad7d0a 2026-10-24 Pavel Safronov314 /** Crumbling crates (once the last enemy is gone): tile index and turns until it is gone. */
beb9f2b7 2026-10-21 Pavel Safronov315 crumbling: { cell: number; turns: number }[]
82adee18 2026-10-20 Pavel Safronov316 decoy: Decoy | null
c37a2b0b 2026-10-20 Pavel Safronov317 /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */
318 aiRng: number
319 dropRng: number
5551eb37 2026-10-20 Pavel Safronov320}
321
322/** The actions the player can pick on the board. */
7f242d36 2026-10-25 Pavel Safronov323export type ActionId =
324 | 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'rpg' | 'icbm' | 'slide'
5f066960 2026-10-26 Pavel Safronov325 /** The carried charge, used (2026-10-24): a Pierce, Power or Nuke bomb; a Freeze; a Decoy. */
326 | 'charged' | 'freeze' | 'decoy'
5551eb37 2026-10-20 Pavel Safronov327
52d68b59 2026-10-24 Pavel Safronov328/** `choose`: one of the gem's offer. It takes no turn. */
e78b7781 2026-10-25 Pavel Safronov329export type Action =
330 | { id: ActionId; target?: Vec }
331 | { id: 'continue' }
332 | { id: 'choose'; kind: OfferKind }
d3e4ed26 2026-10-25 Pavel Safronov333 /** A charge picked up while carrying another: sell the new one, or swap it in (the old one sold). No turn. */
334 | { id: 'sellCharge' }
335 | { id: 'swapCharge' }
bb3823d2 2026-10-31 Pavel Safronov336 /** The same choice, a third way: use the charge held now (`fireableHeld`), then take the new one. No turn. */
337 | { id: 'fireCharge' }
e78b7781 2026-10-25 Pavel Safronov338 /** In the shop (sim/shop.ts): buy one of its items, or restock it. */
339 | { id: 'buy'; kind: OfferKind }
340 | { id: 'reroll' }
341
342/** The shop between levels (sim/shop.ts, PLAN.md §8): what it has, what is sold, how often restocked. */
343export interface Shop {
344 items: OfferKind[]
345 sold: OfferKind[]
346 rerolls: number
347}
5551eb37 2026-10-20 Pavel Safronov348
0580e85e 2026-10-24 Pavel Safronov349/**
350 * How the player died, for the death and game-over banners (user request, 2026-10-24): walked
351 * into an enemy, or one walked into them; caught in a blast of a bomb that was due, or only by
352 * one a chain set off; or by flame still burning from last turn.
353 */
354export interface Death {
5f066960 2026-10-26 Pavel Safronov355 /** `spread`: flame over oil or through bushes, with no blast there (PLAN.md §19.3). */
6c3fcc31 2026-10-26 Pavel Safronov356 /** `boxedIn`: nowhere to step or put a bomb, for good (2026-10-26). */
357 cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering' | 'spread' | 'spikes' | 'crushed' | 'trapped' | 'boxedIn'
0580e85e 2026-10-24 Pavel Safronov358 /** The enemy, for the two enemy causes. */
359 enemy?: EnemyKind
360 /** The turn it happened on (counted from 1, as the top bar shows after it). */
361 turn: number
362}
363
5551eb37 2026-10-20 Pavel Safronov364/** What `step()` reports happened, in order, for the renderer to play back. */
365export type GameEvent =
366 | { type: 'moved'; from: Vec; to: Vec }
1f0038ac 2026-11-01 Pavel Safronov367 /** The player slid on over oil after a move: from the tile they landed on to where they stopped. */
368 | { type: 'oilSlid'; from: Vec; to: Vec }
5551eb37 2026-10-20 Pavel Safronov369 | { type: 'waited' }
370 | { type: 'bombPlaced'; pos: Vec; timer: number }
371 | { type: 'bombTicked'; pos: Vec; timer: number }
372 | { type: 'exploded'; pos: Vec; tiles: Vec[] }
373 | { type: 'blockDestroyed'; pos: Vec }
374 | { type: 'playerDied'; pos: Vec; livesLeft: number }
375 | { type: 'levelRestarted' }
c37a2b0b 2026-10-20 Pavel Safronov376 | { type: 'enemyMoved'; id: number; from: Vec; to: Vec }
377 | { type: 'enemyDied'; id: number; pos: Vec }
378 | { type: 'enemiesSpawned'; pos: Vec; count: number }
379 | { type: 'dropSpawned'; drop: Drop }
380 | { type: 'dropBurned'; drop: Drop }
381 | { type: 'pickedUp'; drop: Drop }
382 | { type: 'exitRevealed'; pos: Vec }
383 | { type: 'exitOpened'; pos: Vec }
0b63d158 2026-10-25 Pavel Safronov384 | { type: 'levelCleared'; level: number; turns: number; clearBonus: number; parBonus: number }
c37a2b0b 2026-10-20 Pavel Safronov385 | { type: 'levelStarted'; level: number }
386 | { type: 'bombKicked'; from: Vec; to: Vec }
7f242d36 2026-10-25 Pavel Safronov387 /** An RPG or ICBM bomb flew from the player to where it landed. */
388 | { type: 'bombFired'; from: Vec; to: Vec }
389 /** Proximity alert: an enemy in a bomb's reach set it off. */
390 | { type: 'proximityTriggered'; pos: Vec }
c37a2b0b 2026-10-20 Pavel Safronov391 | { type: 'shieldBroke'; pos: Vec }
d3e4ed26 2026-10-25 Pavel Safronov392 /** A charge picked up while another was carried: the choice to make. */
393 | { type: 'chargeOffered'; kind: ChargeId; held: ChargeId }
394 /** A charge sold for coins: the new one, or the old one swapped out. */
395 | { type: 'chargeSold'; kind: ChargeId; coins: number }
fcf44f32 2026-10-25 Pavel Safronov396 | { type: 'flameBlocked'; pos: Vec; hitsLeft: number }
d3e4ed26 2026-10-25 Pavel Safronov397 /** Freeze used: every enemy stops for FREEZE_TURNS turns. */
398 | { type: 'frozen'; turns: number }
82adee18 2026-10-20 Pavel Safronov399 | { type: 'decoyPlaced'; pos: Vec }
d3e4ed26 2026-10-25 Pavel Safronov400 /** A Decoy gone: burned by flame, or reached by an enemy. */
82adee18 2026-10-20 Pavel Safronov401 | { type: 'decoyGone'; pos: Vec; burned: boolean }
27f24fed 2026-10-20 Pavel Safronov402 | { type: 'powerUpRevealed'; powerUp: PowerUp }
403 | { type: 'powerUpBurned'; powerUp: PowerUp }
e3f29330 2026-10-24 Pavel Safronov404 | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] }
8edb5cb9 2026-11-01 Pavel Safronov405 /** A second gem taken on the turn: it leaves the board now, its offer comes later (`offerAgain`). */
406 | { type: 'gemQueued'; powerUp: PowerUp }
407 /** A gem taken earlier this turn now makes its offer (the first one is answered). */
408 | { type: 'offerAgain'; powerUp: PowerUp; offer: OfferKind[] }
e3f29330 2026-10-24 Pavel Safronov409 | { type: 'powerUpChosen'; kind: OfferKind }
e78b7781 2026-10-25 Pavel Safronov410 | { type: 'shopOpened' }
411 | { type: 'bought'; kind: OfferKind; price: number }
412 | { type: 'rerolled'; price: number }
27f24fed 2026-10-20 Pavel Safronov413 | { type: 'detonated'; pos: Vec }
414 | { type: 'runWon'; turns: number; deaths: number }
beb9f2b7 2026-10-21 Pavel Safronov415 | { type: 'cratesCrumbling'; count: number }
416 | { type: 'crateCrumbled'; pos: Vec }
5f066960 2026-10-26 Pavel Safronov417 /** Flame that spread without a blast: over an oil spill, or through bushes (PLAN.md §19.3). */
418 | { type: 'fireSpread'; tiles: Vec[] }
419 /** Flame reached an oil barrel: it bursts, spilling oil, and goes off next turn. */
420 | { type: 'barrelLit'; pos: Vec }
421 /** Acid (PLAN.md §19.4): a pillar cracked; a cracked one broken, and the ceiling round it to fall. */
422 | { type: 'pillarCracked'; pos: Vec }
423 | { type: 'pillarBroke'; pos: Vec; tiles: Vec[]; turns: number }
424 | { type: 'ceilingFell'; tiles: Vec[] }