explodorama / files / test / farTiles.test.ts
farTiles.test.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { isValidTarget } from '../src/sim/actions' | |
| 3 | import { isDeadly, predictDanger } from '../src/sim/danger' | |
| 4 | import { autoMoveAction, playerPath, safeStep } from '../src/sim/path' | |
| 5 | import { tapMeaning } from '../src/sim/picks' | |
| 6 | import { eq, newRun } from '../src/sim/state' | |
| 7 | import { step } from '../src/sim/turn' | |
| 8 | import type { GameState, Vec } from '../src/sim/types' | |
| 9 | import { defaultButton, keyButton } from '../src/ui/keyCursor' | |
| 10 | import { actionableTiles, farActions, farTiles, ghostAction, keepCursor, moveCursor, cursorLines } from '../src/ui/padTiles' | |
| 11 | import { board, v } from './helpers' | |
| 12 | ||
| 13 | const room = () => | |
| 14 | board(` | |
| 15 | ####### | |
| 16 | #.....# | |
| 17 | #.....# | |
| 18 | #..P..# | |
| 19 | #.....# | |
| 20 | #.....# | |
| 21 | #######`) | |
| 22 | ||
| 23 | const safeOf = (s: GameState) => { | |
| 24 | const danger = predictDanger(s) | |
| 25 | return (p: Vec) => safeStep(s, danger, p) | |
| 26 | } | |
| 27 | const far = (s: GameState, destination: Vec | null = null) => farTiles(s, safeOf(s), destination) | |
| 28 | const key = (p: Vec) => `${p.x},${p.y}` | |
| 29 | ||
| 30 | describe('farTiles', () => { | |
| 31 | it('is every floor tile the bright blue does not cover, without the player\'s own', () => { | |
| 32 | const s = room() | |
| 33 | const tiles = far(s).map((f) => f.tile) | |
| 34 | expect(tiles.some((t) => eq(t, v(1, 1)))).toBe(true) | |
| 35 | expect(tiles.some((t) => eq(t, v(3, 3)))).toBe(false) | |
| 36 | expect(tiles.some((t) => eq(t, v(4, 3)))).toBe(false) | |
| 37 | expect(tiles.some((t) => t.x === 0)).toBe(false) | |
| 38 | // 25 floor tiles: the player's, 4 neighbours (bright, also a bomb target), the rest faded. | |
| 39 | const bright = new Set(actionableTiles(s).map(key)) | |
| 40 | expect(tiles.length + bright.size).toBe(25) | |
| 41 | }) | |
| 42 | ||
| 43 | it('with Run, the tiles two away are bright, not faded', () => { | |
| 44 | const s = room() | |
| 45 | s.powers.run = true | |
| 46 | const tiles = far(s).map((f) => f.tile) | |
| 47 | expect(tiles.some((t) => eq(t, v(5, 3)))).toBe(false) | |
| 48 | expect(tiles.some((t) => eq(t, v(1, 1)))).toBe(true) | |
| 49 | }) | |
| 50 | ||
| 51 | it('counts the steps of the walk, nearest first', () => { | |
| 52 | const s = room() | |
| 53 | const list = far(s) | |
| 54 | expect(list.find((f) => eq(f.tile, v(1, 1)))!.steps).toBe(4) | |
| 55 | for (let i = 1; i < list.length; i++) expect(list[i]!.steps).toBeGreaterThanOrEqual(list[i - 1]!.steps) | |
| 56 | }) | |
| 57 | ||
| 58 | it('leaves out tiles that cannot be walked to', () => { | |
| 59 | const s = board(` | |
| 60 | ####### | |
| 61 | #P..#.# | |
| 62 | #...#.# | |
| 63 | #######`) | |
| 64 | expect(far(s).some((f) => eq(f.tile, v(5, 1)))).toBe(false) | |
| 65 | }) | |
| 66 | ||
| 67 | it('leaves out tiles whose first move is not safe', () => { | |
| 68 | // The bomb about to go off lights up tiles; auto-move never lands on one as its first move. | |
| 69 | const s = board(` | |
| 70 | ######### | |
| 71 | #P.1....# | |
| 72 | #.......# | |
| 73 | #########`) | |
| 74 | const danger = predictDanger(s) | |
| 75 | expect(danger.now.size).toBeGreaterThan(0) | |
| 76 | const all = far(s).map((f) => f.tile) | |
| 77 | expect(all.length).toBeGreaterThan(0) | |
| 78 | for (const t of all) { | |
| 79 | const move = autoMoveAction(s, t, safeOf(s))! | |
| 80 | expect('target' in move && move.target && danger.now.has(move.target.y * s.width + move.target.x)).toBeFalsy() | |
| 81 | } | |
| 82 | }) | |
| 83 | ||
| 84 | it('with a destination, only it', () => { | |
| 85 | const s = room() | |
| 86 | expect(far(s, v(1, 1)).map((f) => f.tile)).toEqual([v(1, 1)]) | |
| 87 | expect(far(s, v(4, 3))).toEqual([]) | |
| 88 | }) | |
| 89 | ||
| 90 | for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) { | |
| 91 | it(`seed ${seed}: every faded tile is tapped as a walk, with a legal and safe first move, and the label's steps are the walk`, () => { | |
| 92 | const s = newRun(seed, 1 + (seed % 6)) | |
| 93 | const safe = safeOf(s) | |
| 94 | const bright = new Set(actionableTiles(s).map(key)) | |
| 95 | const list = far(s) | |
| 96 | for (const f of list) { | |
| 97 | expect(bright.has(key(f.tile))).toBe(false) | |
| 98 | expect(eq(f.tile, s.player)).toBe(false) | |
| 99 | expect(tapMeaning(s, f.tile)).toBe('walk') | |
| 100 | const move = autoMoveAction(s, f.tile, safe)! | |
| 101 | expect(move).not.toBeNull() | |
| 102 | expect(isDeadly(s, move)).toBe(false) | |
| 103 | expect('target' in move && move.target && isValidTarget(s, move.id, move.target)).toBe(true) | |
| 104 | expect(() => step(s, move)).not.toThrow() | |
| 105 | expect(f.steps).toBe(playerPath(s, f.tile)!.length) | |
| 106 | } | |
| 107 | }) | |
| 108 | ||
| 109 | it(`seed ${seed}: the d-pad reaches every faded tile from the player's tile`, () => { | |
| 110 | const s = newRun(seed, 1 + (seed % 6)) | |
| 111 | const list = far(s) | |
| 112 | const candidates = [...actionableTiles(s), ...list.map((f) => f.tile)] | |
| 113 | // Breadth first over the cursor's hops. | |
| 114 | const seen = new Set([key(s.player)]) | |
| 115 | const queue: Vec[] = [s.player] | |
| 116 | for (let i = 0; i < queue.length; i++) { | |
| 117 | for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]] as const) { | |
| 118 | const next = moveCursor(candidates, queue[i]!, dx, dy) | |
| 119 | if (!seen.has(key(next))) { | |
| 120 | seen.add(key(next)) | |
| 121 | queue.push(next) | |
| 122 | } | |
| 123 | } | |
| 124 | } | |
| 125 | for (const f of list) expect(seen.has(key(f.tile)), `${key(f.tile)}`).toBe(true) | |
| 126 | }) | |
| 127 | } | |
| 128 | }) | |
| 129 | ||
| 130 | describe('farActions', () => { | |
| 131 | const s = room() | |
| 132 | const f = far(s).find((x) => eq(x.tile, v(1, 1)))! | |
| 133 | ||
| 134 | it('only Move is possible, saying how far, and nothing else', () => { | |
| 135 | const actions = farActions(f, false) | |
| 136 | expect(actions.filter((a) => a.possible).map((a) => a.button)).toEqual(['move']) | |
| 137 | expect(actions.find((a) => a.button === 'move')).toMatchObject({ label: 'Walk here · 4 steps', deadly: false, far: true }) | |
| 138 | expect(cursorLines(actions)).toEqual([{ button: 'move', label: 'Walk here · 4 steps' }]) | |
| 139 | }) | |
| 140 | ||
| 141 | it('one step is singular, and a destination already set says Keep walking', () => { | |
| 142 | expect(farActions({ tile: v(1, 1), steps: 1 }, false)[0]!.label).toBe('Walk here · 1 step') | |
| 143 | expect(farActions(f, true)[0]!.label).toBe('Keep walking · 4 steps') | |
| 144 | }) | |
| 145 | ||
| 146 | it('has no ghost, and the keyboard\'s M and Enter both walk', () => { | |
| 147 | const actions = farActions(f, false) | |
| 148 | expect(ghostAction(actions, f.tile, s.player)).toBeNull() | |
| 149 | expect(keyButton(actions, 'move')).toBe('move') | |
| 150 | expect(keyButton(actions, 'bomb')).toBeNull() | |
| 151 | expect(defaultButton(actions, f.tile, s.player)).toBe('move') | |
| 152 | }) | |
| 153 | }) | |
| 154 | ||
| 155 | describe('the cursor over bright and faded tiles', () => { | |
| 156 | it('keeps a faded tile while it stays a candidate, else goes to the nearest', () => { | |
| 157 | const s = room() | |
| 158 | const candidates = [...actionableTiles(s), ...far(s).map((f) => f.tile)] | |
| 159 | expect(keepCursor(candidates, v(1, 1))).toEqual(v(1, 1)) | |
| 160 | expect(keepCursor(candidates.filter((t) => !eq(t, v(1, 1))), v(1, 1))).not.toBeNull() | |
| 161 | }) | |
| 162 | ||
| 163 | it('hops one tile at a time over the open floor, and onto a faded tile', () => { | |
| 164 | const s = room() | |
| 165 | const candidates = [...actionableTiles(s), ...far(s).map((f) => f.tile)] | |
| 166 | let at: Vec = s.player | |
| 167 | at = moveCursor(candidates, at, -1, 0) | |
| 168 | expect(at).toEqual(v(2, 3)) | |
| 169 | at = moveCursor(candidates, at, -1, 0) | |
| 170 | expect(at).toEqual(v(1, 3)) | |
| 171 | }) | |
| 172 | }) |