fuzzyworld

explodorama / files / test / options.test.ts

options.test.ts

8.3 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { boardSize, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen'
3import { newRecord, replay } from '../src/sim/record'
4import { newRun } from '../src/sim/state'
5import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn'
6import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types'
7import { BOMB_BOOST } from '../src/sim/charges'
8import { board, v } from './helpers'
9
10describe('the board (growing with the level since 2026-10-25)', () => {
11 for (let seed = 1; seed <= 100; seed++) {
12 const level = 1 + (seed % 20)
13 it(`seed ${seed}: its level's size, exit and power-up under crates, enemies placed, everything reachable`, () => {
14 const g = generateLevel(seed, level)
15 expect({ width: g.width, height: g.height }).toEqual(boardSize(level))
16 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)
20 expect(g.enemies.length).toBeGreaterThanOrEqual(2)
21 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
37 it('boards grow with the level, from 9 × 11 to 13 × 17, over a 20-level run', () => {
38 expect(boardSize(1)).toEqual({ width: 9, height: 11 })
39 expect(boardSize(4)).toEqual({ width: 11, height: 13 })
40 expect(boardSize(20)).toEqual({ width: 13, height: 17 })
41 expect(boardSize(50)).toEqual({ width: 13, height: 17 })
42 expect(generateLevel(1, 1).enemies).toHaveLength(2)
43 expect(LAST_LEVEL).toBe(20)
44 })
45
46 it('clearing level 20 wins the run', () => {
47 const s = { ...board(`
48 ####
49 #PE#
50 ####`), level: 20 }
51 expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('won')
52 })
53
54 it('every level is 9 × 11', () => {
55 const s = board(`
56 ####
57 #PE#
58 ####`)
59 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
67 it('gives nothing but the gem: the usual stats, abilities and charges', () => {
68 const s = newRun(1, 1, null, dev)
69 const plain = newRun(1, 1)
70 expect(s.stats).toEqual(plain.stats)
71 expect(s.powers).toEqual(plain.powers)
72 expect(s.charge).toEqual(plain.charge)
73 })
74
75 it('the gem is back after a death', () => {
76 const s = newRun(1, 1, null, dev)
77 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
79 expect(dead.status).toBe('dead')
80 const again = step(dead, { id: 'continue' }).state
81 expect(again.gems.find((g) => g.extra)?.pos).toEqual({ x: START.x + 1, y: START.y })
82 })
83
84 it('starts every level with an extra gem uncovered right of the player', () => {
85 for (const level of [1, 7]) {
86 const s = newRun(5, level, null, dev)
87 const extra = s.gems.find((g) => g.extra)!
88 expect(extra.pos).toEqual({ x: START.x + 1, y: START.y })
89 expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor')
90 const { state } = step(s, { id: 'move', target: extra.pos })
91 expect(state.offer?.length).toBeGreaterThan(0)
92 }
93 })
94
95 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++) {
97 const s = newRun(seed, 1, null, dev)
98 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
105 it('the extra gem is the rainbow one, and offers every power-up and charge (user request, 2026-10-24)', () => {
106 const s = newRun(1, 1, null, dev)
107 expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow')
108 expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow')
109 const everything = [...POWER_UP_WEIGHTS.map(([kind]) => kind), ...CHARGE_IDS]
110 expect(drawOffer(s, true)).toEqual(everything)
111 const { state } = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } })
112 expect(state.offer).toEqual(everything)
113 })
114
115 it('a charge chosen from the extra gem goes into the slot', () => {
116 const s = newRun(1, 1, null, dev)
117 const taken = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }).state
118 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 })
121 const decoy = step(taken, { id: 'choose', kind: 'decoy' })
122 expect(decoy.state.charge).toEqual({ kind: 'decoy', left: 1 })
123 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++) {
128 expect(drawOffer(newRun(seed, 1, null, dev)).some(isCharge)).toBe(false)
129 }
130 })
131
132 it('the extra gem leaves out what the player already has', () => {
133 const s = newRun(1, 1, null, dev)
134 s.powers.run = true
135 s.powers.wallPass = true
136 const offer = drawOffer(s, true)
137 expect(offer).not.toContain('run')
138 expect(offer).not.toContain('wallPass')
139 expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2 + CHARGE_IDS.length)
140 // Stats, a Star, a heart and coins can always be had again.
141 expect(offer).toEqual(expect.arrayContaining(['bombPlus', 'firePlus', 'fusePlus', 'star', 'heart', 'coinBag']))
142 })
143
144 it('a normal run has just the one gem, under a crate', () => {
145 const s = newRun(5)
146 expect(s.gems).toHaveLength(1)
147 expect(s.cells[s.gems[0]!.pos.y * s.width + s.gems[0]!.pos.x]).toBe('block')
148 })
149})
150
151describe('Immortal', () => {
152 it('nothing kills the player', () => {
153 const immortal = { ...DEFAULT_OPTIONS, immortal: true }
154 const flame = { ...board(`
155 #####
156 #P1.#
157 #####`), options: immortal }
158 expect(step(flame, { id: 'wait' }).state.status).toBe('playing')
159 const enemy = { ...board(`
160 #####
161 #PB.#
162 #####`), options: immortal }
163 expect(step(enemy, { id: 'move', target: v(2, 1) }).state.status).toBe('playing')
164 })
165})
166
167describe('crumbling crates (always, since 2026-10-23)', () => {
168 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#
173 #########`) }
174 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
189 it('while an enemy lives, crates stay', () => {
190 const s = board(`
191 #########
192 #P...1BB#
193 #+#######
194 #########`)
195 s.enemies[1]!.pos = v(7, 1)
196 const r = step(s, { id: 'wait' }).state
197 expect(r.enemies.length).toBeGreaterThan(0)
198 expect(r.crumbling).toEqual([])
199 })
200})
201
202describe('run record', () => {
203 it('keeps the options, so a replay is the same run', () => {
204 const options = { dev: true, immortal: true }
205 const record = newRecord(77, 1, null, options)
206 let s = newRun(77, 1, null, options)
207 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})