explodorama / files / test / options.test.ts
options.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { boardSize, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen' |
| 3 | import { newRecord, replay } from '../src/sim/record' |
| 4 | import { newRun } from '../src/sim/state' |
| 5 | import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn' |
| 6 | import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types' |
| 7 | import { BOMB_BOOST } from '../src/sim/charges' |
| 8 | import { board, v } from './helpers' |
| 9 | |
| 10 | describe('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 | |
| 64 | describe('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 | |
| 151 | describe('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 | |
| 167 | describe('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 | |
| 202 | describe('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 | }) |