explodorama / files / test / padTiles.test.ts
padTiles.test.ts
ebcd0a8f 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
92cc4ee3 2026-11-02 Pavel Safronov | 2 | import { actionableTiles, cursorDeadly, cursorLines, cursorStart, cursorWanted, ghostAction, keepCursor, l1Hint, moveCursor, restingCursor, tileActions } from '../src/ui/padTiles' |
ebcd0a8f 2026-11-02 Pavel Safronov | 3 | import { board, v } from './helpers' |
| 4 | ||
| 5 | const room = () => | |
| 6 | board(` | |
| 7 | ####### | |
| 8 | #.....# | |
| 9 | #.....# | |
| 10 | #..P..# | |
| 11 | #.....# | |
| 12 | #.....# | |
| 13 | #######`) | |
| 14 | ||
| 15 | const by = (actions: ReturnType<typeof tileActions>, b: string) => actions.find((a) => a.button === b)! | |
| 16 | ||
| 17 | describe('actionableTiles', () => { | |
| 18 | it('the player first, then every tile a button can use, each once', () => { | |
| 19 | const tiles = actionableTiles(room()) | |
| 20 | expect(tiles[0]).toEqual(v(3, 3)) | |
| 21 | expect(tiles.some((t) => t.x === 4 && t.y === 3)).toBe(true) | |
| 22 | expect(new Set(tiles.map((t) => `${t.x},${t.y}`)).size).toBe(tiles.length) | |
| 23 | }) | |
| 24 | it('walls and far tiles are not in it', () => { | |
| 25 | const tiles = actionableTiles(room()) | |
| 26 | expect(tiles.some((t) => t.x === 0)).toBe(false) | |
| 27 | expect(tiles.some((t) => t.x === 1 && t.y === 1)).toBe(false) | |
| 28 | }) | |
| 29 | it('with Run, tiles two away are in it too', () => { | |
| 30 | const s = room() | |
| 31 | s.powers.run = true | |
| 32 | expect(actionableTiles(s).some((t) => t.x === 5 && t.y === 3)).toBe(true) | |
| 33 | }) | |
| 34 | }) | |
| 35 | ||
| 36 | describe('tileActions', () => { | |
| 37 | it('Wait only on the player\'s own tile', () => { | |
| 38 | const s = room() | |
| 39 | expect(by(tileActions(s, v(3, 3)), 'wait').possible).toBe(true) | |
| 40 | expect(by(tileActions(s, v(4, 3)), 'wait').possible).toBe(false) | |
| 41 | }) | |
| 42 | it('a floor tile next to the player: Move and Bomb, not Charge or Wait', () => { | |
| 43 | const a = tileActions(room(), v(4, 3)) | |
| 44 | expect(by(a, 'move')).toMatchObject({ possible: true, id: 'move', deadly: false, label: 'Move to this tile' }) | |
| 45 | expect(by(a, 'bomb')).toMatchObject({ possible: true, id: 'bomb', label: 'Place a bomb on this tile' }) | |
| 46 | expect(by(a, 'charged').possible).toBe(false) | |
| 47 | expect(by(a, 'charged').label).toBe('') | |
| 48 | }) | |
| 49 | it('the own tile with only Wait possible', () => { | |
| 50 | const a = tileActions(room(), v(3, 3)) | |
| 51 | expect(a.filter((x) => x.possible).map((x) => x.button)).toEqual(['wait']) | |
| 52 | expect(by(a, 'wait').label).toBe('Wait a turn') | |
| 53 | }) | |
| 54 | it('marks a button deadly and says so', () => { | |
| 55 | const s = board(` | |
| 56 | ####### | |
| 57 | #P.1..# | |
| 58 | #######`) | |
| 59 | const move = by(tileActions(s, v(2, 1)), 'move') | |
| 60 | expect(move.deadly).toBe(true) | |
| 61 | expect(move.possible).toBe(true) | |
| 62 | expect(move.label).toContain('(costs a life!)') | |
| 63 | }) | |
| 64 | it('Wait is deadly when staying is', () => { | |
| 65 | const s = board(` | |
| 66 | ####### | |
| 67 | #P1...# | |
| 68 | #######`) | |
| 69 | const wait = by(tileActions(s, v(1, 1)), 'wait') | |
| 70 | expect(wait.possible).toBe(true) | |
| 71 | expect(wait.deadly).toBe(true) | |
| 72 | expect(wait.label).toContain('(costs a life!)') | |
| 73 | }) | |
| 74 | it('a Freeze charge is possible on the own tile', () => { | |
| 75 | const s = room() | |
| 76 | s.charge = { kind: 'freeze', left: 1 } | |
| 77 | const a = tileActions(s, v(3, 3)) | |
| 78 | expect(by(a, 'charged')).toMatchObject({ possible: true, id: 'freeze' }) | |
| 79 | expect(by(a, 'wait').possible).toBe(true) | |
| 80 | }) | |
| 81 | }) | |
| 82 | ||
| 83 | describe('the cursor', () => { | |
| 84 | const tiles = [v(3, 3), v(4, 3), v(2, 3), v(3, 2), v(3, 4), v(5, 3)] | |
aef53511 2026-11-02 Pavel Safronov | 85 | it('rests on the player\'s own tile, as a copy', () => { |
| 86 | const s = room() | |
| 87 | const rest = restingCursor(s) | |
| 88 | expect(rest).toEqual(v(3, 3)) | |
| 89 | expect(rest).not.toBe(s.player) | |
| 90 | }) | |
92cc4ee3 2026-11-02 Pavel Safronov | 91 | it('starts on the destination while auto-move walks, else on the player', () => { |
| 92 | const s = room() | |
| 93 | expect(cursorStart(s, null)).toEqual(v(3, 3)) | |
| 94 | const start = cursorStart(s, v(5, 1)) | |
| 95 | expect(start).toEqual(v(5, 1)) | |
| 96 | expect(start).not.toBe(s.player) | |
| 97 | // Arrived (destination is the player's own tile): back on the player. | |
| 98 | expect(cursorStart(s, v(3, 3))).toEqual(v(3, 3)) | |
| 99 | }) | |
720cc9d3 2026-11-03 Pavel Safronov | 100 | it('Q80: a destination that is no longer a candidate (a flame made it unsafe) is not where the cursor starts', () => { |
| 101 | const s = room() | |
| 102 | expect(cursorStart(s, v(5, 1), [v(3, 3), v(5, 1)])).toEqual(v(5, 1)) | |
| 103 | expect(cursorStart(s, v(5, 1), [v(3, 3)])).toEqual(v(3, 3)) | |
| 104 | }) | |
aef53511 2026-11-02 Pavel Safronov | 105 | it('the first d-pad press moves from the resting tile', () => { |
| 106 | expect(moveCursor(tiles, v(3, 3), 1, 0)).toEqual(v(4, 3)) | |
| 107 | expect(moveCursor(tiles, v(3, 3), 0, -1)).toEqual(v(3, 2)) | |
| 108 | expect(moveCursor(tiles, v(3, 3), 1, 1)).toEqual(v(3, 3)) | |
| 109 | expect(moveCursor([v(3, 3)], v(3, 3), 1, 0)).toEqual(v(3, 3)) | |
| 110 | }) | |
| 111 | it('is wanted only while a pad plays the board', () => { | |
92cc4ee3 2026-11-02 Pavel Safronov | 112 | const ok = { padActive: true, demo: false, dialogOpen: false, inspecting: false, offerPending: false, autopilot: false, status: 'playing' as const } |
aef53511 2026-11-02 Pavel Safronov | 113 | expect(cursorWanted(ok)).toBe(true) |
| 114 | expect(cursorWanted({ ...ok, padActive: false })).toBe(false) | |
| 115 | expect(cursorWanted({ ...ok, demo: true })).toBe(false) | |
| 116 | expect(cursorWanted({ ...ok, dialogOpen: true })).toBe(false) | |
| 117 | expect(cursorWanted({ ...ok, inspecting: true })).toBe(false) | |
83b956b0 2026-11-02 Pavel Safronov | 118 | expect(cursorWanted({ ...ok, offerPending: true })).toBe(false) |
92cc4ee3 2026-11-02 Pavel Safronov | 119 | expect(cursorWanted({ ...ok, autopilot: true })).toBe(false) |
aef53511 2026-11-02 Pavel Safronov | 120 | for (const status of ['dead', 'cleared', 'gameOver', 'won'] as const) expect(cursorWanted({ ...ok, status })).toBe(false) |
ebcd0a8f 2026-11-02 Pavel Safronov | 121 | }) |
| 122 | it('moves between actionable tiles, staying put at the edge', () => { | |
| 123 | expect(moveCursor(tiles, v(4, 3), 1, 0)).toEqual(v(5, 3)) | |
| 124 | expect(moveCursor(tiles, v(5, 3), 1, 0)).toEqual(v(5, 3)) | |
| 125 | expect(moveCursor(tiles, v(4, 3), -1, 0)).toEqual(v(3, 3)) | |
| 126 | }) | |
| 127 | it('keepCursor: stays, else the nearest still actionable, else nothing', () => { | |
| 128 | expect(keepCursor(tiles, v(4, 3))).toEqual(v(4, 3)) | |
| 129 | expect(keepCursor(tiles, v(6, 3))).toEqual(v(5, 3)) | |
| 130 | expect(keepCursor([], v(1, 1))).toBeNull() | |
| 131 | }) | |
| 132 | }) | |
| 133 | ||
aef53511 2026-11-02 Pavel Safronov | 134 | describe('the L1 hint', () => { |
| 135 | it('says what L1 will do, dimmed only when there is nothing to undo', () => { | |
720cc9d3 2026-11-03 Pavel Safronov | 136 | expect(l1Hint({ inspecting: true, picked: false, onPlayer: true })).toEqual({ text: 'Move cursor back to you', dim: false }) |
| 137 | expect(l1Hint({ inspecting: false, picked: true, onPlayer: true })).toEqual({ text: 'Cancel what you picked', dim: false }) | |
| 138 | expect(l1Hint({ inspecting: false, picked: false, onPlayer: false })).toEqual({ text: 'Move cursor back to you', dim: false }) | |
| 139 | expect(l1Hint({ inspecting: false, picked: false, onPlayer: true })).toEqual({ text: 'Move cursor back to you', dim: true }) | |
aef53511 2026-11-02 Pavel Safronov | 140 | }) |
| 141 | it('"What is that?" wins over a pick, which wins over the cursor being away', () => { | |
720cc9d3 2026-11-03 Pavel Safronov | 142 | expect(l1Hint({ inspecting: true, picked: true, onPlayer: false }).text).toBe('Move cursor back to you') |
| 143 | expect(l1Hint({ inspecting: false, picked: true, onPlayer: false }).text).toBe('Cancel what you picked') | |
aef53511 2026-11-02 Pavel Safronov | 144 | }) |
| 145 | }) | |
| 146 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 147 | describe('label and ghost', () => { |
| 148 | it('lists each possible button with what it does', () => { | |
| 149 | expect(cursorLines(tileActions(room(), v(4, 3)))).toEqual([ | |
| 150 | { button: 'move', label: 'Move to this tile' }, | |
| 151 | { button: 'bomb', label: 'Place a bomb on this tile' }, | |
| 152 | ]) | |
| 153 | expect(cursorLines(tileActions(room(), v(3, 3)))).toEqual([{ button: 'wait', label: 'Wait a turn' }]) | |
| 154 | }) | |
| 155 | it('the tile is red only when everything possible there would cost a life', () => { | |
| 156 | const s = board(` | |
| 157 | ####### | |
| 158 | #P.1..# | |
| 159 | #######`) | |
| 160 | // Walking there is deadly; so is a bomb at fuse 1? Only what the sim says counts. | |
| 161 | const a = tileActions(s, v(2, 1)) | |
| 162 | const all = a.filter((x) => x.possible) | |
| 163 | expect(cursorDeadly(a)).toBe(all.every((x) => x.deadly)) | |
| 164 | expect(cursorDeadly(a.map((x) => ({ ...x, deadly: x.possible })))).toBe(true) | |
| 165 | expect(cursorDeadly(a.map((x, i) => ({ ...x, deadly: x.possible && i === 0 })))).toBe(all.length === 1) | |
| 166 | expect(cursorDeadly(a.map((x) => ({ ...x, deadly: false })))).toBe(false) | |
| 167 | }) | |
| 168 | it('no possible action: not red', () => { | |
| 169 | expect(cursorDeadly([])).toBe(false) | |
| 170 | expect(cursorDeadly(tileActions(room(), v(1, 1)).map((x) => ({ ...x, deadly: true })))).toBe(false) | |
| 171 | }) | |
| 172 | it('the ghost is the first of Move, Bomb, Charge; none on the own tile', () => { | |
| 173 | const s = room() | |
| 174 | expect(ghostAction(tileActions(s, v(4, 3)), v(4, 3), s.player)).toEqual({ id: 'move', target: v(4, 3) }) | |
| 175 | expect(ghostAction(tileActions(s, v(3, 3)), v(3, 3), s.player)).toBeNull() | |
| 176 | }) | |
| 177 | }) |