fuzzyworld

explodorama / files / src / sim / actions.ts

actions.ts

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

5f066960 2026-10-26 Pavel Safronov1import { BOMB_CHARGES, holds, spendCharge } from './charges'
c37a2b0b 2026-10-20 Pavel Safronov2import { enemyAt } from './enemies'
a313eb7d 2026-10-25 Pavel Safronov3import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter, playerCanPass } from './state'
d3e4ed26 2026-10-25 Pavel Safronov4import { DIRS, DIR_LIST, type ActionId, type Bomb, type GameEvent, type GameState, type Powers, type Vec } from './types'
5551eb37 2026-10-20 Pavel Safronov5
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 */
10export 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
27f24fed 2026-10-20 Pavel Safronov15 /** The power-up ability it needs (PLAN.md §3.2); only offered once the player has it. */
16 ability?: keyof Powers
7f242d36 2026-10-25 Pavel Safronov17 /** Anything else it needs (a charge's launches left). */
18 available?: (s: GameState) => boolean
5551eb37 2026-10-20 Pavel Safronov19 validTargets(s: GameState): Vec[]
20 apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void
21}
22
23function neighbours(p: Vec): Vec[] {
24 return DIR_LIST.map((d) => add(p, DIRS[d]))
25}
26
82adee18 2026-10-20 Pavel Safronov27/** Where a bomb can go: free, not on fire, and no enemy standing there. */
c37a2b0b 2026-10-20 Pavel Safronov28function canHoldBomb(s: GameState, p: Vec): boolean {
82adee18 2026-10-20 Pavel Safronov29 return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p)
c37a2b0b 2026-10-20 Pavel Safronov30}
31
d3e4ed26 2026-10-25 Pavel Safronov32/**
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 */
5f066960 2026-10-26 Pavel Safronov36function placeBomb(s: GameState, pos: Vec, events: GameEvent[], boost?: 'pierce' | 'power' | 'nuke' | 'acid'): Bomb {
52d68b59 2026-10-24 Pavel Safronov37 const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }
d3e4ed26 2026-10-25 Pavel Safronov38 if (boost) bomb[boost] = true
82adee18 2026-10-20 Pavel Safronov39 // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls.
52d68b59 2026-10-24 Pavel Safronov40 if (bomb.power) bomb.fire = Math.max(s.width, s.height)
7f242d36 2026-10-25 Pavel Safronov41 if (s.powers.proximity) bomb.proximity = true
82adee18 2026-10-20 Pavel Safronov42 s.bombs.push(bomb)
c37a2b0b 2026-10-20 Pavel Safronov43 events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse })
7f242d36 2026-10-25 Pavel Safronov44 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 */
52export 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. */
60function 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. */
68function 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 */
78export 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
a313eb7d 2026-10-25 Pavel Safronov91/**
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 */
97export 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
7f242d36 2026-10-25 Pavel Safronov102/** How far Teleport reaches: TELEPORT_RANGE, one more with Run (user request, 2026-10-24). */
103export function teleportRange(s: GameState): number {
104 return TELEPORT_RANGE + (s.powers.run ? 1 : 0)
c37a2b0b 2026-10-20 Pavel Safronov105}
106
e78b7781 2026-10-25 Pavel Safronov107/**
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 */
112export 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
6c3fcc31 2026-10-26 Pavel Safronov116/**
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 */
123export 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
82adee18 2026-10-20 Pavel Safronov128function hasCapacity(s: GameState): boolean {
5f066960 2026-10-26 Pavel Safronov129 // 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
82adee18 2026-10-20 Pavel Safronov131}
132
133/** The adjacent tiles a bomb can go on, if the player may place one. */
134function bombTargets(s: GameState): Vec[] {
6c3fcc31 2026-10-26 Pavel Safronov135 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))
82adee18 2026-10-20 Pavel Safronov139}
140
141function 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). */
148export const TELEPORT_RANGE = 3
99869630 2026-10-23 Pavel Safronov149/**
d3e4ed26 2026-10-25 Pavel Safronov150 * Freeze: the enemy moves skipped, the turn it is used included (user request, 2026-10-23: "frozen
151 * for 3 turns").
99869630 2026-10-23 Pavel Safronov152 */
52d68b59 2026-10-24 Pavel Safronov153export const FREEZE_TURNS = 3
d3e4ed26 2026-10-25 Pavel Safronov154/** How far a Decoy can be put, in a straight line (user request, 2026-10-25). */
155export const DECOY_RANGE = 3
52d68b59 2026-10-24 Pavel Safronov156/** How far a Nuke bomb's blast reaches, counted across plus down, through anything (user's choice). */
157export const NUKE_RANGE = 2
158
c37a2b0b 2026-10-20 Pavel Safronov159/** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */
160export 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
5551eb37 2026-10-20 Pavel Safronov167export const ACTIONS: Record<ActionId, ActionDef> = {
168 move: {
169 id: 'move',
170 label: 'Move',
171 needsTarget: true,
82adee18 2026-10-20 Pavel Safronov172 validTargets: (s) => neighbours(s.player).filter((p) => playerCanEnter(s, p)),
173 apply: (s, target, events) => walkTo(s, target!, events),
5551eb37 2026-10-20 Pavel Safronov174 },
175 bomb: {
176 id: 'bomb',
177 label: 'Bomb',
178 needsTarget: true,
82adee18 2026-10-20 Pavel Safronov179 validTargets: bombTargets,
7f242d36 2026-10-25 Pavel Safronov180 apply: (s, target, events) => void placeBomb(s, target!, events),
5551eb37 2026-10-20 Pavel Safronov181 },
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 },
27f24fed 2026-10-20 Pavel Safronov191 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.
5f066960 2026-10-26 Pavel Safronov212 validTargets: (s) => s.bombs.filter((b) => b.timer > 0 && !b.barrel).map((b) => b.pos),
27f24fed 2026-10-20 Pavel Safronov213 apply(s, target, events) {
214 bombAt(s, target!)!.timer = 0
215 events.push({ type: 'detonated', pos: { ...target! } })
216 },
217 },
c37a2b0b 2026-10-20 Pavel Safronov218 kick: {
219 id: 'kick',
220 label: 'Kick',
221 needsTarget: true,
99869630 2026-10-23 Pavel Safronov222 ability: 'kick',
c37a2b0b 2026-10-20 Pavel Safronov223 // 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,
99869630 2026-10-23 Pavel Safronov240 ability: 'throw',
28670c3e 2026-10-24 Pavel Safronov241 // 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).
c37a2b0b 2026-10-20 Pavel Safronov243 validTargets: (s) =>
82adee18 2026-10-20 Pavel Safronov244 !hasCapacity(s)
c37a2b0b 2026-10-20 Pavel Safronov245 ? []
28670c3e 2026-10-24 Pavel Safronov246 : 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 }),
7f242d36 2026-10-25 Pavel Safronov251 apply: (s, target, events) => void placeBomb(s, target!, events),
c37a2b0b 2026-10-20 Pavel Safronov252 },
82adee18 2026-10-20 Pavel Safronov253 diagonal: {
254 id: 'diagonal',
255 label: 'Diagonal',
256 needsTarget: true,
52d68b59 2026-10-24 Pavel Safronov257 ability: 'diagonal',
a313eb7d 2026-10-25 Pavel Safronov258 // Not between two blocked tiles (no squeezing, since 2026-10-25).
82adee18 2026-10-20 Pavel Safronov259 validTargets: (s) =>
260 [
261 { x: -1, y: -1 },
262 { x: 1, y: -1 },
263 { x: -1, y: 1 },
264 { x: 1, y: 1 },
265 ]
a313eb7d 2026-10-25 Pavel Safronov266 .filter((d) => diagonalAllowed(s, s.player, d))
82adee18 2026-10-20 Pavel Safronov267 .map((d) => add(s.player, d))
268 .filter((p) => playerCanEnter(s, p)),
269 apply: (s, target, events) => walkTo(s, target!, events),
270 },
fcf44f32 2026-10-25 Pavel Safronov271 // A charge of 3 jumps since 2026-10-25 (a power-up until then), used from the charge button.
82adee18 2026-10-20 Pavel Safronov272 teleport: {
273 id: 'teleport',
274 label: 'Teleport',
275 needsTarget: true,
fcf44f32 2026-10-25 Pavel Safronov276 available: (s) => holds(s, 'teleport'),
82adee18 2026-10-20 Pavel Safronov277 validTargets(s) {
7f242d36 2026-10-25 Pavel Safronov278 const range = teleportRange(s)
82adee18 2026-10-20 Pavel Safronov279 const tiles: Vec[] = []
7f242d36 2026-10-25 Pavel Safronov280 for (let dy = -range; dy <= range; dy++) {
281 for (let dx = -range; dx <= range; dx++) {
82adee18 2026-10-20 Pavel Safronov282 const p = add(s.player, { x: dx, y: dy })
e78b7781 2026-10-25 Pavel Safronov283 if ((dx || dy) && teleportSteps(s, dx, dy) <= range && playerCanEnter(s, p)) tiles.push(p)
82adee18 2026-10-20 Pavel Safronov284 }
285 }
286 return tiles
287 },
fcf44f32 2026-10-25 Pavel Safronov288 apply(s, target, events) {
289 spendCharge(s)
290 walkTo(s, target!, events)
291 },
82adee18 2026-10-20 Pavel Safronov292 },
7f242d36 2026-10-25 Pavel Safronov293 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,
d3e4ed26 2026-10-25 Pavel Safronov309 available: (s) => holds(s, 'icbm'),
7f242d36 2026-10-25 Pavel Safronov310 // 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) {
d3e4ed26 2026-10-25 Pavel Safronov313 spendCharge(s)
7f242d36 2026-10-25 Pavel Safronov314 placeBomb(s, target!, events).timer = 0
315 events.push({ type: 'bombFired', from: { ...s.player }, to: { ...target! } })
316 },
317 },
d3e4ed26 2026-10-25 Pavel Safronov318 // 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) {
5f066960 2026-10-26 Pavel Safronov326 const kind = s.charge!.kind as 'pierce' | 'power' | 'nuke' | 'acid'
d3e4ed26 2026-10-25 Pavel Safronov327 spendCharge(s)
328 placeBomb(s, target!, events, kind)
329 },
330 },
e6bb7c42 2026-10-25 Pavel Safronov331 // 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).
d3e4ed26 2026-10-25 Pavel Safronov334 freeze: {
335 id: 'freeze',
336 label: 'Freeze',
e6bb7c42 2026-10-25 Pavel Safronov337 needsTarget: true,
d3e4ed26 2026-10-25 Pavel Safronov338 available: (s) => holds(s, 'freeze'),
e6bb7c42 2026-10-25 Pavel Safronov339 validTargets: (s) => [s.player],
d3e4ed26 2026-10-25 Pavel Safronov340 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 },
7f242d36 2026-10-25 Pavel Safronov371 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). */
387export function slidePassed(s: GameState, target: Vec): Vec[] {
388 return slidePath(s, wayTo(s, target)).slice(0, -1)
82adee18 2026-10-20 Pavel Safronov389}
390
7f242d36 2026-10-25 Pavel Safronov391export const ACTION_ORDER: readonly ActionId[] = [
5f066960 2026-10-26 Pavel Safronov392 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', 'charged', 'freeze', 'decoy',
7f242d36 2026-10-25 Pavel Safronov393]
5551eb37 2026-10-20 Pavel Safronov394
395export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
396 return ACTIONS[id].validTargets(s).some((p) => eq(p, target))
397}
398
bb3823d2 2026-10-31 Pavel Safronov399/**
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 */
404export 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
5551eb37 2026-10-20 Pavel Safronov415/** Whether the action can be picked right now. */
416export function isAvailable(s: GameState, id: ActionId): boolean {
d3e4ed26 2026-10-25 Pavel Safronov417 if (s.status !== 'playing' || s.offer || s.chargeOffer) return false
27f24fed 2026-10-20 Pavel Safronov418 const ability = ACTIONS[id].ability
419 if (ability && !s.powers[ability]) return false
5551eb37 2026-10-20 Pavel Safronov420 const def = ACTIONS[id]
7f242d36 2026-10-25 Pavel Safronov421 if (def.available && !def.available(s)) return false
5551eb37 2026-10-20 Pavel Safronov422 return !def.needsTarget || def.validTargets(s).length > 0
423}