fuzzyworld

explodorama / files / test / charges.test.ts

charges.test.ts

Blame: the commit that last changed each line · file · history

82adee18 2026-10-20 Pavel Safronov1import { describe, expect, it } from 'vitest'
d3e4ed26 2026-10-25 Pavel Safronov2import { ACTIONS, DECOY_RANGE, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions'
5f066960 2026-10-26 Pavel Safronov3import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, salePrice } from '../src/sim/charges'
d3e4ed26 2026-10-25 Pavel Safronov4import { PRICES } from '../src/sim/shop'
82adee18 2026-10-20 Pavel Safronov5import { stepToward } from '../src/sim/enemies'
6import { blastTiles } from '../src/sim/explosions'
fcf44f32 2026-10-25 Pavel Safronov7import { carryOf, createLevel, newRun } from '../src/sim/state'
82adee18 2026-10-20 Pavel Safronov8import { step } from '../src/sim/turn'
fcf44f32 2026-10-25 Pavel Safronov9import { DEFAULT_OPTIONS, type ChargeId, type GameState } from '../src/sim/types'
82adee18 2026-10-20 Pavel Safronov10import { board, v } from './helpers'
11
52d68b59 2026-10-24 Pavel Safronov12/** The state after walking right onto a charge of `kind` lying next to the player. */
13function 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 Safronov19describe('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 Safronov38 expect(s.chargeOffer?.kind).toBe('nuke')
d3e4ed26 2026-10-25 Pavel Safronov39 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 Safronov62 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 Safronov87 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
99describe('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 Safronov113 })
114
5f066960 2026-10-26 Pavel Safronov115 it('Shield works while carried: it absorbs the next hit, and the slot empties (user request, 2026-10-25)', () => {
d3e4ed26 2026-10-25 Pavel Safronov116 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 Safronov122 const { state, events } = step(s, { id: 'wait' })
d3e4ed26 2026-10-25 Pavel Safronov123 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 Safronov126 // 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 Safronov128 })
129
5f066960 2026-10-26 Pavel Safronov130 it('Freeze needs a tile, the player’s own: a confirmation, not a stray tap (2026-10-25)', () => {
e6bb7c42 2026-10-25 Pavel Safronov131 const s = board(`
132 #####
133 #P..#
134 #####`)
5f066960 2026-10-26 Pavel Safronov135 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 Safronov138 })
139
5f066960 2026-10-26 Pavel Safronov140 it('a carried Shield lasts until a hit, level after level (user request, 2026-10-25)', () => {
d3e4ed26 2026-10-25 Pavel Safronov141 let s = board(`
52d68b59 2026-10-24 Pavel Safronov142 #####
143 #P..#
d3e4ed26 2026-10-25 Pavel Safronov144 #####`)
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 Safronov147 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 Safronov149 })
150
d3e4ed26 2026-10-25 Pavel Safronov151 it(`Freeze, used, freezes every enemy for ${FREEZE_TURNS} turns, that one included`, () => {
52d68b59 2026-10-24 Pavel Safronov152 const s = board(`
153 #######
154 #P...B#
155 #######`)
d3e4ed26 2026-10-25 Pavel Safronov156 s.charge = { kind: 'freeze', left: 1 }
52d68b59 2026-10-24 Pavel Safronov157 s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)]
e6bb7c42 2026-10-25 Pavel Safronov158 let after = step(s, { id: 'freeze', target: s.player }).state
52d68b59 2026-10-24 Pavel Safronov159 expect(after.enemies[0]!.pos).toEqual(v(5, 1))
d3e4ed26 2026-10-25 Pavel Safronov160 expect(after.charge).toBeNull()
52d68b59 2026-10-24 Pavel Safronov161 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 Safronov166 it('a Freeze picked up waits in the slot: enemies keep moving', () => {
52d68b59 2026-10-24 Pavel Safronov167 const s = board(`
d3e4ed26 2026-10-25 Pavel Safronov168 #######
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 Safronov197 ########
d3e4ed26 2026-10-25 Pavel Safronov198 #P.....#
199 #.######
52d68b59 2026-10-24 Pavel Safronov200 ########`)
d3e4ed26 2026-10-25 Pavel Safronov201 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 Safronov208 })
209
d3e4ed26 2026-10-25 Pavel Safronov210 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 Safronov230 expect(state.chargeOffer?.kind).toBe('nuke')
d3e4ed26 2026-10-25 Pavel Safronov231 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 Safronov235 })
236})
237
82adee18 2026-10-20 Pavel Safronov238describe('Diagonal', () => {
a313eb7d 2026-10-25 Pavel Safronov239 it('is a power-up: a diagonal step once gained, kept through a death', () => {
82adee18 2026-10-20 Pavel Safronov240 const s = board(`
241 #####
a313eb7d 2026-10-25 Pavel Safronov242 #P..#
82adee18 2026-10-20 Pavel Safronov243 #+..#
244 #####`)
52d68b59 2026-10-24 Pavel Safronov245 expect(isAvailable(s, 'diagonal')).toBe(false)
246 s.powers.diagonal = true
82adee18 2026-10-20 Pavel Safronov247 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 Safronov249 expect(carryOf(s).powers.diagonal).toBe(true)
82adee18 2026-10-20 Pavel Safronov250 })
251})
252
253describe('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 Safronov259 s.charge = { kind: 'teleport', left: 3 }
82adee18 2026-10-20 Pavel Safronov260 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 Safronov270 s.charge = { kind: 'teleport', left: 3 }
82adee18 2026-10-20 Pavel Safronov271 expect(step(s, { id: 'teleport', target: v(3, 1) }).state.coins).toBe(1)
272 })
273})
274
d3e4ed26 2026-10-25 Pavel Safronov275describe('charged bombs', () => {
52d68b59 2026-10-24 Pavel Safronov276 it('Pierce: the flame goes through soft blocks and burns them all', () => {
277 const s = board(`
82adee18 2026-10-20 Pavel Safronov278 ########
279 #P.++..#
280 ########`, { fire: 3 })
52d68b59 2026-10-24 Pavel Safronov281 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 Safronov283 })
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 Safronov290 s.charge = { kind: 'power', left: 1 }
291 const { state } = step(s, { id: 'charged', target: v(2, 1) })
52d68b59 2026-10-24 Pavel Safronov292 expect(state.bombs[0]!.power).toBe(true)
82adee18 2026-10-20 Pavel Safronov293 expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8)
294 })
295
52d68b59 2026-10-24 Pavel Safronov296 it(`Nuke: every tile within ${NUKE_RANGE} steps burns, through crates, but walls stand`, () => {
82adee18 2026-10-20 Pavel Safronov297 const s = board(`
298 #######
52d68b59 2026-10-24 Pavel Safronov299 #.....#
300 #.+#+.#
301 #..P..#
302 #.+.+.#
303 #.....#
82adee18 2026-10-20 Pavel Safronov304 #######`)
52d68b59 2026-10-24 Pavel Safronov305 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 Safronov311 })
312})
fcf44f32 2026-10-25 Pavel Safronov313
314describe('Flame Pass and Teleport, charges since 2026-10-25', () => {
5f066960 2026-10-26 Pavel Safronov315 it('Flame Pass, carried, saves the player from 3 fire hits, then the slot empties', () => {
fcf44f32 2026-10-25 Pavel Safronov316 let s = board(`
317 #####
318 #P..#
319 #####`)
5f066960 2026-10-26 Pavel Safronov320 s.charge = { kind: 'flamePass', left: CHARGE_AMOUNT.flamePass }
321 expect(CHARGE_AMOUNT.flamePass).toBe(FLAME_PASS_HITS)
fcf44f32 2026-10-25 Pavel Safronov322 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 Safronov326 expect(r.state.charge?.left ?? 0).toBe(FLAME_PASS_HITS - hit)
fcf44f32 2026-10-25 Pavel Safronov327 s = { ...r.state, burning: [] }
328 }
5f066960 2026-10-26 Pavel Safronov329 expect(s.charge).toBeNull()
fcf44f32 2026-10-25 Pavel Safronov330 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 Safronov334 it('a Flame Pass with hits left goes on to the next level', () => {
fcf44f32 2026-10-25 Pavel Safronov335 const s = board(`
336 #####
5f066960 2026-10-26 Pavel Safronov337 #P..#
fcf44f32 2026-10-25 Pavel Safronov338 #####`)
5f066960 2026-10-26 Pavel Safronov339 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 Safronov341 })
342
6c3fcc31 2026-10-26 Pavel Safronov343 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 Safronov373 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
386describe('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})