explodorama / files / test / charges.test.ts
charges.test.ts
82adee18 2026-10-20 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
d3e4ed26 2026-10-25 Pavel Safronov | 2 | import { ACTIONS, DECOY_RANGE, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions' |
5f066960 2026-10-26 Pavel Safronov | 3 | import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, salePrice } from '../src/sim/charges' |
d3e4ed26 2026-10-25 Pavel Safronov | 4 | import { PRICES } from '../src/sim/shop' |
82adee18 2026-10-20 Pavel Safronov | 5 | import { stepToward } from '../src/sim/enemies' |
| 6 | import { blastTiles } from '../src/sim/explosions' | |
fcf44f32 2026-10-25 Pavel Safronov | 7 | import { carryOf, createLevel, newRun } from '../src/sim/state' |
82adee18 2026-10-20 Pavel Safronov | 8 | import { step } from '../src/sim/turn' |
fcf44f32 2026-10-25 Pavel Safronov | 9 | import { DEFAULT_OPTIONS, type ChargeId, type GameState } from '../src/sim/types' |
82adee18 2026-10-20 Pavel Safronov | 10 | import { board, v } from './helpers' |
| 11 | ||
52d68b59 2026-10-24 Pavel Safronov | 12 | /** The state after walking right onto a charge of `kind` lying next to the player. */ |
| 13 | function pickUp(s: GameState, kind: ChargeId): GameState { | |
| 14 | const at = v(s.player.x + 1, s.player.y) | |
| 15 | s.drops.push({ pos: at, kind }) | |
| 16 | return step(s, { id: 'move', target: at }).state | |
| 17 | } | |
| 18 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 19 | describe('one charge at a time (PLAN.md §19.7)', () => { |
| 20 | const corridor = () => board(` | |
| 21 | ######## | |
| 22 | #P.....# | |
| 23 | ########`) | |
| 24 | ||
| 25 | it('a charge picked up with none held goes into the slot, with its uses', () => { | |
| 26 | for (const kind of ['pierce', 'power', 'nuke', 'icbm'] as const) expect(pickUp(corridor(), kind).charge).toEqual({ kind, left: BOMB_BOOST }) | |
| 27 | for (const kind of ['shield', 'freeze', 'decoy'] as const) expect(pickUp(corridor(), kind).charge).toEqual({ kind, left: 1 }) | |
| 28 | }) | |
| 29 | ||
| 30 | it('the same kind adds to what is held, no question asked', () => { | |
| 31 | const s = pickUp(pickUp(corridor(), 'pierce'), 'pierce') | |
| 32 | expect(s.charge).toEqual({ kind: 'pierce', left: 2 * BOMB_BOOST }) | |
| 33 | expect(s.chargeOffer).toBeNull() | |
| 34 | }) | |
| 35 | ||
| 36 | it('another kind asks: nothing else happens until the player sells it or swaps', () => { | |
| 37 | const s = pickUp(pickUp(corridor(), 'pierce'), 'nuke') | |
fcf44f32 2026-10-25 Pavel Safronov | 38 | expect(s.chargeOffer?.kind).toBe('nuke') |
d3e4ed26 2026-10-25 Pavel Safronov | 39 | expect(s.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST }) |
| 40 | expect(() => step(s, { id: 'wait' })).toThrow() | |
| 41 | }) | |
| 42 | ||
| 43 | it('selling keeps the charge held and pays for the new one', () => { | |
| 44 | const s = pickUp(pickUp(corridor(), 'pierce'), 'nuke') | |
| 45 | const { state, events } = step(s, { id: 'sellCharge' }) | |
| 46 | expect(state.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST }) | |
| 47 | expect(state.coins).toBe(s.coins + salePrice('nuke')) | |
| 48 | expect(events).toContainEqual({ type: 'chargeSold', kind: 'nuke', coins: salePrice('nuke') }) | |
| 49 | expect(state.turn).toBe(s.turn) | |
| 50 | }) | |
| 51 | ||
| 52 | it('swapping takes the new one and sells what is left of the old one', () => { | |
| 53 | let s = pickUp(corridor(), 'pierce') | |
| 54 | s = step(s, { id: 'charged', target: v(1, 1) }).state | |
| 55 | s = pickUp(step(s, { id: 'wait' }).state, 'nuke') | |
| 56 | expect(s.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST - 1 }) | |
| 57 | const { state } = step(s, { id: 'swapCharge' }) | |
| 58 | expect(state.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST }) | |
| 59 | expect(state.coins).toBe(s.coins + salePrice('pierce', BOMB_BOOST - 1)) | |
| 60 | }) | |
| 61 | ||
bb3823d2 2026-10-31 Pavel Safronov | 62 | it('firing the charge held, then taking the new one (user request, 2026-10-31)', () => { |
| 63 | const s = pickUp(pickUp(corridor(), 'freeze'), 'nuke') | |
| 64 | const { state, events } = step(s, { id: 'fireCharge' }) | |
| 65 | expect(state.frozen).toBe(FREEZE_TURNS) | |
| 66 | expect(events).toContainEqual({ type: 'frozen', turns: FREEZE_TURNS }) | |
| 67 | expect(state.charge).toEqual({ kind: 'nuke', left: CHARGE_AMOUNT.nuke }) | |
| 68 | expect(state.chargeOffer).toBeNull() | |
| 69 | expect(state.coins).toBe(s.coins) | |
| 70 | expect(state.turn).toBe(s.turn) | |
| 71 | }) | |
| 72 | ||
| 73 | it('firing a Freeze with uses left sells what remains', () => { | |
| 74 | let s = pickUp(corridor(), 'freeze') | |
| 75 | s.charge = { kind: 'freeze', left: 3 } | |
| 76 | s = pickUp(s, 'nuke') | |
| 77 | const { state } = step(s, { id: 'fireCharge' }) | |
| 78 | expect(state.charge).toEqual({ kind: 'nuke', left: CHARGE_AMOUNT.nuke }) | |
| 79 | expect(state.coins).toBe(s.coins + salePrice('freeze', 2)) | |
| 80 | }) | |
| 81 | ||
| 82 | it('a charge that needs aiming can not be fired in the middle of a pickup', () => { | |
| 83 | const s = pickUp(pickUp(corridor(), 'decoy'), 'nuke') | |
| 84 | expect(() => step(s, { id: 'fireCharge' })).toThrow() | |
| 85 | }) | |
| 86 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 87 | it('the sale price is half the shop price, rounded up, for what is left', () => { |
| 88 | expect(salePrice('decoy')).toBe(Math.ceil(PRICES.decoy / 2)) | |
| 89 | expect(salePrice('nuke', 1)).toBe(Math.ceil(PRICES.nuke / 2 / BOMB_BOOST)) | |
| 90 | }) | |
| 91 | ||
| 92 | it('what is held goes on to the next level, and through a death', () => { | |
| 93 | const s = pickUp(corridor(), 'pierce') | |
| 94 | expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'pierce', left: BOMB_BOOST }) | |
| 95 | expect(createLevel(s.seed, 1, s.options, carryOf(s)).charge).toEqual({ kind: 'pierce', left: BOMB_BOOST }) | |
| 96 | }) | |
| 97 | }) | |
| 98 | ||
| 99 | describe('charges are used on purpose', () => { | |
| 100 | it('Bomb places a plain bomb; the charge button a charged one, using it up', () => { | |
| 101 | let s = board(` | |
| 102 | ######### | |
| 103 | #P......# | |
| 104 | #########`, { capacity: 5 }) | |
| 105 | s.charge = { kind: 'nuke', left: 1 } | |
| 106 | s = step(s, { id: 'bomb', target: v(2, 1) }).state | |
| 107 | expect(s.bombs[0]!.nuke).toBeUndefined() | |
| 108 | expect(s.charge).toEqual({ kind: 'nuke', left: 1 }) | |
| 109 | s.player = v(4, 1) | |
| 110 | s = step(s, { id: 'charged', target: v(5, 1) }).state | |
| 111 | expect(s.bombs[1]!.nuke).toBe(true) | |
| 112 | expect(s.charge).toBeNull() | |
52d68b59 2026-10-24 Pavel Safronov | 113 | }) |
| 114 | ||
5f066960 2026-10-26 Pavel Safronov | 115 | it('Shield works while carried: it absorbs the next hit, and the slot empties (user request, 2026-10-25)', () => { |
d3e4ed26 2026-10-25 Pavel Safronov | 116 | const s = board(` |
| 117 | ##### | |
| 118 | #P1.# | |
| 119 | #####`) | |
| 120 | s.charge = { kind: 'shield', left: 1 } | |
| 121 | s.bombs[0]!.timer = 1 | |
5f066960 2026-10-26 Pavel Safronov | 122 | const { state, events } = step(s, { id: 'wait' }) |
d3e4ed26 2026-10-25 Pavel Safronov | 123 | expect(state.status).toBe('playing') |
| 124 | expect(state.charge).toBeNull() | |
| 125 | expect(events.some((e) => e.type === 'shieldBroke')).toBe(true) | |
5f066960 2026-10-26 Pavel Safronov | 126 | // Nothing to press: the charge button has nothing to do with it. |
| 127 | expect(isAvailable({ ...s, bombs: [] }, 'charged')).toBe(false) | |
d3e4ed26 2026-10-25 Pavel Safronov | 128 | }) |
| 129 | ||
5f066960 2026-10-26 Pavel Safronov | 130 | it('Freeze needs a tile, the player’s own: a confirmation, not a stray tap (2026-10-25)', () => { |
e6bb7c42 2026-10-25 Pavel Safronov | 131 | const s = board(` |
| 132 | ##### | |
| 133 | #P..# | |
| 134 | #####`) | |
5f066960 2026-10-26 Pavel Safronov | 135 | s.charge = { kind: 'freeze', left: 1 } |
| 136 | expect(ACTIONS.freeze.validTargets(s)).toEqual([s.player]) | |
| 137 | expect(() => step(s, { id: 'freeze' })).toThrow() | |
e6bb7c42 2026-10-25 Pavel Safronov | 138 | }) |
| 139 | ||
5f066960 2026-10-26 Pavel Safronov | 140 | it('a carried Shield lasts until a hit, level after level (user request, 2026-10-25)', () => { |
d3e4ed26 2026-10-25 Pavel Safronov | 141 | let s = board(` |
52d68b59 2026-10-24 Pavel Safronov | 142 | ##### |
| 143 | #P..# | |
d3e4ed26 2026-10-25 Pavel Safronov | 144 | #####`) |
| 145 | s.charge = { kind: 'shield', left: 1 } | |
| 146 | for (let t = 0; t < 20; t++) s = step(s, { id: 'wait' }).state | |
5f066960 2026-10-26 Pavel Safronov | 147 | expect(s.charge).toEqual({ kind: 'shield', left: 1 }) |
| 148 | expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'shield', left: 1 }) | |
52d68b59 2026-10-24 Pavel Safronov | 149 | }) |
| 150 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 151 | it(`Freeze, used, freezes every enemy for ${FREEZE_TURNS} turns, that one included`, () => { |
52d68b59 2026-10-24 Pavel Safronov | 152 | const s = board(` |
| 153 | ####### | |
| 154 | #P...B# | |
| 155 | #######`) | |
d3e4ed26 2026-10-25 Pavel Safronov | 156 | s.charge = { kind: 'freeze', left: 1 } |
52d68b59 2026-10-24 Pavel Safronov | 157 | s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)] |
e6bb7c42 2026-10-25 Pavel Safronov | 158 | let after = step(s, { id: 'freeze', target: s.player }).state |
52d68b59 2026-10-24 Pavel Safronov | 159 | expect(after.enemies[0]!.pos).toEqual(v(5, 1)) |
d3e4ed26 2026-10-25 Pavel Safronov | 160 | expect(after.charge).toBeNull() |
52d68b59 2026-10-24 Pavel Safronov | 161 | for (let t = 1; t < FREEZE_TURNS; t++) after = step(after, { id: 'wait' }).state |
| 162 | expect(after.enemies[0]!.pos).toEqual(v(5, 1)) | |
| 163 | expect(after.frozen).toBe(0) | |
| 164 | }) | |
| 165 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 166 | it('a Freeze picked up waits in the slot: enemies keep moving', () => { |
52d68b59 2026-10-24 Pavel Safronov | 167 | const s = board(` |
d3e4ed26 2026-10-25 Pavel Safronov | 168 | ####### |
| 169 | #P...B# | |
| 170 | #######`) | |
| 171 | s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)] | |
| 172 | s.drops.push({ pos: v(2, 1), kind: 'freeze' }) | |
| 173 | const after = step(s, { id: 'move', target: v(2, 1) }).state | |
| 174 | expect(after.enemies[0]!.pos).toEqual(v(4, 1)) | |
| 175 | expect(after.charge).toEqual({ kind: 'freeze', left: 1 }) | |
| 176 | }) | |
| 177 | ||
| 178 | it(`Decoy goes up to ${DECOY_RANGE} tiles away in a straight line, nothing in the way, and chasers head there`, () => { | |
| 179 | const s = board(` | |
| 180 | ######### | |
| 181 | #P....S.# | |
| 182 | #.####### | |
| 183 | #.####### | |
| 184 | #+####### | |
| 185 | #########`) | |
| 186 | s.charge = { kind: 'decoy', left: 1 } | |
| 187 | // Right: 3 free tiles. Down: 2, then a crate stops it. | |
| 188 | expect(ACTIONS.decoy.validTargets(s)).toEqual([v(1, 2), v(1, 3), v(2, 1), v(3, 1), v(4, 1)]) | |
| 189 | const { state, events } = step(s, { id: 'decoy', target: v(4, 1) }) | |
| 190 | expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(4, 1) }) | |
| 191 | expect(state.decoy).toEqual({ pos: v(4, 1) }) | |
| 192 | expect(stepToward(state, state.enemies[0]!)?.to.x).toBeLessThan(state.enemies[0]!.pos.x) | |
| 193 | }) | |
| 194 | ||
| 195 | it('a Decoy lasts until an enemy reaches it (user request, 2026-10-25: it lasted 3 turns)', () => { | |
| 196 | let s = board(` | |
52d68b59 2026-10-24 Pavel Safronov | 197 | ######## |
d3e4ed26 2026-10-25 Pavel Safronov | 198 | #P.....# |
| 199 | #.###### | |
52d68b59 2026-10-24 Pavel Safronov | 200 | ########`) |
d3e4ed26 2026-10-25 Pavel Safronov | 201 | s.decoy = { pos: v(6, 1) } |
| 202 | for (let t = 0; t < 10; t++) s = step(s, { id: 'move', target: t % 2 ? v(1, 1) : v(1, 2) }).state | |
| 203 | expect(s.decoy).toEqual({ pos: v(6, 1) }) | |
| 204 | s.enemies.push({ id: 0, kind: 'blob', pos: v(5, 1), dir: 'right', plan: [v(6, 1)], spawned: false, sheltered: false }) | |
| 205 | const { state, events } = step(s, { id: 'wait' }) | |
| 206 | expect(state.decoy).toBeNull() | |
| 207 | expect(events).toContainEqual({ type: 'decoyGone', pos: v(6, 1), burned: false }) | |
52d68b59 2026-10-24 Pavel Safronov | 208 | }) |
| 209 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 210 | it('ICBM: the charge button fires it, using a launch', () => { |
| 211 | const s = board(` | |
| 212 | ######## | |
| 213 | #P....B# | |
| 214 | ########`) | |
| 215 | s.charge = { kind: 'icbm', left: 2 } | |
| 216 | expect(isAvailable(s, 'icbm')).toBe(true) | |
| 217 | const { state } = step(s, { id: 'icbm', target: v(5, 1) }) | |
| 218 | expect(state.charge).toEqual({ kind: 'icbm', left: 1 }) | |
| 219 | expect(state.enemies).toEqual([]) | |
| 220 | }) | |
| 221 | ||
| 222 | it('a charge chosen from a gem while carrying another kind asks too (user report, 2026-10-25)', () => { | |
| 223 | const s = board(` | |
| 224 | ##### | |
| 225 | #P..# | |
| 226 | #####`) | |
| 227 | s.charge = { kind: 'pierce', left: 2 } | |
| 228 | s.offer = ['nuke'] | |
| 229 | const { state } = step(s, { id: 'choose', kind: 'nuke' }) | |
fcf44f32 2026-10-25 Pavel Safronov | 230 | expect(state.chargeOffer?.kind).toBe('nuke') |
d3e4ed26 2026-10-25 Pavel Safronov | 231 | expect(state.charge).toEqual({ kind: 'pierce', left: 2 }) |
| 232 | const swapped = step(state, { id: 'swapCharge' }).state | |
| 233 | expect(swapped.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST }) | |
| 234 | expect(swapped.coins).toBe(salePrice('pierce', 2)) | |
52d68b59 2026-10-24 Pavel Safronov | 235 | }) |
| 236 | }) | |
| 237 | ||
82adee18 2026-10-20 Pavel Safronov | 238 | describe('Diagonal', () => { |
a313eb7d 2026-10-25 Pavel Safronov | 239 | it('is a power-up: a diagonal step once gained, kept through a death', () => { |
82adee18 2026-10-20 Pavel Safronov | 240 | const s = board(` |
| 241 | ##### | |
a313eb7d 2026-10-25 Pavel Safronov | 242 | #P..# |
82adee18 2026-10-20 Pavel Safronov | 243 | #+..# |
| 244 | #####`) | |
52d68b59 2026-10-24 Pavel Safronov | 245 | expect(isAvailable(s, 'diagonal')).toBe(false) |
| 246 | s.powers.diagonal = true | |
82adee18 2026-10-20 Pavel Safronov | 247 | expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)]) |
| 248 | expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2)) | |
52d68b59 2026-10-24 Pavel Safronov | 249 | expect(carryOf(s).powers.diagonal).toBe(true) |
82adee18 2026-10-20 Pavel Safronov | 250 | }) |
| 251 | }) | |
| 252 | ||
| 253 | describe('Teleport', () => { | |
| 254 | it(`reaches any free tile within ${TELEPORT_RANGE} steps as the crow walks, over anything`, () => { | |
| 255 | const s = board(` | |
| 256 | ######### | |
| 257 | #P#+...$# | |
| 258 | #########`) | |
fcf44f32 2026-10-25 Pavel Safronov | 259 | s.charge = { kind: 'teleport', left: 3 } |
82adee18 2026-10-20 Pavel Safronov | 260 | expect(ACTIONS.teleport.validTargets(s)).toEqual([v(4, 1)]) |
| 261 | const { state } = step(s, { id: 'teleport', target: v(4, 1) }) | |
| 262 | expect(state.player).toEqual(v(4, 1)) | |
| 263 | }) | |
| 264 | ||
| 265 | it('picks up what is on the tile it lands on', () => { | |
| 266 | const s = board(` | |
| 267 | ###### | |
| 268 | #P#$.# | |
| 269 | ######`) | |
fcf44f32 2026-10-25 Pavel Safronov | 270 | s.charge = { kind: 'teleport', left: 3 } |
82adee18 2026-10-20 Pavel Safronov | 271 | expect(step(s, { id: 'teleport', target: v(3, 1) }).state.coins).toBe(1) |
| 272 | }) | |
| 273 | }) | |
| 274 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 275 | describe('charged bombs', () => { |
52d68b59 2026-10-24 Pavel Safronov | 276 | it('Pierce: the flame goes through soft blocks and burns them all', () => { |
| 277 | const s = board(` | |
82adee18 2026-10-20 Pavel Safronov | 278 | ######## |
| 279 | #P.++..# | |
| 280 | ########`, { fire: 3 }) | |
52d68b59 2026-10-24 Pavel Safronov | 281 | expect(blastTiles(s, { pos: v(2, 1), fire: 3, pierce: true })).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)]) |
| 282 | expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)]) | |
82adee18 2026-10-20 Pavel Safronov | 283 | }) |
| 284 | ||
| 285 | it('Power: the flame reaches all the way to the walls', () => { | |
| 286 | const s = board(` | |
| 287 | ########## | |
| 288 | #P.......# | |
| 289 | ##########`) | |
d3e4ed26 2026-10-25 Pavel Safronov | 290 | s.charge = { kind: 'power', left: 1 } |
| 291 | const { state } = step(s, { id: 'charged', target: v(2, 1) }) | |
52d68b59 2026-10-24 Pavel Safronov | 292 | expect(state.bombs[0]!.power).toBe(true) |
82adee18 2026-10-20 Pavel Safronov | 293 | expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8) |
| 294 | }) | |
| 295 | ||
52d68b59 2026-10-24 Pavel Safronov | 296 | it(`Nuke: every tile within ${NUKE_RANGE} steps burns, through crates, but walls stand`, () => { |
82adee18 2026-10-20 Pavel Safronov | 297 | const s = board(` |
| 298 | ####### | |
52d68b59 2026-10-24 Pavel Safronov | 299 | #.....# |
| 300 | #.+#+.# | |
| 301 | #..P..# | |
| 302 | #.+.+.# | |
| 303 | #.....# | |
82adee18 2026-10-20 Pavel Safronov | 304 | #######`) |
52d68b59 2026-10-24 Pavel Safronov | 305 | const tiles = blastTiles(s, { pos: v(3, 3), fire: 1, nuke: true }) |
| 306 | // 13 tiles within 2 steps, less the pillar at (3, 2); the crates are in. | |
| 307 | expect(tiles).toHaveLength(12) | |
| 308 | expect(tiles).toContainEqual(v(2, 2)) | |
| 309 | expect(tiles).toContainEqual(v(3, 1)) | |
| 310 | expect(tiles).not.toContainEqual(v(3, 2)) | |
82adee18 2026-10-20 Pavel Safronov | 311 | }) |
| 312 | }) | |
fcf44f32 2026-10-25 Pavel Safronov | 313 | |
| 314 | describe('Flame Pass and Teleport, charges since 2026-10-25', () => { | |
5f066960 2026-10-26 Pavel Safronov | 315 | it('Flame Pass, carried, saves the player from 3 fire hits, then the slot empties', () => { |
fcf44f32 2026-10-25 Pavel Safronov | 316 | let s = board(` |
| 317 | ##### | |
| 318 | #P..# | |
| 319 | #####`) | |
5f066960 2026-10-26 Pavel Safronov | 320 | s.charge = { kind: 'flamePass', left: CHARGE_AMOUNT.flamePass } |
| 321 | expect(CHARGE_AMOUNT.flamePass).toBe(FLAME_PASS_HITS) | |
fcf44f32 2026-10-25 Pavel Safronov | 322 | for (let hit = 1; hit <= FLAME_PASS_HITS; hit++) { |
| 323 | s.bombs = [{ pos: v(2, 1), timer: 1, fire: 1, placedTurn: -9 }] | |
| 324 | const r = step(s, { id: 'wait' }) | |
| 325 | expect(r.state.status).toBe('playing') | |
5f066960 2026-10-26 Pavel Safronov | 326 | expect(r.state.charge?.left ?? 0).toBe(FLAME_PASS_HITS - hit) |
fcf44f32 2026-10-25 Pavel Safronov | 327 | s = { ...r.state, burning: [] } |
| 328 | } | |
5f066960 2026-10-26 Pavel Safronov | 329 | expect(s.charge).toBeNull() |
fcf44f32 2026-10-25 Pavel Safronov | 330 | s.bombs = [{ pos: v(2, 1), timer: 1, fire: 1, placedTurn: -9 }] |
| 331 | expect(step(s, { id: 'wait' }).state.status).toBe('dead') | |
| 332 | }) | |
| 333 | ||
5f066960 2026-10-26 Pavel Safronov | 334 | it('a Flame Pass with hits left goes on to the next level', () => { |
fcf44f32 2026-10-25 Pavel Safronov | 335 | const s = board(` |
| 336 | ##### | |
5f066960 2026-10-26 Pavel Safronov | 337 | #P..# |
fcf44f32 2026-10-25 Pavel Safronov | 338 | #####`) |
5f066960 2026-10-26 Pavel Safronov | 339 | s.charge = { kind: 'flamePass', left: 2 } |
| 340 | expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'flamePass', left: 2 }) | |
fcf44f32 2026-10-25 Pavel Safronov | 341 | }) |
| 342 | ||
6c3fcc31 2026-10-26 Pavel Safronov | 343 | it('the lab’s NES rule (`underfoot`): the bomb goes on the player’s own tile, and they walk off it', () => { |
| 344 | const s = board(` | |
| 345 | ##### | |
| 346 | #P..# | |
| 347 | #####`) | |
| 348 | s.options = { ...s.options, lab: { underfoot: true } } | |
| 349 | expect(ACTIONS.bomb.validTargets(s)).toEqual([v(1, 1)]) | |
| 350 | const placed = step(s, { id: 'bomb', target: v(1, 1) }).state | |
| 351 | expect(placed.bombs[0]!.pos).toEqual(v(1, 1)) | |
| 352 | const off = step(placed, { id: 'move', target: v(2, 1) }).state | |
| 353 | expect(off.player).toEqual(v(2, 1)) | |
| 354 | // Not back onto it. | |
| 355 | expect(ACTIONS.move.validTargets(off)).not.toContainEqual(v(1, 1)) | |
| 356 | }) | |
| 357 | ||
| 358 | it('a jump into a pocket with nowhere to step or put a bomb costs a life: boxed in (2026-10-26)', () => { | |
| 359 | const s = board(` | |
| 360 | ####### | |
| 361 | #.P.+.# | |
| 362 | ###.#+# | |
| 363 | #######`) | |
| 364 | // (5, 1): crates left and below, walls round the rest. | |
| 365 | s.charge = { kind: 'teleport', left: 1 } | |
| 366 | const r = step(s, { id: 'teleport', target: v(5, 1) }) | |
| 367 | expect(r.state.death?.cause).toBe('boxedIn') | |
| 368 | // With a jump left, there is a way out: no death. | |
| 369 | s.charge = { kind: 'teleport', left: 2 } | |
| 370 | expect(step(s, { id: 'teleport', target: v(5, 1) }).state.status).toBe('playing') | |
| 371 | }) | |
| 372 | ||
fcf44f32 2026-10-25 Pavel Safronov | 373 | it('Teleport: 3 jumps from the charge button, then it is gone', () => { |
| 374 | let s = board(` | |
| 375 | ########## | |
| 376 | #P.......# | |
| 377 | ##########`) | |
| 378 | s.charge = { kind: 'teleport', left: 3 } | |
| 379 | for (const x of [4, 7, 8]) s = step(s, { id: 'teleport', target: v(x, 1) }).state | |
| 380 | expect(s.player).toEqual(v(8, 1)) | |
| 381 | expect(s.charge).toBeNull() | |
| 382 | expect(isAvailable(s, 'teleport')).toBe(false) | |
| 383 | }) | |
| 384 | }) | |
| 385 | ||
| 386 | describe('a death drops the charge carried (user’s choice, 2026-10-25)', () => { | |
| 387 | it('it lies where the player fell on the retry, with what was left, to pick up again', () => { | |
| 388 | const s = board(` | |
| 389 | ###### | |
| 390 | #P1..# | |
| 391 | ######`) | |
| 392 | s.charge = { kind: 'nuke', left: 2 } | |
| 393 | s.bombs[0]!.timer = 1 | |
| 394 | const dead = step(s, { id: 'wait' }).state | |
| 395 | expect(dead.status).toBe('dead') | |
| 396 | const carry = carryOf(dead) | |
| 397 | expect(carry.charge).toBeNull() | |
| 398 | expect(carry.fallen).toEqual({ pos: v(1, 1), charge: { kind: 'nuke', left: 2 } }) | |
| 399 | }) | |
| 400 | ||
| 401 | it('on the retried level the drop lies on the nearest open floor, and gives back what was left', () => { | |
| 402 | const fresh = newRun(5) | |
| 403 | const s = createLevel(5, 1, DEFAULT_OPTIONS, { ...carryOf(fresh), fallen: { pos: v(1, 1), charge: { kind: 'pierce', left: 1 } } }) | |
| 404 | expect(s.drops).toContainEqual({ pos: v(1, 1), kind: 'pierce', left: 1 }) | |
| 405 | // Walked onto, it is carried again with what was left. | |
| 406 | s.player = v(2, 1) | |
| 407 | const back = step(s, { id: 'move', target: v(1, 1) }).state | |
| 408 | expect(back.charge).toEqual({ kind: 'pierce', left: 1 }) | |
| 409 | }) | |
| 410 | }) |