explodorama / files / test / options.test.ts
options.test.ts
beb9f2b7 2026-10-21 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
e6bb7c42 2026-10-25 Pavel Safronov | 2 | import { boardSize, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen' |
beb9f2b7 2026-10-21 Pavel Safronov | 3 | import { newRecord, replay } from '../src/sim/record' |
52d68b59 2026-10-24 Pavel Safronov | 4 | import { newRun } from '../src/sim/state' |
6e94f995 2026-10-24 Pavel Safronov | 5 | import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn' |
e3f29330 2026-10-24 Pavel Safronov | 6 | import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types' |
d3e4ed26 2026-10-25 Pavel Safronov | 7 | import { BOMB_BOOST } from '../src/sim/charges' |
beb9f2b7 2026-10-21 Pavel Safronov | 8 | import { board, v } from './helpers' |
| 9 | ||
e6bb7c42 2026-10-25 Pavel Safronov | 10 | describe('the board (growing with the level since 2026-10-25)', () => { |
beb9f2b7 2026-10-21 Pavel Safronov | 11 | for (let seed = 1; seed <= 100; seed++) { |
| 12 | const level = 1 + (seed % 20) | |
e6bb7c42 2026-10-25 Pavel Safronov | 13 | it(`seed ${seed}: its level's size, exit and power-up under crates, enemies placed, everything reachable`, () => { |
00ad7d0a 2026-10-24 Pavel Safronov | 14 | const g = generateLevel(seed, level) |
e6bb7c42 2026-10-25 Pavel Safronov | 15 | expect({ width: g.width, height: g.height }).toEqual(boardSize(level)) |
beb9f2b7 2026-10-21 Pavel Safronov | 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) | |
e6bb7c42 2026-10-25 Pavel Safronov | 20 | expect(g.enemies.length).toBeGreaterThanOrEqual(2) |
beb9f2b7 2026-10-21 Pavel Safronov | 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 | ||
fcf44f32 2026-10-25 Pavel Safronov | 37 | it('boards grow with the level, from 9 × 11 to 13 × 17, over a 20-level run', () => { |
e6bb7c42 2026-10-25 Pavel Safronov | 38 | expect(boardSize(1)).toEqual({ width: 9, height: 11 }) |
| 39 | expect(boardSize(4)).toEqual({ width: 11, height: 13 }) | |
fcf44f32 2026-10-25 Pavel Safronov | 40 | expect(boardSize(20)).toEqual({ width: 13, height: 17 }) |
| 41 | expect(boardSize(50)).toEqual({ width: 13, height: 17 }) | |
e6bb7c42 2026-10-25 Pavel Safronov | 42 | expect(generateLevel(1, 1).enemies).toHaveLength(2) |
00ad7d0a 2026-10-24 Pavel Safronov | 43 | expect(LAST_LEVEL).toBe(20) |
beb9f2b7 2026-10-21 Pavel Safronov | 44 | }) |
| 45 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 46 | it('clearing level 20 wins the run', () => { |
beb9f2b7 2026-10-21 Pavel Safronov | 47 | const s = { ...board(` |
| 48 | #### | |
| 49 | #PE# | |
00ad7d0a 2026-10-24 Pavel Safronov | 50 | ####`), level: 20 } |
beb9f2b7 2026-10-21 Pavel Safronov | 51 | expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('won') |
| 52 | }) | |
| 53 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 54 | it('every level is 9 × 11', () => { |
| 55 | const s = board(` | |
beb9f2b7 2026-10-21 Pavel Safronov | 56 | #### |
| 57 | #PE# | |
00ad7d0a 2026-10-24 Pavel Safronov | 58 | ####`) |
beb9f2b7 2026-10-21 Pavel Safronov | 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 | ||
52d68b59 2026-10-24 Pavel Safronov | 67 | it('gives nothing but the gem: the usual stats, abilities and charges', () => { |
d3e4ed26 2026-10-25 Pavel Safronov | 68 | const s = newRun(1, 1, null, dev) |
| 69 | const plain = newRun(1, 1) | |
52d68b59 2026-10-24 Pavel Safronov | 70 | expect(s.stats).toEqual(plain.stats) |
| 71 | expect(s.powers).toEqual(plain.powers) | |
d3e4ed26 2026-10-25 Pavel Safronov | 72 | expect(s.charge).toEqual(plain.charge) |
beb9f2b7 2026-10-21 Pavel Safronov | 73 | }) |
| 74 | ||
52d68b59 2026-10-24 Pavel Safronov | 75 | it('the gem is back after a death', () => { |
d3e4ed26 2026-10-25 Pavel Safronov | 76 | const s = newRun(1, 1, null, dev) |
52d68b59 2026-10-24 Pavel Safronov | 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 | |
beb9f2b7 2026-10-21 Pavel Safronov | 79 | expect(dead.status).toBe('dead') |
| 80 | const again = step(dead, { id: 'continue' }).state | |
39c26813 2026-10-24 Pavel Safronov | 81 | expect(again.gems.find((g) => g.extra)?.pos).toEqual({ x: START.x + 1, y: START.y }) |
52d68b59 2026-10-24 Pavel Safronov | 82 | }) |
| 83 | ||
39c26813 2026-10-24 Pavel Safronov | 84 | it('starts every level with an extra gem uncovered right of the player', () => { |
52d68b59 2026-10-24 Pavel Safronov | 85 | for (const level of [1, 7]) { |
d3e4ed26 2026-10-25 Pavel Safronov | 86 | const s = newRun(5, level, null, dev) |
39c26813 2026-10-24 Pavel Safronov | 87 | const extra = s.gems.find((g) => g.extra)! |
| 88 | expect(extra.pos).toEqual({ x: START.x + 1, y: START.y }) | |
52d68b59 2026-10-24 Pavel Safronov | 89 | expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor') |
39c26813 2026-10-24 Pavel Safronov | 90 | const { state } = step(s, { id: 'move', target: extra.pos }) |
52d68b59 2026-10-24 Pavel Safronov | 91 | expect(state.offer?.length).toBeGreaterThan(0) |
| 92 | } | |
| 93 | }) | |
| 94 | ||
39c26813 2026-10-24 Pavel Safronov | 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++) { | |
d3e4ed26 2026-10-25 Pavel Safronov | 97 | const s = newRun(seed, 1, null, dev) |
39c26813 2026-10-24 Pavel Safronov | 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 | ||
e3f29330 2026-10-24 Pavel Safronov | 105 | it('the extra gem is the rainbow one, and offers every power-up and charge (user request, 2026-10-24)', () => { |
d3e4ed26 2026-10-25 Pavel Safronov | 106 | const s = newRun(1, 1, null, dev) |
6e94f995 2026-10-24 Pavel Safronov | 107 | expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow') |
| 108 | expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow') | |
e3f29330 2026-10-24 Pavel Safronov | 109 | const everything = [...POWER_UP_WEIGHTS.map(([kind]) => kind), ...CHARGE_IDS] |
| 110 | expect(drawOffer(s, true)).toEqual(everything) | |
6e94f995 2026-10-24 Pavel Safronov | 111 | const { state } = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }) |
e3f29330 2026-10-24 Pavel Safronov | 112 | expect(state.offer).toEqual(everything) |
| 113 | }) | |
| 114 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 115 | it('a charge chosen from the extra gem goes into the slot', () => { |
| 116 | const s = newRun(1, 1, null, dev) | |
e3f29330 2026-10-24 Pavel Safronov | 117 | const taken = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }).state |
d3e4ed26 2026-10-25 Pavel Safronov | 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 }) | |
e3f29330 2026-10-24 Pavel Safronov | 121 | const decoy = step(taken, { id: 'choose', kind: 'decoy' }) |
d3e4ed26 2026-10-25 Pavel Safronov | 122 | expect(decoy.state.charge).toEqual({ kind: 'decoy', left: 1 }) |
e3f29330 2026-10-24 Pavel Safronov | 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++) { | |
d3e4ed26 2026-10-25 Pavel Safronov | 128 | expect(drawOffer(newRun(seed, 1, null, dev)).some(isCharge)).toBe(false) |
e3f29330 2026-10-24 Pavel Safronov | 129 | } |
6e94f995 2026-10-24 Pavel Safronov | 130 | }) |
| 131 | ||
| 132 | it('the extra gem leaves out what the player already has', () => { | |
d3e4ed26 2026-10-25 Pavel Safronov | 133 | const s = newRun(1, 1, null, dev) |
6e94f995 2026-10-24 Pavel Safronov | 134 | s.powers.run = true |
fcf44f32 2026-10-25 Pavel Safronov | 135 | s.powers.wallPass = true |
6e94f995 2026-10-24 Pavel Safronov | 136 | const offer = drawOffer(s, true) |
| 137 | expect(offer).not.toContain('run') | |
fcf44f32 2026-10-25 Pavel Safronov | 138 | expect(offer).not.toContain('wallPass') |
e3f29330 2026-10-24 Pavel Safronov | 139 | expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2 + CHARGE_IDS.length) |
6e94f995 2026-10-24 Pavel Safronov | 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'])) | |
39c26813 2026-10-24 Pavel Safronov | 142 | }) |
| 143 | ||
| 144 | it('a normal run has just the one gem, under a crate', () => { | |
52d68b59 2026-10-24 Pavel Safronov | 145 | const s = newRun(5) |
39c26813 2026-10-24 Pavel Safronov | 146 | expect(s.gems).toHaveLength(1) |
| 147 | expect(s.cells[s.gems[0]!.pos.y * s.width + s.gems[0]!.pos.x]).toBe('block') | |
beb9f2b7 2026-10-21 Pavel Safronov | 148 | }) |
| 149 | }) | |
| 150 | ||
3cf3607b 2026-10-24 Pavel Safronov | 151 | describe('Immortal', () => { |
beb9f2b7 2026-10-21 Pavel Safronov | 152 | it('nothing kills the player', () => { |
00ad7d0a 2026-10-24 Pavel Safronov | 153 | const immortal = { ...DEFAULT_OPTIONS, immortal: true } |
beb9f2b7 2026-10-21 Pavel Safronov | 154 | const flame = { ...board(` |
| 155 | ##### | |
| 156 | #P1.# | |
00ad7d0a 2026-10-24 Pavel Safronov | 157 | #####`), options: immortal } |
beb9f2b7 2026-10-21 Pavel Safronov | 158 | expect(step(flame, { id: 'wait' }).state.status).toBe('playing') |
| 159 | const enemy = { ...board(` | |
| 160 | ##### | |
| 161 | #PB.# | |
00ad7d0a 2026-10-24 Pavel Safronov | 162 | #####`), options: immortal } |
beb9f2b7 2026-10-21 Pavel Safronov | 163 | expect(step(enemy, { id: 'move', target: v(2, 1) }).state.status).toBe('playing') |
| 164 | }) | |
| 165 | }) | |
| 166 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 167 | describe('crumbling crates (always, since 2026-10-23)', () => { |
beb9f2b7 2026-10-21 Pavel Safronov | 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# | |
00ad7d0a 2026-10-24 Pavel Safronov | 173 | #########`) } |
beb9f2b7 2026-10-21 Pavel Safronov | 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 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 189 | it('while an enemy lives, crates stay', () => { |
beb9f2b7 2026-10-21 Pavel Safronov | 190 | const s = board(` |
00ad7d0a 2026-10-24 Pavel Safronov | 191 | ######### |
| 192 | #P...1BB# | |
| 193 | #+####### | |
| 194 | #########`) | |
| 195 | s.enemies[1]!.pos = v(7, 1) | |
beb9f2b7 2026-10-21 Pavel Safronov | 196 | const r = step(s, { id: 'wait' }).state |
00ad7d0a 2026-10-24 Pavel Safronov | 197 | expect(r.enemies.length).toBeGreaterThan(0) |
beb9f2b7 2026-10-21 Pavel Safronov | 198 | expect(r.crumbling).toEqual([]) |
| 199 | }) | |
| 200 | }) | |
| 201 | ||
| 202 | describe('run record', () => { | |
| 203 | it('keeps the options, so a replay is the same run', () => { | |
00ad7d0a 2026-10-24 Pavel Safronov | 204 | const options = { dev: true, immortal: true } |
d3e4ed26 2026-10-25 Pavel Safronov | 205 | const record = newRecord(77, 1, null, options) |
| 206 | let s = newRun(77, 1, null, options) | |
beb9f2b7 2026-10-21 Pavel Safronov | 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 | }) |