explodorama / files / test / powerups.test.ts
powerups.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { ACTIONS, isAvailable } from '../src/sim/actions' |
| 3 | import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen' |
| 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' |
| 6 | import type { GameState, OfferKind, PowerUpKind } from '../src/sim/types' |
| 7 | import { board, v } from './helpers' |
| 8 | |
| 9 | describe('levelgen: the hidden gem', () => { |
| 10 | for (let seed = 1; seed <= 100; seed++) { |
| 11 | it(`seed ${seed}: under a soft block that isn't the exit's, in one of the four colours`, () => { |
| 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) |
| 15 | expect(['blue', 'green', 'red', 'yellow']).toContain(g.powerUp.color) |
| 16 | }) |
| 17 | } |
| 18 | }) |
| 19 | |
| 20 | /** A board with the gem lying uncovered at (2, 1). */ |
| 21 | function withGem(picture = ` |
| 22 | ###### |
| 23 | #P...# |
| 24 | ######`): GameState { |
| 25 | const s = board(picture) |
| 26 | s.gems = [{ pos: v(2, 1), color: 'red' }] |
| 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 |
| 37 | return s |
| 38 | } |
| 39 | |
| 40 | const ORDER: OfferKind[] = POWER_UP_WEIGHTS.map(([kind]) => kind) |
| 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) |
| 56 | s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, rpg: true, proximity: true, slide: true } |
| 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) |
| 67 | s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true } |
| 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 |
| 74 | let proximity = 0 |
| 75 | for (let seed = 1; seed <= 400; seed++) { |
| 76 | const offer = drawOffer(newRun(seed)) |
| 77 | if (offer.includes('firePlus')) fire++ |
| 78 | if (offer.includes('proximity')) proximity++ |
| 79 | } |
| 80 | expect(fire).toBeGreaterThan(proximity * 2) |
| 81 | expect(proximity).toBeGreaterThan(0) |
| 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())) |
| 89 | expect(state.gems).toEqual([]) |
| 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 }) |
| 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) |
| 148 | // Chosen between turns, so all its turns are still to come. |
| 149 | expect(take('star').state.star).toBe(STAR_TURNS) |
| 150 | }) |
| 151 | |
| 152 | it('abilities and passes', () => { |
| 153 | expect(take('run').state.powers.run).toBe(true) |
| 154 | expect(take('detonator').state.powers.detonate).toBe(true) |
| 155 | expect(take('kick').state.powers.kick).toBe(true) |
| 156 | expect(take('throw').state.powers.throw).toBe(true) |
| 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) |
| 160 | }) |
| 161 | |
| 162 | it('a gem still under its block cannot be taken', () => { |
| 163 | const s = withGem(` |
| 164 | ###### |
| 165 | #P+..# |
| 166 | ######`) |
| 167 | s.powers.wallPass = true |
| 168 | expect(step(s, { id: 'move', target: v(2, 1) }).state.offer).toBeNull() |
| 169 | }) |
| 170 | |
| 171 | it('burning its block reveals it', () => { |
| 172 | const s = withGem(` |
| 173 | ###### |
| 174 | #P.1+# |
| 175 | ######`) |
| 176 | s.gems[0]!.pos = v(4, 1) |
| 177 | const { state, events } = step(s, { id: 'wait' }) |
| 178 | expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } }) |
| 179 | expect(state.gems).toHaveLength(1) |
| 180 | }) |
| 181 | |
| 182 | it('flame on it once uncovered destroys it and lets enemies out', () => { |
| 183 | const s = withGem(` |
| 184 | ###### |
| 185 | #P..1# |
| 186 | ######`) |
| 187 | s.gems[0]!.pos = v(3, 1) |
| 188 | const { state, events } = step(s, { id: 'wait' }) |
| 189 | expect(state.gems).toEqual([]) |
| 190 | expect(events.map((e) => e.type)).toContain('powerUpBurned') |
| 191 | expect(state.enemies).toHaveLength(EXIT_SPAWN) |
| 192 | }) |
| 193 | }) |
| 194 | |
| 195 | describe('Run', () => { |
| 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', () => { |
| 244 | it('Crate Pass walks through soft blocks', () => { |
| 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 | #####`) |
| 268 | s.charge = { kind: 'flamePass', left: 99 } |
| 269 | expect(step(s, { id: 'wait' }).state.status).toBe('playing') |
| 270 | const e = board(` |
| 271 | ##### |
| 272 | #PB.# |
| 273 | #####`) |
| 274 | e.charge = { kind: 'flamePass', left: 99 } |
| 275 | expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') |
| 276 | }) |
| 277 | |
| 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 |
| 288 | s.charge = { kind: 'flamePass', left: 99 } |
| 289 | expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) |
| 290 | }) |
| 291 | |
| 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 | |
| 301 | it('a death keeps every power-up and the stats (since 2026-10-23)', () => { |
| 302 | const s = { ...board(` |
| 303 | ##### |
| 304 | #P1.# |
| 305 | #####`, { capacity: 2 }), star: 0 } |
| 306 | s.powers = { run: true, kick: true, throw: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, rpg: true, proximity: true, slide: true } |
| 307 | const dead = step(s, { id: 'wait' }).state |
| 308 | expect(dead.deaths).toBe(1) |
| 309 | const again = step(dead, { id: 'continue' }).state |
| 310 | expect(again.powers).toEqual(s.powers) |
| 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 | }) |
| 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 | }) |