explodorama / files / src / sim / types.ts
types.ts
| 1 | /** |
| 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. */ |
| 7 | export interface Vec { |
| 8 | x: number |
| 9 | y: number |
| 10 | } |
| 11 | |
| 12 | export type Dir = 'up' | 'down' | 'left' | 'right' |
| 13 | |
| 14 | export 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 | |
| 21 | export const DIR_LIST: readonly Dir[] = ['up', 'down', 'left', 'right'] |
| 22 | |
| 23 | /** |
| 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 | */ |
| 29 | export type Cell = 'floor' | 'wall' | 'block' | 'metal' | 'barrel' | 'rubble' |
| 30 | |
| 31 | /** Cells nothing goes through or into, and flame stops before: walls, metal crates, rubble. */ |
| 32 | export 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. */ |
| 37 | export interface Spike { |
| 38 | cell: number |
| 39 | phase: number |
| 40 | } |
| 41 | |
| 42 | export 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 |
| 50 | /** 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 |
| 56 | /** Proximity alert: it goes off the turn an enemy is in its reach (the power-up, when placed). */ |
| 57 | proximity?: boolean |
| 58 | /** 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 |
| 62 | } |
| 63 | |
| 64 | export 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 | |
| 72 | /** |
| 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. |
| 75 | * `won`: cleared the last level (PLAN.md §11); the run is over. |
| 76 | */ |
| 77 | export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won' |
| 78 | |
| 79 | /** |
| 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 | */ |
| 83 | export interface RunOptions { |
| 84 | /** |
| 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 | */ |
| 88 | dev: boolean |
| 89 | /** |
| 90 | * Immortal: nothing can kill the player, so the run |
| 91 | * can be played to the end — but it earns no achievements (when they exist: the victory |
| 92 | * certificate). |
| 93 | */ |
| 94 | immortal: boolean |
| 95 | /** |
| 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). |
| 98 | * `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). |
| 100 | */ |
| 101 | lab?: { squeeze?: boolean; underfoot?: boolean } |
| 102 | } |
| 103 | |
| 104 | export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false } |
| 105 | |
| 106 | /** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */ |
| 107 | export type PowerUpKind = |
| 108 | | 'bombPlus' | 'firePlus' | 'tnt' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'detonator' |
| 109 | | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'diagonal' |
| 110 | | 'rpg' | 'proximity' | 'slide' |
| 111 | |
| 112 | /** |
| 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 | */ |
| 116 | export type GemColor = 'blue' | 'green' | 'red' | 'yellow' | 'rainbow' |
| 117 | |
| 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 | */ |
| 122 | export interface PowerUp { |
| 123 | pos: Vec |
| 124 | color: GemColor |
| 125 | /** |
| 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 | */ |
| 129 | extra?: boolean |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * 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). |
| 135 | */ |
| 136 | export interface Powers { |
| 137 | /** The Run power-up (called Skates until 2026-10-22): 2 tiles in a straight line. */ |
| 138 | run: boolean |
| 139 | /** Kick: walk into a bomb and it slides away. */ |
| 140 | kick: boolean |
| 141 | /** Throw: a bomb 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar or wall). */ |
| 142 | throw: boolean |
| 143 | /** 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. */ |
| 146 | detonate: boolean |
| 147 | /** Walk through soft blocks. */ |
| 148 | wallPass: boolean |
| 149 | /** Walk over bombs. */ |
| 150 | bombPass: boolean |
| 151 | /** 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 |
| 157 | } |
| 158 | |
| 159 | /** The enemy types so far (PLAN.md §10); the Reaper is for later. */ |
| 160 | export type EnemyKind = 'blob' | 'seeker' | 'drifter' | 'sprinter' | 'ghost' | 'wisp' | 'hunter' |
| 161 | |
| 162 | export 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 |
| 167 | /** The way its last planned move goes. Blobs and Drifters keep going this way. */ |
| 168 | dir: Dir |
| 169 | /** |
| 170 | * 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. |
| 174 | */ |
| 175 | plan: Vec[] |
| 176 | /** Spawned as a punishment (the exit was flamed): drops nothing. */ |
| 177 | spawned: boolean |
| 178 | /** |
| 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 |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * 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.) |
| 192 | */ |
| 193 | export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' | 'flamePass' | 'teleport' | 'acid' |
| 194 | |
| 195 | /** Flame Pass and Teleport were power-ups until 2026-10-25, when the balance lab found each won the game alone. */ |
| 196 | export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport', 'acid'] |
| 197 | |
| 198 | /** |
| 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 | */ |
| 202 | export type OfferKind = PowerUpKind | ChargeId |
| 203 | |
| 204 | export function isCharge(kind: OfferKind): kind is ChargeId { |
| 205 | return (CHARGE_IDS as readonly OfferKind[]).includes(kind) |
| 206 | } |
| 207 | |
| 208 | /** What picked-up charges still have to give. Kept from level to level and through deaths. */ |
| 209 | /** |
| 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 | */ |
| 214 | export interface HeldCharge { |
| 215 | kind: ChargeId |
| 216 | left: number |
| 217 | } |
| 218 | |
| 219 | /** A stand-in for the player that chasing enemies go after instead, for a few turns. */ |
| 220 | /** |
| 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 | */ |
| 225 | export interface Decoy { |
| 226 | pos: Vec |
| 227 | } |
| 228 | |
| 229 | /** Something lying on a floor tile to walk over and collect: coins, or one action charge. */ |
| 230 | export 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 } |
| 234 | |
| 235 | export interface GameState { |
| 236 | seed: number |
| 237 | options: RunOptions |
| 238 | 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 |
| 250 | 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 |
| 258 | /** 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 | */ |
| 264 | chargeOffer: HeldCharge | null |
| 265 | /** |
| 266 | * 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. |
| 269 | */ |
| 270 | 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 }[] |
| 281 | /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */ |
| 282 | frozen: number |
| 283 | powers: Powers |
| 284 | /** Turns played and lives lost over the whole run, for the ending. */ |
| 285 | runTurns: number |
| 286 | /** Enemies killed over the whole run, for the high-score board (2026-10-24). */ |
| 287 | runKills: number |
| 288 | deaths: number |
| 289 | /** Turns of Star invincibility left. */ |
| 290 | star: number |
| 291 | /** |
| 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[] |
| 296 | /** |
| 297 | * 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. |
| 300 | */ |
| 301 | offer: OfferKind[] | null |
| 302 | /** 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[] |
| 304 | /** The shop, open after a cleared level every few levels until the player goes on (sim/shop.ts). */ |
| 305 | shop: Shop | null |
| 306 | /** |
| 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[] |
| 312 | /** How the player last died on this attempt, for the banner; null while alive. */ |
| 313 | death: Death | null |
| 314 | /** Crumbling crates (once the last enemy is gone): tile index and turns until it is gone. */ |
| 315 | crumbling: { cell: number; turns: number }[] |
| 316 | decoy: Decoy | null |
| 317 | /** 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 |
| 320 | } |
| 321 | |
| 322 | /** The actions the player can pick on the board. */ |
| 323 | export type ActionId = |
| 324 | | 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'rpg' | 'icbm' | 'slide' |
| 325 | /** The carried charge, used (2026-10-24): a Pierce, Power or Nuke bomb; a Freeze; a Decoy. */ |
| 326 | | 'charged' | 'freeze' | 'decoy' |
| 327 | |
| 328 | /** `choose`: one of the gem's offer. It takes no turn. */ |
| 329 | export type Action = |
| 330 | | { id: ActionId; target?: Vec } |
| 331 | | { id: 'continue' } |
| 332 | | { id: 'choose'; kind: OfferKind } |
| 333 | /** 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' } |
| 336 | /** The same choice, a third way: use the charge held now (`fireableHeld`), then take the new one. No turn. */ |
| 337 | | { id: 'fireCharge' } |
| 338 | /** 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. */ |
| 343 | export interface Shop { |
| 344 | items: OfferKind[] |
| 345 | sold: OfferKind[] |
| 346 | rerolls: number |
| 347 | } |
| 348 | |
| 349 | /** |
| 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 | */ |
| 354 | export interface Death { |
| 355 | /** `spread`: flame over oil or through bushes, with no blast there (PLAN.md §19.3). */ |
| 356 | /** `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' |
| 358 | /** 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 | |
| 364 | /** What `step()` reports happened, in order, for the renderer to play back. */ |
| 365 | export type GameEvent = |
| 366 | | { type: 'moved'; from: Vec; to: Vec } |
| 367 | /** 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 } |
| 369 | | { 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' } |
| 376 | | { 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 } |
| 384 | | { type: 'levelCleared'; level: number; turns: number; clearBonus: number; parBonus: number } |
| 385 | | { type: 'levelStarted'; level: number } |
| 386 | | { type: 'bombKicked'; from: Vec; to: Vec } |
| 387 | /** 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 } |
| 391 | | { type: 'shieldBroke'; pos: Vec } |
| 392 | /** 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 } |
| 396 | | { type: 'flameBlocked'; pos: Vec; hitsLeft: number } |
| 397 | /** Freeze used: every enemy stops for FREEZE_TURNS turns. */ |
| 398 | | { type: 'frozen'; turns: number } |
| 399 | | { type: 'decoyPlaced'; pos: Vec } |
| 400 | /** A Decoy gone: burned by flame, or reached by an enemy. */ |
| 401 | | { type: 'decoyGone'; pos: Vec; burned: boolean } |
| 402 | | { type: 'powerUpRevealed'; powerUp: PowerUp } |
| 403 | | { type: 'powerUpBurned'; powerUp: PowerUp } |
| 404 | | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] } |
| 405 | /** 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[] } |
| 409 | | { type: 'powerUpChosen'; kind: OfferKind } |
| 410 | | { type: 'shopOpened' } |
| 411 | | { type: 'bought'; kind: OfferKind; price: number } |
| 412 | | { type: 'rerolled'; price: number } |
| 413 | | { type: 'detonated'; pos: Vec } |
| 414 | | { type: 'runWon'; turns: number; deaths: number } |
| 415 | | { type: 'cratesCrumbling'; count: number } |
| 416 | | { type: 'crateCrumbled'; pos: Vec } |
| 417 | /** 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[] } |