fuzzyworld

explodorama / files / test / powerups.test.ts

powerups.test.ts

12.3 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { ACTIONS, isAvailable } from '../src/sim/actions'
3import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen'
4import { newRun, START_STATS } from '../src/sim/state'
5import { applyPowerUp, COIN_BAG, drawOffer, EXIT_SPAWN, heldAlready, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, STAT_MAX, step } from '../src/sim/turn'
6import type { GameState, OfferKind, PowerUpKind } from '../src/sim/types'
7import { board, v } from './helpers'
8
9describe('levelgen: the hidden gem', () => {
10 for (let seed = 1; seed <= 100; seed++) {
11 it(`seed ${seed}: under a soft block that isn't the exit's, in one of the four colours`, () => {
12 const g = generateLevel(seed, 1 + (seed % 50))
13 expect(g.cells[g.powerUp.pos.y * g.width + g.powerUp.pos.x]).toBe('block')
14 expect(g.powerUp.pos).not.toEqual(g.exit)
15 expect(['blue', 'green', 'red', 'yellow']).toContain(g.powerUp.color)
16 })
17 }
18})
19
20/** A board with the gem lying uncovered at (2, 1). */
21function withGem(picture = `
22 ######
23 #P...#
24 ######`): GameState {
25 const s = board(picture)
26 s.gems = [{ pos: v(2, 1), color: 'red' }]
27 return s
28}
29
30/** A state waiting for a choice from `offer`. */
31function offering(...offer: PowerUpKind[]): GameState {
32 const s = board(`
33 ######
34 #P...#
35 ######`)
36 s.offer = offer
37 return s
38}
39
40const ORDER: OfferKind[] = POWER_UP_WEIGHTS.map(([kind]) => kind)
41
42describe('the offer', () => {
43 it(`is ${OFFER_SIZE} different power-ups, in the almanac's order, the same for the same seed and level`, () => {
44 for (let seed = 1; seed <= 50; seed++) {
45 const s = newRun(seed, 1 + (seed % 20))
46 const offer = drawOffer(s)
47 expect(offer).toHaveLength(OFFER_SIZE)
48 expect(new Set(offer).size).toBe(OFFER_SIZE)
49 expect([...offer].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))).toEqual(offer)
50 expect(drawOffer(s)).toEqual(offer)
51 }
52 })
53
54 it('leaves out abilities and passes already held', () => {
55 const s = newRun(3)
56 s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, rpg: true, proximity: true, slide: true }
57 for (let level = 1; level <= 30; level++) {
58 s.level = level
59 const offer = drawOffer(s)
60 expect(offer).toHaveLength(OFFER_SIZE)
61 for (const k of ['run', 'kick', 'throw', 'teleport', 'diagonal', 'detonator', 'wallPass', 'flamePass'] as PowerUpKind[]) expect(offer).not.toContain(k)
62 }
63 })
64
65 it('is smaller when fewer would do anything', () => {
66 const s = newRun(3)
67 s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true }
68 // Left: Bomb+, Fire+, Fuse+, Star, Heart, Coin bag.
69 expect(drawOffer(s)).toHaveLength(OFFER_SIZE)
70 })
71
72 it('offers rare ones less often than common ones', () => {
73 let fire = 0
74 let proximity = 0
75 for (let seed = 1; seed <= 400; seed++) {
76 const offer = drawOffer(newRun(seed))
77 if (offer.includes('firePlus')) fire++
78 if (offer.includes('proximity')) proximity++
79 }
80 expect(fire).toBeGreaterThan(proximity * 2)
81 expect(proximity).toBeGreaterThan(0)
82 })
83})
84
85describe('taking the gem', () => {
86 it('sets up the offer and gives nothing yet; the gem is gone', () => {
87 const { state, events } = step(withGem(), { id: 'move', target: v(2, 1) })
88 expect(state.offer).toEqual(drawOffer(withGem()))
89 expect(state.gems).toEqual([])
90 expect(state.stats).toEqual(withGem().stats)
91 expect(events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), color: 'red' }, offer: state.offer })
92 })
93
94 it('nothing but choosing is possible until then', () => {
95 const s = offering('heart', 'bombPlus')
96 expect(isAvailable(s, 'move')).toBe(false)
97 expect(isAvailable(s, 'wait')).toBe(false)
98 expect(() => step(s, { id: 'wait' })).toThrow()
99 })
100
101 it('a power-up that is not on offer cannot be chosen', () => {
102 expect(() => step(offering('heart'), { id: 'choose', kind: 'run' })).toThrow()
103 })
104
105 it('choosing takes no turn and nothing else happens', () => {
106 const s = offering('heart', 'bombPlus')
107 s.bombs.push({ pos: v(4, 1), timer: 1, fire: 1, placedTurn: 0 })
108 const { state, events } = step(s, { id: 'choose', kind: 'heart' })
109 expect(events).toEqual([{ type: 'powerUpChosen', kind: 'heart' }])
110 expect(state.offer).toBeNull()
111 expect(state.turn).toBe(s.turn)
112 expect(state.bombs).toEqual(s.bombs)
113 })
114
115 it('a death on the same turn drops the offer; the retry has the gem back', () => {
116 const s = withGem(`
117 ######
118 #P...#
119 ######`)
120 s.bombs.push({ pos: v(3, 1), timer: 1, fire: 1, placedTurn: 0 })
121 const { state } = step(s, { id: 'move', target: v(2, 1) })
122 expect(state.status).toBe('dead')
123 expect(state.offer).toBeNull()
124 })
125
126 it('reaching the exit on the same turn still needs the choice before the next level', () => {
127 const s = offering('heart')
128 s.status = 'cleared'
129 expect(() => step(s, { id: 'continue' })).toThrow()
130 const chosen = step(s, { id: 'choose', kind: 'heart' }).state
131 expect(chosen.lives).toBe(4)
132 expect(step(chosen, { id: 'continue' }).state.level).toBe(2)
133 })
134})
135
136describe('the power-ups', () => {
137 const take = (kind: PowerUpKind) => step(offering(kind), { id: 'choose', kind })
138
139 it('stats: Bomb+, Fire+, Fuse+', () => {
140 expect(take('bombPlus').state.stats.capacity).toBe(2)
141 expect(take('firePlus').state.stats.fire).toBe(2)
142 expect(take('fusePlus').state.stats.fuse).toBe(4)
143 })
144
145 it('Heart, Coin bag, Star', () => {
146 expect(take('heart').state.lives).toBe(4)
147 expect(take('coinBag').state.coins).toBe(COIN_BAG)
148 // Chosen between turns, so all its turns are still to come.
149 expect(take('star').state.star).toBe(STAR_TURNS)
150 })
151
152 it('abilities and passes', () => {
153 expect(take('run').state.powers.run).toBe(true)
154 expect(take('detonator').state.powers.detonate).toBe(true)
155 expect(take('kick').state.powers.kick).toBe(true)
156 expect(take('throw').state.powers.throw).toBe(true)
157 expect(take('diagonal').state.powers.diagonal).toBe(true)
158 expect(take('wallPass').state.powers.wallPass).toBe(true)
159 expect(take('bombPass').state.powers.bombPass).toBe(true)
160 })
161
162 it('a gem still under its block cannot be taken', () => {
163 const s = withGem(`
164 ######
165 #P+..#
166 ######`)
167 s.powers.wallPass = true
168 expect(step(s, { id: 'move', target: v(2, 1) }).state.offer).toBeNull()
169 })
170
171 it('burning its block reveals it', () => {
172 const s = withGem(`
173 ######
174 #P.1+#
175 ######`)
176 s.gems[0]!.pos = v(4, 1)
177 const { state, events } = step(s, { id: 'wait' })
178 expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } })
179 expect(state.gems).toHaveLength(1)
180 })
181
182 it('flame on it once uncovered destroys it and lets enemies out', () => {
183 const s = withGem(`
184 ######
185 #P..1#
186 ######`)
187 s.gems[0]!.pos = v(3, 1)
188 const { state, events } = step(s, { id: 'wait' })
189 expect(state.gems).toEqual([])
190 expect(events.map((e) => e.type)).toContain('powerUpBurned')
191 expect(state.enemies).toHaveLength(EXIT_SPAWN)
192 })
193})
194
195describe('Run', () => {
196 it('goes 2 tiles in a straight line, only over free tiles', () => {
197 const s = board(`
198 #######
199 #P...3#
200 #.#####
201 #+#####
202 #######`)
203 expect(isAvailable(s, 'run')).toBe(false)
204 s.powers.run = true
205 expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)])
206 expect(step(s, { id: 'run', target: v(3, 1) }).state.player).toEqual(v(3, 1))
207 })
208
209 it('collects what is on the tile it runs through', () => {
210 const s = board(`
211 ######
212 #P$..#
213 ######`)
214 s.powers.run = true
215 expect(step(s, { id: 'run', target: v(3, 1) }).state.coins).toBe(1)
216 })
217
218 it('running into an enemy on the tile in between is death', () => {
219 const s = board(`
220 ######
221 #PB..#
222 ######`)
223 s.powers.run = true
224 expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)])
225 expect(step(s, { id: 'run', target: v(3, 1) }).state.status).toBe('dead')
226 })
227})
228
229describe('Detonate', () => {
230 it('sets one of your bombs off this turn', () => {
231 const s = board(`
232 #######
233 #P..3+#
234 #######`)
235 s.powers.detonate = true
236 const { state, events } = step(s, { id: 'detonate', target: v(4, 1) })
237 expect(events).toContainEqual({ type: 'detonated', pos: v(4, 1) })
238 expect(state.bombs).toEqual([])
239 expect(state.cells[7 + 5]).toBe('floor')
240 })
241})
242
243describe('passes', () => {
244 it('Crate Pass walks through soft blocks', () => {
245 const s = board(`
246 #####
247 #P+.#
248 #####`)
249 expect(ACTIONS.move.validTargets(s)).toEqual([])
250 s.powers.wallPass = true
251 expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
252 })
253
254 it('Bomb Pass walks over bombs', () => {
255 const s = board(`
256 #####
257 #P3.#
258 #####`)
259 s.powers.bombPass = true
260 expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
261 })
262
263 it('Flame Pass: flame does not hurt; enemies still do', () => {
264 const s = board(`
265 #####
266 #P1.#
267 #####`)
268 s.charge = { kind: 'flamePass', left: 99 }
269 expect(step(s, { id: 'wait' }).state.status).toBe('playing')
270 const e = board(`
271 #####
272 #PB.#
273 #####`)
274 e.charge = { kind: 'flamePass', left: 99 }
275 expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead')
276 })
277
278 it('fire can be stepped into only while it is safe: Flame Pass or a Star', () => {
279 const s = board(`
280 #####
281 #P..#
282 #####`)
283 s.burning = [s.width + 2]
284 expect(ACTIONS.move.validTargets(s)).toEqual([])
285 s.star = 2
286 expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
287 s.star = 0
288 s.charge = { kind: 'flamePass', left: 99 }
289 expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
290 })
291
292 it('Star: nothing hurts while it lasts', () => {
293 const s = board(`
294 #####
295 #PB1#
296 #####`)
297 s.star = 3
298 expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('playing')
299 })
300
301 it('a death keeps every power-up and the stats (since 2026-10-23)', () => {
302 const s = { ...board(`
303 #####
304 #P1.#
305 #####`, { capacity: 2 }), star: 0 }
306 s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true }
307 const dead = step(s, { id: 'wait' }).state
308 expect(dead.deaths).toBe(1)
309 const again = step(dead, { id: 'continue' }).state
310 expect(again.powers).toEqual(s.powers)
311 expect(again.stats.capacity).toBe(2)
312 })
313
314 it('clearing a level keeps everything', () => {
315 const s = { ...board(`
316 ####
317 #PE#
318 ####`), star: 4 }
319 s.powers.wallPass = true
320 const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state
321 expect(next.powers.wallPass).toBe(true)
322 expect(next.star).toBe(4)
323 })
324})
325
326describe('the ending', () => {
327 it(`clearing level ${LAST_LEVEL} wins the run`, () => {
328 const s = { ...board(`
329 ####
330 #PE#
331 ####`), level: LAST_LEVEL, runTurns: 1234, deaths: 2 }
332 const { state, events } = step(s, { id: 'move', target: v(2, 1) })
333 expect(state.status).toBe('won')
334 expect(events.at(-1)).toEqual({ type: 'runWon', turns: 1235, deaths: 2 })
335 expect(() => step(state, { id: 'continue' })).toThrow()
336 })
337
338 it('counts turns across the whole run', () => {
339 let s = newRun(3)
340 s = step(s, { id: 'wait' }).state
341 s = step(s, { id: 'wait' }).state
342 expect(s.runTurns).toBe(2)
343 })
344})
345
346describe('TNT and the limits on stats (user request, 2026-10-25)', () => {
347 it('TNT raises the blast by 2; every stat stops at its limit', () => {
348 const s = newRun(1)
349 applyPowerUp(s, 'tnt')
350 expect(s.stats.fire).toBe(START_STATS.fire + 2)
351 for (let i = 0; i < 10; i++) {
352 applyPowerUp(s, 'firePlus')
353 applyPowerUp(s, 'bombPlus')
354 applyPowerUp(s, 'fusePlus')
355 }
356 expect(s.stats).toEqual(STAT_MAX)
357 })
358
359 it('a stat power-up is not offered without room for all of it', () => {
360 const s = newRun(1)
361 s.stats.fire = STAT_MAX.fire - 1
362 expect(heldAlready(s, 'firePlus')).toBe(false)
363 expect(heldAlready(s, 'tnt')).toBe(true)
364 s.stats.fire = STAT_MAX.fire
365 expect(heldAlready(s, 'firePlus')).toBe(true)
366 for (let seed = 1; seed <= 30; seed++) expect(drawOffer({ ...s, seed })).not.toContain('firePlus')
367 })
368})