fuzzyworld

explodorama / files / test / autopilot.test.ts

autopilot.test.ts

7.6 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { autopilotPlan, autopilotTargets } from '../src/sim/autopilot'
3import { predictDanger } from '../src/sim/danger'
4import { playerPath } from '../src/sim/path'
5import { idx, newRun } from '../src/sim/state'
6import { step } from '../src/sim/turn'
7import type { GameState } from '../src/sim/types'
8import { board, v } from './helpers'
9
10const plan = (s: GameState) => autopilotPlan(s, predictDanger(s))
11
12/** Plays the autopilot to the end, answering every offer with its first choice; the states after each turn. */
13function play(start: GameState, limit = start.width * start.height * 4): GameState[] {
14 const out: GameState[] = []
15 let s = start
16 for (let i = 0; i < limit && s.status === 'playing'; i++) {
17 const action = s.offer ? ({ id: 'choose', kind: s.offer[0]! } as const) : s.chargeOffer ? ({ id: 'sellCharge' } as const) : plan(s).action
18 if (!action) break
19 s = step(s, action).state
20 out.push(s)
21 }
22 return out
23}
24
25describe('the autopilot (Q73)', () => {
26 it('targets every drop and every gem on open floor, not the exit nor a gem under a crate', () => {
27 const s = board(`
28 ##########
29 #P.$.%+.E#
30 ##########`)
31 s.drops.push({ pos: v(6, 1), kind: 'nuke' })
32 s.gems.push({ pos: v(7, 1), color: 'red' })
33 s.cells[idx(s, v(7, 1))] = 'block'
34 expect(autopilotTargets(s)).toEqual(expect.arrayContaining([v(3, 1), v(6, 1), v(5, 1)]))
35 expect(autopilotTargets(s)).toHaveLength(3)
36 })
37
38 it('takes the order with the fewest turns in all, not the nearest target first', () => {
39 // The coin on the left is nearer (1 step) but the walk then costs 14 turns in all; the far coin
40 // first is 12, the near coin lying on the way back to the exit.
41 const s = board(`
42 ###########
43 #E..$P...$#
44 ###########`)
45 const p = plan(s)
46 expect(p.phase).toBe('gather')
47 expect(p.goal).toEqual(v(9, 1))
48 const nearest = autopilotTargets(s).sort((a, b) => playerPath(s, a)!.length - playerPath(s, b)!.length)[0]
49 expect(nearest).toEqual(v(4, 1))
50 const turns = play(s)
51 expect(turns.at(-1)!.status).toBe('cleared')
52 expect(turns).toHaveLength(12)
53 expect(turns.at(-1)!.drops).toHaveLength(0)
54 })
55
56 it('breaks a tie by the smaller y, then the smaller x', () => {
57 const s = board(`
58 #######
59 #..$..#
60 #.P...#
61 #..$..#
62 #######`)
63 s.exit = v(-1, -1)
64 expect(plan(s).goal).toEqual(v(3, 1))
65 })
66
67 it('skips targets that cannot be reached', () => {
68 const s = board(`
69 ##########
70 #P.$#$..E#
71 ##########`)
72 const p = plan(s)
73 expect(p.goal).toEqual(v(3, 1))
74 expect(p.action).toEqual({ id: 'move', target: v(2, 1) })
75 // With only the walled-off coin left, nothing is fetched and the exit is the goal.
76 s.drops.shift()
77 expect(plan(s).phase).toBe('exit')
78 })
79
80 it('never steps on the open exit before the end', () => {
81 const s = board(`
82 #######
83 #PE$..#
84 #.....#
85 #######`)
86 const turns = play(s)
87 expect(turns.at(-1)!.status).toBe('cleared')
88 expect(turns.at(-1)!.drops).toHaveLength(0)
89 // The exit is the last tile stood on.
90 expect(turns.slice(0, -1).every((t) => !(t.player.x === 1 + 1 && t.player.y === 1))).toBe(true)
91 })
92
93 it('goes to the exit when nothing is left, as phase exit', () => {
94 const s = board(`
95 #####
96 #P.E#
97 #####`)
98 expect(plan(s)).toEqual({ phase: 'exit', goal: v(3, 1), action: { id: 'move', target: v(2, 1) } })
99 })
100
101 it('waits while a gem lies under a crumbling crate', () => {
102 const s = board(`
103 ##########
104 #P..%...E#
105 ##########`)
106 s.cells[idx(s, v(4, 1))] = 'block'
107 s.crumbling.push({ cell: idx(s, v(4, 1)), turns: 2 })
108 expect(plan(s)).toEqual({ phase: 'crates', goal: null, action: { id: 'wait' } })
109 const turns = play(s)
110 expect(turns.some((t) => t.offer)).toBe(true)
111 expect(turns.at(-1)!.status).toBe('cleared')
112 })
113
114 it('waits for its own ticking bomb when a coin lies behind it, then collects the coin before the exit', () => {
115 const s = board(`
116 #########
117 #$.3.PE.#
118 #########`)
119 expect(s.bombs).toHaveLength(1)
120 expect(plan(s)).toEqual({ phase: 'bombs', goal: null, action: { id: 'wait' } })
121 const turns = play(s)
122 const gone = turns.findIndex((t) => t.bombs.length === 0)
123 expect(gone).toBeGreaterThan(0)
124 // The coin is not picked up before the bomb has gone off, and the level does not end without it.
125 expect(turns.slice(0, gone).every((t) => t.drops.length === 1)).toBe(true)
126 expect(turns.at(-1)!.status).toBe('cleared')
127 expect(turns.at(-1)!.drops).toHaveLength(0)
128 })
129
130 it('does not walk through its own bomb into the blast with Bomb Pass, and gets out of a blast zone (Q101)', () => {
131 const s = board(`
132 #############
133 #E..P.4..$..#
134 #############`)
135 s.powers.bombPass = true
136 expect(s.bombs).toHaveLength(1)
137 const p = plan(s)
138 expect(p.phase).toBe('bombs')
139 expect(p.action).not.toEqual({ id: 'move', target: v(5, 1) })
140 const turns = play(s)
141 expect(turns.every((t) => t.lives === s.lives)).toBe(true)
142 expect(turns.at(-1)!.status).toBe('cleared')
143 expect(turns.at(-1)!.drops).toHaveLength(0)
144 })
145
146 it('still crosses a spike that blocks the only way to the exit (no endless wait, Q101)', () => {
147 const s = board(`
148 #######
149 #P.v.E#
150 #######`)
151 expect(s.spikes).toHaveLength(1)
152 const turns = play(s)
153 expect(turns.at(-1)!.status).toBe('cleared')
154 expect(turns.every((t) => t.lives === s.lives)).toBe(true)
155 })
156
157 it('waits for crates that shut the way to the exit, but not when nothing crumbles', () => {
158 const s = board(`
159 #######
160 #P+..E#
161 #######`)
162 s.crumbling.push({ cell: idx(s, v(2, 1)), turns: 1 })
163 expect(plan(s)).toEqual({ phase: 'crates', goal: null, action: { id: 'wait' } })
164 s.crumbling = []
165 expect(plan(s).phase).toBe('exit')
166 // Boxed in is the game's own rule (waiting loses a life), so there is no turn to take.
167 expect(plan(s).action).toBeNull()
168 })
169
170 it('waits when the next step is unsafe, and has no turn at all when boxed in', () => {
171 const s = board(`
172 ########
173 #P$..E.#
174 ########`)
175 s.burning = [idx(s, v(2, 1))]
176 expect(plan(s).action).toEqual({ id: 'wait' })
177 s.burning = [idx(s, v(1, 1)), idx(s, v(2, 1))]
178 expect(plan(s).action).toBeNull()
179 })
180
181 it('takes a Run when one is owned', () => {
182 const s = board(`
183 ############
184 #P.....$..E#
185 ############`)
186 s.powers.run = true
187 expect(plan(s).action).toEqual({ id: 'run', target: v(3, 1) })
188 })
189
190 it('is deterministic', () => {
191 const s = board(`
192 #########
193 #P.$.$.$#
194 #.......#
195 #$.$..E.#
196 #########`)
197 expect(plan(s)).toEqual(plan(structuredClone(s)))
198 })
199
200 it('clears generated levels with no enemies: everything gathered, within the turn limit', () => {
201 for (let seed = 1; seed <= 20; seed++) {
202 const s = newRun(seed, 1 + (seed % 5))
203 s.enemies = []
204 // What the last kill sets off: every crate crumbles within 1-3 turns.
205 s.cells.forEach((c, cell) => {
206 if (c === 'block') s.crumbling.push({ cell, turns: 1 + (cell % 3) })
207 })
208 s.drops.push({ pos: s.player.x > 1 ? v(s.player.x - 1, s.player.y) : s.player, kind: 'coins', amount: 3 })
209 const turns = play(s)
210 const last = turns.at(-1)!
211 expect(last.status, `seed ${seed}`).toMatch(/cleared|won/)
212 expect(last.drops, `seed ${seed}`).toHaveLength(0)
213 expect(last.gems.filter((g) => last.cells[idx(last, g.pos)] === 'floor'), `seed ${seed}`).toHaveLength(0)
214 expect(turns.length, `seed ${seed}`).toBeLessThanOrEqual(s.width * s.height * 4)
215 }
216 })
217})