explodorama / files / test / hazards.test.ts
hazards.test.ts
5f066960 2026-10-26 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { predictDanger } from '../src/sim/danger' | |
| 3 | import { stepToward } from '../src/sim/enemies' | |
e3d8dc49 2026-10-27 Pavel Safronov | 4 | import { blastTiles, COLLAPSE_TURNS } from '../src/sim/explosions' |
5f066960 2026-10-26 Pavel Safronov | 5 | import { generateLevel, OBJECTS_FROM } from '../src/sim/levelgen' |
| 6 | import { idx } from '../src/sim/state' | |
| 7 | import { step } from '../src/sim/turn' | |
| 8 | import { isSolid, type GameState } from '../src/sim/types' | |
| 9 | import { board, v } from './helpers' | |
| 10 | ||
| 11 | const wait = (s: GameState) => step(s, { id: 'wait' }) | |
| 12 | ||
| 13 | describe('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 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 27 | it('can’t be walked through, even with Crate Pass', () => { |
5f066960 2026-10-26 Pavel Safronov | 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 | ||
| 37 | describe('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 | ||
| 57 | describe('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 | ||
| 83 | describe('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 | ||
| 114 | describe('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 | ||
| 140 | describe('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 | ||
e3d8dc49 2026-10-27 Pavel Safronov | 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 | ||
5f066960 2026-10-26 Pavel Safronov | 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 | ||
| 228 | describe('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 | }) |