explodorama / files / src / sim / actions.ts
actions.ts
| 1 | import { BOMB_CHARGES, holds, spendCharge } from './charges' |
| 2 | import { enemyAt } from './enemies' |
| 3 | import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter, playerCanPass } from './state' |
| 4 | import { DIRS, DIR_LIST, type ActionId, type Bomb, type GameEvent, type GameState, type Powers, type Vec } from './types' |
| 5 | |
| 6 | /** |
| 7 | * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button. |
| 8 | * `apply` mutates the state it is given; `step()` hands it a clone. |
| 9 | */ |
| 10 | export interface ActionDef { |
| 11 | id: ActionId |
| 12 | label: string |
| 13 | /** False for actions like Wait that happen as soon as they are picked. */ |
| 14 | needsTarget: boolean |
| 15 | /** The power-up ability it needs (PLAN.md §3.2); only offered once the player has it. */ |
| 16 | ability?: keyof Powers |
| 17 | /** Anything else it needs (a charge's launches left). */ |
| 18 | available?: (s: GameState) => boolean |
| 19 | validTargets(s: GameState): Vec[] |
| 20 | apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void |
| 21 | } |
| 22 | |
| 23 | function neighbours(p: Vec): Vec[] { |
| 24 | return DIR_LIST.map((d) => add(p, DIRS[d])) |
| 25 | } |
| 26 | |
| 27 | /** Where a bomb can go: free, not on fire, and no enemy standing there. */ |
| 28 | function canHoldBomb(s: GameState, p: Vec): boolean { |
| 29 | return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p) |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * A bomb from the player: a plain one, or with `boost` a Pierce, Power or Nuke bomb (the charge |
| 34 | * button; since 2026-10-24 a charge is used on purpose, no longer on every bomb). |
| 35 | */ |
| 36 | function placeBomb(s: GameState, pos: Vec, events: GameEvent[], boost?: 'pierce' | 'power' | 'nuke' | 'acid'): Bomb { |
| 37 | const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn } |
| 38 | if (boost) bomb[boost] = true |
| 39 | // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls. |
| 40 | if (bomb.power) bomb.fire = Math.max(s.width, s.height) |
| 41 | if (s.powers.proximity) bomb.proximity = true |
| 42 | s.bombs.push(bomb) |
| 43 | events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse }) |
| 44 | return bomb |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Where a bomb fired from the player in direction `d` lands (RPG, ICBM): it flies until the next |
| 49 | * tile is a wall, crate, bomb or enemy (or flame), and lands just before it (user's choice, |
| 50 | * 2026-10-24). Null if the tile next to the player can't hold it. |
| 51 | */ |
| 52 | export function flightEnd(s: GameState, d: Vec): Vec | null { |
| 53 | let p = add(s.player, d) |
| 54 | if (!canHoldBomb(s, p)) return null |
| 55 | while (canHoldBomb(s, add(p, d))) p = add(p, d) |
| 56 | return p |
| 57 | } |
| 58 | |
| 59 | /** The landing tiles of a bomb fired each way, at least `min` tiles from the player. */ |
| 60 | function flightTargets(s: GameState, min: number): Vec[] { |
| 61 | return DIR_LIST.flatMap((d) => { |
| 62 | const p = flightEnd(s, DIRS[d]) |
| 63 | return p && Math.abs(p.x - s.player.x) + Math.abs(p.y - s.player.y) >= min ? [p] : [] |
| 64 | }) |
| 65 | } |
| 66 | |
| 67 | /** The way from the player to a tile in a straight line, one step. */ |
| 68 | function wayTo(s: GameState, target: Vec): Vec { |
| 69 | return { x: Math.sign(target.x - s.player.x), y: Math.sign(target.y - s.player.y) } |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * The tiles a slide from the player in direction `d` passes, in order, the last where it stops |
| 74 | * (user's choice, 2026-10-24): it goes on over free floor until the next tile is a wall, crate or |
| 75 | * bomb; it stops on an enemy (crashing into it) and on the open exit. Flame on the way doesn't |
| 76 | * stop it (step() burns the player, unless flame can't hurt them). |
| 77 | */ |
| 78 | export function slidePath(s: GameState, d: Vec): Vec[] { |
| 79 | const path: Vec[] = [] |
| 80 | let p = s.player |
| 81 | for (;;) { |
| 82 | const q = add(p, d) |
| 83 | if (!isFree(s, q)) break |
| 84 | path.push(q) |
| 85 | if (enemyAt(s, q) || (exitOpen(s) && eq(q, s.exit))) break |
| 86 | p = q |
| 87 | } |
| 88 | return path |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Whether a diagonal step from `from` by `d` may be taken: one of the two tiles beside the step |
| 93 | * must be passable — no squeezing between two blocked tiles (user's choice, 2026-10-25: the lab |
| 94 | * found the squeeze was most of Diagonal's strength, +7.7 levels for the average bot, +3.4 |
| 95 | * without). The lab's `squeeze` variant brings the old rule back, to compare. |
| 96 | */ |
| 97 | export function diagonalAllowed(s: GameState, from: Vec, d: Vec): boolean { |
| 98 | if (s.options.lab?.squeeze) return true |
| 99 | return playerCanPass(s, add(from, { x: d.x, y: 0 })) || playerCanPass(s, add(from, { x: 0, y: d.y })) |
| 100 | } |
| 101 | |
| 102 | /** How far Teleport reaches: TELEPORT_RANGE, one more with Run (user request, 2026-10-24). */ |
| 103 | export function teleportRange(s: GameState): number { |
| 104 | return TELEPORT_RANGE + (s.powers.run ? 1 : 0) |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * How many steps Teleport counts to a tile: across plus down (a diamond of reach); with Diagonal, |
| 109 | * a diagonal step counts as one, so it reaches a square (user's choice, 2026-10-24: Teleport |
| 110 | * already covered every diagonal step, so Diagonal widens it instead of being wasted). |
| 111 | */ |
| 112 | export function teleportSteps(s: GameState, dx: number, dy: number): number { |
| 113 | return s.powers.diagonal ? Math.max(Math.abs(dx), Math.abs(dy)) : Math.abs(dx) + Math.abs(dy) |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Boxed in for good (user's choice, 2026-10-26): nothing the player can do but wait, and nothing |
| 118 | * left that could change that — no bomb on the board, no flame, no ceiling about to fall, no bush |
| 119 | * smouldering, no crates crumbling. Nowhere to step and nowhere to put a bomb: a |
| 120 | * jump into a pocket between crates, or rubble falling round the player. It costs a life, as |
| 121 | * walled in by rubble does, rather than leaving the player waiting for ever. |
| 122 | */ |
| 123 | export function boxedIn(s: GameState): boolean { |
| 124 | if (s.bombs.length || s.burning.length || s.collapsing.length || s.smoldering.length || s.crumbling.length) return false |
| 125 | return ACTION_ORDER.every((id) => id === 'wait' || !isAvailable(s, id)) |
| 126 | } |
| 127 | |
| 128 | function hasCapacity(s: GameState): boolean { |
| 129 | // A burst oil barrel's blast isn't one of the player's bombs. |
| 130 | return s.bombs.filter((b) => !b.barrel).length < s.stats.capacity |
| 131 | } |
| 132 | |
| 133 | /** The adjacent tiles a bomb can go on, if the player may place one. */ |
| 134 | function bombTargets(s: GameState): Vec[] { |
| 135 | if (!hasCapacity(s)) return [] |
| 136 | // The lab's `underfoot` variant: the NES rule, on the player's own tile. |
| 137 | if (s.options.lab?.underfoot) return canHoldBomb(s, s.player) ? [s.player] : [] |
| 138 | return neighbours(s.player).filter((p) => canHoldBomb(s, p)) |
| 139 | } |
| 140 | |
| 141 | function walkTo(s: GameState, target: Vec, events: GameEvent[]): void { |
| 142 | const from = s.player |
| 143 | s.player = { ...target } |
| 144 | events.push({ type: 'moved', from, to: s.player }) |
| 145 | } |
| 146 | |
| 147 | /** How far Teleport reaches (Manhattan distance). */ |
| 148 | export const TELEPORT_RANGE = 3 |
| 149 | /** |
| 150 | * Freeze: the enemy moves skipped, the turn it is used included (user request, 2026-10-23: "frozen |
| 151 | * for 3 turns"). |
| 152 | */ |
| 153 | export const FREEZE_TURNS = 3 |
| 154 | /** How far a Decoy can be put, in a straight line (user request, 2026-10-25). */ |
| 155 | export const DECOY_RANGE = 3 |
| 156 | /** How far a Nuke bomb's blast reaches, counted across plus down, through anything (user's choice). */ |
| 157 | export const NUKE_RANGE = 2 |
| 158 | |
| 159 | /** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */ |
| 160 | export function kickDestination(s: GameState, from: Vec): Vec { |
| 161 | const d = { x: from.x - s.player.x, y: from.y - s.player.y } |
| 162 | let p = from |
| 163 | while (canHoldBomb(s, add(p, d))) p = add(p, d) |
| 164 | return p |
| 165 | } |
| 166 | |
| 167 | export const ACTIONS: Record<ActionId, ActionDef> = { |
| 168 | move: { |
| 169 | id: 'move', |
| 170 | label: 'Move', |
| 171 | needsTarget: true, |
| 172 | validTargets: (s) => neighbours(s.player).filter((p) => playerCanEnter(s, p)), |
| 173 | apply: (s, target, events) => walkTo(s, target!, events), |
| 174 | }, |
| 175 | bomb: { |
| 176 | id: 'bomb', |
| 177 | label: 'Bomb', |
| 178 | needsTarget: true, |
| 179 | validTargets: bombTargets, |
| 180 | apply: (s, target, events) => void placeBomb(s, target!, events), |
| 181 | }, |
| 182 | wait: { |
| 183 | id: 'wait', |
| 184 | label: 'Wait', |
| 185 | needsTarget: false, |
| 186 | validTargets: () => [], |
| 187 | apply(_s, _target, events) { |
| 188 | events.push({ type: 'waited' }) |
| 189 | }, |
| 190 | }, |
| 191 | run: { |
| 192 | id: 'run', |
| 193 | label: 'Run', |
| 194 | needsTarget: true, |
| 195 | ability: 'run', |
| 196 | // 2 tiles in a straight line; both must be free, so nothing is jumped over. An enemy on the |
| 197 | // first tile is run into (step() checks it). |
| 198 | validTargets: (s) => |
| 199 | DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { |
| 200 | const mid = add(s.player, d) |
| 201 | const land = add(mid, d) |
| 202 | return playerCanEnter(s, mid) && playerCanEnter(s, land) ? [land] : [] |
| 203 | }), |
| 204 | apply: (s, target, events) => walkTo(s, target!, events), |
| 205 | }, |
| 206 | detonate: { |
| 207 | id: 'detonate', |
| 208 | label: 'Detonate', |
| 209 | needsTarget: true, |
| 210 | ability: 'detonate', |
| 211 | // Any of your bombs: it explodes this turn, in phase 3, with everything it sets off. |
| 212 | validTargets: (s) => s.bombs.filter((b) => b.timer > 0 && !b.barrel).map((b) => b.pos), |
| 213 | apply(s, target, events) { |
| 214 | bombAt(s, target!)!.timer = 0 |
| 215 | events.push({ type: 'detonated', pos: { ...target! } }) |
| 216 | }, |
| 217 | }, |
| 218 | kick: { |
| 219 | id: 'kick', |
| 220 | label: 'Kick', |
| 221 | needsTarget: true, |
| 222 | ability: 'kick', |
| 223 | // An adjacent bomb with room to slide at least one tile away from the player. |
| 224 | validTargets: (s) => |
| 225 | DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { |
| 226 | const p = add(s.player, d) |
| 227 | return bombAt(s, p) && canHoldBomb(s, add(p, d)) ? [p] : [] |
| 228 | }), |
| 229 | apply(s, target, events) { |
| 230 | const bomb = bombAt(s, target!)! |
| 231 | const from = bomb.pos |
| 232 | bomb.pos = kickDestination(s, from) |
| 233 | events.push({ type: 'bombKicked', from, to: bomb.pos }) |
| 234 | }, |
| 235 | }, |
| 236 | throw: { |
| 237 | id: 'throw', |
| 238 | label: 'Throw', |
| 239 | needsTarget: true, |
| 240 | ability: 'throw', |
| 241 | // Exactly 2 tiles away in a straight line, over a crate, bomb or enemy in between, but not |
| 242 | // over a pillar or the outer wall (user's choice, 2026-10-23: it went over anything). |
| 243 | validTargets: (s) => |
| 244 | !hasCapacity(s) |
| 245 | ? [] |
| 246 | : DIR_LIST.flatMap((d) => { |
| 247 | const mid = add(s.player, DIRS[d]) |
| 248 | const land = add(mid, DIRS[d]) |
| 249 | return cellAt(s, mid) !== 'wall' && canHoldBomb(s, land) ? [land] : [] |
| 250 | }), |
| 251 | apply: (s, target, events) => void placeBomb(s, target!, events), |
| 252 | }, |
| 253 | diagonal: { |
| 254 | id: 'diagonal', |
| 255 | label: 'Diagonal', |
| 256 | needsTarget: true, |
| 257 | ability: 'diagonal', |
| 258 | // Not between two blocked tiles (no squeezing, since 2026-10-25). |
| 259 | validTargets: (s) => |
| 260 | [ |
| 261 | { x: -1, y: -1 }, |
| 262 | { x: 1, y: -1 }, |
| 263 | { x: -1, y: 1 }, |
| 264 | { x: 1, y: 1 }, |
| 265 | ] |
| 266 | .filter((d) => diagonalAllowed(s, s.player, d)) |
| 267 | .map((d) => add(s.player, d)) |
| 268 | .filter((p) => playerCanEnter(s, p)), |
| 269 | apply: (s, target, events) => walkTo(s, target!, events), |
| 270 | }, |
| 271 | // A charge of 3 jumps since 2026-10-25 (a power-up until then), used from the charge button. |
| 272 | teleport: { |
| 273 | id: 'teleport', |
| 274 | label: 'Teleport', |
| 275 | needsTarget: true, |
| 276 | available: (s) => holds(s, 'teleport'), |
| 277 | validTargets(s) { |
| 278 | const range = teleportRange(s) |
| 279 | const tiles: Vec[] = [] |
| 280 | for (let dy = -range; dy <= range; dy++) { |
| 281 | for (let dx = -range; dx <= range; dx++) { |
| 282 | const p = add(s.player, { x: dx, y: dy }) |
| 283 | if ((dx || dy) && teleportSteps(s, dx, dy) <= range && playerCanEnter(s, p)) tiles.push(p) |
| 284 | } |
| 285 | } |
| 286 | return tiles |
| 287 | }, |
| 288 | apply(s, target, events) { |
| 289 | spendCharge(s) |
| 290 | walkTo(s, target!, events) |
| 291 | }, |
| 292 | }, |
| 293 | rpg: { |
| 294 | id: 'rpg', |
| 295 | label: 'RPG', |
| 296 | needsTarget: true, |
| 297 | ability: 'rpg', |
| 298 | // Fired each way, landing 2 tiles away or more (1 is a plain bomb); within the bomb limit. |
| 299 | validTargets: (s) => (hasCapacity(s) ? flightTargets(s, 2) : []), |
| 300 | apply(s, target, events) { |
| 301 | placeBomb(s, target!, events) |
| 302 | events.push({ type: 'bombFired', from: { ...s.player }, to: { ...target! } }) |
| 303 | }, |
| 304 | }, |
| 305 | icbm: { |
| 306 | id: 'icbm', |
| 307 | label: 'ICBM', |
| 308 | needsTarget: true, |
| 309 | available: (s) => holds(s, 'icbm'), |
| 310 | // Fired as an RPG's, at any distance; it goes off this turn, so the bomb limit doesn't apply. |
| 311 | validTargets: (s) => flightTargets(s, 1), |
| 312 | apply(s, target, events) { |
| 313 | spendCharge(s) |
| 314 | placeBomb(s, target!, events).timer = 0 |
| 315 | events.push({ type: 'bombFired', from: { ...s.player }, to: { ...target! } }) |
| 316 | }, |
| 317 | }, |
| 318 | // The carried charge, used on purpose (PLAN.md §19.7; user request, 2026-10-24). |
| 319 | charged: { |
| 320 | id: 'charged', |
| 321 | label: 'Charged bomb', |
| 322 | needsTarget: true, |
| 323 | available: (s) => !!s.charge && BOMB_CHARGES.includes(s.charge.kind), |
| 324 | validTargets: bombTargets, |
| 325 | apply(s, target, events) { |
| 326 | const kind = s.charge!.kind as 'pierce' | 'power' | 'nuke' | 'acid' |
| 327 | spendCharge(s) |
| 328 | placeBomb(s, target!, events, kind) |
| 329 | }, |
| 330 | }, |
| 331 | // Freeze and Shield are aimed at the player's own tile: the charge button picks them, and a tap on |
| 332 | // yourself (or the button again) uses them — a confirmation, so one can't go off by a stray tap |
| 333 | // (user request, 2026-10-25: the Shield went up at once). |
| 334 | freeze: { |
| 335 | id: 'freeze', |
| 336 | label: 'Freeze', |
| 337 | needsTarget: true, |
| 338 | available: (s) => holds(s, 'freeze'), |
| 339 | validTargets: (s) => [s.player], |
| 340 | apply(s, _target, events) { |
| 341 | spendCharge(s) |
| 342 | s.frozen = Math.max(s.frozen, FREEZE_TURNS) |
| 343 | events.push({ type: 'frozen', turns: FREEZE_TURNS }) |
| 344 | }, |
| 345 | }, |
| 346 | decoy: { |
| 347 | id: 'decoy', |
| 348 | label: 'Decoy', |
| 349 | needsTarget: true, |
| 350 | available: (s) => holds(s, 'decoy'), |
| 351 | // Up to DECOY_RANGE tiles away in a straight line, each tile on the way free: like a slide or a |
| 352 | // kick, nothing gets past a wall, crate, bomb, enemy or flame (user request, 2026-10-25; it went |
| 353 | // on the player's tile or next to it). Chasing enemies go there instead. |
| 354 | validTargets: (s) => |
| 355 | DIR_LIST.flatMap((d) => { |
| 356 | const tiles: Vec[] = [] |
| 357 | let p = s.player |
| 358 | for (let i = 0; i < DECOY_RANGE; i++) { |
| 359 | p = add(p, DIRS[d]) |
| 360 | if (cellAt(s, p) !== 'floor' || bombAt(s, p) || enemyAt(s, p) || isBurning(s, p)) break |
| 361 | tiles.push(p) |
| 362 | } |
| 363 | return tiles |
| 364 | }), |
| 365 | apply(s, target, events) { |
| 366 | spendCharge(s) |
| 367 | s.decoy = { pos: { ...target! } } |
| 368 | events.push({ type: 'decoyPlaced', pos: s.decoy.pos }) |
| 369 | }, |
| 370 | }, |
| 371 | slide: { |
| 372 | id: 'slide', |
| 373 | label: 'Slide', |
| 374 | needsTarget: true, |
| 375 | ability: 'slide', |
| 376 | // Where each way's slide stops, 2 tiles away or more (1 is a step). |
| 377 | validTargets: (s) => |
| 378 | DIR_LIST.flatMap((d) => { |
| 379 | const path = slidePath(s, DIRS[d]) |
| 380 | return path.length >= 2 ? [path[path.length - 1]!] : [] |
| 381 | }), |
| 382 | apply: (s, target, events) => walkTo(s, target!, events), |
| 383 | }, |
| 384 | } |
| 385 | |
| 386 | /** The tiles a slide to `target` passed before stopping there (step() picks up and burns on them). */ |
| 387 | export function slidePassed(s: GameState, target: Vec): Vec[] { |
| 388 | return slidePath(s, wayTo(s, target)).slice(0, -1) |
| 389 | } |
| 390 | |
| 391 | export const ACTION_ORDER: readonly ActionId[] = [ |
| 392 | 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', 'charged', 'freeze', 'decoy', |
| 393 | ] |
| 394 | |
| 395 | export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { |
| 396 | return ACTIONS[id].validTargets(s).some((p) => eq(p, target)) |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * The action that uses the held charge on the spot, with a charge on offer (turn.ts `fireCharge`; |
| 401 | * user request, 2026-10-31): one with nothing to aim, which is Freeze (aimed at the player's own |
| 402 | * tile). A charge that needs aiming can't be fired in the middle of picking one up. Null if none. |
| 403 | */ |
| 404 | export function fireableHeld(s: GameState): ActionId | null { |
| 405 | if (!s.charge || s.status !== 'playing') return null |
| 406 | return ( |
| 407 | (['freeze'] as const).find((id) => { |
| 408 | const def = ACTIONS[id] |
| 409 | const targets = def.validTargets(s) |
| 410 | return def.available!(s) && targets.length === 1 && eq(targets[0]!, s.player) |
| 411 | }) ?? null |
| 412 | ) |
| 413 | } |
| 414 | |
| 415 | /** Whether the action can be picked right now. */ |
| 416 | export function isAvailable(s: GameState, id: ActionId): boolean { |
| 417 | if (s.status !== 'playing' || s.offer || s.chargeOffer) return false |
| 418 | const ability = ACTIONS[id].ability |
| 419 | if (ability && !s.powers[ability]) return false |
| 420 | const def = ACTIONS[id] |
| 421 | if (def.available && !def.available(s)) return false |
| 422 | return !def.needsTarget || def.validTargets(s).length > 0 |
| 423 | } |