fuzzyworld

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

new-powers.test.ts

7.5 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { BOMB_BOOST } from '../src/sim/charges'
3import { ACTIONS, isAvailable, TELEPORT_RANGE } from '../src/sim/actions'
4import { predictDanger } from '../src/sim/danger'
5import { idx } from '../src/sim/state'
6import { step } from '../src/sim/turn'
7import { board, v } from './helpers'
8
9// The power-ups and charge added 2026-10-24 (user request; the rules are the user's choices).
10
11describe('Run extends Teleport', () => {
12 it(`with Run, Teleport reaches ${TELEPORT_RANGE + 1} steps instead of ${TELEPORT_RANGE}`, () => {
13 const s = board(`
14 ########
15 #P.....#
16 ########`)
17 s.charge = { kind: 'teleport', left: 3 }
18 expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(5, 1))
19 s.powers.run = true
20 expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(5, 1))
21 expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(6, 1))
22 })
23})
24
25describe('RPG', () => {
26 it('fires a bomb that lands just before the first wall, crate, bomb or enemy, 2 tiles away or more', () => {
27 const s = board(`
28 #########
29 #P....+.#
30 #.#######
31 #.......#
32 #B#######`)
33 s.powers.rpg = true
34 // Right: lands before the crate at x = 6. Down: lands before the Blob at y = 4.
35 expect(ACTIONS.rpg.validTargets(s)).toEqual(expect.arrayContaining([v(5, 1), v(1, 3)]))
36 const { state, events } = step(s, { id: 'rpg', target: v(5, 1) })
37 expect(state.bombs.map((b) => b.pos)).toEqual([v(5, 1)])
38 expect(state.bombs[0]!.timer).toBe(s.stats.fuse)
39 expect(events).toContainEqual({ type: 'bombFired', from: v(1, 1), to: v(5, 1) })
40 })
41
42 it('needs room in the bomb limit, and the tile next to the player free', () => {
43 const s = board(`
44 #######
45 #P1...#
46 #######`)
47 s.powers.rpg = true
48 expect(ACTIONS.rpg.validTargets(s)).toEqual([])
49 })
50})
51
52describe('ICBM', () => {
53 it(`gives ${BOMB_BOOST} launches; each lands as an RPG bomb and goes off that turn, outside the bomb limit`, () => {
54 const s = board(`
55 #########
56 #P....B+#
57 #########`)
58 s.drops.push({ pos: v(2, 1), kind: 'icbm' })
59 let state = step(s, { id: 'move', target: v(2, 1) }).state
60 expect(state.charge).toEqual({ kind: 'icbm', left: BOMB_BOOST })
61 expect(isAvailable(state, 'icbm')).toBe(true)
62 state.stats.capacity = 0
63 // The Blob is at x = 6 (it stays put): the bomb lands before it, at x = 5, and its blast gets it.
64 expect(ACTIONS.icbm.validTargets(state)).toContainEqual(v(5, 1))
65 const r = step(state, { id: 'icbm', target: v(5, 1) })
66 expect(r.events.some((e) => e.type === 'exploded')).toBe(true)
67 expect(r.state.enemies).toEqual([])
68 expect(r.state.bombs).toEqual([])
69 expect(r.state.charge).toEqual({ kind: 'icbm', left: BOMB_BOOST - 1 })
70 })
71
72 it('is not there once its launches are spent', () => {
73 const s = board(`
74 #####
75 #P..#
76 #####`)
77 expect(isAvailable(s, 'icbm')).toBe(false)
78 })
79})
80
81describe('Proximity alert', () => {
82 it('sets off a bomb the turn an enemy walks up next to it', () => {
83 const s = board(`
84 ########
85 #P.5..B#
86 ########`)
87 s.powers.proximity = true
88 // The player's bomb (timer 5) gets the flag when placed; mark this one as theirs.
89 s.bombs[0]!.proximity = true
90 // The Blob steps to x = 4, next to the bomb at x = 3.
91 s.enemies[0]!.plan = [v(5, 1), v(4, 1)]
92 let state = step(s, { id: 'wait' }).state
93 expect(state.bombs).toHaveLength(1)
94 const r = step(state, { id: 'wait' })
95 const events = r.events
96 state = r.state
97 expect(events).toContainEqual({ type: 'proximityTriggered', pos: v(3, 1) })
98 expect(state.enemies).toEqual([])
99 })
100
101 it('not for an enemy in its reach but further than next to it (since 2026-10-25)', () => {
102 const s = board(`
103 ########
104 #P.5.B.#
105 ########`, { fire: 3 })
106 s.bombs[0]!.proximity = true
107 s.bombs[0]!.fire = 3
108 const r = step(s, { id: 'wait' })
109 expect(r.events.some((e) => e.type === 'proximityTriggered')).toBe(false)
110 })
111
112 it('also when the enemy is already next to it as the bomb is placed', () => {
113 const s = board(`
114 #######
115 #P.B..#
116 #######`)
117 s.powers.proximity = true
118 const { state } = step(s, { id: 'bomb', target: v(2, 1) })
119 expect(state.enemies).toEqual([])
120 })
121
122 it('the danger overlay shows it ahead, from the enemies’ next moves', () => {
123 const s = board(`
124 ########
125 #P.5..B#
126 ########`)
127 s.bombs[0]!.proximity = true
128 s.enemies[0]!.plan = [v(5, 1), v(4, 1)]
129 // Not this turn (the Blob is still out of reach), but next turn.
130 expect(predictDanger(s).now.has(idx(s, v(3, 1)))).toBe(false)
131 expect(predictDanger(s).next.has(idx(s, v(3, 1)))).toBe(true)
132 })
133
134 it('leaves a bomb placed without it alone', () => {
135 const s = board(`
136 ########
137 #P.5..B#
138 ########`)
139 s.enemies[0]!.plan = [v(4, 1)]
140 expect(step(s, { id: 'wait' }).state.enemies).toHaveLength(1)
141 })
142})
143
144describe('Slide', () => {
145 it('slides until a wall, crate or bomb is next, picking up what it passes', () => {
146 const s = board(`
147 #########
148 #P.$..+.#
149 #########`)
150 s.powers.slide = true
151 expect(ACTIONS.slide.validTargets(s)).toEqual([v(5, 1)])
152 const { state } = step(s, { id: 'slide', target: v(5, 1) })
153 expect(state.player).toEqual(v(5, 1))
154 expect(state.coins).toBe(1)
155 })
156
157 it('crashes into an enemy on the way', () => {
158 const s = board(`
159 ########
160 #P..B..#
161 ########`)
162 s.powers.slide = true
163 expect(ACTIONS.slide.validTargets(s)).toEqual([v(4, 1)])
164 expect(step(s, { id: 'slide', target: v(4, 1) }).state.status).toBe('dead')
165 })
166
167 it('is burned by flame on the way, unless flame can’t hurt', () => {
168 const s = board(`
169 ########
170 #P.....#
171 ########`)
172 s.powers.slide = true
173 s.burning = [idx(s, v(3, 1))]
174 expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('dead')
175 s.charge = { kind: 'flamePass', left: 99 }
176 expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('playing')
177 })
178
179 it('stops on the open exit', () => {
180 const s = board(`
181 ########
182 #P..E..#
183 ########`)
184 s.powers.slide = true
185 expect(ACTIONS.slide.validTargets(s)).toEqual([v(4, 1)])
186 expect(step(s, { id: 'slide', target: v(4, 1) }).state.status).toBe('cleared')
187 })
188})
189
190describe('Diagonal widens Teleport', () => {
191 it('with Diagonal, Teleport counts a diagonal step as one: a square of reach', () => {
192 const s = board(`
193 #########
194 #.......#
195 #.......#
196 #.......#
197 #...P...#
198 #########`)
199 s.charge = { kind: 'teleport', left: 3 }
200 // 3 across and 3 up: 6 steps across plus down, 3 counting diagonal steps as one.
201 expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(1, 1))
202 s.powers.diagonal = true
203 expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(1, 1))
204 expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(7, 1))
205 })
206})
207
208describe('Diagonal doesn’t squeeze between two blocked tiles (since 2026-10-25)', () => {
209 it('needs one of the two tiles beside the step open; the lab’s squeeze variant allows the old way', () => {
210 const s = board(`
211 #####
212 #P+.#
213 #+..#
214 #####`)
215 s.powers.diagonal = true
216 expect(ACTIONS.diagonal.validTargets(s)).toEqual([])
217 s.options = { ...s.options, lab: { squeeze: true } }
218 expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)])
219 s.options = { ...s.options, lab: undefined }
220 s.cells[1 * s.width + 2] = 'floor'
221 expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)])
222 })
223})