explodorama / files / test / hazards.test.ts
hazards.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { predictDanger } from '../src/sim/danger' |
| 3 | import { stepToward } from '../src/sim/enemies' |
| 4 | import { blastTiles, COLLAPSE_TURNS } from '../src/sim/explosions' |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | }) |