fuzzyworld

explodorama / files / test / enemies.test.ts

enemies.test.ts

23.1 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { ACTIONS, FREEZE_TURNS, isAvailable } from '../src/sim/actions'
3import { LASTING_CHARGES, PLAN_AHEAD, planIntents, rollDrops, stepToward } from '../src/sim/enemies'
4import { concentration, ENEMY_THREAT, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen'
5import { CLEAR_BONUS, newRun, PAR_BONUS } from '../src/sim/state'
6import { EXIT_SPAWN, step } from '../src/sim/turn'
7import type { GameState, Vec } from '../src/sim/types'
8import { board, v } from './helpers'
9
10const dist = (a: Vec, b: Vec) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
11
12describe('levelgen: enemies and exit', () => {
13 for (let seed = 1; seed <= 100; seed++) {
14 const level = 1 + (seed % 50)
15 it(`seed ${seed}: exit under a block, enemies on floor away from the start`, () => {
16 const g = generateLevel(seed, level)
17 const at = (p: Vec) => g.cells[p.y * g.width + p.x]
18 expect(at(g.exit)).toBe('block')
19 expect(dist(g.exit, START)).toBeGreaterThan(SAFE_DISTANCE)
20 // Enough enemies for the level's crowding: their threat reaches its concentration (at least 2),
21 // and one fewer would fall short.
22 const open = g.cells.filter((c) => c !== 'wall').length
23 const threat = g.enemies.reduce((sum, e) => sum + ENEMY_THREAT[e.kind], 0)
24 expect(g.enemies.length).toBeGreaterThanOrEqual(2)
25 expect(threat).toBeGreaterThanOrEqual(concentration(level) * open)
26 if (g.enemies.length > 2) expect(threat - ENEMY_THREAT[g.enemies.at(-1)!.kind]).toBeLessThan(concentration(level) * open)
27 expect(new Set(g.enemies.map((e) => `${e.pos.x},${e.pos.y}`)).size).toBe(g.enemies.length)
28 for (const e of g.enemies) {
29 expect(at(e.pos)).toBe('floor')
30 expect(dist(e.pos, START)).toBeGreaterThan(SAFE_DISTANCE)
31 expect(enemyKinds(level)).toContain(e.kind)
32 }
33 })
34 }
35
36 it('brings in a new type every 3 levels, all seven by level 19', () => {
37 expect(enemyKinds(1)).toEqual(['blob'])
38 expect(enemyKinds(3)).toEqual(['blob'])
39 expect(enemyKinds(4)).toEqual(['blob', 'seeker'])
40 expect(enemyKinds(7)).toEqual(['blob', 'seeker', 'drifter'])
41 expect(enemyKinds(10)).toContain('sprinter')
42 expect(enemyKinds(13)).toContain('ghost')
43 expect(enemyKinds(16)).toContain('wisp')
44 expect(enemyKinds(19)).toEqual(['blob', 'seeker', 'drifter', 'sprinter', 'ghost', 'wisp', 'hunter'])
45 expect(enemyKinds(50)).toEqual(enemyKinds(19))
46 })
47
48 it('a new level already shows every enemy plan, up to 3 moves', () => {
49 const s = newRun(5)
50 expect(s.enemies.length).toBeGreaterThan(0)
51 expect(s.enemies.some((e) => e.plan.length === PLAN_AHEAD)).toBe(true)
52 expect(s.enemies.every((e) => e.plan.length <= PLAN_AHEAD)).toBe(true)
53 })
54})
55
56/** Give every enemy a one-move plan: the nth enemy steps to the nth target next turn. */
57function intend(s: GameState, ...targets: Vec[]): GameState {
58 s.enemies.forEach((e, i) => (e.plan = [targets[i]!]))
59 return s
60}
61
62describe('turn order with enemies', () => {
63 it('enemies move before bombs explode: one walking into a blast dies', () => {
64 const s = intend(board(`
65 #######
66 #P.1.B#
67 #######`), v(4, 1))
68 const { state, events } = step(s, { id: 'wait' })
69 expect(events.map((e) => e.type).slice(0, 5)).toEqual(['waited', 'enemyMoved', 'bombTicked', 'exploded', 'enemyDied'])
70 expect(state.enemies).toEqual([])
71 })
72
73 it('an enemy whose tile got a bomb this turn stays put and does not re-plan', () => {
74 const s = intend(board(`
75 ######
76 #P.B.#
77 ######`), v(2, 1))
78 const { state, events } = step(s, { id: 'bomb', target: v(2, 1) })
79 expect(state.enemies[0]!.pos).toEqual(v(3, 1))
80 expect(events.some((e) => e.type === 'enemyMoved')).toBe(false)
81 })
82
83 it('walking into an enemy kills the player', () => {
84 const s = board(`
85 #####
86 #PB.#
87 #####`)
88 const { state } = step(s, { id: 'move', target: v(2, 1) })
89 expect(state.status).toBe('dead')
90 })
91
92 it('an enemy walking into the player kills them', () => {
93 const s = intend(board(`
94 #####
95 #PB.#
96 #####`), v(1, 1))
97 expect(step(s, { id: 'wait' }).state.status).toBe('dead')
98 })
99
100 it('swapping tiles with an enemy counts as contact', () => {
101 const s = intend(board(`
102 #####
103 #PB.#
104 #####`), v(1, 1))
105 expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('dead')
106 })
107
108 it('a bomb cannot go on an enemy', () => {
109 const s = board(`
110 #####
111 #PB.#
112 #.###
113 #####`)
114 expect(ACTIONS.bomb.validTargets(s)).toEqual([v(1, 2)])
115 })
116})
117
118describe('enemy movement', () => {
119 it('a Seeker can find the shortest way round', () => {
120 const s = board(`
121 #######
122 #P....#
123 ####.##
124 #S....#
125 #######`)
126 expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 3))
127 })
128
129 it('a Drifter keeps going straight until blocked, then turns', () => {
130 const s = board(`
131 #####
132 #D..#
133 #.#.#
134 #P..#
135 #####`)
136 s.enemies[0]!.kind = 'drifter'
137 planIntents(s)
138 expect(s.enemies[0]!.plan.slice(0, 2)).toEqual([v(2, 1), v(3, 1)])
139 s.enemies[0]!.pos = v(3, 1)
140 s.enemies[0]!.plan = []
141 s.cells[1 * s.width + 2] = 'block' // behind it, so the only way on is down
142 planIntents(s)
143 expect(s.enemies[0]!.plan.slice(0, 2)).toEqual([v(3, 2), v(3, 3)])
144 })
145
146 it('an enemy with nowhere to go stays put', () => {
147 const s = board(`
148 #####
149 #B#P#
150 #####`)
151 planIntents(s)
152 expect(s.enemies[0]!.plan).toEqual([])
153 })
154
155 it('the same state always plans the same intents', () => {
156 const a = newRun(77, 13)
157 const b = newRun(77, 13)
158 expect(a.enemies).toEqual(b.enemies)
159 expect(a.aiRng).toBe(b.aiRng)
160 })
161})
162
163describe('plans three moves ahead', () => {
164 it('an enemy makes exactly the moves it showed', () => {
165 let s = newRun(5)
166 const e = s.enemies.find((e) => e.plan.length === PLAN_AHEAD)!
167 const shown = [...e.plan]
168 for (let t = 0; t < PLAN_AHEAD; t++) {
169 s = step(s, { id: 'wait' }).state
170 if (s.status !== 'playing') return
171 expect(s.enemies.find((x) => x.id === e.id)!.pos).toEqual(shown[t])
172 }
173 })
174
175 it('tops the plan up to 3 every turn', () => {
176 let s = newRun(5)
177 s = step(s, { id: 'wait' }).state
178 expect(s.enemies.some((e) => e.plan.length === PLAN_AHEAD)).toBe(true)
179 })
180
181 it('a later move the player blocks is dropped, with everything after it, and planned again', () => {
182 const s = board(`
183 ########
184 #P.....#
185 ########`)
186 s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false, sheltered: false })
187 s.cells[1 * s.width + 4] = 'block' // the second move is now blocked
188 planIntents(s)
189 // Keeps (5,1); the Drifter, blocked going left, bounces back and forth from there.
190 expect(s.enemies[0]!.plan).toEqual([v(5, 1), v(6, 1), v(5, 1)])
191 })
192
193 it('frozen enemies keep their plans', () => {
194 const s = intend(board(`
195 ######
196 #P..B#
197 ######`), v(3, 1))
198 s.charge = { kind: 'freeze', left: 1 }
199 const { state } = step(s, { id: 'freeze', target: s.player })
200 expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1))
201 })
202})
203
204describe('lingering flame', () => {
205 it('stays burning for the next turn, then goes out', () => {
206 let s = board(`
207 #######
208 #P..1.#
209 #######`)
210 s = step(s, { id: 'wait' }).state
211 expect(s.burning.sort((a, b) => a - b)).toEqual([7 + 3, 7 + 4, 7 + 5])
212 s = step(s, { id: 'wait' }).state
213 expect(s.burning).toEqual([])
214 })
215
216 it('kills an enemy that walks into it the turn after', () => {
217 let s = board(`
218 ########
219 #P..1.B#
220 ########`)
221 s = step(s, { id: 'wait' }).state // explodes: tiles 3-5 burn; the Blob at 6 is untouched
222 expect(s.enemies).toHaveLength(1)
223 s.enemies[0]!.plan = [v(5, 1)]
224 const { state, events } = step(s, { id: 'wait' })
225 expect(state.enemies).toEqual([])
226 expect(events.map((e) => e.type)).toContain('enemyDied')
227 })
228
229 it('the player cannot step or put a bomb into it', () => {
230 let s = board(`
231 #######
232 #P.1..#
233 #######`)
234 s = step(s, { id: 'wait' }).state
235 expect(s.status).toBe('playing')
236 expect(ACTIONS.move.validTargets(s)).toEqual([])
237 expect(ACTIONS.bomb.validTargets(s)).toEqual([])
238 })
239
240 it('does not burn the drops its own kills left', () => {
241 let found = false
242 for (let r = 1; r < 200 && !found; r++) {
243 let s = board(`
244 #######
245 #P..1B#
246 #######`)
247 s.dropRng = r
248 s = step(s, { id: 'wait' }).state
249 if (!s.drops.length) continue
250 found = true
251 const after = step(s, { id: 'wait' })
252 expect(after.state.drops.length).toBe(s.drops.length)
253 }
254 expect(found).toBe(true)
255 })
256})
257
258describe('drops', () => {
259 it('walking onto coins collects them', () => {
260 const s = board(`
261 #####
262 #P$.#
263 #####`)
264 const { state, events } = step(s, { id: 'move', target: v(2, 1) })
265 expect(state.coins).toBe(1)
266 expect(state.drops).toEqual([])
267 expect(events[1]).toEqual({ type: 'pickedUp', drop: { pos: v(2, 1), kind: 'coins', amount: 1 } })
268 })
269
270 it('walking onto a charge applies it', () => {
271 const s = board(`
272 #####
273 #P..#
274 #####`)
275 s.drops.push({ pos: v(2, 1), kind: 'shield' })
276 expect(step(s, { id: 'move', target: v(2, 1) }).state.charge).toEqual({ kind: 'shield', left: 1 })
277 })
278
279 it('flame burns drops lying in the open, but not the ones its own kills leave', () => {
280 let found = false
281 // Try drop streams until a slain enemy leaves something; the old coin always burns.
282 for (let r = 1; r < 200 && !found; r++) {
283 const s = board(`
284 #######
285 #P.$1B#
286 #######`)
287 s.dropRng = r
288 const { state, events } = step(s, { id: 'wait' })
289 expect(events.some((e) => e.type === 'dropBurned')).toBe(true)
290 expect(state.drops.some((d) => d.kind === 'coins' && d.pos.x === 3)).toBe(false)
291 if (state.drops.length) {
292 found = true
293 expect(state.drops.every((d) => d.pos.x === 5)).toBe(true)
294 }
295 }
296 expect(found).toBe(true)
297 })
298})
299
300describe('drops never share a tile', () => {
301 it('a second drop rolls to the nearest free floor tile', () => {
302 let both = false
303 for (let r = 1; r < 500 && !both; r++) {
304 const s = board(`
305 #########
306 #P..1S..#
307 #########`)
308 s.dropRng = r
309 const { state } = step(s, { id: 'wait' })
310 if (state.drops.length < 2) continue
311 both = true
312 const spots = state.drops.map((d) => `${d.pos.x},${d.pos.y}`)
313 expect(new Set(spots).size).toBe(spots.length)
314 for (const d of state.drops) expect(Math.abs(d.pos.x - 5)).toBeLessThanOrEqual(2)
315 }
316 expect(both).toBe(true)
317 })
318
319 it('a drop does not land where one already lies', () => {
320 let found = false
321 for (let r = 1; r < 500 && !found; r++) {
322 // The Blob dies in lingering flame, which (unlike a new blast) leaves the old drop alone.
323 const s = board(`
324 #########
325 #P...B..#
326 #########`)
327 s.burning = [1 * s.width + 5]
328 s.drops.push({ pos: v(5, 1), kind: 'shield' })
329 s.dropRng = r
330 const { state } = step(s, { id: 'wait' })
331 expect(state.drops[0]).toEqual({ pos: v(5, 1), kind: 'shield' })
332 const fresh = state.drops.slice(1)
333 if (!fresh.length) continue
334 found = true
335 for (const d of fresh) expect(d.pos).not.toEqual(v(5, 1))
336 }
337 expect(found).toBe(true)
338 })
339 it('a drop never lands on the uncovered exit: it rolls off it', () => {
340 let found = false
341 for (let r = 1; r < 500 && !found; r++) {
342 // A Blob on the uncovered exit dies in lingering flame (a new blast there would let enemies out).
343 const s = board(`
344 #########
345 #P...E..#
346 #########`)
347 s.enemies.push({ id: 9, kind: 'blob', pos: v(5, 1), dir: 'left', plan: [], spawned: false, sheltered: false })
348 s.burning = [1 * s.width + 5]
349 s.dropRng = r
350 const { state } = step(s, { id: 'wait' })
351 if (!state.drops.length) continue
352 found = true
353 for (const d of state.drops) expect(d.pos).not.toEqual(v(5, 1))
354 }
355 expect(found).toBe(true)
356 })
357})
358
359describe('the exit', () => {
360 it('a block over the exit burns away and reveals it', () => {
361 const s = board(`
362 ######
363 #P.1e#
364 ######`)
365 const { events } = step(s, { id: 'wait' })
366 expect(events).toContainEqual({ type: 'exitRevealed', pos: v(4, 1) })
367 })
368
369 it('stays shut while any enemy lives', () => {
370 const s = board(`
371 ######
372 #PE.B#
373 ######`)
374 const { state } = step(s, { id: 'move', target: v(2, 1) })
375 expect(state.status).toBe('playing')
376 })
377
378 it('opens when the last enemy dies, and stepping on it clears the level with a par bonus', () => {
379 let s = board(`
380 #######
381 #PE.1B#
382 #######`)
383 const r = step(s, { id: 'wait' })
384 expect(r.events).toContainEqual({ type: 'exitOpened', pos: v(2, 1) })
385 s = r.state
386 const cleared = step(s, { id: 'move', target: v(2, 1) })
387 expect(cleared.state.status).toBe('cleared')
388 expect(cleared.state.coins).toBe(CLEAR_BONUS + PAR_BONUS)
389 expect(cleared.events.at(-1)).toEqual({ type: 'levelCleared', level: 1, turns: 7, clearBonus: CLEAR_BONUS, parBonus: PAR_BONUS })
390 })
391
392 it('no par bonus over par', () => {
393 const s = { ...board(`
394 ####
395 #PE#
396 ####`), turn: 100, par: 50 }
397 const { state } = step(s, { id: 'move', target: v(2, 1) })
398 expect(state.status).toBe('cleared')
399 expect(state.coins).toBe(CLEAR_BONUS)
400 })
401
402 it('continue after clearing starts the next level, carrying lives, stats, coins and charges', () => {
403 const s = { ...board(`
404 ####
405 #PE#
406 ####`, { fire: 3 }), coins: 4, lives: 2 }
407 s.charge = { kind: 'pierce', left: 2 }
408 const cleared = step(s, { id: 'move', target: v(2, 1) }).state
409 const { state, events } = step(cleared, { id: 'continue' })
410 expect(events).toEqual([{ type: 'levelStarted', level: 2 }])
411 expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + CLEAR_BONUS + PAR_BONUS, status: 'playing' })
412 expect(state.stats.fire).toBe(3)
413 expect(state.charge).toEqual({ kind: 'pierce', left: 2 })
414 expect(state.enemies.length).toBe(generateLevel(state.seed, 2).enemies.length)
415 })
416
417 it('flaming the uncovered exit lets out enemies that drop nothing', () => {
418 const s = board(`
419 ######
420 #P.1E#
421 ######`)
422 const { state, events } = step(s, { id: 'wait' })
423 expect(events).toContainEqual({ type: 'enemiesSpawned', pos: v(4, 1), count: EXIT_SPAWN })
424 expect(state.enemies).toHaveLength(EXIT_SPAWN)
425 expect(state.enemies.every((e) => e.spawned && e.kind === 'blob')).toBe(true)
426 })
427
428 it('the flame that let them out, still lingering, doesn\'t kill them', () => {
429 const s = board(`
430 ########
431 #P...1E#
432 ########`)
433 const spawned = step(s, { id: 'wait' }).state
434 expect(spawned.burning.length).toBeGreaterThan(0)
435 const next = step(spawned, { id: 'wait' }).state
436 expect(next.enemies).toHaveLength(EXIT_SPAWN)
437 expect(next.enemies.every((e) => !e.sheltered)).toBe(true)
438 })
439
440 it('a new explosion the next turn still kills them', () => {
441 const s = board(`
442 ########
443 #P...1E#
444 ########`)
445 const spawned = step(s, { id: 'wait' }).state
446 expect(spawned.enemies).toHaveLength(EXIT_SPAWN)
447 // A fresh bomb next to the exit, going off this turn: its blast covers the exit and beside it.
448 spawned.bombs.push({ pos: v(5, 1), timer: 1, fire: 1, placedTurn: 0 })
449 const { state: next, events } = step(spawned, { id: 'wait' })
450 // All three die; flaming the exit again lets out three more.
451 const first = spawned.enemies.map((e) => e.id)
452 expect(events.filter((e) => e.type === 'enemyDied').map((e) => 'id' in e && e.id)).toEqual(first)
453 expect(next.enemies.some((e) => first.includes(e.id))).toBe(false)
454 })
455})
456
457describe('action charges and abilities', () => {
458 it('kick is an ability: only offered once gained, and never used up', () => {
459 const s = board(`
460 ######
461 #P3..#
462 ######`)
463 expect(isAvailable(s, 'kick')).toBe(false)
464 s.powers.kick = true
465 expect(isAvailable(s, 'kick')).toBe(true)
466 const { state } = step(s, { id: 'kick', target: v(2, 1) })
467 expect(state.powers.kick).toBe(true)
468 })
469
470 it('kick: the bomb slides until the next tile cannot hold it, keeping its timer', () => {
471 const s = board(`
472 #######
473 #P3..B#
474 #######`)
475 s.powers.kick = true
476 const { state, events } = step(s, { id: 'kick', target: v(2, 1) })
477 expect(events).toContainEqual({ type: 'bombKicked', from: v(2, 1), to: v(4, 1) })
478 expect(state.bombs[0]).toMatchObject({ pos: v(4, 1), timer: 2 })
479 })
480
481 it('kick: a bomb with no room to slide is not a target', () => {
482 const s = board(`
483 #####
484 #P3+#
485 #####`)
486 s.powers.kick = true
487 expect(ACTIONS.kick.validTargets(s)).toEqual([])
488 })
489
490 it('throw: exactly 2 tiles away, over a crate', () => {
491 const s = board(`
492 ######
493 #P+..#
494 #.####
495 #.####
496 ######`)
497 s.powers.throw = true
498 expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)])
499 const { state } = step(s, { id: 'throw', target: v(3, 1) })
500 expect(state.bombs).toEqual([{ pos: v(3, 1), timer: 3, fire: 1, placedTurn: 5 }])
501 })
502
503 it('throw: not over a pillar or the outer wall (user\'s choice, 2026-10-23)', () => {
504 const s = board(`
505 #######
506 #P#..##
507 #.#####
508 #.....#
509 #######`)
510 s.powers.throw = true
511 // Right: the pillar at (2, 1) is in between. Down: (1, 2) is floor, so (1, 3) is fine.
512 expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3)])
513 })
514
515 it('throw: over a bomb or an enemy in between', () => {
516 const s = board(`
517 ######
518 #P3..#
519 #B####
520 #.####
521 ######`)
522 s.powers.throw = true
523 s.stats.capacity = 2
524 expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)])
525 })
526
527 it('throw: needs bomb capacity', () => {
528 const s = board(`
529 #######
530 #P...3#
531 #######`)
532 s.powers.throw = true
533 expect(isAvailable(s, 'throw')).toBe(false)
534 })
535
536 it('shield: absorbs a hit, then is gone', () => {
537 const s = board(`
538 #####
539 #P1.#
540 #####`)
541 s.charge = { kind: 'shield', left: 1 }
542 const r = step(s, { id: 'wait' })
543 expect(r.state.status).toBe('playing')
544 expect(r.events).toContainEqual({ type: 'shieldBroke', pos: v(1, 1) })
545 expect(r.state.charge).toBeNull()
546 expect(r.state.lives).toBe(3)
547 })
548
549 it('shield: absorbs every hit in the turn it breaks (enemy, then flame)', () => {
550 const s = intend(board(`
551 #####
552 #PB.#
553 #1###
554 #####`), v(1, 1))
555 s.charge = { kind: 'shield', left: 1 }
556 const { state } = step(s, { id: 'wait' })
557 expect(state.status).toBe('playing')
558 expect(state.lives).toBe(3)
559 expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile
560 })
561
562 it(`freeze: enemies skip the move of the turn it is used and the next ${FREEZE_TURNS - 1}, then move again`, () => {
563 let s = intend(board(`
564 ########
565 #P....B#
566 ########`), v(5, 1))
567 s.charge = { kind: 'freeze', left: 1 }
568 s = step(s, { id: 'freeze', target: s.player }).state
569 expect(s.frozen).toBe(FREEZE_TURNS - 1)
570 for (let i = 1; i < FREEZE_TURNS; i++) {
571 s = step(s, { id: 'wait' }).state
572 expect(s.enemies[0]!.pos).toEqual(v(6, 1))
573 }
574 expect(s.frozen).toBe(0)
575 s = step(s, { id: 'wait' }).state
576 expect(s.enemies[0]!.pos).not.toEqual(v(6, 1))
577 })
578})
579
580describe('enemies spread out', () => {
581 /** Three enemies of `kind` on one tile in the middle of an open room, none planned yet. */
582 function stacked(kind: 'blob' | 'seeker' | 'drifter') {
583 const s = board(`
584 #########
585 #P......#
586 #.......#
587 #.......#
588 #.......#
589 #.......#
590 #########`)
591 for (let i = 0; i < 3; i++) s.enemies.push({ id: s.nextEnemyId++, kind, pos: v(5, 3), dir: 'up', plan: [], spawned: true, sheltered: false })
592 planIntents(s)
593 return s
594 }
595
596 for (const kind of ['blob', 'seeker', 'drifter'] as const) {
597 it(`${kind}s on one tile take different first steps, and never share a tile while they can help it`, () => {
598 const s = stacked(kind)
599 for (let t = 0; t < 3; t++) {
600 const tiles = s.enemies.map((e) => `${e.plan[t]!.x},${e.plan[t]!.y}`)
601 expect(new Set(tiles).size).toBe(3)
602 }
603 })
604 }
605
606 it('still share a tile when there is nowhere else to go', () => {
607 const s = board(`
608 #####
609 #P###
610 #.B.#
611 #####`)
612 s.enemies.push({ id: s.nextEnemyId++, kind: 'blob', pos: v(2, 2), dir: 'left', plan: [], spawned: true, sheltered: false })
613 s.cells[2 * s.width + 3] = 'wall'
614 planIntents(s)
615 expect(s.enemies.map((e) => e.plan[0])).toEqual([v(1, 2), v(1, 2)])
616 })
617
618 it('their moves are still exactly the ones shown', () => {
619 let s = stacked('drifter')
620 const shown = s.enemies.map((e) => e.plan[0])
621 s = step(s, { id: 'wait' }).state
622 expect(s.enemies.map((e) => e.pos)).toEqual(shown)
623 })
624})
625
626describe('the last enemy\'s drop', () => {
627 it('is a charge that lasts (Pierce, Power, Nuke or Shield), never Freeze or Decoy', () => {
628 const s = board(`
629 #####
630 #P.D#
631 #####`)
632 const kinds = (last: boolean) => {
633 const seen = new Set<string>()
634 for (let i = 1; i <= 2000; i++) {
635 s.dropRng = i * 7919
636 for (const d of rollDrops(s, s.enemies[0]!, last)) if (d.kind !== 'coins') seen.add(d.kind)
637 }
638 return seen
639 }
640 expect([...kinds(true)].sort()).toEqual([...LASTING_CHARGES].sort())
641 expect(kinds(false)).toContain('freeze')
642 expect(kinds(false)).toContain('decoy')
643 })
644
645 it('is decided by whether any enemy is left once the turn\'s kills are done', () => {
646 const s = board(`
647 #######
648 #P.D1D#
649 #######`)
650 const { state, events } = step(s, { id: 'wait' })
651 expect(state.enemies).toEqual([])
652 const charges = events.flatMap((e) => (e.type === 'dropSpawned' && e.drop.kind !== 'coins' ? [e.drop.kind] : []))
653 for (const c of charges) expect(LASTING_CHARGES).toContain(c)
654 })
655})
656
657describe('charge drops are never wasted', () => {
658 /** Every charge kind a Drifter drops over many rolls, with `setup` applied first. */
659 function dropped(setup: (s: ReturnType<typeof board>) => void): Set<string> {
660 const s = board(`
661 #####
662 #P.D#
663 #####`)
664 setup(s)
665 const seen = new Set<string>()
666 for (let i = 1; i <= 2000; i++) {
667 s.dropRng = i * 7919
668 for (const d of rollDrops(s, s.enemies[0]!)) if (d.kind !== 'coins') seen.add(d.kind)
669 }
670 return seen
671 }
672
673 it('no second Decoy, Freeze or Shield while one lies on the board', () => {
674 for (const kind of ['decoy', 'freeze', 'shield'] as const) {
675 expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind }))).not.toContain(kind)
676 }
677 })
678
679 it('Pierce, Power, Nuke and ICBM still drop when one lies there: they add up', () => {
680 expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'pierce' }))).toContain('pierce')
681 expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'icbm' }))).toContain('icbm')
682 expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport', 'acid']))
683 })
684})