fuzzyworld

explodorama / files / test / options.test.ts

options.test.ts

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

beb9f2b7 2026-10-21 Pavel Safronov1import { describe, expect, it } from 'vitest'
e6bb7c42 2026-10-25 Pavel Safronov2import { boardSize, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen'
beb9f2b7 2026-10-21 Pavel Safronov3import { newRecord, replay } from '../src/sim/record'
52d68b59 2026-10-24 Pavel Safronov4import { newRun } from '../src/sim/state'
6e94f995 2026-10-24 Pavel Safronov5import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn'
e3f29330 2026-10-24 Pavel Safronov6import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types'
d3e4ed26 2026-10-25 Pavel Safronov7import { BOMB_BOOST } from '../src/sim/charges'
beb9f2b7 2026-10-21 Pavel Safronov8import { board, v } from './helpers'
9
e6bb7c42 2026-10-25 Pavel Safronov10describe('the board (growing with the level since 2026-10-25)', () => {
beb9f2b7 2026-10-21 Pavel Safronov11 for (let seed = 1; seed <= 100; seed++) {
12 const level = 1 + (seed % 20)
e6bb7c42 2026-10-25 Pavel Safronov13 it(`seed ${seed}: its level's size, exit and power-up under crates, enemies placed, everything reachable`, () => {
00ad7d0a 2026-10-24 Pavel Safronov14 const g = generateLevel(seed, level)
e6bb7c42 2026-10-25 Pavel Safronov15 expect({ width: g.width, height: g.height }).toEqual(boardSize(level))
beb9f2b7 2026-10-21 Pavel Safronov16 const at = (p: Vec) => g.cells[p.y * g.width + p.x]
17 expect(at(g.exit)).toBe('block')
18 expect(at(g.powerUp.pos)).toBe('block')
19 expect(g.powerUp.pos).not.toEqual(g.exit)
e6bb7c42 2026-10-25 Pavel Safronov20 expect(g.enemies.length).toBeGreaterThanOrEqual(2)
beb9f2b7 2026-10-21 Pavel Safronov21 for (const p of [START, { x: 2, y: 1 }, { x: 1, y: 2 }]) expect(at(p)).toBe('floor')
22 const seen = new Set([`${START.x},${START.y}`])
23 const queue = [START]
24 while (queue.length) {
25 const p = queue.pop()!
26 for (const q of [{ x: p.x + 1, y: p.y }, { x: p.x - 1, y: p.y }, { x: p.x, y: p.y + 1 }, { x: p.x, y: p.y - 1 }]) {
27 if (at(q) !== 'wall' && !seen.has(`${q.x},${q.y}`)) {
28 seen.add(`${q.x},${q.y}`)
29 queue.push(q)
30 }
31 }
32 }
33 expect(seen.size).toBe(g.cells.filter((c) => c !== 'wall').length)
34 })
35 }
36
fcf44f32 2026-10-25 Pavel Safronov37 it('boards grow with the level, from 9 × 11 to 13 × 17, over a 20-level run', () => {
e6bb7c42 2026-10-25 Pavel Safronov38 expect(boardSize(1)).toEqual({ width: 9, height: 11 })
39 expect(boardSize(4)).toEqual({ width: 11, height: 13 })
fcf44f32 2026-10-25 Pavel Safronov40 expect(boardSize(20)).toEqual({ width: 13, height: 17 })
41 expect(boardSize(50)).toEqual({ width: 13, height: 17 })
e6bb7c42 2026-10-25 Pavel Safronov42 expect(generateLevel(1, 1).enemies).toHaveLength(2)
00ad7d0a 2026-10-24 Pavel Safronov43 expect(LAST_LEVEL).toBe(20)
beb9f2b7 2026-10-21 Pavel Safronov44 })
45
00ad7d0a 2026-10-24 Pavel Safronov46 it('clearing level 20 wins the run', () => {
beb9f2b7 2026-10-21 Pavel Safronov47 const s = { ...board(`
48 ####
49 #PE#
00ad7d0a 2026-10-24 Pavel Safronov50 ####`), level: 20 }
beb9f2b7 2026-10-21 Pavel Safronov51 expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('won')
52 })
53
00ad7d0a 2026-10-24 Pavel Safronov54 it('every level is 9 × 11', () => {
55 const s = board(`
beb9f2b7 2026-10-21 Pavel Safronov56 ####
57 #PE#
00ad7d0a 2026-10-24 Pavel Safronov58 ####`)
beb9f2b7 2026-10-21 Pavel Safronov59 const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state
60 expect([next.width, next.height]).toEqual([9, 11])
61 })
62})
63
64describe('developer mode', () => {
65 const dev = { ...DEFAULT_OPTIONS, dev: true }
66
52d68b59 2026-10-24 Pavel Safronov67 it('gives nothing but the gem: the usual stats, abilities and charges', () => {
d3e4ed26 2026-10-25 Pavel Safronov68 const s = newRun(1, 1, null, dev)
69 const plain = newRun(1, 1)
52d68b59 2026-10-24 Pavel Safronov70 expect(s.stats).toEqual(plain.stats)
71 expect(s.powers).toEqual(plain.powers)
d3e4ed26 2026-10-25 Pavel Safronov72 expect(s.charge).toEqual(plain.charge)
beb9f2b7 2026-10-21 Pavel Safronov73 })
74
52d68b59 2026-10-24 Pavel Safronov75 it('the gem is back after a death', () => {
d3e4ed26 2026-10-25 Pavel Safronov76 const s = newRun(1, 1, null, dev)
52d68b59 2026-10-24 Pavel Safronov77 s.enemies.push({ id: 99, kind: 'blob', pos: { x: 1, y: 2 }, dir: 'up', plan: [], spawned: false, sheltered: false })
78 const dead = step(s, { id: 'move', target: { x: 1, y: 2 } }).state
beb9f2b7 2026-10-21 Pavel Safronov79 expect(dead.status).toBe('dead')
80 const again = step(dead, { id: 'continue' }).state
39c26813 2026-10-24 Pavel Safronov81 expect(again.gems.find((g) => g.extra)?.pos).toEqual({ x: START.x + 1, y: START.y })
52d68b59 2026-10-24 Pavel Safronov82 })
83
39c26813 2026-10-24 Pavel Safronov84 it('starts every level with an extra gem uncovered right of the player', () => {
52d68b59 2026-10-24 Pavel Safronov85 for (const level of [1, 7]) {
d3e4ed26 2026-10-25 Pavel Safronov86 const s = newRun(5, level, null, dev)
39c26813 2026-10-24 Pavel Safronov87 const extra = s.gems.find((g) => g.extra)!
88 expect(extra.pos).toEqual({ x: START.x + 1, y: START.y })
52d68b59 2026-10-24 Pavel Safronov89 expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor')
39c26813 2026-10-24 Pavel Safronov90 const { state } = step(s, { id: 'move', target: extra.pos })
52d68b59 2026-10-24 Pavel Safronov91 expect(state.offer?.length).toBeGreaterThan(0)
92 }
93 })
94
39c26813 2026-10-24 Pavel Safronov95 it('keeps the level\'s own gem, hidden under its crate as usual (user request, 2026-10-23)', () => {
96 for (let seed = 1; seed <= 20; seed++) {
d3e4ed26 2026-10-25 Pavel Safronov97 const s = newRun(seed, 1, null, dev)
39c26813 2026-10-24 Pavel Safronov98 const own = s.gems.filter((g) => !g.extra)
99 expect(own).toHaveLength(1)
100 expect(own[0]).toEqual(newRun(seed).gems[0])
101 expect(s.cells[own[0]!.pos.y * s.width + own[0]!.pos.x]).toBe('block')
102 }
103 })
104
e3f29330 2026-10-24 Pavel Safronov105 it('the extra gem is the rainbow one, and offers every power-up and charge (user request, 2026-10-24)', () => {
d3e4ed26 2026-10-25 Pavel Safronov106 const s = newRun(1, 1, null, dev)
6e94f995 2026-10-24 Pavel Safronov107 expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow')
108 expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow')
e3f29330 2026-10-24 Pavel Safronov109 const everything = [...POWER_UP_WEIGHTS.map(([kind]) => kind), ...CHARGE_IDS]
110 expect(drawOffer(s, true)).toEqual(everything)
6e94f995 2026-10-24 Pavel Safronov111 const { state } = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } })
e3f29330 2026-10-24 Pavel Safronov112 expect(state.offer).toEqual(everything)
113 })
114
d3e4ed26 2026-10-25 Pavel Safronov115 it('a charge chosen from the extra gem goes into the slot', () => {
116 const s = newRun(1, 1, null, dev)
e3f29330 2026-10-24 Pavel Safronov117 const taken = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }).state
d3e4ed26 2026-10-25 Pavel Safronov118 expect(step(taken, { id: 'choose', kind: 'freeze' }).state.charge).toEqual({ kind: 'freeze', left: 1 })
119 expect(step(taken, { id: 'choose', kind: 'shield' }).state.charge).toEqual({ kind: 'shield', left: 1 })
120 expect(step(taken, { id: 'choose', kind: 'nuke' }).state.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST })
e3f29330 2026-10-24 Pavel Safronov121 const decoy = step(taken, { id: 'choose', kind: 'decoy' })
d3e4ed26 2026-10-25 Pavel Safronov122 expect(decoy.state.charge).toEqual({ kind: 'decoy', left: 1 })
e3f29330 2026-10-24 Pavel Safronov123 expect(decoy.state.offer).toBeNull()
124 })
125
126 it('a level\'s own gem never offers a charge', () => {
127 for (let seed = 1; seed <= 30; seed++) {
d3e4ed26 2026-10-25 Pavel Safronov128 expect(drawOffer(newRun(seed, 1, null, dev)).some(isCharge)).toBe(false)
e3f29330 2026-10-24 Pavel Safronov129 }
6e94f995 2026-10-24 Pavel Safronov130 })
131
132 it('the extra gem leaves out what the player already has', () => {
d3e4ed26 2026-10-25 Pavel Safronov133 const s = newRun(1, 1, null, dev)
6e94f995 2026-10-24 Pavel Safronov134 s.powers.run = true
fcf44f32 2026-10-25 Pavel Safronov135 s.powers.wallPass = true
6e94f995 2026-10-24 Pavel Safronov136 const offer = drawOffer(s, true)
137 expect(offer).not.toContain('run')
fcf44f32 2026-10-25 Pavel Safronov138 expect(offer).not.toContain('wallPass')
e3f29330 2026-10-24 Pavel Safronov139 expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2 + CHARGE_IDS.length)
6e94f995 2026-10-24 Pavel Safronov140 // Stats, a Star, a heart and coins can always be had again.
141 expect(offer).toEqual(expect.arrayContaining(['bombPlus', 'firePlus', 'fusePlus', 'star', 'heart', 'coinBag']))
39c26813 2026-10-24 Pavel Safronov142 })
143
144 it('a normal run has just the one gem, under a crate', () => {
52d68b59 2026-10-24 Pavel Safronov145 const s = newRun(5)
39c26813 2026-10-24 Pavel Safronov146 expect(s.gems).toHaveLength(1)
147 expect(s.cells[s.gems[0]!.pos.y * s.width + s.gems[0]!.pos.x]).toBe('block')
beb9f2b7 2026-10-21 Pavel Safronov148 })
149})
150
3cf3607b 2026-10-24 Pavel Safronov151describe('Immortal', () => {
beb9f2b7 2026-10-21 Pavel Safronov152 it('nothing kills the player', () => {
00ad7d0a 2026-10-24 Pavel Safronov153 const immortal = { ...DEFAULT_OPTIONS, immortal: true }
beb9f2b7 2026-10-21 Pavel Safronov154 const flame = { ...board(`
155 #####
156 #P1.#
00ad7d0a 2026-10-24 Pavel Safronov157 #####`), options: immortal }
beb9f2b7 2026-10-21 Pavel Safronov158 expect(step(flame, { id: 'wait' }).state.status).toBe('playing')
159 const enemy = { ...board(`
160 #####
161 #PB.#
00ad7d0a 2026-10-24 Pavel Safronov162 #####`), options: immortal }
beb9f2b7 2026-10-21 Pavel Safronov163 expect(step(enemy, { id: 'move', target: v(2, 1) }).state.status).toBe('playing')
164 })
165})
166
00ad7d0a 2026-10-24 Pavel Safronov167describe('crumbling crates (always, since 2026-10-23)', () => {
beb9f2b7 2026-10-21 Pavel Safronov168 it('once the last enemy dies, every crate goes within 3 turns, revealing the exit, with no punishment', () => {
169 let s = { ...board(`
170 #########
171 #P...1B+#
172 #+++++#e#
00ad7d0a 2026-10-24 Pavel Safronov173 #########`) }
beb9f2b7 2026-10-21 Pavel Safronov174 const r = step(s, { id: 'wait' })
175 expect(r.events.map((e) => e.type)).toContain('cratesCrumbling')
176 s = r.state
177 expect(s.crumbling.length).toBe(s.cells.filter((c) => c === 'block').length)
178 const events = []
179 for (let t = 0; t < 3; t++) {
180 const next = step(s, { id: 'wait' })
181 events.push(...next.events)
182 s = next.state
183 }
184 expect(s.cells.filter((c) => c === 'block')).toEqual([])
185 expect(events.map((e) => e.type)).toContain('exitRevealed')
186 expect(s.enemies).toEqual([])
187 })
188
00ad7d0a 2026-10-24 Pavel Safronov189 it('while an enemy lives, crates stay', () => {
beb9f2b7 2026-10-21 Pavel Safronov190 const s = board(`
00ad7d0a 2026-10-24 Pavel Safronov191 #########
192 #P...1BB#
193 #+#######
194 #########`)
195 s.enemies[1]!.pos = v(7, 1)
beb9f2b7 2026-10-21 Pavel Safronov196 const r = step(s, { id: 'wait' }).state
00ad7d0a 2026-10-24 Pavel Safronov197 expect(r.enemies.length).toBeGreaterThan(0)
beb9f2b7 2026-10-21 Pavel Safronov198 expect(r.crumbling).toEqual([])
199 })
200})
201
202describe('run record', () => {
203 it('keeps the options, so a replay is the same run', () => {
00ad7d0a 2026-10-24 Pavel Safronov204 const options = { dev: true, immortal: true }
d3e4ed26 2026-10-25 Pavel Safronov205 const record = newRecord(77, 1, null, options)
206 let s = newRun(77, 1, null, options)
beb9f2b7 2026-10-21 Pavel Safronov207 for (const a of [{ id: 'wait' as const }, { id: 'wait' as const }]) {
208 record.actions.push(a)
209 s = step(s, a).state
210 }
211 expect(replay(structuredClone(record))).toEqual(s)
212 })
213})