explodorama / files / src / sim / turn.ts
turn.ts
bb3823d2 2026-10-31 Pavel Safronov | 1 | import { ACTIONS, boxedIn, fireableHeld, isAvailable, isValidTarget, slidePassed } from './actions' |
5f066960 2026-10-26 Pavel Safronov | 2 | import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './charges' |
c37a2b0b 2026-10-20 Pavel Safronov | 3 | import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' |
00ad7d0a 2026-10-24 Pavel Safronov | 4 | import { enemyKinds, LAST_LEVEL } from './levelgen' |
5f066960 2026-10-26 Pavel Safronov | 5 | import { collapse, detonate, proximityAlert, spikesUp, tickBombs, walledIn } from './explosions' |
1f0038ac 2026-11-01 Pavel Safronov | 6 | import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, nearestFloor, oilSlide, playerCanPass, CLEAR_BONUS, PAR_BONUS } from './state' |
beb9f2b7 2026-10-21 Pavel Safronov | 7 | import { stream } from './rng' |
e78b7781 2026-10-25 Pavel Safronov | 8 | import { buy, openShop, reroll, shopAfter } from './shop' |
e6bb7c42 2026-10-25 Pavel Safronov | 9 | import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Stats, type Vec } from './types' |
5551eb37 2026-10-20 Pavel Safronov | 10 | |
| 11 | export interface StepResult { | |
| 12 | state: GameState | |
| 13 | events: GameEvent[] | |
| 14 | } | |
| 15 | ||
27f24fed 2026-10-20 Pavel Safronov | 16 | /** How many enemies come out of an exit or power-up caught in a flame. */ |
c37a2b0b 2026-10-20 Pavel Safronov | 17 | export const EXIT_SPAWN = 3 |
27f24fed 2026-10-20 Pavel Safronov | 18 | /** Turns of invincibility from a Star. */ |
| 19 | export const STAR_TURNS = 8 | |
| 20 | /** Coins in a Coin bag. */ | |
| 21 | export const COIN_BAG = 10 | |
c37a2b0b 2026-10-20 Pavel Safronov | 22 | |
5551eb37 2026-10-20 Pavel Safronov | 23 | /** |
| 24 | * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right | |
| 25 | * now; the UI only offers allowed ones, so a throw is a bug. | |
| 26 | */ | |
| 27 | export function step(prev: GameState, action: Action): StepResult { | |
| 28 | const s = cloneState(prev) | |
| 29 | const events: GameEvent[] = [] | |
| 30 | ||
52d68b59 2026-10-24 Pavel Safronov | 31 | // The gem's choice: no turn passes, nothing else moves. |
| 32 | if (action.id === 'choose') { | |
| 33 | if (!s.offer?.includes(action.kind)) throw new Error(`${action.kind} is not on offer`) | |
| 34 | s.offer = null | |
| 35 | events.push({ type: 'powerUpChosen', kind: action.kind }) | |
d3e4ed26 2026-10-25 Pavel Safronov | 36 | // A charge chosen while carrying another kind asks, as a pickup does. |
| 37 | if (isCharge(action.kind)) takeCharge(s, action.kind, events) | |
e3f29330 2026-10-24 Pavel Safronov | 38 | else applyPowerUp(s, action.kind) |
8edb5cb9 2026-11-01 Pavel Safronov | 39 | nextOffer(s, events) |
52d68b59 2026-10-24 Pavel Safronov | 40 | return { state: s, events } |
| 41 | } | |
d3e4ed26 2026-10-25 Pavel Safronov | 42 | // A charge picked up while carrying another: sell it, or swap it in. No turn passes. |
bb3823d2 2026-10-31 Pavel Safronov | 43 | if (action.id === 'sellCharge' || action.id === 'swapCharge' || action.id === 'fireCharge') { |
d3e4ed26 2026-10-25 Pavel Safronov | 44 | if (!s.chargeOffer) throw new Error(`${action.id} with no charge on offer`) |
| 45 | if (action.id === 'sellCharge') sellOffered(s, events) | |
bb3823d2 2026-10-31 Pavel Safronov | 46 | else if (action.id === 'swapCharge') swapOffered(s, events) |
| 47 | else { | |
| 48 | // Use the charge held, then swap the new one in (what is left of the old one is sold). | |
| 49 | const use = fireableHeld(s) | |
| 50 | if (!use) throw new Error('fireCharge: the charge held can\'t be used on the spot') | |
| 51 | ACTIONS[use].apply(s, s.player, events) | |
| 52 | swapOffered(s, events) | |
| 53 | } | |
8edb5cb9 2026-11-01 Pavel Safronov | 54 | nextOffer(s, events) |
d3e4ed26 2026-10-25 Pavel Safronov | 55 | return { state: s, events } |
| 56 | } | |
52d68b59 2026-10-24 Pavel Safronov | 57 | // An offer must be answered first, even with the level cleared; a death drops it. |
| 58 | if (s.offer && s.status !== 'dead') throw new Error(`${action.id} while a power-up is on offer`) | |
d3e4ed26 2026-10-25 Pavel Safronov | 59 | if (s.chargeOffer && s.status !== 'dead') throw new Error(`${action.id} while a charge is on offer`) |
52d68b59 2026-10-24 Pavel Safronov | 60 | |
e78b7781 2026-10-25 Pavel Safronov | 61 | // The shop: buying and restocking take no turn; going on leaves it for the next level. |
| 62 | if (action.id === 'buy' || action.id === 'reroll') { | |
| 63 | if (s.status !== 'cleared' || !s.shop) throw new Error(`${action.id} with no shop open`) | |
| 64 | if (action.id === 'buy') buy(s, action.kind, events) | |
| 65 | else reroll(s, events) | |
| 66 | return { state: s, events } | |
| 67 | } | |
5551eb37 2026-10-20 Pavel Safronov | 68 | if (action.id === 'continue') { |
c37a2b0b 2026-10-20 Pavel Safronov | 69 | if (s.status === 'dead') { |
| 70 | events.push({ type: 'levelRestarted' }) | |
52d68b59 2026-10-24 Pavel Safronov | 71 | return { state: createLevel(s.seed, s.level, s.options, carryOf(s)), events } |
c37a2b0b 2026-10-20 Pavel Safronov | 72 | } |
e78b7781 2026-10-25 Pavel Safronov | 73 | // Every few levels, the shop first (once: going on from it starts the next level). |
| 74 | if (s.status === 'cleared' && !s.shop && shopAfter(s.level)) { | |
| 75 | openShop(s, events) | |
| 76 | return { state: s, events } | |
| 77 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 78 | if (s.status === 'cleared') { |
| 79 | events.push({ type: 'levelStarted', level: s.level + 1 }) | |
beb9f2b7 2026-10-21 Pavel Safronov | 80 | return { state: createLevel(s.seed, s.level + 1, s.options, carryOf(s)), events } |
c37a2b0b 2026-10-20 Pavel Safronov | 81 | } |
| 82 | throw new Error(`continue while ${s.status}`) | |
5551eb37 2026-10-20 Pavel Safronov | 83 | } |
c37a2b0b 2026-10-20 Pavel Safronov | 84 | if (!isAvailable(s, action.id)) throw new Error(`${action.id} is not available`) |
5551eb37 2026-10-20 Pavel Safronov | 85 | |
c37a2b0b 2026-10-20 Pavel Safronov | 86 | // Phase 1: the player acts, and picks up whatever is where they end up. |
5551eb37 2026-10-20 Pavel Safronov | 87 | const def = ACTIONS[action.id] |
| 88 | if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) { | |
| 89 | throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`) | |
| 90 | } | |
27f24fed 2026-10-20 Pavel Safronov | 91 | const from = s.player |
7f242d36 2026-10-25 Pavel Safronov | 92 | // A slide passes over every tile before where it stops (worked out before the player moves). |
1f0038ac 2026-11-01 Pavel Safronov | 93 | const slid: Vec[] = action.id === 'slide' ? slidePassed(s, action.target!) : [] |
5551eb37 2026-10-20 Pavel Safronov | 94 | def.apply(s, action.target, events) |
27f24fed 2026-10-20 Pavel Safronov | 95 | // A run passes through the tile in between: anything there is picked up, or run into. |
| 96 | const passed: Vec | null = action.id === 'run' ? add(from, { x: (s.player.x - from.x) / 2, y: (s.player.y - from.y) / 2 }) : null | |
| 97 | if (passed) pickUp(s, passed, events) | |
7f242d36 2026-10-25 Pavel Safronov | 98 | // A slide picks up whatever it passes over. |
| 99 | for (const p of slid) pickUp(s, p, events) | |
27f24fed 2026-10-20 Pavel Safronov | 100 | pickUp(s, s.player, events) |
1f0038ac 2026-11-01 Pavel Safronov | 101 | // Landing on oil after a step or a run, the player slips on to the last oiled tile of the line |
| 102 | // (user request, 2026-10-31), picking up what lies on the way. Not on a diagonal step. | |
| 103 | const oiled: Vec[] = [] | |
| 104 | // Where the step landed, before any slide: an enemy standing there is walked into all the same. | |
| 105 | let landedOn: Vec | null = null | |
| 106 | if (action.id === 'move' || action.id === 'run') { | |
| 107 | const d = { x: Math.sign(s.player.x - from.x), y: Math.sign(s.player.y - from.y) } | |
| 108 | // Stops before a bomb (unless Bomb Pass) and an enemy; flame doesn't stop it, as with Slide. | |
| 109 | const path = oilSlide(s, s.player, d, (q) => playerCanPass(s, q) && !enemyAt(s, q)) | |
| 110 | // It stops on the open exit (spilled oil can reach it), as a Slide does. | |
| 111 | const atExit = exitOpen(s) ? path.findIndex((q) => eq(q, s.exit)) : -1 | |
| 112 | if (atExit >= 0) path.length = atExit + 1 | |
| 113 | if (path.length) { | |
| 114 | const landed = s.player | |
| 115 | landedOn = landed | |
| 116 | s.player = path[path.length - 1]! | |
| 117 | events.push({ type: 'oilSlid', from: landed, to: s.player }) | |
| 118 | oiled.push(...path.slice(0, -1)) | |
| 119 | for (const p of oiled) pickUp(s, p, events) | |
| 120 | pickUp(s, s.player, events) | |
| 121 | slid.push(landed, ...oiled) | |
| 122 | } | |
| 123 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 124 | if (exitOpen(s) && eq(s.player, s.exit)) { |
| 125 | const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0 | |
0b63d158 2026-10-25 Pavel Safronov | 126 | s.coins += CLEAR_BONUS + parBonus |
c37a2b0b 2026-10-20 Pavel Safronov | 127 | s.turn++ |
27f24fed 2026-10-20 Pavel Safronov | 128 | s.runTurns++ |
0b63d158 2026-10-25 Pavel Safronov | 129 | events.push({ type: 'levelCleared', level: s.level, turns: s.turn, clearBonus: CLEAR_BONUS, parBonus }) |
00ad7d0a 2026-10-24 Pavel Safronov | 130 | if (s.level >= LAST_LEVEL) { |
27f24fed 2026-10-20 Pavel Safronov | 131 | s.status = 'won' |
| 132 | events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths }) | |
| 133 | } else s.status = 'cleared' | |
c37a2b0b 2026-10-20 Pavel Safronov | 134 | return { state: s, events } |
| 135 | } | |
| 136 | ||
27f24fed 2026-10-20 Pavel Safronov | 137 | // A shield absorbs every hit for the rest of the turn it breaks in. A Star stops every hit; |
| 138 | // Flame Pass stops flame. | |
c37a2b0b 2026-10-20 Pavel Safronov | 139 | let shielded = false |
fcf44f32 2026-10-25 Pavel Safronov | 140 | let flameBlocked = false |
0580e85e 2026-10-24 Pavel Safronov | 141 | const hurt = (how: Death): void => { |
6c3fcc31 2026-10-26 Pavel Safronov | 142 | const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : how.cause === 'spikes' ? 'spikes' : how.cause === 'crushed' || how.cause === 'trapped' || how.cause === 'boxedIn' ? 'rubble' : 'flame' |
5f066960 2026-10-26 Pavel Safronov | 143 | if (s.status !== 'playing' || s.options.immortal) return |
| 144 | // Nothing saves the player from a fallen ceiling: not a Shield, nor a Star. | |
| 145 | if (cause !== 'rubble' && (shielded || s.star > 0 || (cause === 'flame' && flameBlocked))) return | |
| 146 | // A Flame Pass carried takes a fire hit (and every other flame that turn), using one of its hits. | |
| 147 | if (cause === 'flame' && holds(s, 'flamePass')) { | |
| 148 | spendCharge(s) | |
fcf44f32 2026-10-25 Pavel Safronov | 149 | flameBlocked = true |
5f066960 2026-10-26 Pavel Safronov | 150 | events.push({ type: 'flameBlocked', pos: s.player, hitsLeft: s.charge?.left ?? 0 }) |
fcf44f32 2026-10-25 Pavel Safronov | 151 | return |
| 152 | } | |
5f066960 2026-10-26 Pavel Safronov | 153 | // A Shield carried takes any other hit, and is used up. |
| 154 | if (holds(s, 'shield') && cause !== 'rubble') { | |
| 155 | spendCharge(s) | |
c37a2b0b 2026-10-20 Pavel Safronov | 156 | shielded = true |
| 157 | events.push({ type: 'shieldBroke', pos: s.player }) | |
| 158 | return | |
| 159 | } | |
| 160 | s.lives-- | |
27f24fed 2026-10-20 Pavel Safronov | 161 | s.deaths++ |
c37a2b0b 2026-10-20 Pavel Safronov | 162 | s.status = s.lives > 0 ? 'dead' : 'gameOver' |
0580e85e 2026-10-24 Pavel Safronov | 163 | s.death = how |
c37a2b0b 2026-10-20 Pavel Safronov | 164 | events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives }) |
| 165 | } | |
| 166 | ||
27f24fed 2026-10-20 Pavel Safronov | 167 | // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one. |
1f0038ac 2026-11-01 Pavel Safronov | 168 | const walkedInto = enemyAt(s, s.player) ?? (passed ? enemyAt(s, passed) : undefined) ?? (landedOn ? enemyAt(s, landedOn) : undefined) |
0580e85e 2026-10-24 Pavel Safronov | 169 | if (walkedInto) hurt({ cause: 'walkedIntoEnemy', enemy: walkedInto.kind, turn: s.turn + 1 }) |
7f242d36 2026-10-25 Pavel Safronov | 170 | // A slide over last turn's flame burns (it doesn't stop for it: user's choice, 2026-10-24). |
| 171 | if (s.status === 'playing' && slid.some((p) => s.burning.includes(idx(s, p)))) hurt({ cause: 'lingering', turn: s.turn + 1 }) | |
5551eb37 2026-10-20 Pavel Safronov | 172 | |
c37a2b0b 2026-10-20 Pavel Safronov | 173 | // Phase 2: enemies carry out their announced intents. Second contact check. |
| 174 | if (s.status === 'playing') { | |
| 175 | moveEnemies(s, events) | |
0580e85e 2026-10-24 Pavel Safronov | 176 | const caughtBy = enemyAt(s, s.player) |
| 177 | if (caughtBy) hurt({ cause: 'caughtByEnemy', enemy: caughtBy.kind, turn: s.turn + 1 }) | |
d3e4ed26 2026-10-25 Pavel Safronov | 178 | // An enemy that reaches the Decoy takes it: one hit and it is gone. |
| 179 | if (s.decoy && enemyAt(s, s.decoy.pos)) { | |
| 180 | events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false }) | |
| 181 | s.decoy = null | |
| 182 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 183 | } |
7f242d36 2026-10-25 Pavel Safronov | 184 | // Proximity alert: a bomb with an enemy in its reach goes off this turn (user's choice, |
| 185 | // 2026-10-24: wherever the enemy is now, one that just walked in or one already there). | |
| 186 | proximityAlert(s, events) | |
5551eb37 2026-10-20 Pavel Safronov | 187 | |
82adee18 2026-10-20 Pavel Safronov | 188 | // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too. |
| 189 | const lingering = new Set(s.burning) | |
c37a2b0b 2026-10-20 Pavel Safronov | 190 | const exitWasOpen = cellAt(s, s.exit) === 'floor' |
39c26813 2026-10-24 Pavel Safronov | 191 | const gemsOpen = s.gems.filter((g) => cellAt(s, g.pos) === 'floor') |
c37a2b0b 2026-10-20 Pavel Safronov | 192 | const enemiesBefore = s.enemies.length |
5551eb37 2026-10-20 Pavel Safronov | 193 | tickBombs(s, events) |
0580e85e 2026-10-24 Pavel Safronov | 194 | // The bombs going off by themselves (or by the Detonator); the rest of what explodes, a chain set off. |
| 195 | const due = new Set(s.bombs.filter((b) => b.timer <= 0).map((b) => idx(s, b.pos))) | |
5f066960 2026-10-26 Pavel Safronov | 196 | // A ceiling falling now comes down before the blasts: what it crushes doesn't burn. |
| 197 | const fell = collapse(s, events) | |
| 198 | if (fell.has(idx(s, s.player))) { | |
| 199 | // A Shield carried pushes the player out to an empty tile next to them, and is used up | |
| 200 | // (user request, 2026-10-25); with none, nothing saves them. | |
| 201 | const out = holds(s, 'shield') ? pushOut(s) : null | |
| 202 | if (out) { | |
| 203 | spendCharge(s) | |
| 204 | shielded = true | |
| 205 | events.push({ type: 'shieldBroke', pos: s.player }) | |
| 206 | s.player = out | |
| 207 | } else { | |
| 208 | hurt({ cause: 'crushed', turn: s.turn + 1 }) | |
| 209 | // Immortal (a testing aid): out from under it. | |
| 210 | if (s.status === 'playing') s.player = nearestFloor(s, s.player) | |
| 211 | } | |
| 212 | } | |
0580e85e 2026-10-24 Pavel Safronov | 213 | const firstEvent = events.length |
5551eb37 2026-10-20 Pavel Safronov | 214 | const burned = detonate(s, events) |
beb9f2b7 2026-10-21 Pavel Safronov | 215 | crumble(s, events) |
5551eb37 2026-10-20 Pavel Safronov | 216 | |
82adee18 2026-10-20 Pavel Safronov | 217 | // Phase 4: damage. New flame and lingering flame both burn creatures: the player, enemies and |
| 218 | // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own | |
| 219 | // drops, so those survive the flame that made them (and its lingering). | |
| 220 | const fire = new Set([...burned, ...lingering]) | |
0580e85e 2026-10-24 Pavel Safronov | 221 | if (s.status === 'playing' && fire.has(idx(s, s.player))) { |
| 222 | const at = idx(s, s.player) | |
| 223 | const hits = events.slice(firstEvent).filter((e) => e.type === 'exploded' && e.tiles.some((t) => idx(s, t) === at)) | |
5f066960 2026-10-26 Pavel Safronov | 224 | const cause = !burned.has(at) ? 'lingering' : !hits.length ? 'spread' : hits.every((e) => e.type === 'exploded' && !due.has(idx(s, e.pos))) ? 'chain' : 'blast' |
0580e85e 2026-10-24 Pavel Safronov | 225 | hurt({ cause, turn: s.turn + 1 }) |
| 226 | } | |
5f066960 2026-10-26 Pavel Safronov | 227 | // Spikes up under the player (PLAN.md §19.3). |
| 228 | if (spikesUp(s, idx(s, s.player))) hurt({ cause: 'spikes', turn: s.turn + 1 }) | |
c37a2b0b 2026-10-20 Pavel Safronov | 229 | for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) { |
| 230 | s.drops.splice(s.drops.indexOf(d), 1) | |
| 231 | events.push({ type: 'dropBurned', drop: d }) | |
| 232 | } | |
52d68b59 2026-10-24 Pavel Safronov | 233 | // Enemies let out by last turn's flame are only hurt by new explosions, not by that flame. |
| 234 | const slain = s.enemies.filter((e) => (e.sheltered ? burned : fire).has(idx(s, e.pos))) | |
| 235 | s.enemies = s.enemies.filter((e) => !slain.includes(e)) | |
0580e85e 2026-10-24 Pavel Safronov | 236 | s.runKills += slain.length |
52d68b59 2026-10-24 Pavel Safronov | 237 | for (const e of slain) { |
c37a2b0b 2026-10-20 Pavel Safronov | 238 | events.push({ type: 'enemyDied', id: e.id, pos: e.pos }) |
52d68b59 2026-10-24 Pavel Safronov | 239 | // When these were the last, their charges are ones that last past the enemies (user's choice). |
| 240 | for (const drop of rollDrops(s, e, s.enemies.length === 0)) { | |
beb9f2b7 2026-10-21 Pavel Safronov | 241 | drop.pos = dropSpot(s, e.pos) |
c37a2b0b 2026-10-20 Pavel Safronov | 242 | s.drops.push(drop) |
| 243 | events.push({ type: 'dropSpawned', drop }) | |
| 244 | } | |
5551eb37 2026-10-20 Pavel Safronov | 245 | } |
52d68b59 2026-10-24 Pavel Safronov | 246 | for (const e of s.enemies) e.sheltered = false |
82adee18 2026-10-20 Pavel Safronov | 247 | if (s.decoy && fire.has(idx(s, s.decoy.pos))) { |
| 248 | events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: true }) | |
| 249 | s.decoy = null | |
| 250 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 251 | if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit }) |
| 252 | // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5). | |
27f24fed 2026-10-20 Pavel Safronov | 253 | if (exitWasOpen && burned.has(idx(s, s.exit))) spawnPunishment(s, s.exit, events) |
39c26813 2026-10-24 Pavel Safronov | 254 | // The same for a gem: flame on it once uncovered destroys it and lets enemies out. |
| 255 | for (const gem of [...s.gems]) { | |
| 256 | if (gemsOpen.includes(gem) && burned.has(idx(s, gem.pos))) { | |
| 257 | events.push({ type: 'powerUpBurned', powerUp: gem }) | |
| 258 | spawnPunishment(s, gem.pos, events) | |
| 259 | s.gems.splice(s.gems.indexOf(gem), 1) | |
| 260 | } else if (!gemsOpen.includes(gem) && cellAt(s, gem.pos) === 'floor') { | |
| 261 | events.push({ type: 'powerUpRevealed', powerUp: gem }) | |
c37a2b0b 2026-10-20 Pavel Safronov | 262 | } |
| 263 | } | |
5f066960 2026-10-26 Pavel Safronov | 264 | // Walled in by fallen rubble, with the exit or an enemy out of reach: the level can't be finished. |
| 265 | if (fell.size && walledIn(s)) hurt({ cause: 'trapped', turn: s.turn + 1 }) | |
beb9f2b7 2026-10-21 Pavel Safronov | 266 | if (enemiesBefore > 0 && s.enemies.length === 0) { |
| 267 | events.push({ type: 'exitOpened', pos: s.exit }) | |
00ad7d0a 2026-10-24 Pavel Safronov | 268 | startCrumbling(s, events) |
beb9f2b7 2026-10-21 Pavel Safronov | 269 | } |
5551eb37 2026-10-20 Pavel Safronov | 270 | |
82adee18 2026-10-20 Pavel Safronov | 271 | // This turn's flame lingers through the next turn; last turn's goes out. |
| 272 | s.burning = [...burned] | |
| 273 | ||
5f066960 2026-10-26 Pavel Safronov | 274 | // Phase 5: enemies announce their next intents, toward where they last saw the player if |
| 275 | // the player is hidden in a bush (PLAN.md §19.3). A Star lasts a fixed number of these. | |
| 276 | if (!s.bushes.includes(idx(s, s.player))) s.lastSeen = { ...s.player } | |
c37a2b0b 2026-10-20 Pavel Safronov | 277 | planIntents(s) |
27f24fed 2026-10-20 Pavel Safronov | 278 | if (s.star > 0) s.star-- |
5551eb37 2026-10-20 Pavel Safronov | 279 | |
6c3fcc31 2026-10-26 Pavel Safronov | 280 | // Boxed in for good: nowhere to step or put a bomb, and nothing left that could change it. |
| 281 | if (s.status === 'playing' && !s.offer && !s.chargeOffer && boxedIn(s)) hurt({ cause: 'boxedIn', turn: s.turn + 1 }) | |
| 282 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 283 | // Died the turn a gem was taken: no choice. The retry has the gem back. A charge on offer the |
| 284 | // same turn is gone too: the retry has it lying where it was. | |
| 285 | if (s.status === 'dead' || s.status === 'gameOver') { | |
| 286 | s.offer = null | |
| 287 | s.chargeOffer = null | |
8edb5cb9 2026-11-01 Pavel Safronov | 288 | s.waitingGems = [] |
d3e4ed26 2026-10-25 Pavel Safronov | 289 | } |
52d68b59 2026-10-24 Pavel Safronov | 290 | |
5551eb37 2026-10-20 Pavel Safronov | 291 | s.turn++ |
27f24fed 2026-10-20 Pavel Safronov | 292 | s.runTurns++ |
5551eb37 2026-10-20 Pavel Safronov | 293 | return { state: s, events } |
| 294 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 295 | |
5f066960 2026-10-26 Pavel Safronov | 296 | /** |
| 297 | * Where a Shield pushes the player from under a falling ceiling: the first empty tile next to them | |
| 298 | * (open floor, no bomb, no enemy), straight before diagonal; null if there is none. | |
| 299 | */ | |
| 300 | function pushOut(s: GameState): Vec | null { | |
| 301 | const steps = [...DIR_LIST.map((d) => DIRS[d]), { x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: 1 }] | |
| 302 | for (const d of steps) { | |
| 303 | const p = add(s.player, d) | |
| 304 | if (cellAt(s, p) === 'floor' && !bombAt(s, p) && !enemyAt(s, p)) return p | |
| 305 | } | |
| 306 | return null | |
| 307 | } | |
| 308 | ||
beb9f2b7 2026-10-21 Pavel Safronov | 309 | /** How far (in steps) a drop may roll from where the enemy died to find a tile of its own. */ |
| 310 | export const DROP_SPREAD = 2 | |
| 311 | ||
| 312 | /** | |
| 313 | * Where a new drop lands: the enemy's tile if nothing lies there yet, otherwise the nearest | |
| 314 | * open floor tile (within DROP_SPREAD steps, never through walls or crates) without a drop, a | |
28670c3e 2026-10-24 Pavel Safronov | 315 | * bomb, the uncovered power-up or the uncovered exit — so drops never pile up on one tile, and |
| 316 | * never lie on the door, where taking one would mean leaving the level (user report, | |
| 317 | * 2026-10-23). If there is none, it stays on the enemy's tile after all. | |
beb9f2b7 2026-10-21 Pavel Safronov | 318 | */ |
| 319 | function dropSpot(s: GameState, from: Vec): Vec { | |
| 320 | const taken = (p: Vec) => | |
28670c3e 2026-10-24 Pavel Safronov | 321 | s.drops.some((d) => eq(d.pos, p)) || |
| 322 | !!bombAt(s, p) || | |
39c26813 2026-10-24 Pavel Safronov | 323 | s.gems.some((g) => eq(g.pos, p) && cellAt(s, p) === 'floor') || |
28670c3e 2026-10-24 Pavel Safronov | 324 | (eq(s.exit, p) && cellAt(s, p) === 'floor') |
beb9f2b7 2026-10-21 Pavel Safronov | 325 | const seen = new Set([idx(s, from)]) |
| 326 | let ring = [from] | |
| 327 | for (let dist = 0; dist <= DROP_SPREAD && ring.length; dist++) { | |
| 328 | const free = ring.find((p) => !taken(p)) | |
| 329 | if (free) return { ...free } | |
| 330 | const next: Vec[] = [] | |
| 331 | for (const p of ring) { | |
| 332 | for (const d of DIR_LIST) { | |
| 333 | const q = add(p, DIRS[d]) | |
| 334 | if (seen.has(idx(s, q)) || cellAt(s, q) !== 'floor') continue | |
| 335 | seen.add(idx(s, q)) | |
| 336 | next.push(q) | |
| 337 | } | |
| 338 | } | |
| 339 | ring = next | |
| 340 | } | |
| 341 | return { ...from } | |
| 342 | } | |
| 343 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 344 | /** |
| 345 | * Once the last enemy is gone, every crate gets 1–3 turns before it goes, revealing the exit and | |
| 346 | * the gem. Always, since 2026-10-23 (user request: it was the Crumbling crates option). | |
| 347 | */ | |
beb9f2b7 2026-10-21 Pavel Safronov | 348 | function startCrumbling(s: GameState, events: GameEvent[]): void { |
| 349 | if (s.crumbling.length) return | |
| 350 | const rng = stream(s.seed, 'crumble', s.level) | |
| 351 | s.cells.forEach((c, cell) => { | |
| 352 | if (c === 'block') s.crumbling.push({ cell, turns: 1 + rng.int(3) }) | |
| 353 | }) | |
| 354 | events.push({ type: 'cratesCrumbling', count: s.crumbling.length }) | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * Phase 3, after the blasts: crumbling crates count down, and those at 0 are gone. It is not | |
| 359 | * flame: nothing burns, nothing is punished, and an exit or power-up under one is simply revealed. | |
| 360 | */ | |
| 361 | function crumble(s: GameState, events: GameEvent[]): void { | |
| 362 | for (const c of s.crumbling) { | |
| 363 | if (--c.turns > 0 || s.cells[c.cell] !== 'block') continue | |
| 364 | s.cells[c.cell] = 'floor' | |
| 365 | events.push({ type: 'crateCrumbled', pos: { x: c.cell % s.width, y: Math.floor(c.cell / s.width) } }) | |
| 366 | } | |
| 367 | s.crumbling = s.crumbling.filter((c) => c.turns > 0) | |
| 368 | } | |
| 369 | ||
27f24fed 2026-10-20 Pavel Safronov | 370 | /** A group of the level's toughest enemy type comes out at `pos` (NES rule, PLAN.md §5). They drop nothing. */ |
| 371 | function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void { | |
| 372 | const kinds = enemyKinds(s.level) | |
| 373 | for (let n = 0; n < EXIT_SPAWN; n++) { | |
52d68b59 2026-10-24 Pavel Safronov | 374 | s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true, sheltered: true }) |
27f24fed 2026-10-20 Pavel Safronov | 375 | } |
| 376 | events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN }) | |
| 377 | } | |
| 378 | ||
39c26813 2026-10-24 Pavel Safronov | 379 | /** Collect every drop at `p`, and a gem if one lies there uncovered (which sets up its offer). */ |
27f24fed 2026-10-20 Pavel Safronov | 380 | function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { |
| 381 | for (const d of s.drops.filter((d) => eq(d.pos, p))) { | |
c37a2b0b 2026-10-20 Pavel Safronov | 382 | s.drops.splice(s.drops.indexOf(d), 1) |
| 383 | if (d.kind === 'coins') s.coins += d.amount | |
| 384 | events.push({ type: 'pickedUp', drop: d }) | |
fcf44f32 2026-10-25 Pavel Safronov | 385 | if (d.kind !== 'coins') takeCharge(s, d.kind, events, false, d.left) |
c37a2b0b 2026-10-20 Pavel Safronov | 386 | } |
39c26813 2026-10-24 Pavel Safronov | 387 | const gem = s.gems.find((g) => eq(g.pos, p) && cellAt(s, p) === 'floor') |
| 388 | if (gem) { | |
| 389 | s.gems.splice(s.gems.indexOf(gem), 1) | |
8edb5cb9 2026-11-01 Pavel Safronov | 390 | // A second gem on the same turn (a slide over two) waits its turn: its offer is drawn once the |
| 391 | // first is answered, so it knows what that choice gave (user report, 2026-10-31). | |
| 392 | if (s.offer) { | |
| 393 | s.waitingGems.push(gem) | |
| 394 | events.push({ type: 'gemQueued', powerUp: gem }) | |
| 395 | } | |
| 396 | else { | |
| 397 | s.offer = drawOffer(s, gem.extra) | |
| 398 | events.push({ type: 'powerUpTaken', powerUp: gem, offer: s.offer }) | |
| 399 | } | |
27f24fed 2026-10-20 Pavel Safronov | 400 | } |
| 401 | } | |
| 402 | ||
8edb5cb9 2026-11-01 Pavel Safronov | 403 | /** Once the offer and any charge choice are answered, the next gem waiting (if any) makes its offer. */ |
| 404 | function nextOffer(s: GameState, events: GameEvent[]): void { | |
| 405 | if (s.offer || s.chargeOffer) return | |
| 406 | const gem = s.waitingGems.shift() | |
| 407 | if (!gem) return | |
| 408 | s.offer = drawOffer(s, gem.extra) | |
| 409 | events.push({ type: 'offerAgain', powerUp: gem, offer: s.offer }) | |
| 410 | } | |
| 411 | ||
52d68b59 2026-10-24 Pavel Safronov | 412 | /** How many power-ups a gem offers (user request, 2026-10-23). */ |
| 413 | export const OFFER_SIZE = 5 | |
| 414 | ||
| 415 | /** | |
| 416 | * How likely each power-up is to be offered, in the order an offer lists them (stats, | |
| 417 | * abilities, passes, the rest). Run is in the draw like the rest; it was guaranteed on level 1 | |
| 418 | * or 2 before gems (the user chose no guarantee). To tune by playtesting. | |
| 419 | */ | |
| 420 | export const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [ | |
fcf44f32 2026-10-25 Pavel Safronov | 421 | ['bombPlus', 20], ['firePlus', 20], ['tnt', 6], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['diagonal', 8], |
| 422 | ['slide', 6], ['rpg', 6], ['detonator', 9], ['proximity', 5], ['wallPass', 8], ['bombPass', 8], ['star', 8], | |
7f242d36 2026-10-25 Pavel Safronov | 423 | ['heart', 4], ['coinBag', 12], |
52d68b59 2026-10-24 Pavel Safronov | 424 | ] |
| 425 | ||
| 426 | /** The power-ups that are an ability or pass: offering one already held would change nothing. */ | |
| 427 | const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = { | |
fcf44f32 2026-10-25 Pavel Safronov | 428 | run: 'run', kick: 'kick', throw: 'throw', diagonal: 'diagonal', detonator: 'detonate', |
| 429 | wallPass: 'wallPass', bombPass: 'bombPass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', | |
52d68b59 2026-10-24 Pavel Safronov | 430 | } |
| 431 | ||
| 432 | /** | |
| 433 | * A gem's offer: OFFER_SIZE different power-ups (fewer if fewer would do anything), drawn by | |
| 434 | * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death | |
e3f29330 2026-10-24 Pavel Safronov | 435 | * offers the same. Developer mode's extra gem offers every one that would do anything, then |
| 436 | * every charge, nothing drawn (user request, 2026-10-24). | |
52d68b59 2026-10-24 Pavel Safronov | 437 | */ |
e6bb7c42 2026-10-25 Pavel Safronov | 438 | /** |
| 439 | * The most each stat can be raised to (user request, 2026-10-25: a limit stops board-spanning | |
| 440 | * blasts and the like). To tune by playtesting. | |
| 441 | */ | |
| 442 | export const STAT_MAX: Stats = { capacity: 5, fire: 5, fuse: 5 } | |
| 443 | ||
| 444 | /** The stat each stat power-up raises, and by how much (TNT: Fire +2, user request, 2026-10-25). */ | |
| 445 | export const STAT_STEP: Partial<Record<PowerUpKind, { stat: keyof Stats; by: number }>> = { | |
| 446 | bombPlus: { stat: 'capacity', by: 1 }, firePlus: { stat: 'fire', by: 1 }, tnt: { stat: 'fire', by: 2 }, fusePlus: { stat: 'fuse', by: 1 }, | |
| 447 | } | |
| 448 | ||
| 449 | /** A stat raised by `by`, never past its STAT_MAX. */ | |
| 450 | function raise(s: GameState, stat: keyof Stats, by: number): void { | |
| 451 | s.stats[stat] = Math.min(STAT_MAX[stat], s.stats[stat] + by) | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Whether offering `kind` would be (partly) wasted: an ability or pass already held, or a stat | |
| 456 | * power-up with no room for all of its step under the limit (TNT at a blast of 4 would give only 1). | |
| 457 | */ | |
e78b7781 2026-10-25 Pavel Safronov | 458 | export function heldAlready(s: GameState, kind: PowerUpKind): boolean { |
| 459 | const held = HELD_AS[kind] | |
e6bb7c42 2026-10-25 Pavel Safronov | 460 | const step = STAT_STEP[kind] |
| 461 | return (!!held && s.powers[held]) || (!!step && s.stats[step.stat] + step.by > STAT_MAX[step.stat]) | |
e78b7781 2026-10-25 Pavel Safronov | 462 | } |
| 463 | ||
e3f29330 2026-10-24 Pavel Safronov | 464 | export function drawOffer(s: GameState, extra = false): OfferKind[] { |
e78b7781 2026-10-25 Pavel Safronov | 465 | const pool = POWER_UP_WEIGHTS.filter(([kind]) => !heldAlready(s, kind)) |
e3f29330 2026-10-24 Pavel Safronov | 466 | if (extra) return [...pool.map(([kind]) => kind), ...CHARGE_IDS] |
6e94f995 2026-10-24 Pavel Safronov | 467 | const rng = stream(s.seed, 'offer', s.level) |
52d68b59 2026-10-24 Pavel Safronov | 468 | const picked = new Set<PowerUpKind>() |
| 469 | while (picked.size < OFFER_SIZE && pool.length) { | |
| 470 | let r = rng.next() * pool.reduce((sum, [, w]) => sum + w, 0) | |
| 471 | const i = Math.max(0, pool.findIndex(([, w]) => (r -= w) < 0)) | |
| 472 | picked.add(pool.splice(i, 1)[0]![0]) | |
| 473 | } | |
| 474 | return POWER_UP_WEIGHTS.map(([kind]) => kind).filter((kind) => picked.has(kind)) | |
| 475 | } | |
| 476 | ||
e78b7781 2026-10-25 Pavel Safronov | 477 | export function applyPowerUp(s: GameState, kind: PowerUpKind): void { |
27f24fed 2026-10-20 Pavel Safronov | 478 | switch (kind) { |
e6bb7c42 2026-10-25 Pavel Safronov | 479 | case 'bombPlus': raise(s, 'capacity', 1); break |
| 480 | case 'firePlus': raise(s, 'fire', 1); break | |
| 481 | case 'tnt': raise(s, 'fire', 2); break | |
| 482 | case 'fusePlus': raise(s, 'fuse', 1); break | |
99869630 2026-10-23 Pavel Safronov | 483 | case 'run': s.powers.run = true; break |
27f24fed 2026-10-20 Pavel Safronov | 484 | case 'detonator': s.powers.detonate = true; break |
| 485 | case 'wallPass': s.powers.wallPass = true; break | |
| 486 | case 'bombPass': s.powers.bombPass = true; break | |
| 487 | case 'star': s.star = STAR_TURNS; break | |
| 488 | case 'coinBag': s.coins += COIN_BAG; break | |
| 489 | case 'heart': s.lives++; break | |
99869630 2026-10-23 Pavel Safronov | 490 | case 'kick': s.powers.kick = true; break |
| 491 | case 'throw': s.powers.throw = true; break | |
52d68b59 2026-10-24 Pavel Safronov | 492 | case 'diagonal': s.powers.diagonal = true; break |
7f242d36 2026-10-25 Pavel Safronov | 493 | case 'rpg': s.powers.rpg = true; break |
| 494 | case 'proximity': s.powers.proximity = true; break | |
| 495 | case 'slide': s.powers.slide = true; break | |
27f24fed 2026-10-20 Pavel Safronov | 496 | } |
c37a2b0b 2026-10-20 Pavel Safronov | 497 | } |