explodorama / files / test / powerups.test.ts
powerups.test.ts
27f24fed 2026-10-20 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { ACTIONS, isAvailable } from '../src/sim/actions' | |
52d68b59 2026-10-24 Pavel Safronov | 3 | import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen' |
e6bb7c42 2026-10-25 Pavel Safronov | 4 | import { newRun, START_STATS } from '../src/sim/state' |
| 5 | import { applyPowerUp, COIN_BAG, drawOffer, EXIT_SPAWN, heldAlready, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, STAT_MAX, step } from '../src/sim/turn' | |
e3f29330 2026-10-24 Pavel Safronov | 6 | import type { GameState, OfferKind, PowerUpKind } from '../src/sim/types' |
27f24fed 2026-10-20 Pavel Safronov | 7 | import { board, v } from './helpers' |
| 8 | ||
52d68b59 2026-10-24 Pavel Safronov | 9 | describe('levelgen: the hidden gem', () => { |
27f24fed 2026-10-20 Pavel Safronov | 10 | for (let seed = 1; seed <= 100; seed++) { |
52d68b59 2026-10-24 Pavel Safronov | 11 | it(`seed ${seed}: under a soft block that isn't the exit's, in one of the four colours`, () => { |
27f24fed 2026-10-20 Pavel Safronov | 12 | const g = generateLevel(seed, 1 + (seed % 50)) |
| 13 | expect(g.cells[g.powerUp.pos.y * g.width + g.powerUp.pos.x]).toBe('block') | |
| 14 | expect(g.powerUp.pos).not.toEqual(g.exit) | |
52d68b59 2026-10-24 Pavel Safronov | 15 | expect(['blue', 'green', 'red', 'yellow']).toContain(g.powerUp.color) |
27f24fed 2026-10-20 Pavel Safronov | 16 | }) |
| 17 | } | |
| 18 | }) | |
| 19 | ||
52d68b59 2026-10-24 Pavel Safronov | 20 | /** A board with the gem lying uncovered at (2, 1). */ |
| 21 | function withGem(picture = ` | |
27f24fed 2026-10-20 Pavel Safronov | 22 | ###### |
| 23 | #P...# | |
| 24 | ######`): GameState { | |
| 25 | const s = board(picture) | |
39c26813 2026-10-24 Pavel Safronov | 26 | s.gems = [{ pos: v(2, 1), color: 'red' }] |
52d68b59 2026-10-24 Pavel Safronov | 27 | return s |
| 28 | } | |
| 29 | ||
| 30 | /** A state waiting for a choice from `offer`. */ | |
| 31 | function offering(...offer: PowerUpKind[]): GameState { | |
| 32 | const s = board(` | |
| 33 | ###### | |
| 34 | #P...# | |
| 35 | ######`) | |
| 36 | s.offer = offer | |
27f24fed 2026-10-20 Pavel Safronov | 37 | return s |
| 38 | } | |
| 39 | ||
e3f29330 2026-10-24 Pavel Safronov | 40 | const ORDER: OfferKind[] = POWER_UP_WEIGHTS.map(([kind]) => kind) |
52d68b59 2026-10-24 Pavel Safronov | 41 | |
| 42 | describe('the offer', () => { | |
| 43 | it(`is ${OFFER_SIZE} different power-ups, in the almanac's order, the same for the same seed and level`, () => { | |
| 44 | for (let seed = 1; seed <= 50; seed++) { | |
| 45 | const s = newRun(seed, 1 + (seed % 20)) | |
| 46 | const offer = drawOffer(s) | |
| 47 | expect(offer).toHaveLength(OFFER_SIZE) | |
| 48 | expect(new Set(offer).size).toBe(OFFER_SIZE) | |
| 49 | expect([...offer].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))).toEqual(offer) | |
| 50 | expect(drawOffer(s)).toEqual(offer) | |
| 51 | } | |
| 52 | }) | |
| 53 | ||
| 54 | it('leaves out abilities and passes already held', () => { | |
| 55 | const s = newRun(3) | |
fcf44f32 2026-10-25 Pavel Safronov | 56 | s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, rpg: true, proximity: true, slide: true } |
52d68b59 2026-10-24 Pavel Safronov | 57 | for (let level = 1; level <= 30; level++) { |
| 58 | s.level = level | |
| 59 | const offer = drawOffer(s) | |
| 60 | expect(offer).toHaveLength(OFFER_SIZE) | |
| 61 | for (const k of ['run', 'kick', 'throw', 'teleport', 'diagonal', 'detonator', 'wallPass', 'flamePass'] as PowerUpKind[]) expect(offer).not.toContain(k) | |
| 62 | } | |
| 63 | }) | |
| 64 | ||
| 65 | it('is smaller when fewer would do anything', () => { | |
| 66 | const s = newRun(3) | |
fcf44f32 2026-10-25 Pavel Safronov | 67 | s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true } |
52d68b59 2026-10-24 Pavel Safronov | 68 | // Left: Bomb+, Fire+, Fuse+, Star, Heart, Coin bag. |
| 69 | expect(drawOffer(s)).toHaveLength(OFFER_SIZE) | |
| 70 | }) | |
| 71 | ||
| 72 | it('offers rare ones less often than common ones', () => { | |
| 73 | let fire = 0 | |
fcf44f32 2026-10-25 Pavel Safronov | 74 | let proximity = 0 |
52d68b59 2026-10-24 Pavel Safronov | 75 | for (let seed = 1; seed <= 400; seed++) { |
| 76 | const offer = drawOffer(newRun(seed)) | |
| 77 | if (offer.includes('firePlus')) fire++ | |
fcf44f32 2026-10-25 Pavel Safronov | 78 | if (offer.includes('proximity')) proximity++ |
52d68b59 2026-10-24 Pavel Safronov | 79 | } |
fcf44f32 2026-10-25 Pavel Safronov | 80 | expect(fire).toBeGreaterThan(proximity * 2) |
| 81 | expect(proximity).toBeGreaterThan(0) | |
52d68b59 2026-10-24 Pavel Safronov | 82 | }) |
| 83 | }) | |
| 84 | ||
| 85 | describe('taking the gem', () => { | |
| 86 | it('sets up the offer and gives nothing yet; the gem is gone', () => { | |
| 87 | const { state, events } = step(withGem(), { id: 'move', target: v(2, 1) }) | |
| 88 | expect(state.offer).toEqual(drawOffer(withGem())) | |
39c26813 2026-10-24 Pavel Safronov | 89 | expect(state.gems).toEqual([]) |
52d68b59 2026-10-24 Pavel Safronov | 90 | expect(state.stats).toEqual(withGem().stats) |
| 91 | expect(events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), color: 'red' }, offer: state.offer }) | |
| 92 | }) | |
| 93 | ||
| 94 | it('nothing but choosing is possible until then', () => { | |
| 95 | const s = offering('heart', 'bombPlus') | |
| 96 | expect(isAvailable(s, 'move')).toBe(false) | |
| 97 | expect(isAvailable(s, 'wait')).toBe(false) | |
| 98 | expect(() => step(s, { id: 'wait' })).toThrow() | |
| 99 | }) | |
| 100 | ||
| 101 | it('a power-up that is not on offer cannot be chosen', () => { | |
| 102 | expect(() => step(offering('heart'), { id: 'choose', kind: 'run' })).toThrow() | |
| 103 | }) | |
| 104 | ||
| 105 | it('choosing takes no turn and nothing else happens', () => { | |
| 106 | const s = offering('heart', 'bombPlus') | |
| 107 | s.bombs.push({ pos: v(4, 1), timer: 1, fire: 1, placedTurn: 0 }) | |
| 108 | const { state, events } = step(s, { id: 'choose', kind: 'heart' }) | |
| 109 | expect(events).toEqual([{ type: 'powerUpChosen', kind: 'heart' }]) | |
| 110 | expect(state.offer).toBeNull() | |
| 111 | expect(state.turn).toBe(s.turn) | |
| 112 | expect(state.bombs).toEqual(s.bombs) | |
| 113 | }) | |
| 114 | ||
| 115 | it('a death on the same turn drops the offer; the retry has the gem back', () => { | |
| 116 | const s = withGem(` | |
| 117 | ###### | |
| 118 | #P...# | |
| 119 | ######`) | |
| 120 | s.bombs.push({ pos: v(3, 1), timer: 1, fire: 1, placedTurn: 0 }) | |
| 121 | const { state } = step(s, { id: 'move', target: v(2, 1) }) | |
| 122 | expect(state.status).toBe('dead') | |
| 123 | expect(state.offer).toBeNull() | |
| 124 | }) | |
| 125 | ||
| 126 | it('reaching the exit on the same turn still needs the choice before the next level', () => { | |
| 127 | const s = offering('heart') | |
| 128 | s.status = 'cleared' | |
| 129 | expect(() => step(s, { id: 'continue' })).toThrow() | |
| 130 | const chosen = step(s, { id: 'choose', kind: 'heart' }).state | |
| 131 | expect(chosen.lives).toBe(4) | |
| 132 | expect(step(chosen, { id: 'continue' }).state.level).toBe(2) | |
| 133 | }) | |
| 134 | }) | |
| 135 | ||
| 136 | describe('the power-ups', () => { | |
| 137 | const take = (kind: PowerUpKind) => step(offering(kind), { id: 'choose', kind }) | |
27f24fed 2026-10-20 Pavel Safronov | 138 | |
| 139 | it('stats: Bomb+, Fire+, Fuse+', () => { | |
| 140 | expect(take('bombPlus').state.stats.capacity).toBe(2) | |
| 141 | expect(take('firePlus').state.stats.fire).toBe(2) | |
| 142 | expect(take('fusePlus').state.stats.fuse).toBe(4) | |
| 143 | }) | |
| 144 | ||
| 145 | it('Heart, Coin bag, Star', () => { | |
| 146 | expect(take('heart').state.lives).toBe(4) | |
| 147 | expect(take('coinBag').state.coins).toBe(COIN_BAG) | |
52d68b59 2026-10-24 Pavel Safronov | 148 | // Chosen between turns, so all its turns are still to come. |
| 149 | expect(take('star').state.star).toBe(STAR_TURNS) | |
27f24fed 2026-10-20 Pavel Safronov | 150 | }) |
| 151 | ||
52d68b59 2026-10-24 Pavel Safronov | 152 | it('abilities and passes', () => { |
| 153 | expect(take('run').state.powers.run).toBe(true) | |
27f24fed 2026-10-20 Pavel Safronov | 154 | expect(take('detonator').state.powers.detonate).toBe(true) |
99869630 2026-10-23 Pavel Safronov | 155 | expect(take('kick').state.powers.kick).toBe(true) |
| 156 | expect(take('throw').state.powers.throw).toBe(true) | |
52d68b59 2026-10-24 Pavel Safronov | 157 | expect(take('diagonal').state.powers.diagonal).toBe(true) |
| 158 | expect(take('wallPass').state.powers.wallPass).toBe(true) | |
| 159 | expect(take('bombPass').state.powers.bombPass).toBe(true) | |
27f24fed 2026-10-20 Pavel Safronov | 160 | }) |
| 161 | ||
52d68b59 2026-10-24 Pavel Safronov | 162 | it('a gem still under its block cannot be taken', () => { |
| 163 | const s = withGem(` | |
27f24fed 2026-10-20 Pavel Safronov | 164 | ###### |
| 165 | #P+..# | |
| 166 | ######`) | |
| 167 | s.powers.wallPass = true | |
52d68b59 2026-10-24 Pavel Safronov | 168 | expect(step(s, { id: 'move', target: v(2, 1) }).state.offer).toBeNull() |
27f24fed 2026-10-20 Pavel Safronov | 169 | }) |
| 170 | ||
| 171 | it('burning its block reveals it', () => { | |
52d68b59 2026-10-24 Pavel Safronov | 172 | const s = withGem(` |
27f24fed 2026-10-20 Pavel Safronov | 173 | ###### |
| 174 | #P.1+# | |
| 175 | ######`) | |
39c26813 2026-10-24 Pavel Safronov | 176 | s.gems[0]!.pos = v(4, 1) |
27f24fed 2026-10-20 Pavel Safronov | 177 | const { state, events } = step(s, { id: 'wait' }) |
52d68b59 2026-10-24 Pavel Safronov | 178 | expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } }) |
39c26813 2026-10-24 Pavel Safronov | 179 | expect(state.gems).toHaveLength(1) |
27f24fed 2026-10-20 Pavel Safronov | 180 | }) |
| 181 | ||
| 182 | it('flame on it once uncovered destroys it and lets enemies out', () => { | |
52d68b59 2026-10-24 Pavel Safronov | 183 | const s = withGem(` |
27f24fed 2026-10-20 Pavel Safronov | 184 | ###### |
| 185 | #P..1# | |
| 186 | ######`) | |
39c26813 2026-10-24 Pavel Safronov | 187 | s.gems[0]!.pos = v(3, 1) |
27f24fed 2026-10-20 Pavel Safronov | 188 | const { state, events } = step(s, { id: 'wait' }) |
39c26813 2026-10-24 Pavel Safronov | 189 | expect(state.gems).toEqual([]) |
27f24fed 2026-10-20 Pavel Safronov | 190 | expect(events.map((e) => e.type)).toContain('powerUpBurned') |
| 191 | expect(state.enemies).toHaveLength(EXIT_SPAWN) | |
| 192 | }) | |
| 193 | }) | |
| 194 | ||
99869630 2026-10-23 Pavel Safronov | 195 | describe('Run', () => { |
27f24fed 2026-10-20 Pavel Safronov | 196 | it('goes 2 tiles in a straight line, only over free tiles', () => { |
| 197 | const s = board(` | |
| 198 | ####### | |
| 199 | #P...3# | |
| 200 | #.##### | |
| 201 | #+##### | |
| 202 | #######`) | |
| 203 | expect(isAvailable(s, 'run')).toBe(false) | |
| 204 | s.powers.run = true | |
| 205 | expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)]) | |
| 206 | expect(step(s, { id: 'run', target: v(3, 1) }).state.player).toEqual(v(3, 1)) | |
| 207 | }) | |
| 208 | ||
| 209 | it('collects what is on the tile it runs through', () => { | |
| 210 | const s = board(` | |
| 211 | ###### | |
| 212 | #P$..# | |
| 213 | ######`) | |
| 214 | s.powers.run = true | |
| 215 | expect(step(s, { id: 'run', target: v(3, 1) }).state.coins).toBe(1) | |
| 216 | }) | |
| 217 | ||
| 218 | it('running into an enemy on the tile in between is death', () => { | |
| 219 | const s = board(` | |
| 220 | ###### | |
| 221 | #PB..# | |
| 222 | ######`) | |
| 223 | s.powers.run = true | |
| 224 | expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)]) | |
| 225 | expect(step(s, { id: 'run', target: v(3, 1) }).state.status).toBe('dead') | |
| 226 | }) | |
| 227 | }) | |
| 228 | ||
| 229 | describe('Detonate', () => { | |
| 230 | it('sets one of your bombs off this turn', () => { | |
| 231 | const s = board(` | |
| 232 | ####### | |
| 233 | #P..3+# | |
| 234 | #######`) | |
| 235 | s.powers.detonate = true | |
| 236 | const { state, events } = step(s, { id: 'detonate', target: v(4, 1) }) | |
| 237 | expect(events).toContainEqual({ type: 'detonated', pos: v(4, 1) }) | |
| 238 | expect(state.bombs).toEqual([]) | |
| 239 | expect(state.cells[7 + 5]).toBe('floor') | |
| 240 | }) | |
| 241 | }) | |
| 242 | ||
| 243 | describe('passes', () => { | |
ebcd0a8f 2026-11-02 Pavel Safronov | 244 | it('Crate Pass walks through soft blocks', () => { |
27f24fed 2026-10-20 Pavel Safronov | 245 | const s = board(` |
| 246 | ##### | |
| 247 | #P+.# | |
| 248 | #####`) | |
| 249 | expect(ACTIONS.move.validTargets(s)).toEqual([]) | |
| 250 | s.powers.wallPass = true | |
| 251 | expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) | |
| 252 | }) | |
| 253 | ||
| 254 | it('Bomb Pass walks over bombs', () => { | |
| 255 | const s = board(` | |
| 256 | ##### | |
| 257 | #P3.# | |
| 258 | #####`) | |
| 259 | s.powers.bombPass = true | |
| 260 | expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) | |
| 261 | }) | |
| 262 | ||
| 263 | it('Flame Pass: flame does not hurt; enemies still do', () => { | |
| 264 | const s = board(` | |
| 265 | ##### | |
| 266 | #P1.# | |
| 267 | #####`) | |
5f066960 2026-10-26 Pavel Safronov | 268 | s.charge = { kind: 'flamePass', left: 99 } |
27f24fed 2026-10-20 Pavel Safronov | 269 | expect(step(s, { id: 'wait' }).state.status).toBe('playing') |
| 270 | const e = board(` | |
| 271 | ##### | |
| 272 | #PB.# | |
| 273 | #####`) | |
5f066960 2026-10-26 Pavel Safronov | 274 | e.charge = { kind: 'flamePass', left: 99 } |
27f24fed 2026-10-20 Pavel Safronov | 275 | expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') |
| 276 | }) | |
| 277 | ||
99869630 2026-10-23 Pavel Safronov | 278 | it('fire can be stepped into only while it is safe: Flame Pass or a Star', () => { |
| 279 | const s = board(` | |
| 280 | ##### | |
| 281 | #P..# | |
| 282 | #####`) | |
| 283 | s.burning = [s.width + 2] | |
| 284 | expect(ACTIONS.move.validTargets(s)).toEqual([]) | |
| 285 | s.star = 2 | |
| 286 | expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) | |
| 287 | s.star = 0 | |
5f066960 2026-10-26 Pavel Safronov | 288 | s.charge = { kind: 'flamePass', left: 99 } |
99869630 2026-10-23 Pavel Safronov | 289 | expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) |
| 290 | }) | |
| 291 | ||
27f24fed 2026-10-20 Pavel Safronov | 292 | it('Star: nothing hurts while it lasts', () => { |
| 293 | const s = board(` | |
| 294 | ##### | |
| 295 | #PB1# | |
| 296 | #####`) | |
| 297 | s.star = 3 | |
| 298 | expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('playing') | |
| 299 | }) | |
| 300 | ||
52d68b59 2026-10-24 Pavel Safronov | 301 | it('a death keeps every power-up and the stats (since 2026-10-23)', () => { |
27f24fed 2026-10-20 Pavel Safronov | 302 | const s = { ...board(` |
| 303 | ##### | |
| 304 | #P1.# | |
| 305 | #####`, { capacity: 2 }), star: 0 } | |
fcf44f32 2026-10-25 Pavel Safronov | 306 | s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true } |
27f24fed 2026-10-20 Pavel Safronov | 307 | const dead = step(s, { id: 'wait' }).state |
| 308 | expect(dead.deaths).toBe(1) | |
| 309 | const again = step(dead, { id: 'continue' }).state | |
52d68b59 2026-10-24 Pavel Safronov | 310 | expect(again.powers).toEqual(s.powers) |
27f24fed 2026-10-20 Pavel Safronov | 311 | expect(again.stats.capacity).toBe(2) |
| 312 | }) | |
| 313 | ||
| 314 | it('clearing a level keeps everything', () => { | |
| 315 | const s = { ...board(` | |
| 316 | #### | |
| 317 | #PE# | |
| 318 | ####`), star: 4 } | |
| 319 | s.powers.wallPass = true | |
| 320 | const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state | |
| 321 | expect(next.powers.wallPass).toBe(true) | |
| 322 | expect(next.star).toBe(4) | |
| 323 | }) | |
| 324 | }) | |
| 325 | ||
| 326 | describe('the ending', () => { | |
| 327 | it(`clearing level ${LAST_LEVEL} wins the run`, () => { | |
| 328 | const s = { ...board(` | |
| 329 | #### | |
| 330 | #PE# | |
| 331 | ####`), level: LAST_LEVEL, runTurns: 1234, deaths: 2 } | |
| 332 | const { state, events } = step(s, { id: 'move', target: v(2, 1) }) | |
| 333 | expect(state.status).toBe('won') | |
| 334 | expect(events.at(-1)).toEqual({ type: 'runWon', turns: 1235, deaths: 2 }) | |
| 335 | expect(() => step(state, { id: 'continue' })).toThrow() | |
| 336 | }) | |
| 337 | ||
| 338 | it('counts turns across the whole run', () => { | |
| 339 | let s = newRun(3) | |
| 340 | s = step(s, { id: 'wait' }).state | |
| 341 | s = step(s, { id: 'wait' }).state | |
| 342 | expect(s.runTurns).toBe(2) | |
| 343 | }) | |
| 344 | }) | |
e6bb7c42 2026-10-25 Pavel Safronov | 345 | |
| 346 | describe('TNT and the limits on stats (user request, 2026-10-25)', () => { | |
| 347 | it('TNT raises the blast by 2; every stat stops at its limit', () => { | |
| 348 | const s = newRun(1) | |
| 349 | applyPowerUp(s, 'tnt') | |
| 350 | expect(s.stats.fire).toBe(START_STATS.fire + 2) | |
| 351 | for (let i = 0; i < 10; i++) { | |
| 352 | applyPowerUp(s, 'firePlus') | |
| 353 | applyPowerUp(s, 'bombPlus') | |
| 354 | applyPowerUp(s, 'fusePlus') | |
| 355 | } | |
| 356 | expect(s.stats).toEqual(STAT_MAX) | |
| 357 | }) | |
| 358 | ||
| 359 | it('a stat power-up is not offered without room for all of it', () => { | |
| 360 | const s = newRun(1) | |
| 361 | s.stats.fire = STAT_MAX.fire - 1 | |
| 362 | expect(heldAlready(s, 'firePlus')).toBe(false) | |
| 363 | expect(heldAlready(s, 'tnt')).toBe(true) | |
| 364 | s.stats.fire = STAT_MAX.fire | |
| 365 | expect(heldAlready(s, 'firePlus')).toBe(true) | |
| 366 | for (let seed = 1; seed <= 30; seed++) expect(drawOffer({ ...s, seed })).not.toContain('firePlus') | |
| 367 | }) | |
| 368 | }) |