fuzzyworld

explodorama / files / src / sim / turn.ts

turn.ts

23.0 KB · history · blame

1import { ACTIONS, boxedIn, fireableHeld, isAvailable, isValidTarget, slidePassed } from './actions'
2import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './charges'
3import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
4import { enemyKinds, LAST_LEVEL } from './levelgen'
5import { collapse, detonate, proximityAlert, spikesUp, tickBombs, walledIn } from './explosions'
6import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, nearestFloor, oilSlide, playerCanPass, CLEAR_BONUS, PAR_BONUS } from './state'
7import { stream } from './rng'
8import { buy, openShop, reroll, shopAfter } from './shop'
9import { 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'
10
11export interface StepResult {
12 state: GameState
13 events: GameEvent[]
14}
15
16/** How many enemies come out of an exit or power-up caught in a flame. */
17export const EXIT_SPAWN = 3
18/** Turns of invincibility from a Star. */
19export const STAR_TURNS = 8
20/** Coins in a Coin bag. */
21export const COIN_BAG = 10
22
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 */
27export function step(prev: GameState, action: Action): StepResult {
28 const s = cloneState(prev)
29 const events: GameEvent[] = []
30
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 })
36 // A charge chosen while carrying another kind asks, as a pickup does.
37 if (isCharge(action.kind)) takeCharge(s, action.kind, events)
38 else applyPowerUp(s, action.kind)
39 nextOffer(s, events)
40 return { state: s, events }
41 }
42 // A charge picked up while carrying another: sell it, or swap it in. No turn passes.
43 if (action.id === 'sellCharge' || action.id === 'swapCharge' || action.id === 'fireCharge') {
44 if (!s.chargeOffer) throw new Error(`${action.id} with no charge on offer`)
45 if (action.id === 'sellCharge') sellOffered(s, events)
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 }
54 nextOffer(s, events)
55 return { state: s, events }
56 }
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`)
59 if (s.chargeOffer && s.status !== 'dead') throw new Error(`${action.id} while a charge is on offer`)
60
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 }
68 if (action.id === 'continue') {
69 if (s.status === 'dead') {
70 events.push({ type: 'levelRestarted' })
71 return { state: createLevel(s.seed, s.level, s.options, carryOf(s)), events }
72 }
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 }
78 if (s.status === 'cleared') {
79 events.push({ type: 'levelStarted', level: s.level + 1 })
80 return { state: createLevel(s.seed, s.level + 1, s.options, carryOf(s)), events }
81 }
82 throw new Error(`continue while ${s.status}`)
83 }
84 if (!isAvailable(s, action.id)) throw new Error(`${action.id} is not available`)
85
86 // Phase 1: the player acts, and picks up whatever is where they end up.
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 }
91 const from = s.player
92 // A slide passes over every tile before where it stops (worked out before the player moves).
93 const slid: Vec[] = action.id === 'slide' ? slidePassed(s, action.target!) : []
94 def.apply(s, action.target, events)
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)
98 // A slide picks up whatever it passes over.
99 for (const p of slid) pickUp(s, p, events)
100 pickUp(s, s.player, events)
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 }
124 if (exitOpen(s) && eq(s.player, s.exit)) {
125 const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0
126 s.coins += CLEAR_BONUS + parBonus
127 s.turn++
128 s.runTurns++
129 events.push({ type: 'levelCleared', level: s.level, turns: s.turn, clearBonus: CLEAR_BONUS, parBonus })
130 if (s.level >= LAST_LEVEL) {
131 s.status = 'won'
132 events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths })
133 } else s.status = 'cleared'
134 return { state: s, events }
135 }
136
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.
139 let shielded = false
140 let flameBlocked = false
141 const hurt = (how: Death): void => {
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'
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)
149 flameBlocked = true
150 events.push({ type: 'flameBlocked', pos: s.player, hitsLeft: s.charge?.left ?? 0 })
151 return
152 }
153 // A Shield carried takes any other hit, and is used up.
154 if (holds(s, 'shield') && cause !== 'rubble') {
155 spendCharge(s)
156 shielded = true
157 events.push({ type: 'shieldBroke', pos: s.player })
158 return
159 }
160 s.lives--
161 s.deaths++
162 s.status = s.lives > 0 ? 'dead' : 'gameOver'
163 s.death = how
164 events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives })
165 }
166
167 // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one.
168 const walkedInto = enemyAt(s, s.player) ?? (passed ? enemyAt(s, passed) : undefined) ?? (landedOn ? enemyAt(s, landedOn) : undefined)
169 if (walkedInto) hurt({ cause: 'walkedIntoEnemy', enemy: walkedInto.kind, turn: s.turn + 1 })
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 })
172
173 // Phase 2: enemies carry out their announced intents. Second contact check.
174 if (s.status === 'playing') {
175 moveEnemies(s, events)
176 const caughtBy = enemyAt(s, s.player)
177 if (caughtBy) hurt({ cause: 'caughtByEnemy', enemy: caughtBy.kind, turn: s.turn + 1 })
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 }
183 }
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)
187
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)
190 const exitWasOpen = cellAt(s, s.exit) === 'floor'
191 const gemsOpen = s.gems.filter((g) => cellAt(s, g.pos) === 'floor')
192 const enemiesBefore = s.enemies.length
193 tickBombs(s, events)
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)))
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 }
213 const firstEvent = events.length
214 const burned = detonate(s, events)
215 crumble(s, events)
216
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])
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))
224 const cause = !burned.has(at) ? 'lingering' : !hits.length ? 'spread' : hits.every((e) => e.type === 'exploded' && !due.has(idx(s, e.pos))) ? 'chain' : 'blast'
225 hurt({ cause, turn: s.turn + 1 })
226 }
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 })
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 }
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))
236 s.runKills += slain.length
237 for (const e of slain) {
238 events.push({ type: 'enemyDied', id: e.id, pos: e.pos })
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)) {
241 drop.pos = dropSpot(s, e.pos)
242 s.drops.push(drop)
243 events.push({ type: 'dropSpawned', drop })
244 }
245 }
246 for (const e of s.enemies) e.sheltered = false
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 }
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).
253 if (exitWasOpen && burned.has(idx(s, s.exit))) spawnPunishment(s, s.exit, events)
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 })
262 }
263 }
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 })
266 if (enemiesBefore > 0 && s.enemies.length === 0) {
267 events.push({ type: 'exitOpened', pos: s.exit })
268 startCrumbling(s, events)
269 }
270
271 // This turn's flame lingers through the next turn; last turn's goes out.
272 s.burning = [...burned]
273
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 }
277 planIntents(s)
278 if (s.star > 0) s.star--
279
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
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
288 s.waitingGems = []
289 }
290
291 s.turn++
292 s.runTurns++
293 return { state: s, events }
294}
295
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 */
300function 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
309/** How far (in steps) a drop may roll from where the enemy died to find a tile of its own. */
310export 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
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.
318 */
319function dropSpot(s: GameState, from: Vec): Vec {
320 const taken = (p: Vec) =>
321 s.drops.some((d) => eq(d.pos, p)) ||
322 !!bombAt(s, p) ||
323 s.gems.some((g) => eq(g.pos, p) && cellAt(s, p) === 'floor') ||
324 (eq(s.exit, p) && cellAt(s, p) === 'floor')
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
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 */
348function 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 */
361function 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
370/** A group of the level's toughest enemy type comes out at `pos` (NES rule, PLAN.md §5). They drop nothing. */
371function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void {
372 const kinds = enemyKinds(s.level)
373 for (let n = 0; n < EXIT_SPAWN; n++) {
374 s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true, sheltered: true })
375 }
376 events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN })
377}
378
379/** Collect every drop at `p`, and a gem if one lies there uncovered (which sets up its offer). */
380function pickUp(s: GameState, p: Vec, events: GameEvent[]): void {
381 for (const d of s.drops.filter((d) => eq(d.pos, p))) {
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 })
385 if (d.kind !== 'coins') takeCharge(s, d.kind, events, false, d.left)
386 }
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)
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 }
400 }
401}
402
403/** Once the offer and any charge choice are answered, the next gem waiting (if any) makes its offer. */
404function 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
412/** How many power-ups a gem offers (user request, 2026-10-23). */
413export 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 */
420export const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [
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],
423 ['heart', 4], ['coinBag', 12],
424]
425
426/** The power-ups that are an ability or pass: offering one already held would change nothing. */
427const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = {
428 run: 'run', kick: 'kick', throw: 'throw', diagonal: 'diagonal', detonator: 'detonate',
429 wallPass: 'wallPass', bombPass: 'bombPass', rpg: 'rpg', proximity: 'proximity', slide: 'slide',
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
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).
437 */
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 */
442export 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). */
445export 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. */
450function 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 */
458export function heldAlready(s: GameState, kind: PowerUpKind): boolean {
459 const held = HELD_AS[kind]
460 const step = STAT_STEP[kind]
461 return (!!held && s.powers[held]) || (!!step && s.stats[step.stat] + step.by > STAT_MAX[step.stat])
462}
463
464export function drawOffer(s: GameState, extra = false): OfferKind[] {
465 const pool = POWER_UP_WEIGHTS.filter(([kind]) => !heldAlready(s, kind))
466 if (extra) return [...pool.map(([kind]) => kind), ...CHARGE_IDS]
467 const rng = stream(s.seed, 'offer', s.level)
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
477export function applyPowerUp(s: GameState, kind: PowerUpKind): void {
478 switch (kind) {
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
483 case 'run': s.powers.run = true; break
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
490 case 'kick': s.powers.kick = true; break
491 case 'throw': s.powers.throw = true; break
492 case 'diagonal': s.powers.diagonal = true; break
493 case 'rpg': s.powers.rpg = true; break
494 case 'proximity': s.powers.proximity = true; break
495 case 'slide': s.powers.slide = true; break
496 }
497}