explodorama / files / test / autopilot.test.ts
autopilot.test.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { autopilotPlan, autopilotTargets } from '../src/sim/autopilot' | |
| 3 | import { predictDanger } from '../src/sim/danger' | |
| 4 | import { playerPath } from '../src/sim/path' | |
| 5 | import { idx, newRun } from '../src/sim/state' | |
| 6 | import { step } from '../src/sim/turn' | |
| 7 | import type { GameState } from '../src/sim/types' | |
| 8 | import { board, v } from './helpers' | |
| 9 | ||
| 10 | const 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. */ | |
| 13 | function 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 | ||
| 25 | describe('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 | ||
351348d1 2026-11-04 Pavel Safronov | 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 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 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 | }) |