fuzzyworld

explodorama / files / src / sim / demo.ts

demo.ts

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

d5e3ad4d 2026-10-24 Pavel Safronov1import { ACTIONS, isAvailable, isValidTarget } from './actions'
e3d8dc49 2026-10-27 Pavel Safronov2import { isDeadly, predictDanger } from './danger'
b8849d36 2026-11-01 Pavel Safronov3import { blastTiles, COLLAPSE_TURNS } from './explosions'
52d68b59 2026-10-24 Pavel Safronov4import { planIntents } from './enemies'
5import { Rng } from './rng'
36c5d6a2 2026-10-24 Pavel Safronov6import { autoMoveAction, playerPath, safeStep } from './path'
7import { membersOf, resolvePick, tapMeaning } from './picks'
1f0038ac 2026-11-01 Pavel Safronov8import { exitOpen, idx, inBounds, noPowers, playerCanEnter, START_LIVES, START_STATS } from './state'
8edb5cb9 2026-11-01 Pavel Safronov9import { buildTestScene, TEST_KINDS, TEST_SCENES, type TestKind } from './testScenes'
52d68b59 2026-10-24 Pavel Safronov10import { step } from './turn'
fa3e81a7 2026-10-24 Pavel Safronov11import {
12 DEFAULT_OPTIONS,
36c5d6a2 2026-10-24 Pavel Safronov13 DIRS,
14 DIR_LIST,
fa3e81a7 2026-10-24 Pavel Safronov15 type Action,
16 type ActionId,
17 type Cell,
18 type ChargeId,
19 type EnemyKind,
20 type GameEvent,
21 type GameState,
22 type Vec,
23} from './types'
52d68b59 2026-10-24 Pavel Safronov24
25/**
26 * The demo played before the game starts, for players who never played the original (user
27 * request, 2026-10-23). Each scene is made up on the spot: a small random room, then random play
28 * on it, by the real rules, until a game shows what the scene is about. The scene is that room
29 * and those actions; the UI replays them, view-only. Pure and seeded like the rest of the sim.
fa3e81a7 2026-10-24 Pavel Safronov30 * The scenes show every feature that happens on the board (user request, 2026-10-23: all but
31 * the shop and the exit) — Heart and Coin bag only change numbers, so they have none.
36c5d6a2 2026-10-24 Pavel Safronov32 * Since 2026-10-24 (user request) they also show the controls: each turn comes with the gesture
33 * that plays it (a tap, a long press, a button then a tile), which the UI acts out with a finger,
b8849d36 2026-11-01 Pavel Safronov34 * and a few scenes are about the controls alone. And each scene's moment comes 3 to 6 turns in (`maxSteps` asks for more, `tail` for turns after it):
36c5d6a2 2026-10-24 Pavel Safronov35 * the scene is the last few turns of a longer game, from a calm moment (no bomb, no flame).
52d68b59 2026-10-24 Pavel Safronov36 */
fa3e81a7 2026-10-24 Pavel Safronov37export type SceneKind =
36c5d6a2 2026-10-24 Pavel Safronov38 | 'tapMove' | 'holdBomb' | 'bombButton' | 'wait' | 'autoMove'
fa3e81a7 2026-10-24 Pavel Safronov39 | 'kill' | 'arrows' | 'escape' | 'chain' | 'lingering' | 'enemies' | 'coins' | 'gem'
40 | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate'
7f242d36 2026-10-25 Pavel Safronov41 | 'rpg' | 'proximity' | 'slide' | 'icbm'
fa3e81a7 2026-10-24 Pavel Safronov42 | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
b8849d36 2026-11-01 Pavel Safronov43 | 'pierce' | 'power' | 'nuke' | 'acid' | 'rubble' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap'
d5e3ad4d 2026-10-24 Pavel Safronov44 | 'exit' | 'doorBlast'
ac534e57 2026-10-25 Pavel Safronov45 | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal'
1f0038ac 2026-11-01 Pavel Safronov46 | 'bush' | 'bushFire' | 'metal' | 'oil' | 'oilSlide' | 'spikes' | 'barrel'
8edb5cb9 2026-11-01 Pavel Safronov47 | TestKind
d5e3ad4d 2026-10-24 Pavel Safronov48 | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
52d68b59 2026-10-24 Pavel Safronov49
36c5d6a2 2026-10-24 Pavel Safronov50/**
51 * How the player plays a turn, for the demo's finger (main.ts): every gesture is one the real
52 * controls read as that turn's action (test/demo.test.ts checks it).
53 * - tap: a tap on a board tile, nothing picked (picks.ts `tapMeaning`);
54 * - hold: a long press on a tile next to the player: a bomb there;
55 * - button: an action button, then (unless it acts at once, as Wait) a tap on a tile;
56 * - walk: a tap on a far tile: auto-move's destination, and its first move;
57 * - go: the Move button while auto-move has a destination (it shows as Go): the next move.
58 */
59export type Gesture =
60 | { kind: 'tap'; tile: Vec }
61 | { kind: 'hold'; tile: Vec }
62 | { kind: 'walk'; tile: Vec }
63 | { kind: 'button'; button: ButtonId; tile?: Vec }
64 | { kind: 'go' }
0bfdc8e6 2026-10-25 Pavel Safronov65 /** A charge picked up while carrying another: a tap on the dialog's Take (swap) or Keep button. */
66 | { kind: 'choice'; swap: boolean }
8edb5cb9 2026-11-01 Pavel Safronov67 /** A gem's offer, or a charge's sell-or-swap, answered (test scenes; the demo opens the dialog). */
68 | { kind: 'pick' }
36c5d6a2 2026-10-24 Pavel Safronov69
70/** The action bar's buttons (ui/controls.ts). */
d3e4ed26 2026-10-25 Pavel Safronov71export type ButtonId = 'move' | 'bomb' | 'charged' | 'wait'
36c5d6a2 2026-10-24 Pavel Safronov72
52d68b59 2026-10-24 Pavel Safronov73export interface Scene {
74 kind: SceneKind
75 start: GameState
76 actions: Action[]
36c5d6a2 2026-10-24 Pavel Safronov77 /** How each action is played, one per action. */
78 gestures: Gesture[]
b8849d36 2026-11-01 Pavel Safronov79 /** The turn (index in `actions`) the scene is about; any after it are the aftermath (`SceneDef.tail`). Last if absent. */
80 moment?: number
52d68b59 2026-10-24 Pavel Safronov81}
82
fa3e81a7 2026-10-24 Pavel Safronov83/** What a scene's game has done so far, for moments that need an earlier turn (a charge picked up). */
84interface Seen {
85 picked: Set<ChargeId>
36c5d6a2 2026-10-24 Pavel Safronov86 /** Each kind of action taken, and how many times. */
87 used: Map<ActionId, number>
88 /** The far tile auto-move was last sent to. */
89 walk: Vec | null
90}
91
92/** One turn as played: the action and its gesture. */
93interface Play {
94 action: Action
95 gesture: Gesture
fa3e81a7 2026-10-24 Pavel Safronov96}
97
36c5d6a2 2026-10-24 Pavel Safronov98/** A game's auto-move: the far tile it is walking to, or null. */
99interface Walking {
100 to: Vec | null
101}
102
103/** One turn of a game: the state before it, the action and its gesture, its events and the state after. */
fa3e81a7 2026-10-24 Pavel Safronov104interface Turn {
105 prev: GameState
106 action: Action
36c5d6a2 2026-10-24 Pavel Safronov107 gesture: Gesture
fa3e81a7 2026-10-24 Pavel Safronov108 events: GameEvent[]
109 next: GameState
110}
111
112interface SceneDef {
113 /** One line under the scene. */
114 caption: string
92cc4ee3 2026-11-02 Pavel Safronov115 /** The caption when a gamepad is in use (Q58): `{dpad}`, `{move}` ... stand for the controller's pictures (ui/padGlyph.ts `padCaptionHtml`). Absent: `caption` is used. */
116 padCaption?: string
117 /** The caption for the keyboard demo (Q65): `{arrows}`, `{M}`, `{shift}` ... stand for key pictures (ui/keyDemo.ts `keyCaptionHtml`). Absent: `caption` is used. */
118 keyCaption?: string
fa3e81a7 2026-10-24 Pavel Safronov119 enemies: EnemyKind[]
120 /** What the player starts with, or what lies near them. */
121 prepare?: (s: GameState, rng: Rng) => void
122 /** Whether this turn is the moment the scene is about. */
123 shows: (t: Turn, seen: Seen) => boolean
d5e3ad4d 2026-10-24 Pavel Safronov124 /** A scene about a way to die: its moment is the turn the player dies (user request, 2026-10-23). */
125 dies?: boolean
32dd53cd 2026-10-25 Pavel Safronov126 /** Only for a dialog's clip (ui/clip.ts), not in the demo. */
127 clipOnly?: boolean
36c5d6a2 2026-10-24 Pavel Safronov128 /** A nudge for the random play: a turn to play now, or null for a random one. */
129 steer?: (s: GameState, rng: Rng, walking: Walking) => Play | null
130 /** Bombs placed with the Bomb button and a tap, not a long press. */
131 bombButton?: boolean
b8849d36 2026-11-01 Pavel Safronov132 /** More turns to a scene than MAX_STEPS, for a moment that takes a while to come (rubble). */
133 maxSteps?: number
134 /** Turns of waiting played after the moment, as far as they are safe, to see its result (user request, 2026-10-31). */
135 tail?: number
8edb5cb9 2026-11-01 Pavel Safronov136 /** A fixed scene (sim/testScenes.ts), not rolled: Developer mode's edge-case checks. Not in the demo's rotation. */
137 fixed?: () => Scene
0bfdc8e6 2026-10-25 Pavel Safronov138 /** A scene about the sell-or-swap choice: turns that ask it are kept (elsewhere they are skipped). */
139 choice?: boolean
fa3e81a7 2026-10-24 Pavel Safronov140}
141
92cc4ee3 2026-11-02 Pavel Safronov142/** The scenes about the controls alone, which the gamepad's demo has its own version of (Q58). */
143export const PAD_CONTROL_KINDS: readonly SceneKind[] = ['tapMove', 'bombButton', 'wait']
144
145/** The scenes the gamepad's demo leaves out for now: they ask a dialog's question (the sell-or-swap choice), which the pad demo cannot show yet. */
146export const PAD_LATER: readonly SceneKind[] = ['chargeSwap']
147
52d68b59 2026-10-24 Pavel Safronov148const WIDTH = 9
149const HEIGHT = 7
36c5d6a2 2026-10-24 Pavel Safronov150/** How many turns a random game may take, how many are tried per room, and how many rooms. */
52d68b59 2026-10-24 Pavel Safronov151const MAX_TURNS = 24
152const TRIES = 150
fa3e81a7 2026-10-24 Pavel Safronov153const ROOMS = 8
36c5d6a2 2026-10-24 Pavel Safronov154/** A scene's moment comes this many turns in, at the least and at the most (user request, 2026-10-24). */
155export const MIN_STEPS = 3
156export const MAX_STEPS = 6
e3384c6b 2026-10-24 Pavel Safronov157/** A room's par, as a level of about its size would have (the top bar shows it). */
158const DEMO_PAR = 20
fa3e81a7 2026-10-24 Pavel Safronov159
160const kills = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'enemyDied' }> => e.type === 'enemyDied')
161const explosions = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'exploded' }> => e.type === 'exploded')
162const at = (p: Vec, q: Vec) => p.x === q.x && p.y === q.y
163const steps = (p: Vec, q: Vec) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y)
164/** The player was in a blast's way (danger this turn) and the action took them out of it. */
165const escaped = (t: Turn) => predictDanger(t.prev).now.has(idx(t.prev, t.prev.player)) && t.next.status === 'playing'
d5e3ad4d 2026-10-24 Pavel Safronov166/** The player died this turn, and how: caught by flame (new or lingering), or by an enemy. */
167const died = (t: Turn) => t.events.some((e) => e.type === 'playerDied')
168const burnt = (t: Turn) =>
169 died(t) && (explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.prev.burning.includes(idx(t.prev, t.next.player)))
32dd53cd 2026-10-25 Pavel Safronov170/** A tile of the room's outer wall (the rest of the walls are pillars). */
171const isEdge = (s: GameState, p: Vec) => p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1
d5e3ad4d 2026-10-24 Pavel Safronov172const moved = (t: Turn) => !at(t.prev.player, t.next.player)
173const inBlast = (t: Turn, p: Vec) => explosions(t).some((e) => e.tiles.some((q) => at(q, p)))
36c5d6a2 2026-10-24 Pavel Safronov174/** A charge lying on a free floor tile two steps from the player (or `n`), to walk over first. */
175const dropping = (kind: ChargeId, n = 2) => (s: GameState, rng: Rng) => dropNear(s, rng, kind, n)
fa3e81a7 2026-10-24 Pavel Safronov176
9ed991c9 2026-11-03 Pavel Safronov177/**
178 * A nudge for the Decoy scene: head for its drop, then put it down. Random play rarely chains
179 * pickup, use and a kill (seeds 1-6 took 3.2 s to find, now 0.9 s); a little randomness stays so
180 * the scenes differ. (Tried for Freeze too: no faster, its slow part is the kill while frozen.)
181 */
182const fetchAndUse = (kind: 'decoy') => (s: GameState, rng: Rng): Play | null => {
183 if (rng.next() < 0.15) return null
184 if (isAvailable(s, kind)) return playOf(SCENES[kind], s, randomTarget(s, rng, kind))
185 const drop = s.drops.find((d) => d.kind === kind)
186 const next = drop && playerPath(s, drop.pos)?.[0]
187 return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? playOf(SCENES[kind], s, { id: 'move', target: next }) : null
188}
189
8edb5cb9 2026-11-01 Pavel Safronov190/** A test scene's entry (sim/testScenes.ts). */
191const testDef = (kind: TestKind): SceneDef => ({
192 caption: TEST_SCENES[kind].caption,
193 enemies: [],
194 shows: () => true,
195 clipOnly: true,
196 fixed: () => buildTestScene(kind),
197})
198
fa3e81a7 2026-10-24 Pavel Safronov199export const SCENES: Record<SceneKind, SceneDef> = {
8edb5cb9 2026-11-01 Pavel Safronov200 ...(Object.fromEntries(TEST_KINDS.map((k) => [k, testDef(k)])) as Record<TestKind, SceneDef>),
36c5d6a2 2026-10-24 Pavel Safronov201 // The controls (user request, 2026-10-24), shown first.
202 tapMove: {
203 caption: 'Tap a tile next to you to step onto it.',
92cc4ee3 2026-11-02 Pavel Safronov204 padCaption: 'Move the cursor to a tile next to you with {dpad}, then press {move} to step there.',
205 keyCaption: 'Press {arrows} (or W A S D) to step onto the tile next to you that way.',
36c5d6a2 2026-10-24 Pavel Safronov206 enemies: ['blob'],
207 steer: (s, rng) => playOf(SCENES.tapMove, s, randomTarget(s, rng, 'move')),
208 shows: (t, seen) => t.action.id === 'move' && (seen.used.get('move') ?? 0) >= MIN_STEPS,
209 },
210 holdBomb: {
211 caption: 'Hold your finger on a tile next to you: a bomb goes there when you let go. Then get away!',
212 enemies: ['blob'],
213 shows: (t) => explosions(t).length > 0,
214 },
215 bombButton: {
216 caption: 'Or press Bomb, then tap where the bomb goes.',
92cc4ee3 2026-11-02 Pavel Safronov217 padCaption: 'Put the cursor next to you and press {bomb}: a bomb goes there. Then get away!',
218 keyCaption: 'Press {B}, then {arrows}: a bomb goes on the tile next to you that way. Then get away!',
36c5d6a2 2026-10-24 Pavel Safronov219 enemies: ['blob'],
220 bombButton: true,
221 shows: (t) => explosions(t).length > 0,
222 },
223 wait: {
224 caption: 'Press Wait to stay put for a turn, and let an enemy walk into your blast.',
92cc4ee3 2026-11-02 Pavel Safronov225 padCaption: 'The cursor starts on you each turn: press {wait} to stay put, and let an enemy walk into your blast.',
226 keyCaption: 'Press {space} to stay put for a turn, and let an enemy walk into your blast.',
36c5d6a2 2026-10-24 Pavel Safronov227 enemies: ['blob', 'seeker'],
228 shows: (t, seen) => seen.used.has('wait') && kills(t).length > 0,
229 },
230 autoMove: {
231 caption: 'Tap a tile further away to walk there: the Move button turns into Go, one step per press.',
232 enemies: ['blob'],
233 // A far tile to walk to, then Go until there.
234 steer: (s, rng, walking) => {
235 const danger = predictDanger(s)
236 const safe = (p: Vec) => safeStep(s, danger, p)
237 if (!walking.to) {
238 const far = s.cells.flatMap((_, i) => {
239 const p = { x: i % s.width, y: Math.floor(i / s.width) }
240 const path = tapMeaning(s, p) === 'walk' ? playerPath(s, p) : null
241 return path && path.length >= 3 && path.length <= 4 ? [p] : []
242 })
243 if (!far.length) return null
244 const to = far[rng.int(far.length)]!
245 const action = autoMoveAction(s, to, safe)
e3d8dc49 2026-10-27 Pavel Safronov246 if (!action || isDeadly(s, action)) return null
36c5d6a2 2026-10-24 Pavel Safronov247 walking.to = to
248 return { action, gesture: { kind: 'walk', tile: to } }
249 }
e3d8dc49 2026-10-27 Pavel Safronov250 // Not safe yet: wait for the way to clear (the destination stays). Go never takes a deadly move.
36c5d6a2 2026-10-24 Pavel Safronov251 const action = autoMoveAction(s, walking.to, safe)
e3d8dc49 2026-10-27 Pavel Safronov252 return action && !isDeadly(s, action) ? { action, gesture: { kind: 'go' } } : { action: { id: 'wait' }, gesture: { kind: 'button', button: 'wait' } }
36c5d6a2 2026-10-24 Pavel Safronov253 },
254 shows: (t, seen) => !!seen.walk && at(t.next.player, seen.walk),
255 },
fa3e81a7 2026-10-24 Pavel Safronov256 kill: {
257 caption: 'Bombs count down with your moves. Their flame kills enemies.',
258 enemies: ['blob', 'seeker'],
259 shows: (t) => t.next.enemies.length === 0,
260 },
261 arrows: {
262 caption: 'The red arrows are each enemy’s next 3 moves. Watch them, and bomb where an enemy is going.',
263 enemies: ['blob', 'drifter'],
264 // An enemy killed on the tile it stepped onto this turn: the bomb was waiting where its arrows led.
265 shows: (t) => kills(t).some((k) => t.events.some((e) => e.type === 'enemyMoved' && e.id === k.id && at(e.to, k.pos)) && explosions(t).length > 0),
266 },
267 escape: {
268 caption: 'Step out of the blast just in time.',
269 enemies: ['seeker'],
270 // Stood in a blast's reach, stepped out of it on the very turn it went off.
271 shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.prev.player))),
272 },
273 chain: {
274 caption: 'A blast sets off every bomb it reaches.',
275 enemies: ['blob', 'blob'],
276 prepare: (s) => (s.stats.capacity = 2),
277 shows: (t) => explosions(t).length >= 2 && kills(t).length > 0,
278 },
279 lingering: {
280 caption: 'Flame keeps burning for one more turn: enemies that walk in die.',
281 enemies: ['blob', 'drifter'],
282 shows: (t) => kills(t).some((k) => t.prev.burning.includes(idx(t.prev, k.pos))),
283 },
284 enemies: {
565a6873 2026-10-25 Pavel Safronov285 caption: 'Each kind of enemy moves its own way: Blobs wander, Seekers chase you, Drifters go straight. More kinds come later.',
fa3e81a7 2026-10-24 Pavel Safronov286 enemies: ['blob', 'seeker', 'drifter'],
287 shows: (t) => kills(t).length > 0,
288 },
289 coins: {
290 caption: 'Enemies sometimes leave coins or a charge. Walk over them to take them.',
291 enemies: ['seeker', 'seeker'],
292 shows: (t) => t.events.some((e) => e.type === 'pickedUp'),
293 },
294 gem: {
295 caption: 'Every level hides a gem under a crate. Take it to choose a power-up.',
296 enemies: ['blob'],
297 prepare: (s, rng) => hideGem(s, rng),
298 shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'),
299 },
300 bombPlus: {
301 caption: 'Bomb +1: one more bomb on the board at a time.',
302 enemies: ['blob', 'seeker'],
303 prepare: (s) => (s.stats.capacity = 3),
304 shows: (t) => t.prev.bombs.length >= 2 && kills(t).length > 0,
305 },
306 firePlus: {
307 caption: 'Fire +1: your blasts reach further.',
308 enemies: ['blob', 'drifter'],
309 prepare: (s) => (s.stats.fire = 3),
310 shows: (t) => kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 2)),
311 },
312 fusePlus: {
313 caption: 'Fuse +1: your bombs start higher, so you have longer to get away.',
314 enemies: ['seeker'],
36c5d6a2 2026-10-24 Pavel Safronov315 prepare: (s) => (s.stats.fuse = START_STATS.fuse + 1),
fa3e81a7 2026-10-24 Pavel Safronov316 shows: (t) => kills(t).length > 0,
317 },
318 run: {
36c5d6a2 2026-10-24 Pavel Safronov319 caption: 'Run: press Move, then tap two tiles away — out of trouble fast.',
92cc4ee3 2026-11-02 Pavel Safronov320 padCaption: 'Run: put the cursor two tiles away and press {move} — out of trouble fast.',
321 keyCaption: 'Run: {M} and {arrows} reach a tile two away, or put the {shift} cursor there and press {M} — out of trouble fast.',
fa3e81a7 2026-10-24 Pavel Safronov322 enemies: ['seeker', 'blob'],
323 prepare: (s) => (s.powers.run = true),
324 shows: (t) => t.action.id === 'run' && escaped(t),
325 },
326 kick: {
36c5d6a2 2026-10-24 Pavel Safronov327 caption: 'Kick: tap a bomb next to you and it slides away until something stops it.',
92cc4ee3 2026-11-02 Pavel Safronov328 padCaption: 'Kick: put the cursor on a bomb next to you and press {move}: it slides away until something stops it.',
329 keyCaption: 'Kick: press {arrows} into a bomb next to you, or put the {shift} cursor on it and press {M}: it slides away until something stops it.',
fa3e81a7 2026-10-24 Pavel Safronov330 enemies: ['blob', 'drifter'],
331 prepare: (s) => (s.powers.kick = true),
332 shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0,
333 },
334 throw: {
36c5d6a2 2026-10-24 Pavel Safronov335 caption: 'Throw: press Bomb, then tap two tiles away — over a crate, a bomb or an enemy.',
92cc4ee3 2026-11-02 Pavel Safronov336 padCaption: 'Throw: put the cursor two tiles away and press {bomb} — over a crate, a bomb or an enemy.',
337 keyCaption: 'Throw: {B} and {arrows} reach a tile two away, or put the {shift} cursor there and press {B} — over a crate, a bomb or an enemy.',
fa3e81a7 2026-10-24 Pavel Safronov338 enemies: ['blob', 'seeker'],
339 prepare: (s) => (s.powers.throw = true),
340 shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0,
341 },
342 teleport: {
fcf44f32 2026-10-25 Pavel Safronov343 caption: 'Teleport: press the charge button, then tap any empty tile up to 3 steps away, over anything.',
92cc4ee3 2026-11-02 Pavel Safronov344 padCaption: 'Teleport: put the cursor on any empty tile up to 3 steps away and press {charge}, over anything.',
345 keyCaption: 'Teleport: put the {shift} cursor on any empty tile up to 3 steps away and press {V}, over anything.',
fa3e81a7 2026-10-24 Pavel Safronov346 enemies: ['seeker', 'blob'],
fcf44f32 2026-10-25 Pavel Safronov347 prepare: (s) => (s.charge = { kind: 'teleport', left: 3 }),
fa3e81a7 2026-10-24 Pavel Safronov348 shows: (t) => t.action.id === 'teleport' && escaped(t),
349 },
350 diagonal: {
36c5d6a2 2026-10-24 Pavel Safronov351 caption: 'Diagonal: tap a corner tile to step diagonally, even between two blocked tiles.',
92cc4ee3 2026-11-02 Pavel Safronov352 padCaption: 'Diagonal: put the cursor on a corner tile and press {move} to step diagonally, even between two blocked tiles.',
353 keyCaption: 'Diagonal: put the {shift} cursor on a corner tile and press {M} to step diagonally, even between two blocked tiles.',
fa3e81a7 2026-10-24 Pavel Safronov354 enemies: ['seeker', 'blob'],
355 prepare: (s) => (s.powers.diagonal = true),
356 shows: (t) => t.action.id === 'diagonal' && escaped(t),
357 },
358 detonate: {
36c5d6a2 2026-10-24 Pavel Safronov359 caption: 'Detonator: tap your bomb to set it off right when an enemy walks in.',
92cc4ee3 2026-11-02 Pavel Safronov360 padCaption: 'Detonator: put the cursor on your bomb and press {bomb} to set it off right when an enemy walks in.',
361 keyCaption: 'Detonator: put the {shift} cursor on your bomb and press {B} to set it off right when an enemy walks in.',
fa3e81a7 2026-10-24 Pavel Safronov362 enemies: ['blob', 'seeker'],
363 // A long fuse, so the Detonator is what sets the bomb off.
364 prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)),
365 shows: (t) => t.events.some((e) => e.type === 'detonated') && kills(t).length > 0,
366 },
7f242d36 2026-10-25 Pavel Safronov367 // Added 2026-10-24 (user request), with their power-ups and charge.
368 rpg: {
369 caption: 'RPG: press Bomb, then tap where it lands — it flies as far as it can.',
92cc4ee3 2026-11-02 Pavel Safronov370 padCaption: 'RPG: put the cursor where it lands and press {bomb} — it flies as far as it can.',
371 keyCaption: 'RPG: {B} and {arrows} aim it, or put the {shift} cursor where it lands and press {B} — it flies as far as it can.',
7f242d36 2026-10-25 Pavel Safronov372 enemies: ['blob', 'drifter'],
373 prepare: (s) => (s.powers.rpg = true),
374 shows: (t, seen) => seen.used.has('rpg') && kills(t).length > 0,
375 },
376 proximity: {
d3e4ed26 2026-10-25 Pavel Safronov377 caption: 'Proximity alert: your bombs go off as soon as an enemy is right next to one.',
7f242d36 2026-10-25 Pavel Safronov378 enemies: ['blob', 'seeker'],
379 prepare: (s) => (s.powers.proximity = true),
380 shows: (t) => t.events.some((e) => e.type === 'proximityTriggered') && kills(t).length > 0,
381 },
382 slide: {
383 caption: 'Slide: tap where you stop — you slide until a wall, crate or bomb.',
92cc4ee3 2026-11-02 Pavel Safronov384 padCaption: 'Slide: put the cursor where you stop and press {move} — you slide until a wall, crate or bomb.',
385 keyCaption: 'Slide: {M} and {arrows} aim it, or put the {shift} cursor where you stop and press {M} — you slide until a wall, crate or bomb.',
7f242d36 2026-10-25 Pavel Safronov386 enemies: ['blob'],
387 prepare: (s) => (s.powers.slide = true),
388 shows: (t) => t.action.id === 'slide' && steps(t.prev.player, t.next.player) >= 3,
389 },
390 icbm: {
d3e4ed26 2026-10-25 Pavel Safronov391 caption: 'ICBM: press the charge button, then tap where it lands — it goes off at once.',
92cc4ee3 2026-11-02 Pavel Safronov392 padCaption: 'ICBM: put the cursor where it lands and press {charge} — it goes off at once.',
393 keyCaption: 'ICBM: put the {shift} cursor where it lands and press {V} — it goes off at once.',
7f242d36 2026-10-25 Pavel Safronov394 enemies: ['blob', 'drifter'],
395 prepare: dropping('icbm', 1),
396 shows: (t) => t.action.id === 'icbm' && kills(t).length > 0,
397 },
fa3e81a7 2026-10-24 Pavel Safronov398 wallPass: {
ebcd0a8f 2026-11-02 Pavel Safronov399 caption: 'Crate Pass: walk through crates.',
fa3e81a7 2026-10-24 Pavel Safronov400 enemies: ['blob'],
401 prepare: (s) => (s.powers.wallPass = true),
402 shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.cells[idx(t.prev, e.to)] === 'block'),
403 },
404 bombPass: {
405 caption: 'Bomb Pass: walk over bombs.',
406 enemies: ['blob'],
407 prepare: (s) => (s.powers.bombPass = true),
408 shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.bombs.some((b) => at(b.pos, e.to))),
409 },
410 flamePass: {
5f066960 2026-10-26 Pavel Safronov411 caption: 'Flame Pass: while you carry it, the next 3 fire hits can’t hurt you.',
fa3e81a7 2026-10-24 Pavel Safronov412 enemies: ['blob'],
5f066960 2026-10-26 Pavel Safronov413 prepare: (s) => (s.charge = { kind: 'flamePass', left: 3 }),
fa3e81a7 2026-10-24 Pavel Safronov414 shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))),
415 },
416 star: {
417 caption: 'Star: nothing can hurt you for 8 turns.',
418 enemies: ['seeker', 'blob'],
419 prepare: (s) => (s.star = 8),
420 shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)),
421 },
422 pierce: {
d3e4ed26 2026-10-25 Pavel Safronov423 caption: 'Pierce: the charge button puts down bombs that burn through crates.',
fa3e81a7 2026-10-24 Pavel Safronov424 enemies: ['blob'],
36c5d6a2 2026-10-24 Pavel Safronov425 // Fire 2, and a row of two crates to burn through, with the charge on the way there.
426 prepare: (s, rng) => ((s.stats.fire = 2), crateRow(s, rng)),
427 // A crate burnt behind another one: without Pierce the first would have stopped the flame.
428 shows: (t, seen) => {
429 const burnt = t.events.flatMap((e) => (e.type === 'blockDestroyed' ? [e.pos] : []))
430 return seen.picked.has('pierce') && explosions(t).some((e) =>
431 burnt.some((far) => steps(far, e.pos) === 2 && burnt.some((near) => steps(near, e.pos) === 1 && steps(near, far) === 1)))
432 },
fa3e81a7 2026-10-24 Pavel Safronov433 },
434 power: {
d3e4ed26 2026-10-25 Pavel Safronov435 caption: 'Power: the charge button puts down bombs whose flame reaches the walls.',
fa3e81a7 2026-10-24 Pavel Safronov436 enemies: ['blob', 'drifter'],
36c5d6a2 2026-10-24 Pavel Safronov437 prepare: dropping('power', 1),
fa3e81a7 2026-10-24 Pavel Safronov438 shows: (t, seen) => seen.picked.has('power') && kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 3)),
439 },
440 nuke: {
d3e4ed26 2026-10-25 Pavel Safronov441 caption: 'Nuke: the charge button puts down bombs that burn every tile 2 steps around, round corners too.',
fa3e81a7 2026-10-24 Pavel Safronov442 enemies: ['blob', 'seeker'],
36c5d6a2 2026-10-24 Pavel Safronov443 prepare: dropping('nuke', 1),
fa3e81a7 2026-10-24 Pavel Safronov444 // Killed off the bomb's row and column: only a Nuke reaches there.
445 shows: (t, seen) => seen.picked.has('nuke') && kills(t).some((k) => explosions(t).some((e) => e.pos.x !== k.pos.x && e.pos.y !== k.pos.y)),
446 },
5f066960 2026-10-26 Pavel Safronov447 acid: {
448 caption: 'Acid: the charge button puts down a bomb whose flame cracks pillars. Blast a cracked one and the ceiling round it falls.',
449 enemies: ['blob', 'drifter'],
450 prepare: (s, rng) => ((s.stats.fire = 2), dropping('acid', 1)(s, rng)),
451 shows: (t, seen) => seen.picked.has('acid') && t.events.some((e) => e.type === 'pillarCracked'),
452 },
b8849d36 2026-11-01 Pavel Safronov453 rubble: {
454 caption: `Rubble: the next blast breaks a cracked pillar, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down for good.`,
455 enemies: ['blob'],
456 maxSteps: 12,
457 tail: 2,
458 prepare: crackedPillar,
459 shows: (t) => t.events.some((e) => e.type === 'ceilingFell'),
460 },
fa3e81a7 2026-10-24 Pavel Safronov461 shield: {
5f066960 2026-10-26 Pavel Safronov462 caption: 'Shield: while you carry it, it absorbs the next hit.',
fa3e81a7 2026-10-24 Pavel Safronov463 enemies: ['seeker', 'seeker'],
464 prepare: dropping('shield'),
465 shows: (t) => t.events.some((e) => e.type === 'shieldBroke'),
466 },
467 freeze: {
d3e4ed26 2026-10-25 Pavel Safronov468 caption: 'Freeze: press the charge button and every enemy stops in its tracks. Then bomb them.',
92cc4ee3 2026-11-02 Pavel Safronov469 padCaption: 'Freeze: press {charge} and every enemy stops in its tracks. Then bomb them.',
470 keyCaption: 'Freeze: press {V} twice (the second confirms) and every enemy stops in its tracks. Then bomb them.',
fa3e81a7 2026-10-24 Pavel Safronov471 enemies: ['seeker', 'blob', 'blob'],
472 prepare: dropping('freeze'),
473 shows: (t, seen) => seen.picked.has('freeze') && kills(t).length > 0 && t.prev.frozen > 0,
474 },
475 decoy: {
d3e4ed26 2026-10-25 Pavel Safronov476 caption: 'Decoy: press the charge button and put down a copy of you that Seekers chase instead.',
92cc4ee3 2026-11-02 Pavel Safronov477 padCaption: 'Decoy: press {charge} and put down a copy of you that Seekers chase instead.',
478 keyCaption: 'Decoy: {V} and {arrows} (or the {shift} cursor, then {V}) put down a copy of you that Seekers chase instead.',
fa3e81a7 2026-10-24 Pavel Safronov479 enemies: ['seeker', 'seeker'],
480 prepare: dropping('decoy'),
9ed991c9 2026-11-03 Pavel Safronov481 steer: fetchAndUse('decoy'),
fa3e81a7 2026-10-24 Pavel Safronov482 shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy,
483 },
0bfdc8e6 2026-10-25 Pavel Safronov484 // One charge at a time (user question, 2026-10-25: "is there already a demo for the charge
485 // swap?"): carrying a Pierce, the player picks up a Nuke, and the finger takes it.
486 chargeSwap: {
487 caption: 'You carry one charge. Picking up another asks: keep yours, or take the new one. The other is sold for coins.',
488 enemies: ['blob'],
489 prepare: (s, rng) => ((s.charge = { kind: 'pierce', left: 3 }), dropNear(s, rng, 'nuke', 2)),
490 choice: true,
491 steer: (s) => (s.chargeOffer ? { action: { id: 'swapCharge' }, gesture: { kind: 'choice', swap: true } } : null),
492 shows: (t) => t.action.id === 'swapCharge',
493 },
d5e3ad4d 2026-10-24 Pavel Safronov494 // The exit (user request, 2026-10-23: "demos end naturally when the player walks into an open door").
495 exit: {
496 caption: 'A crate hides the exit. Once every enemy is gone it opens: walk in to finish the level.',
497 enemies: ['blob'],
498 prepare: (s, rng) => placeExit(s, rng, true),
499 // Once the exit is open, mostly head for it.
500 steer: (s, rng) => {
501 if (!exitOpen(s) || rng.next() < 0.2) return null
502 const next = playerPath(s, s.exit)?.[0]
36c5d6a2 2026-10-24 Pavel Safronov503 return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? playOf(SCENES.exit, s, { id: 'move', target: next }) : null
d5e3ad4d 2026-10-24 Pavel Safronov504 },
505 shows: (t) => t.events.some((e) => e.type === 'levelCleared'),
506 },
507 doorBlast: {
508 caption: 'Don’t blast the exit once it’s uncovered: three more enemies come out.',
509 enemies: ['blob'],
510 prepare: (s, rng) => placeExit(s, rng, false),
511 shows: (t) => t.events.some((e) => e.type === 'enemiesSpawned'),
512 },
32dd53cd 2026-10-25 Pavel Safronov513 // Only for the dialogs' clips (PLAN.md §13c, step 4).
514 lastEnemy: {
515 caption: 'The last enemy gone: the exit opens.',
516 enemies: ['blob'],
517 prepare: (s, rng) => placeExit(s, rng, false),
518 clipOnly: true,
519 shows: (t) => t.prev.enemies.length > 0 && t.next.enemies.length === 0 && exitOpen(t.next),
520 },
521 crate: {
522 caption: 'A blast breaks a crate, and what it hid is uncovered.',
523 enemies: ['blob'],
524 prepare: (s, rng) => hideGem(s, rng),
525 clipOnly: true,
526 shows: (t) => t.events.some((e) => e.type === 'blockDestroyed' && t.next.gems.some((g) => at(g.pos, e.pos))),
527 },
ac534e57 2026-10-25 Pavel Safronov528 // The run's modes, for their tag's dialog.
529 rainbowGem: {
530 caption: 'Developer mode: a rainbow gem beside you, offering every power-up and charge.',
531 enemies: ['blob'],
532 prepare: (s, rng) => {
533 s.options = { ...s.options, dev: true }
534 const near = tilesAt(s, 1)
535 if (near.length) s.gems.push({ pos: near[rng.int(near.length)]!, color: 'rainbow', extra: true })
536 },
537 clipOnly: true,
538 shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'),
539 },
540 immortal: {
541 caption: 'Immortal: nothing can hurt you.',
542 enemies: ['seeker', 'blob'],
543 prepare: (s) => (s.options = { ...s.options, immortal: true }),
544 clipOnly: true,
545 shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)),
546 },
32dd53cd 2026-10-25 Pavel Safronov547 pillar: {
548 caption: 'Flame stops at a pillar.',
549 enemies: ['blob'],
550 // Fire 2, so the flame would reach past a pillar if it could.
551 prepare: (s) => (s.stats.fire = 2),
552 clipOnly: true,
553 shows: (t) => explosions(t).some((e) => DIR_LIST.some((d) => t.prev.cells[idx(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y })] === 'wall' && !isEdge(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y }))),
554 },
6c3fcc31 2026-10-26 Pavel Safronov555 // The board's objects (PLAN.md §19.3; 2026-10-25), each in a room with a few of them by the player.
556 bush: {
557 caption: 'Bushes: step into one to hide. Enemies chasing you head for where they last saw you.',
558 enemies: ['hunter', 'seeker'],
559 prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 3),
560 shows: (t) =>
561 t.next.status === 'playing' && t.next.bushes.includes(idx(t.next, t.next.player)) && !t.prev.bushes.includes(idx(t.prev, t.prev.player)),
562 },
563 bushFire: {
564 caption: 'Bushes burn, and the fire creeps on to the next bush a turn later.',
565 enemies: ['blob'],
566 prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 4),
567 shows: (t) => t.next.status === 'playing' && t.prev.smoldering.length > 0 && t.events.some((e) => e.type === 'fireSpread'),
568 },
569 metal: {
570 caption: 'Metal crates: nothing breaks them, and flame stops before them.',
571 enemies: ['blob'],
572 prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'metal', 2, 2)),
573 // A flame that stopped short of its reach at a metal crate.
574 shows: (t) =>
575 explosions(t).some((e) =>
576 DIR_LIST.some((d) => {
577 let p = e.pos
578 while (e.tiles.some((q) => at(q, { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }))) p = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }
579 const next = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }
580 return inBounds(t.prev, next) && t.prev.cells[idx(t.prev, next)] === 'metal'
581 }),
582 ),
583 },
584 oil: {
585 caption: 'Oil: safe to walk on, but flame on any of it burns the whole spill at once.',
586 enemies: ['blob', 'blob'],
587 prepare: (s, rng) => placeObjects(s, rng, 'oil', 2, 4),
588 shows: (t) => t.next.status === 'playing' && t.events.some((e) => e.type === 'fireSpread' && e.tiles.length >= 2),
589 },
1f0038ac 2026-11-01 Pavel Safronov590 oilSlide: {
591 caption: 'Oil is slippery: step onto a spill and you slide to its far end. Most enemies slip on it too.',
592 enemies: ['blob', 'drifter'],
593 prepare: (s, rng) => placeOilLine(s, rng, 4),
594 // From the third turn on, step onto the spill along its length when the way is there.
595 steer: (s) => (s.turn >= 2 ? playOf(SCENES.oilSlide, s, slipMove(s)) : null),
596 shows: (t) => t.events.some((e) => e.type === 'oilSlid'),
597 tail: 1,
598 },
6c3fcc31 2026-10-26 Pavel Safronov599 spikes: {
600 caption: 'Spikes: up one turn, down the next. Cross while they are down; enemies never step on them.',
601 enemies: ['blob'],
602 prepare: (s, rng) => placeObjects(s, rng, 'spikes', 1, 3),
603 shows: (t) => t.next.status === 'playing' && moved(t) && t.prev.spikes.some((sp) => sp.cell === idx(t.next, t.next.player)),
604 },
605 barrel: {
606 caption: 'Oil barrel: flame bursts it, spilling oil, and the next turn it explodes.',
607 enemies: ['blob'],
608 // Fire 2 and two barrels, so random play bursts one often enough.
609 prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'barrel', 2, 2)),
610 shows: (t) => t.next.status === 'playing' && explosions(t).some((e) => t.prev.bombs.some((b) => b.barrel && at(b.pos, e.pos))),
611 },
d5e3ad4d 2026-10-24 Pavel Safronov612 // The ways to die.
613 dieIntoBlast: {
614 caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.',
615 enemies: ['blob'],
616 dies: true,
617 shows: (t) => burnt(t) && moved(t) && !inBlast(t, t.prev.player),
618 },
619 dieNotFar: {
620 caption: 'Ouch! Not far enough: a blast reaches as far as its fire. Get further, or round a corner.',
621 enemies: ['blob'],
622 // A long blast, easy to run along and stay in.
623 prepare: (s) => (s.stats.fire = 3),
624 dies: true,
625 shows: (t) => burnt(t) && moved(t) && inBlast(t, t.prev.player),
626 },
627 dieBlockedIn: {
628 caption: 'Ouch! Never put a bomb in your only way out.',
629 enemies: ['blob'],
630 dies: true,
631 // Nowhere to step that isn't about to burn.
632 shows: (t) => {
633 if (!burnt(t)) return false
634 const danger = predictDanger(t.prev).now
635 return ACTIONS.move.validTargets(t.prev).every((p) => danger.has(idx(t.prev, p)))
636 },
637 },
638 dieChain: {
639 caption: 'Ouch! One bomb set off another, and its blast reached you.',
640 enemies: ['blob'],
641 prepare: (s) => (s.stats.capacity = 2),
642 dies: true,
643 // Caught only by a bomb that wasn't due yet: the chain set it off.
644 shows: (t) => {
645 if (!burnt(t)) return false
646 const hits = explosions(t).filter((e) => e.tiles.some((p) => at(p, t.next.player)))
647 return hits.length > 0 && hits.every((e) => (t.prev.bombs.find((b) => at(b.pos, e.pos))?.timer ?? 0) > 1)
648 },
649 },
650 dieEnemy: {
651 caption: 'Ouch! Touching an enemy costs a life. Watch where its arrows go.',
652 enemies: ['seeker', 'blob'],
653 dies: true,
654 shows: (t) => died(t) && !burnt(t) && t.action.id !== 'run',
655 },
656 dieDetonator: {
657 caption: 'Oops! The Detonator sets a bomb off at once, even with you right next to it.',
658 enemies: ['blob'],
659 prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)),
660 dies: true,
661 shows: (t) => burnt(t) && t.events.some((e) => e.type === 'detonated'),
662 },
663 dieRun: {
664 caption: 'Oops! Ran two tiles, straight into the enemy in between.',
665 enemies: ['seeker', 'seeker', 'blob'],
666 prepare: (s) => (s.powers.run = true),
667 dies: true,
668 shows: (t) => died(t) && !burnt(t) && t.action.id === 'run',
669 },
52d68b59 2026-10-24 Pavel Safronov670}
671
fa3e81a7 2026-10-24 Pavel Safronov672export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[]
36c5d6a2 2026-10-24 Pavel Safronov673/** The scenes about the controls, in the order the UI shows them, first. */
674export const CONTROL_KINDS: readonly SceneKind[] = ['tapMove', 'holdBomb', 'bombButton', 'wait', 'autoMove']
d5e3ad4d 2026-10-24 Pavel Safronov675/** The scenes about a way to die, and the rest, for the UI to take turns between. */
676export const DYING_KINDS = SCENE_KINDS.filter((k) => SCENES[k].dies)
32dd53cd 2026-10-25 Pavel Safronov677export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies && !SCENES[k].clipOnly && !CONTROL_KINDS.includes(k))
fa3e81a7 2026-10-24 Pavel Safronov678
52d68b59 2026-10-24 Pavel Safronov679/** A scene of `kind`, from `seed`; null if no room gave one (rare). */
680export function generateScene(kind: SceneKind, seed: number): Scene | null {
8edb5cb9 2026-11-01 Pavel Safronov681 const fixed = SCENES[kind].fixed
682 if (fixed) return fixed()
52d68b59 2026-10-24 Pavel Safronov683 const rng = new Rng(seed)
684 for (let room = 0; room < ROOMS; room++) {
685 const start = makeRoom(kind, rng)
686 for (let t = 0; t < TRIES; t++) {
36c5d6a2 2026-10-24 Pavel Safronov687 const game = rollout(kind, start, rng)
688 const scene = game && ending(kind, game)
689 if (scene) return scene
52d68b59 2026-10-24 Pavel Safronov690 }
691 }
692 return null
693}
694
b8849d36 2026-11-01 Pavel Safronov695/** Replays a scene: its turns, and whether its moment turn shows it (for tests, and `ending`). */
36c5d6a2 2026-10-24 Pavel Safronov696export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } {
fa3e81a7 2026-10-24 Pavel Safronov697 let s = scene.start
36c5d6a2 2026-10-24 Pavel Safronov698 const seen = newSeen()
699 const turns: Turn[] = []
fa3e81a7 2026-10-24 Pavel Safronov700 let last = false
36c5d6a2 2026-10-24 Pavel Safronov701 for (const [i, action] of scene.actions.entries()) {
fa3e81a7 2026-10-24 Pavel Safronov702 const r = step(s, action)
36c5d6a2 2026-10-24 Pavel Safronov703 const turn = { prev: s, action, gesture: scene.gestures[i]!, events: r.events, next: r.state }
fa3e81a7 2026-10-24 Pavel Safronov704 remember(turn, seen)
36c5d6a2 2026-10-24 Pavel Safronov705 turns.push(turn)
b8849d36 2026-11-01 Pavel Safronov706 if (i === (scene.moment ?? scene.actions.length - 1)) last = SCENES[scene.kind].shows(turn, seen)
fa3e81a7 2026-10-24 Pavel Safronov707 s = r.state
708 }
36c5d6a2 2026-10-24 Pavel Safronov709 return { turns, shows: last }
710}
711
b8849d36 2026-11-01 Pavel Safronov712/** Whether a scene's moment turn shows its moment, replaying it from the start (for tests). */
36c5d6a2 2026-10-24 Pavel Safronov713export function showsAtEnd(scene: Scene): boolean {
714 return replayScene(scene).shows
715}
716
717/**
b8849d36 2026-11-01 Pavel Safronov718 * The scene in a game that shows its moment: the turns up to its moment, 3 to 6 of them (`maxSteps` allows more; as few as do), then `tail` turns of waiting, starting
36c5d6a2 2026-10-24 Pavel Safronov719 * from a calm moment — no bomb or flame on the board, no drop but those the room started with
720 * (a coin or charge left by an enemy killed before the scene would be a mystery), auto-move not
721 * under way — and still showing the moment when played from there. Null if no such stretch.
e3384c6b 2026-10-24 Pavel Safronov722 * The top bar shows during the demo, so the scene starts at turn 0 with no coins, as if fresh;
723 * with no bomb out, nothing depends on the turn number.
36c5d6a2 2026-10-24 Pavel Safronov724 */
725function ending(kind: SceneKind, game: GameTurn[]): Scene | null {
726 const room = game[0]!.prev
b8849d36 2026-11-01 Pavel Safronov727 const def = SCENES[kind]
728 for (let n = MIN_STEPS; n <= Math.min(def.maxSteps ?? MAX_STEPS, game.length); n++) {
36c5d6a2 2026-10-24 Pavel Safronov729 const first = game[game.length - n]!
730 const s = first.prev
b8849d36 2026-11-01 Pavel Safronov731 if (s.bombs.length || s.burning.length || s.collapsing.length || first.gesture.kind === 'go' || first.walking) continue
36c5d6a2 2026-10-24 Pavel Safronov732 if (s.drops.some((d) => !room.drops.some((r) => at(r.pos, d.pos) && r.kind === d.kind))) continue
b8849d36 2026-11-01 Pavel Safronov733 const turns: Play[] = game.slice(-n)
6c3fcc31 2026-10-26 Pavel Safronov734 // Spikes keep their rhythm across the reset to turn 0.
735 const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 }))
b8849d36 2026-11-01 Pavel Safronov736 const scene: Scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture), moment: n - 1 }
737 if (!showsAtEnd(scene)) continue
738 // The aftermath: a few turns of waiting, while that is safe, to see what the moment did.
739 let after = game[game.length - 1]!.next
740 for (let i = 0; i < (def.tail ?? 0) && after.status === 'playing' && !after.offer; i++) {
741 const play = playOf(def, after, { id: 'wait' })
742 if (!play || isDeadly(after, play.action)) break
743 scene.actions.push(play.action)
744 scene.gestures.push(play.gesture)
745 after = step(after, play.action).state
746 }
747 return scene
36c5d6a2 2026-10-24 Pavel Safronov748 }
749 return null
fa3e81a7 2026-10-24 Pavel Safronov750}
751
52d68b59 2026-10-24 Pavel Safronov752/** A small room: walls round it, pillars on every other tile, a few crates; the player and enemies apart. */
753function makeRoom(kind: SceneKind, rng: Rng): GameState {
754 const cells: Cell[] = []
755 for (let y = 0; y < HEIGHT; y++) {
756 for (let x = 0; x < WIDTH; x++) {
757 const edge = x === 0 || y === 0 || x === WIDTH - 1 || y === HEIGHT - 1
758 cells.push(edge || (x % 2 === 0 && y % 2 === 0) ? 'wall' : 'floor')
759 }
760 }
761 const floor = () => cells.flatMap((c, i) => (c === 'floor' ? [{ x: i % WIDTH, y: Math.floor(i / WIDTH) }] : []))
762 const tiles = floor()
763 const player = tiles[rng.int(tiles.length)]!
764 // Crates on about one free tile in six, never next to the player (room to place a bomb and go).
fa3e81a7 2026-10-24 Pavel Safronov765 for (const p of tiles) if (steps(p, player) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block'
52d68b59 2026-10-24 Pavel Safronov766 const s: GameState = {
00ad7d0a 2026-10-24 Pavel Safronov767 seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS }, level: 1, turn: 0, width: WIDTH, height: HEIGHT,
52d68b59 2026-10-24 Pavel Safronov768 cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing',
e3384c6b 2026-10-24 Pavel Safronov769 enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: DEMO_PAR, coins: 0,
8edb5cb9 2026-11-01 Pavel Safronov770 charge: null, chargeOffer: null, waitingGems: [], frozen: 0, decoy: null, burning: [],
6c3fcc31 2026-10-26 Pavel Safronov771 powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { ...player },
52d68b59 2026-10-24 Pavel Safronov772 aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
773 }
fa3e81a7 2026-10-24 Pavel Safronov774 const open = floor().filter((p) => steps(p, player) >= 3)
775 for (const enemy of SCENES[kind].enemies) {
52d68b59 2026-10-24 Pavel Safronov776 if (!open.length) break
777 const pos = open.splice(rng.int(open.length), 1)[0]!
778 s.enemies.push({ id: s.nextEnemyId++, kind: enemy, pos, dir: 'left', plan: [], spawned: false, sheltered: false })
779 }
fa3e81a7 2026-10-24 Pavel Safronov780 SCENES[kind].prepare?.(s, rng)
e3384c6b 2026-10-24 Pavel Safronov781 if (s.exit.x < 0) placeDoor(s, rng)
52d68b59 2026-10-24 Pavel Safronov782 planIntents(s)
783 return s
784}
785
e3384c6b 2026-10-24 Pavel Safronov786/**
787 * Every room has its exit, as every level does (user request, 2026-10-24: captions talk about it,
788 * and most rooms had none): mostly under a crate, as in play; one room in six with it already
789 * uncovered, still locked while enemies live. Not under the gem's crate, not on a drop, and at
790 * least 3 steps from the player.
791 */
792function placeDoor(s: GameState, rng: Rng): void {
793 const tiles = (cell: Cell) =>
794 s.cells.flatMap((c, i) => {
795 const p = { x: i % s.width, y: Math.floor(i / s.width) }
796 const free = !s.enemies.some((e) => at(e.pos, p)) && !s.drops.some((d) => at(d.pos, p)) && !s.gems.some((g) => at(g.pos, p))
797 return c === cell && free && steps(p, s.player) >= 3 ? [p] : []
798 })
799 const crates = tiles('block')
800 const floor = tiles('floor')
801 const pick = crates.length && (rng.next() >= 1 / 6 || !floor.length) ? crates : floor
802 if (pick.length) s.exit = pick[rng.int(pick.length)]!
803}
804
b8849d36 2026-11-01 Pavel Safronov805/**
806 * Rubble's room: one pillar already cracked (as Acid leaves it), the player a few steps from it with
807 * a blast long enough to reach it from beside it.
808 */
809function crackedPillar(s: GameState, rng: Rng): void {
810 const pillars = s.cells.flatMap((c, i) => {
811 const p = { x: i % s.width, y: Math.floor(i / s.width) }
812 return c === 'wall' && !(p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1) ? [i] : []
813 })
814 const pillar = pillars[rng.int(pillars.length)]!
815 s.cracked.push(pillar)
816 const at2 = { x: pillar % s.width, y: Math.floor(pillar / s.width) }
817 const near = s.cells.flatMap((c, i) => {
818 const p = { x: i % s.width, y: Math.floor(i / s.width) }
819 const d = steps(p, at2)
820 return c === 'floor' && d >= 2 && d <= 3 && !s.enemies.some((e) => at(e.pos, p)) ? [p] : []
821 })
822 if (near.length) s.player = { ...near[rng.int(near.length)]! }
823 s.lastSeen = { ...s.player }
824 s.stats.fire = 2
825}
826
fa3e81a7 2026-10-24 Pavel Safronov827/** Free floor tiles `n` steps from the player, with no enemy. */
828function tilesAt(s: GameState, n: number): Vec[] {
829 return s.cells.flatMap((c, i) => {
52d68b59 2026-10-24 Pavel Safronov830 const p = { x: i % s.width, y: Math.floor(i / s.width) }
fa3e81a7 2026-10-24 Pavel Safronov831 return c === 'floor' && steps(p, s.player) === n && !s.enemies.some((e) => at(e.pos, p)) ? [p] : []
52d68b59 2026-10-24 Pavel Safronov832 })
fa3e81a7 2026-10-24 Pavel Safronov833}
834
36c5d6a2 2026-10-24 Pavel Safronov835/**
836 * Pierce's room: in a straight line from the player, Pierce on the next tile, a free one for the
837 * bomb, then two crates; or, with no room for that, Pierce on any tile next to the player.
838 */
839function crateRow(s: GameState, rng: Rng): void {
840 const dirs = DIR_LIST.map((d) => DIRS[d]).filter((d) =>
841 [1, 2, 3, 4].every((n) => {
842 const p = { x: s.player.x + n * d.x, y: s.player.y + n * d.y }
843 return inBounds(s, p) && s.cells[idx(s, p)] !== 'wall' && !s.enemies.some((e) => at(e.pos, p))
844 }),
845 )
846 if (!dirs.length) return dropNear(s, rng, 'pierce', 1)
847 const d = dirs[rng.int(dirs.length)]!
848 const along = (n: number) => ({ x: s.player.x + n * d.x, y: s.player.y + n * d.y })
849 for (const n of [1, 2]) s.cells[idx(s, along(n))] = 'floor'
850 for (const n of [3, 4]) s.cells[idx(s, along(n))] = 'block'
851 s.drops.push({ pos: along(1), kind: 'pierce' })
fa3e81a7 2026-10-24 Pavel Safronov852}
853
d5e3ad4d 2026-10-24 Pavel Safronov854/** The exit two steps from the player: under a crate, or uncovered (but locked while enemies live). */
855function placeExit(s: GameState, rng: Rng, covered: boolean): void {
856 const near = tilesAt(s, 2)
857 if (!near.length) return
858 s.exit = near[rng.int(near.length)]!
859 if (covered) s.cells[idx(s, s.exit)] = 'block'
860}
861
6c3fcc31 2026-10-26 Pavel Safronov862/**
863 * `count` of a board object round a free floor tile `n` steps from the player: bushes and oil in a
864 * touching clump, spikes, metal crates and barrels on tiles of their own (never next to the player,
865 * for crates and barrels, so there is room to move).
866 */
867function placeObjects(s: GameState, rng: Rng, kind: 'bush' | 'oil' | 'spikes' | 'metal' | 'barrel', n: number, count: number): void {
868 const near = tilesAt(s, n)
869 if (!near.length) return
870 const placed: Vec[] = [near[rng.int(near.length)]!]
871 const free = (p: Vec) =>
872 inBounds(s, p) && s.cells[idx(s, p)] === 'floor' && !at(p, s.player) && !placed.some((q) => at(q, p)) && !s.enemies.some((e) => at(e.pos, p))
873 while (placed.length < count) {
874 const from = kind === 'bush' || kind === 'oil' ? placed : tilesAt(s, n).concat(tilesAt(s, n + 1))
875 const next = from.flatMap((p) => (kind === 'bush' || kind === 'oil' ? DIR_LIST.map((d) => ({ x: p.x + DIRS[d].x, y: p.y + DIRS[d].y })) : [p])).filter(free)
876 if (!next.length) break
877 placed.push(next[rng.int(next.length)]!)
878 }
879 for (const p of placed) {
880 const i = idx(s, p)
881 if (kind === 'bush') s.bushes.push(i)
882 else if (kind === 'oil') s.oil.push(i)
883 else if (kind === 'spikes') s.spikes.push({ cell: i, phase: rng.int(2) })
884 else if (steps(p, s.player) > 1) s.cells[i] = kind
885 }
886}
887
1f0038ac 2026-11-01 Pavel Safronov888/**
889 * An oil spill in a straight line of `len` tiles, 2 steps from the player at its nearer end (the
890 * tiles are made floor); or, with no room, a plain clump. A slide is only seen along a spill.
891 */
892function placeOilLine(s: GameState, rng: Rng, len: number): void {
893 const lines = tilesAt(s, 2).flatMap((from) =>
894 DIR_LIST.map((d) => DIRS[d]).flatMap((d) => {
895 const line = Array.from({ length: len }, (_, n) => ({ x: from.x + n * d.x, y: from.y + n * d.y }))
896 const ok = line.every((p) => inBounds(s, p) && s.cells[idx(s, p)] !== 'wall' && !at(p, s.player) && !s.enemies.some((e) => at(e.pos, p)))
897 return ok ? [line] : []
898 }),
899 )
900 if (!lines.length) return placeObjects(s, rng, 'oil', 2, len)
901 for (const p of lines[rng.int(lines.length)]!) {
902 s.cells[idx(s, p)] = 'floor'
903 s.oil.push(idx(s, p))
904 }
905}
906
907/** A step onto an oil tile that has oil behind it, the way the step goes: it slides on. Null if none. */
908function slipMove(s: GameState): Action | null {
909 for (const d of DIR_LIST) {
910 const first = { x: s.player.x + DIRS[d].x, y: s.player.y + DIRS[d].y }
911 const second = { x: first.x + DIRS[d].x, y: first.y + DIRS[d].y }
912 if (playerCanEnter(s, first) && s.oil.includes(idx(s, first)) && inBounds(s, second) && s.oil.includes(idx(s, second))) return { id: 'move', target: first }
913 }
914 return null
915}
916
36c5d6a2 2026-10-24 Pavel Safronov917/** Put a charge `n` steps from the player. */
918function dropNear(s: GameState, rng: Rng, kind: ChargeId, n = 2): void {
919 const near = tilesAt(s, n)
52d68b59 2026-10-24 Pavel Safronov920 if (near.length) s.drops.push({ pos: near[rng.int(near.length)]!, kind })
921}
922
fa3e81a7 2026-10-24 Pavel Safronov923/** Hide the level's gem under a crate two steps from the player. */
924function hideGem(s: GameState, rng: Rng): void {
925 const near = tilesAt(s, 2)
926 if (!near.length) return
927 const pos = near[rng.int(near.length)]!
928 s.cells[idx(s, pos)] = 'block'
39c26813 2026-10-24 Pavel Safronov929 s.gems.push({ pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! })
fa3e81a7 2026-10-24 Pavel Safronov930}
931
36c5d6a2 2026-10-24 Pavel Safronov932function newSeen(): Seen {
933 return { picked: new Set(), used: new Map(), walk: null }
934}
935
fa3e81a7 2026-10-24 Pavel Safronov936function remember(t: Turn, seen: Seen): void {
36c5d6a2 2026-10-24 Pavel Safronov937 seen.used.set(t.action.id as ActionId, (seen.used.get(t.action.id as ActionId) ?? 0) + 1)
938 if (t.gesture.kind === 'walk') seen.walk = t.gesture.tile
fa3e81a7 2026-10-24 Pavel Safronov939 for (const e of t.events) if (e.type === 'pickedUp' && e.drop.kind !== 'coins') seen.picked.add(e.drop.kind)
940}
941
36c5d6a2 2026-10-24 Pavel Safronov942/** A turn of a random game; `walking`: auto-move had a destination before it. */
943type GameTurn = Turn & { walking: boolean }
944
945/** One random game from `start`; its turns if it shows the scene, otherwise null. */
946function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null {
52d68b59 2026-10-24 Pavel Safronov947 let s = start
36c5d6a2 2026-10-24 Pavel Safronov948 const turns: GameTurn[] = []
949 const seen = newSeen()
950 const walking: Walking = { to: null }
951 const def = SCENES[kind]
fa3e81a7 2026-10-24 Pavel Safronov952 for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) {
33a0256b 2026-10-24 Pavel Safronov953 // A random turn (or the scene's nudge) that doesn't get the player killed and that a sensible
954 // player might take, a few goes at it — unless dying is what the scene is about, and this is
955 // the way it shows. Only turns a gesture can play.
36c5d6a2 2026-10-24 Pavel Safronov956 let turn: GameTurn | null = null
957 const was = walking.to
33a0256b 2026-10-24 Pavel Safronov958 for (let go = 0; go < 6 && !turn; go++) {
36c5d6a2 2026-10-24 Pavel Safronov959 walking.to = was
960 const play = def.steer?.(s, rng, walking) ?? playOf(def, s, randomAction(s, rng))
961 if (!play) continue
962 const r = step(s, play.action)
0bfdc8e6 2026-10-25 Pavel Safronov963 // A second charge picked up asks to sell or swap: only in the scene about that.
964 if (r.state.chargeOffer && !def.choice) continue
36c5d6a2 2026-10-24 Pavel Safronov965 const t = { prev: s, ...play, events: r.events, next: r.state, walking: was !== null }
d5e3ad4d 2026-10-24 Pavel Safronov966 const alive = r.state.status === 'playing' || r.state.status === 'cleared' || r.state.status === 'won'
33a0256b 2026-10-24 Pavel Safronov967 if ((alive && (def.dies || reasonable(kind, t))) || (def.dies && def.shows(t, seen))) turn = t
52d68b59 2026-10-24 Pavel Safronov968 }
fa3e81a7 2026-10-24 Pavel Safronov969 if (!turn) return null
36c5d6a2 2026-10-24 Pavel Safronov970 turns.push(turn)
fa3e81a7 2026-10-24 Pavel Safronov971 remember(turn, seen)
36c5d6a2 2026-10-24 Pavel Safronov972 if (walking.to && at(turn.next.player, walking.to)) walking.to = null
973 if (def.shows(turn, seen) && !(def.dies && turn.next.status === 'playing')) return turns
fa3e81a7 2026-10-24 Pavel Safronov974 s = turn.next
52d68b59 2026-10-24 Pavel Safronov975 }
976 return null
977}
978
33a0256b 2026-10-24 Pavel Safronov979/**
980 * Whether a turn is one a sensible player might take (user request, 2026-10-24: a scene put a
981 * bomb on the exit). Not asked in the scenes about dying, whose point is a mistake.
982 * - No flame on an uncovered exit or gem, which lets enemies out — unless that is the scene.
983 * - A bomb (placed or thrown) only where its blast reaches a crate, an enemy, a tile an enemy is
984 * going to, or another bomb (to set it off): never one that does nothing.
985 */
986function reasonable(kind: SceneKind, t: Turn): boolean {
987 const punishing = kind !== 'doorBlast'
988 if (punishing && t.events.some((e) => e.type === 'enemiesSpawned')) return false
d3e4ed26 2026-10-25 Pavel Safronov989 const placing = t.action.id === 'bomb' || t.action.id === 'throw' || t.action.id === 'rpg' || t.action.id === 'charged'
7f242d36 2026-10-25 Pavel Safronov990 if (!placing || !('target' in t.action) || !t.action.target) return true
33a0256b 2026-10-24 Pavel Safronov991 const target = t.action.target
992 const bomb = t.next.bombs.find((b) => at(b.pos, target))
993 if (!bomb) return true
994 const tiles = blastTiles(t.next, bomb)
995 const s = t.prev
996 const uncovered = (p: Vec) => s.cells[idx(s, p)] === 'floor' && (at(p, s.exit) || s.gems.some((g) => at(g.pos, p)))
997 if (punishing && tiles.some(uncovered)) return false
998 return tiles.some(
b8849d36 2026-11-01 Pavel Safronov999 (p) => s.cells[idx(s, p)] === 'block' || s.cracked.includes(idx(s, p)) || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))),
33a0256b 2026-10-24 Pavel Safronov1000 )
1001}
1002
36c5d6a2 2026-10-24 Pavel Safronov1003/**
1004 * The gesture that plays `action` in a scene, or null if none does (a step onto a bomb with Bomb
7f242d36 2026-10-25 Pavel Safronov1005 * Pass: a tap there kicks it, or means nothing). A step, a diagonal step, a slide, a kick or a detonation is
1006 * a tap on the tile (a slide too); a bomb is a long press, or the Bomb button and a tap in the scene about it;
36c5d6a2 2026-10-24 Pavel Safronov1007 * Wait is its button; Run, Teleport and Throw are their button (Move or Bomb), then the tile.
1008 */
1009function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null {
1010 if (action.id === 'wait') return { kind: 'button', button: 'wait' }
1011 if (!('target' in action) || !action.target) return null
1012 const tile = action.target
e3d8dc49 2026-10-27 Pavel Safronov1013 // A long press never puts down a bomb that costs a life (main.ts `startPress`): its button does.
1014 if (action.id === 'bomb' && !def.bombButton && !isDeadly(s, action)) return { kind: 'hold', tile }
36c5d6a2 2026-10-24 Pavel Safronov1015 if (TAPPED.has(action.id) && tapMeaning(s, tile) === action.id) return { kind: 'tap', tile }
d3e4ed26 2026-10-25 Pavel Safronov1016 const button: ButtonId = membersOf('move').includes(action.id) ? 'move' : membersOf('charged').includes(action.id) ? 'charged' : 'bomb'
36c5d6a2 2026-10-24 Pavel Safronov1017 return resolvePick(s, button, tile) === action.id ? { kind: 'button', button, tile } : null
1018}
1019
7f242d36 2026-10-25 Pavel Safronov1020const TAPPED: ReadonlySet<ActionId> = new Set(['move', 'diagonal', 'kick', 'detonate', 'slide'])
36c5d6a2 2026-10-24 Pavel Safronov1021
1022/** An action and the gesture that plays it in this scene, or null if no gesture does. */
1023function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null {
1024 const gesture = action && gestureFor(def, s, action)
1025 return gesture ? { action: action!, gesture } : null
1026}
1027
fa3e81a7 2026-10-24 Pavel Safronov1028/** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */
7f242d36 2026-10-25 Pavel Safronov1029const WEIGHTS: Partial<Record<ActionId, number>> = {
1030 move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2, rpg: 2, icbm: 3, slide: 3,
5f066960 2026-10-26 Pavel Safronov1031 charged: 3, freeze: 2, decoy: 2,
7f242d36 2026-10-25 Pavel Safronov1032}
52d68b59 2026-10-24 Pavel Safronov1033
1034function randomAction(s: GameState, rng: Rng): Action {
1035 const choices = (Object.keys(WEIGHTS) as ActionId[]).filter((id) => isAvailable(s, id))
1036 let r = rng.next() * choices.reduce((sum, id) => sum + WEIGHTS[id]!, 0)
1037 const id = choices.find((c) => (r -= WEIGHTS[c]!) < 0) ?? 'wait'
1038 if (!ACTIONS[id].needsTarget) return { id }
36c5d6a2 2026-10-24 Pavel Safronov1039 return randomTarget(s, rng, id)!
1040}
1041
1042/** `id` aimed at one of its tiles, at random; null if it has none. */
1043function randomTarget(s: GameState, rng: Rng, id: ActionId): Action | null {
52d68b59 2026-10-24 Pavel Safronov1044 const targets = ACTIONS[id].validTargets(s)
36c5d6a2 2026-10-24 Pavel Safronov1045 return targets.length ? { id, target: targets[rng.int(targets.length)]! } : null
52d68b59 2026-10-24 Pavel Safronov1046}