fuzzyworld

explodorama / files / test / hazards.test.ts

hazards.test.ts

9.3 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { predictDanger } from '../src/sim/danger'
3import { stepToward } from '../src/sim/enemies'
4import { blastTiles, COLLAPSE_TURNS } from '../src/sim/explosions'
5import { generateLevel, OBJECTS_FROM } from '../src/sim/levelgen'
6import { idx } from '../src/sim/state'
7import { step } from '../src/sim/turn'
8import { isSolid, type GameState } from '../src/sim/types'
9import { board, v } from './helpers'
10
11const wait = (s: GameState) => step(s, { id: 'wait' })
12
13describe('metal crates (PLAN.md §19.3)', () => {
14 it('stop flame like a wall, even a Pierce bomb’s; a Nuke’s goes round, but they never break', () => {
15 const s = board(`
16 ########
17 #P..1M.#
18 ########`, { fire: 3 })
19 s.bombs[0]!.pierce = true
20 s.bombs[0]!.nuke = true
21 const r = wait(s)
22 const tiles = r.events.flatMap((e) => (e.type === 'exploded' ? e.tiles : []))
23 expect(tiles).not.toContainEqual(v(5, 1))
24 expect(r.state.cells[idx(s, v(5, 1))]).toBe('metal')
25 })
26
27 it('can’t be walked through, even with Crate Pass', () => {
28 const s = board(`
29 #####
30 #PM.#
31 #####`)
32 s.powers.wallPass = true
33 expect(() => step(s, { id: 'move', target: v(2, 1) })).toThrow()
34 })
35})
36
37describe('oil barrels', () => {
38 it('flame bursts one: oil round it, and its own blast the next turn', () => {
39 const s = board(`
40 #########
41 #P..1O..#
42 #########`, { fire: 2 })
43 const lit = wait(s)
44 expect(lit.events.some((e) => e.type === 'barrelLit')).toBe(true)
45 const t = lit.state
46 expect(t.cells[idx(t, v(5, 1))]).toBe('floor')
47 expect(t.oil).toEqual(expect.arrayContaining([idx(t, v(5, 1)), idx(t, v(6, 1))]))
48 // It isn't the player's bomb: it doesn't count to their limit, nor can it be set off.
49 expect(t.bombs).toEqual([expect.objectContaining({ pos: v(5, 1), barrel: true, timer: 1 })])
50 const after = wait(t)
51 expect(after.events.some((e) => e.type === 'exploded' && e.pos.x === 5)).toBe(true)
52 // Its blast lights its own spill: all of it burns.
53 expect(after.state.oil).toEqual([])
54 })
55})
56
57describe('oil spills', () => {
58 it('flame on one tile burns the whole spill, and sets off a bomb lying in it', () => {
59 const s = board(`
60 ##########
61 #P.1~~~..#
62 #......4.#
63 ##########`)
64 s.oil.push(idx(s, v(6, 2)), idx(s, v(7, 2)))
65 const r = wait(s)
66 const spread = r.events.find((e) => e.type === 'fireSpread')
67 expect(spread && spread.type === 'fireSpread' && spread.tiles).toEqual(expect.arrayContaining([v(5, 1), v(6, 1), v(6, 2), v(7, 2)]))
68 // The bomb at (7, 2) went off with it.
69 expect(r.state.bombs).toEqual([])
70 expect(r.state.oil).toEqual([])
71 })
72
73 it('is safe to stand on until flame comes', () => {
74 const s = board(`
75 #####
76 #P~.#
77 #####`)
78 const r = step(s, { id: 'move', target: v(2, 1) })
79 expect(r.state.status).toBe('playing')
80 })
81})
82
83describe('bushes', () => {
84 it('burn, and the fire creeps on a bush a turn', () => {
85 const s = board(`
86 ########
87 #P.1**.#
88 ########`)
89 const first = wait(s).state
90 expect(first.bushes).toEqual([idx(s, v(5, 1))])
91 expect(first.smoldering).toEqual([idx(s, v(5, 1))])
92 const second = wait(first)
93 expect(second.events.some((e) => e.type === 'fireSpread' && e.tiles.some((t) => t.x === 5))).toBe(true)
94 expect(second.state.bushes).toEqual([])
95 expect(predictDanger(first).now.has(idx(s, v(5, 1)))).toBe(true)
96 })
97
98 it('hide the player: enemies go where they last saw them', () => {
99 const s = board(`
100 #########
101 #.......#
102 #H.P*...#
103 #.......#
104 #########`)
105 s.enemies[0]!.kind = 'hunter'
106 const hidden = step(s, { id: 'move', target: v(4, 2) }).state
107 // Last seen on (3, 2): the hunter heads there, not into the bush.
108 expect(hidden.lastSeen).toEqual(v(3, 2))
109 const w = { pos: v(3, 2), kind: 'hunter' as const }
110 expect(stepToward(hidden, w)).toBeNull()
111 })
112})
113
114describe('spikes', () => {
115 it('hurt the player when up, not when down; up and down by turns', () => {
116 const up = board(`
117 #####
118 #P^.#
119 #####`)
120 expect(step(up, { id: 'move', target: v(2, 1) }).state.death?.cause).toBe('spikes')
121 const down = board(`
122 #####
123 #Pv.#
124 #####`)
125 const r = step(down, { id: 'move', target: v(2, 1) })
126 expect(r.state.status).toBe('playing')
127 // Next turn they are up.
128 expect(wait(r.state).state.death?.cause).toBe('spikes')
129 })
130
131 it('keep every enemy off them', () => {
132 const s = board(`
133 ######
134 #P.vH#
135 ######`)
136 expect(stepToward(s, { pos: v(4, 1), kind: 'hunter' })).toBeNull()
137 })
138})
139
140describe('Acid bomb (PLAN.md §19.4)', () => {
141 // A pillar at (4, 2); the player far off; an enemy so the level goes on.
142 const room = () =>
143 board(`
144 #########
145 #P......#
146 #...#...#
147 #.......#
148 #......B#
149 #########`, { fire: 2 })
150
151 it('cracks a pillar; a later blast breaks it, and the ceiling round it falls 4 turns on', () => {
152 const s = room()
153 s.bombs.push({ pos: v(4, 3), timer: 1, fire: 2, placedTurn: 0, acid: true })
154 const cracked = wait(s).state
155 expect(cracked.cracked).toEqual([idx(s, v(4, 2))])
156 expect(cracked.cells[idx(s, v(4, 2))]).toBe('wall')
157 cracked.bombs.push({ pos: v(5, 2), timer: 1, fire: 1, placedTurn: 0 })
158 const broke = wait(cracked)
159 expect(broke.events.some((e) => e.type === 'pillarBroke')).toBe(true)
160 let t = broke.state
161 expect(t.cells[idx(t, v(4, 2))]).toBe('floor')
162 expect(t.collapsing[0]!.turns).toBe(COLLAPSE_TURNS)
163 for (let k = 1; k < COLLAPSE_TURNS; k++) t = wait(t).state
164 expect(predictDanger(t).now.has(idx(t, v(3, 2)))).toBe(true)
165 t = wait(t).state
166 for (const p of [v(3, 1), v(3, 2), v(5, 3)]) expect(t.cells[idx(t, p)]).toBe('rubble')
167 // The pillar's own tile stays floor (PLAN.md §19.4).
168 expect(t.cells[idx(t, v(4, 2))]).toBe('floor')
169 expect(isSolid('rubble')).toBe(true)
170 })
171
172 it('burns the Nuke’s area, and cracks every pillar in it, diagonal ones too (2026-10-26)', () => {
173 const s = room()
174 s.bombs.push({ pos: v(3, 3), timer: 1, fire: 1, placedTurn: 0, acid: true })
175 const r = wait(s).state
176 // (4, 2) is diagonal to the bomb: no arm of a cross reaches it.
177 expect(r.cracked).toEqual([idx(s, v(4, 2))])
178 expect(blastTiles(s, s.bombs[0]!)).toContainEqual(v(4, 3))
179 expect(blastTiles(s, s.bombs[0]!)).toContainEqual(v(2, 2))
180 expect(blastTiles(s, s.bombs[0]!)).toContainEqual(v(3, 1))
181 })
182
183 it('a plain bomb doesn’t crack a pillar', () => {
184 const s = room()
185 s.bombs.push({ pos: v(4, 3), timer: 1, fire: 2, placedTurn: 0 })
186 expect(wait(s).state.cracked).toEqual([])
187 })
188
189 it('crushes the player under it', () => {
190 const s = room()
191 s.player = v(3, 3)
192 s.collapsing.push({ cells: [idx(s, v(3, 3))], turns: 1 })
193 expect(wait(s).state.death?.cause).toBe('crushed')
194 })
195
196 it('a Shield pushes the player out to an empty tile next to them, and is used up (2026-10-25)', () => {
197 const s = room()
198 s.player = v(3, 3)
199 s.charge = { kind: 'shield', left: 1 }
200 s.collapsing.push({ cells: [idx(s, v(3, 3))], turns: 1 })
201 const r = wait(s)
202 expect(r.state.status).toBe('playing')
203 expect(r.state.player).toEqual(v(3, 2))
204 expect(r.state.charge).toBeNull()
205 expect(r.events.some((e) => e.type === 'shieldBroke')).toBe(true)
206 })
207
208 it('with nowhere to push to, the Shield doesn’t save the player', () => {
209 const s = room()
210 s.player = v(3, 3)
211 s.charge = { kind: 'shield', left: 1 }
212 const cells: number[] = []
213 for (let y = 2; y <= 4; y++) for (let x = 2; x <= 4; x++) if (s.cells[idx(s, v(x, y))] === 'floor') cells.push(idx(s, v(x, y)))
214 s.collapsing.push({ cells, turns: 1 })
215 expect(wait(s).state.death?.cause).toBe('crushed')
216 })
217
218 it('walls the player in for good: trapped', () => {
219 const s = board(`
220 #######
221 #P.#.B#
222 #######`)
223 s.collapsing.push({ cells: [idx(s, v(2, 1))], turns: 1 })
224 expect(wait(s).state.death?.cause).toBe('trapped')
225 })
226})
227
228describe('level generation places the objects', () => {
229 it('from their first levels, never on the exit or gem, and every open tile reachable', () => {
230 for (let seed = 1; seed <= 30; seed++) {
231 for (const level of [1, OBJECTS_FROM.bush, OBJECTS_FROM.metal, OBJECTS_FROM.oil, OBJECTS_FROM.spikes, OBJECTS_FROM.barrel, 20]) {
232 const l = generateLevel(seed, level)
233 const i = (p: { x: number; y: number }) => p.y * l.width + p.x
234 if (level < OBJECTS_FROM.bush) expect(l.bushes).toEqual([])
235 if (level < OBJECTS_FROM.metal) expect(l.cells).not.toContain('metal')
236 expect(l.cells[i(l.exit)]).toBe('block')
237 expect(l.cells[i(l.powerUp.pos)]).toBe('block')
238 for (const c of [...l.bushes, ...l.oil, ...l.spikes.map((sp) => sp.cell)]) expect(l.cells[c]).toBe('floor')
239 // Reachable from the start, crates burnt away.
240 const seen = new Set([i(l.start)])
241 const queue = [i(l.start)]
242 for (let k = 0; k < queue.length; k++) {
243 for (const j of [queue[k]! - 1, queue[k]! + 1, queue[k]! - l.width, queue[k]! + l.width]) {
244 if (!seen.has(j) && !isSolid(l.cells[j]!)) {
245 seen.add(j)
246 queue.push(j)
247 }
248 }
249 }
250 expect(seen.size).toBe(l.cells.filter((c) => !isSolid(c)).length)
251 }
252 }
253 expect(generateLevel(3, 20).cells).toContain('metal')
254 })
255})