fuzzyworld

explodorama / files / test / new-enemies.test.ts

new-enemies.test.ts

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

565a6873 2026-10-25 Pavel Safronov1import { describe, expect, it } from 'vitest'
2import { predictDanger } from '../src/sim/danger'
3import { enemyCanEnter, moveTiles, planIntents } from '../src/sim/enemies'
4import { safeStep } from '../src/sim/path'
5import { step } from '../src/sim/turn'
6import type { GameState, Vec } from '../src/sim/types'
7import { board, v } from './helpers'
8
9const steps = (a: Vec, b: Vec) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
10
11/** The size of each planned move: 0 a rest, 1 a step, 2 a Sprinter's dash. */
12function moveSizes(s: GameState, i = 0): number[] {
13 const e = s.enemies[i]!
14 return e.plan.map((to, k) => steps(k ? e.plan[k - 1]! : e.pos, to))
15}
16
17describe('Ghosts and Wisps go through crates', () => {
18 it('a Ghost or a Wisp can enter a crate; a Blob, Hunter or Sprinter cannot; a bomb stops all', () => {
19 const s = board(`
20 #####
21 #P+1#
22 #####`)
23 for (const kind of ['ghost', 'wisp'] as const) expect(enemyCanEnter(s, v(2, 1), kind)).toBe(true)
24 for (const kind of ['blob', 'hunter', 'sprinter'] as const) expect(enemyCanEnter(s, v(2, 1), kind)).toBe(false)
25 expect(enemyCanEnter(s, v(3, 1), 'ghost')).toBe(false)
26 })
27
28 it('a Ghost chases straight through a wall of crates', () => {
29 const s = board(`
30 #######
31 #P++.G#
6c3fcc31 2026-10-26 Pavel Safronov32 #.#####
565a6873 2026-10-25 Pavel Safronov33 #######`)
6c3fcc31 2026-10-26 Pavel Safronov34 // (A tile below the player, so they aren't boxed in.)
565a6873 2026-10-25 Pavel Safronov35 let s2 = s
36 planIntents(s2)
37 const been: Vec[] = []
38 for (let turn = 0; turn < 5; turn++) {
39 s2 = step(s2, { id: 'wait' }).state
40 been.push(s2.enemies[0]!.pos)
41 }
42 // Into the first crate, and on to the next, a step every other turn.
43 expect(been).toContainEqual(v(3, 1))
44 expect(been.filter((p) => p.x === 3)).toHaveLength(2)
45 })
46
47 it('flame that burns the crate a Ghost is in kills it', () => {
48 let s = board(`
49 ######
50 #P.1+#
51 ######`)
52 s.enemies.push({ id: 0, kind: 'ghost', pos: v(4, 1), dir: 'left', plan: [v(4, 1)], spawned: true, sheltered: false })
53 s.bombs[0]!.timer = 1
54 s = step(s, { id: 'wait' }).state
55 expect(s.enemies).toHaveLength(0)
56 })
57})
58
59describe('Ghosts and Sprinters rest every other turn', () => {
60 it('a Ghost alternates a rest and a step', () => {
61 const s = board(`
62 #########
63 #P.....G#
64 #########`)
65 planIntents(s)
66 const sizes = moveSizes(s)
67 expect(sizes).toHaveLength(3)
68 expect(sizes.every((n, k) => n === (sizes[0]! === 0 ? [0, 1, 0] : [1, 0, 1])[k])).toBe(true)
69 })
70
71 it('keeps alternating as its plan is topped up turn after turn', () => {
72 let s = board(`
73 ###########
74 #P.......G#
75 ###########`)
76 planIntents(s)
77 const done: number[] = []
78 for (let turn = 0; turn < 6; turn++) {
79 const before = s.enemies[0]!.pos
80 s = step(s, { id: 'wait' }).state
81 done.push(steps(before, s.enemies[0]!.pos))
82 }
83 for (let k = 1; k < done.length; k++) expect(done[k]).not.toBe(done[k - 1])
84 })
85
86 it('a Sprinter dashes 2 tiles along a corridor, resting between dashes', () => {
87 const s = board(`
88 ###########
89 #P.......R#
90 ###########`)
91 planIntents(s)
92 const sizes = moveSizes(s).filter((n) => n > 0)
93 expect(sizes.length).toBeGreaterThan(0)
94 expect(sizes.every((n) => n === 2)).toBe(true)
95 })
96
97 it('a dash goes only 1 tile when the second is blocked', () => {
98 const s = board(`
99 #####
100 #.R.#
101 #P###
102 #####`)
103 s.enemies[0]!.plan = [v(2, 1)]
104 planIntents(s)
105 for (const [k, to] of s.enemies[0]!.plan.entries()) expect(steps(k ? s.enemies[0]!.plan[k - 1]! : v(2, 1), to)).toBeLessThanOrEqual(1)
106 })
107
108 it('a dash through the player catches them on the way', () => {
109 const s = board(`
110 ######
111 #R.P.#
112 ######`)
113 s.enemies[0]!.plan = [v(3, 1)]
114 const r = step(s, { id: 'wait' })
115 // The dash would have ended at (3, 1) anyway here; a longer one stops on the player.
116 expect(r.state.status).toBe('dead')
117 })
118
119 it('a dash whose middle tile is the player stops there', () => {
120 const s = board(`
121 #######
122 #.R.P.#
123 #.#####
124 #######`)
125 s.player = v(3, 1)
126 s.enemies[0]!.plan = [v(4, 1)]
127 const r = step(s, { id: 'wait' })
128 expect(r.events).toContainEqual({ type: 'enemyMoved', id: 0, from: v(2, 1), to: v(3, 1) })
129 expect(r.state.status).toBe('dead')
130 })
131
132 it('a bomb on the middle tile stops a dash', () => {
133 const s = board(`
134 #######
135 #R5..P#
136 #######`)
137 s.enemies[0]!.plan = [v(3, 1)]
138 const r = step(s, { id: 'wait' })
139 expect(r.state.enemies[0]!.pos).toEqual(v(1, 1))
140 })
141
142 it('moveTiles: none for a rest, the tile for a step, the middle and the end for a dash', () => {
143 expect(moveTiles(v(1, 1), v(1, 1))).toEqual([])
144 expect(moveTiles(v(1, 1), v(2, 1))).toEqual([v(2, 1)])
145 expect(moveTiles(v(1, 1), v(1, 3))).toEqual([v(1, 2), v(1, 3)])
146 })
147})
148
149describe('Hunters always chase', () => {
150 it('every planned step of a Hunter gets closer to the player', () => {
151 for (let seed = 1; seed <= 20; seed++) {
152 const s = board(`
153 #########
154 #P......#
155 #.#.#.#.#
156 #......H#
157 #########`)
158 s.aiRng = seed
159 planIntents(s)
160 let from = s.enemies[0]!.pos
161 for (const to of s.enemies[0]!.plan) {
162 expect(steps(to, s.player)).toBe(steps(from, s.player) - 1)
163 from = to
164 }
165 }
166 })
167})
168
169describe('auto-move and a dash', () => {
170 it('will not step onto the middle tile of a Sprinter’s next dash', () => {
171 const s = board(`
172 #######
173 #R..P.#
174 #######`)
175 s.enemies[0]!.plan = [v(3, 1)]
176 expect(safeStep(s, predictDanger(s), v(2, 1))).toBe(false)
177 expect(safeStep(s, predictDanger(s), v(5, 1))).toBe(true)
178 })
179})