explodorama / files / test / danger.test.ts
danger.test.ts
5551eb37 2026-10-20 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { ACTIONS, ACTION_ORDER, isAvailable } from '../src/sim/actions' | |
| 3 | import { predictDanger } from '../src/sim/danger' | |
5f066960 2026-10-26 Pavel Safronov | 4 | import { spikesUp } from '../src/sim/explosions' |
5551eb37 2026-10-20 Pavel Safronov | 5 | import { newRecord, replay } from '../src/sim/record' |
| 6 | import { idx, newRun } from '../src/sim/state' | |
| 7 | import { step } from '../src/sim/turn' | |
| 8 | import type { Action, GameState } from '../src/sim/types' | |
| 9 | import { Rng } from '../src/sim/rng' | |
5f066960 2026-10-26 Pavel Safronov | 10 | import { board, v } from './helpers' |
5551eb37 2026-10-20 Pavel Safronov | 11 | |
c37a2b0b 2026-10-20 Pavel Safronov | 12 | /** A random but legal action; `continue` when dead or the level is cleared. */ |
5551eb37 2026-10-20 Pavel Safronov | 13 | function randomAction(s: GameState, rng: Rng): Action { |
c37a2b0b 2026-10-20 Pavel Safronov | 14 | if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' } |
00ad7d0a 2026-10-24 Pavel Safronov | 15 | // A gem taken: its choice comes first. |
| 16 | if (s.offer) return { id: 'choose', kind: s.offer[0]! } | |
d3e4ed26 2026-10-25 Pavel Safronov | 17 | // A second charge picked up: sell it or swap, at random. |
| 18 | if (s.chargeOffer) return { id: rng.next() < 0.5 ? 'sellCharge' : 'swapCharge' } | |
5551eb37 2026-10-20 Pavel Safronov | 19 | const ids = ACTION_ORDER.filter((id) => isAvailable(s, id)) |
| 20 | const id = ids[rng.int(ids.length)]! | |
| 21 | const targets = ACTIONS[id].validTargets(s) | |
| 22 | return ACTIONS[id].needsTarget ? { id, target: targets[rng.int(targets.length)]! } : { id } | |
| 23 | } | |
| 24 | ||
5f066960 2026-10-26 Pavel Safronov | 25 | /** |
| 26 | * What hurts this turn if the player waits: new explosions and flame spread over oil and bushes, | |
| 27 | * last turn's flame still burning, raised spikes, and a ceiling coming down. | |
| 28 | */ | |
5551eb37 2026-10-20 Pavel Safronov | 29 | function burnedBy(s: GameState): Set<number> { |
82adee18 2026-10-20 Pavel Safronov | 30 | const tiles = new Set<number>(s.burning) |
5f066960 2026-10-26 Pavel Safronov | 31 | for (const sp of s.spikes) if (spikesUp(s, sp.cell)) tiles.add(sp.cell) |
| 32 | for (const e of step(s, { id: 'wait' }).events) { | |
| 33 | if (e.type === 'exploded' || e.type === 'fireSpread' || e.type === 'ceilingFell') e.tiles.forEach((t) => tiles.add(idx(s, t))) | |
| 34 | } | |
5551eb37 2026-10-20 Pavel Safronov | 35 | return tiles |
| 36 | } | |
| 37 | ||
| 38 | describe('danger overlay', () => { | |
| 39 | it('"now" is exactly what burns if the player waits, and "next" what burns the turn after', () => { | |
| 40 | const rng = new Rng(7) | |
| 41 | for (let seed = 1; seed <= 20; seed++) { | |
d3e4ed26 2026-10-25 Pavel Safronov | 42 | let s = newRun(seed, 1 + (seed % 20), 'nuke') |
5551eb37 2026-10-20 Pavel Safronov | 43 | for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) { |
00ad7d0a 2026-10-24 Pavel Safronov | 44 | if (s.status === 'playing' && !s.offer) { |
5551eb37 2026-10-20 Pavel Safronov | 45 | const danger = predictDanger(s) |
| 46 | expect(danger.now).toEqual(burnedBy(s)) | |
| 47 | const waited = step(s, { id: 'wait' }).state | |
| 48 | if (waited.status === 'playing') expect(danger.next).toEqual(burnedBy(waited)) | |
| 49 | } | |
| 50 | s = step(s, randomAction(s, rng)).state | |
| 51 | } | |
| 52 | } | |
| 53 | }) | |
| 54 | }) | |
| 55 | ||
5f066960 2026-10-26 Pavel Safronov | 56 | describe('danger later on', () => { |
| 57 | it('a long fuse: its blast is marked from the start, as "later", then "next", then "now"', () => { | |
| 58 | let s = board(` | |
| 59 | ####### | |
| 60 | #P....# | |
| 61 | #######`, { fire: 1 }) | |
| 62 | s.bombs.push({ pos: v(4, 1), timer: 5, fire: 1, placedTurn: 0 }) | |
| 63 | const blast = [v(3, 1), v(4, 1), v(5, 1)].map((p) => idx(s, p)) | |
| 64 | const d = predictDanger(s) | |
| 65 | expect([...d.later].sort()).toEqual(blast.sort()) | |
| 66 | expect(d.next.size + d.now.size).toBe(0) | |
| 67 | for (let k = 0; k < 3; k++) s = step(s, { id: 'wait' }).state | |
| 68 | expect(predictDanger(s).next).toEqual(new Set(blast)) | |
| 69 | expect(predictDanger(s).later.size).toBe(0) | |
| 70 | }) | |
| 71 | }) | |
| 72 | ||
5551eb37 2026-10-20 Pavel Safronov | 73 | describe('run record', () => { |
| 74 | it('replays to the identical state', () => { | |
| 75 | const rng = new Rng(99) | |
d3e4ed26 2026-10-25 Pavel Safronov | 76 | // With a charge, so charged bombs and the sell-or-swap choice are in the mix too. |
| 77 | const record = newRecord(1234, 1, 'pierce') | |
| 78 | let s = newRun(record.seed, 1, 'pierce') | |
5551eb37 2026-10-20 Pavel Safronov | 79 | for (let t = 0; t < 300 && s.status !== 'gameOver'; t++) { |
| 80 | const a = randomAction(s, rng) | |
| 81 | record.actions.push(a) | |
| 82 | s = step(s, a).state | |
| 83 | } | |
| 84 | expect(replay(structuredClone(record))).toEqual(s) | |
| 85 | }) | |
0b61e800 2026-10-23 Pavel Safronov | 86 | |
| 87 | it('shows every event on the way, the same as playing it', () => { | |
| 88 | const rng = new Rng(5) | |
d3e4ed26 2026-10-25 Pavel Safronov | 89 | const record = newRecord(77, 1, 'freeze') |
| 90 | let s = newRun(record.seed, 1, 'freeze') | |
0b61e800 2026-10-23 Pavel Safronov | 91 | const played: string[] = [] |
| 92 | for (let t = 0; t < 200 && s.status !== 'gameOver'; t++) { | |
| 93 | const a = randomAction(s, rng) | |
| 94 | record.actions.push(a) | |
| 95 | const result = step(s, a) | |
| 96 | played.push(...result.events.map((e) => e.type)) | |
| 97 | s = result.state | |
| 98 | } | |
| 99 | const seen: string[] = [] | |
| 100 | replay(structuredClone(record), (e) => seen.push(e.type)) | |
| 101 | expect(seen).toEqual(played) | |
| 102 | }) | |
5551eb37 2026-10-20 Pavel Safronov | 103 | }) |