fuzzyworld

explodorama / files / test / danger.test.ts

danger.test.ts

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

5551eb37 2026-10-20 Pavel Safronov1import { describe, expect, it } from 'vitest'
2import { ACTIONS, ACTION_ORDER, isAvailable } from '../src/sim/actions'
3import { predictDanger } from '../src/sim/danger'
5f066960 2026-10-26 Pavel Safronov4import { spikesUp } from '../src/sim/explosions'
5551eb37 2026-10-20 Pavel Safronov5import { newRecord, replay } from '../src/sim/record'
6import { idx, newRun } from '../src/sim/state'
7import { step } from '../src/sim/turn'
8import type { Action, GameState } from '../src/sim/types'
9import { Rng } from '../src/sim/rng'
5f066960 2026-10-26 Pavel Safronov10import { board, v } from './helpers'
5551eb37 2026-10-20 Pavel Safronov11
c37a2b0b 2026-10-20 Pavel Safronov12/** A random but legal action; `continue` when dead or the level is cleared. */
5551eb37 2026-10-20 Pavel Safronov13function randomAction(s: GameState, rng: Rng): Action {
c37a2b0b 2026-10-20 Pavel Safronov14 if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' }
00ad7d0a 2026-10-24 Pavel Safronov15 // A gem taken: its choice comes first.
16 if (s.offer) return { id: 'choose', kind: s.offer[0]! }
d3e4ed26 2026-10-25 Pavel Safronov17 // 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 Safronov19 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 Safronov25/**
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 Safronov29function burnedBy(s: GameState): Set<number> {
82adee18 2026-10-20 Pavel Safronov30 const tiles = new Set<number>(s.burning)
5f066960 2026-10-26 Pavel Safronov31 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 Safronov35 return tiles
36}
37
38describe('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 Safronov42 let s = newRun(seed, 1 + (seed % 20), 'nuke')
5551eb37 2026-10-20 Pavel Safronov43 for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) {
00ad7d0a 2026-10-24 Pavel Safronov44 if (s.status === 'playing' && !s.offer) {
5551eb37 2026-10-20 Pavel Safronov45 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 Safronov56describe('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 Safronov73describe('run record', () => {
74 it('replays to the identical state', () => {
75 const rng = new Rng(99)
d3e4ed26 2026-10-25 Pavel Safronov76 // 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 Safronov79 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 Safronov86
87 it('shows every event on the way, the same as playing it', () => {
88 const rng = new Rng(5)
d3e4ed26 2026-10-25 Pavel Safronov89 const record = newRecord(77, 1, 'freeze')
90 let s = newRun(record.seed, 1, 'freeze')
0b61e800 2026-10-23 Pavel Safronov91 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 Safronov103})