explodorama / files / test / danger.test.ts
danger.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { ACTIONS, ACTION_ORDER, isAvailable } from '../src/sim/actions' |
| 3 | import { predictDanger } from '../src/sim/danger' |
| 4 | import { spikesUp } from '../src/sim/explosions' |
| 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' |
| 10 | import { board, v } from './helpers' |
| 11 | |
| 12 | /** A random but legal action; `continue` when dead or the level is cleared. */ |
| 13 | function randomAction(s: GameState, rng: Rng): Action { |
| 14 | if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' } |
| 15 | // A gem taken: its choice comes first. |
| 16 | if (s.offer) return { id: 'choose', kind: s.offer[0]! } |
| 17 | // A second charge picked up: sell it or swap, at random. |
| 18 | if (s.chargeOffer) return { id: rng.next() < 0.5 ? 'sellCharge' : 'swapCharge' } |
| 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 | |
| 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 | */ |
| 29 | function burnedBy(s: GameState): Set<number> { |
| 30 | const tiles = new Set<number>(s.burning) |
| 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 | } |
| 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++) { |
| 42 | let s = newRun(seed, 1 + (seed % 20), 'nuke') |
| 43 | for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) { |
| 44 | if (s.status === 'playing' && !s.offer) { |
| 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 | |
| 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 | |
| 73 | describe('run record', () => { |
| 74 | it('replays to the identical state', () => { |
| 75 | const rng = new Rng(99) |
| 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') |
| 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 | }) |
| 86 | |
| 87 | it('shows every event on the way, the same as playing it', () => { |
| 88 | const rng = new Rng(5) |
| 89 | const record = newRecord(77, 1, 'freeze') |
| 90 | let s = newRun(record.seed, 1, 'freeze') |
| 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 | }) |
| 103 | }) |