explodorama / files / test / q75.test.ts
q75.test.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { isDeadly, predictDanger } from '../src/sim/danger' | |
| 3 | import { autoMoveAction, safeStep } from '../src/sim/path' | |
| 4 | import { tapMeaning } from '../src/sim/picks' | |
| 5 | import { eq } from '../src/sim/state' | |
| 6 | import { step } from '../src/sim/turn' | |
| 7 | import type { GameState, Vec } from '../src/sim/types' | |
| 8 | import { actionableTiles, cursorActions, cursorLines, farTiles, moveCursor, tileActions } from '../src/ui/padTiles' | |
| 9 | import { board, v } from './helpers' | |
| 10 | ||
| 11 | // Q75: a coin dropped by an enemy, in a pocket (block to the right, the edge to the left), reachable by walking round. | |
| 12 | const scene = (): GameState => { | |
| 13 | const s = board(` | |
| 14 | ..P..+B | |
| 15 | $#.#+#. | |
| 16 | ...+... | |
| 17 | .#.#.#. | |
| 18 | ..+...B | |
| 19 | .#.#+#. | |
| 20 | ....... | |
| 21 | .#.#.#.`) | |
| 22 | s.powers.rpg = true | |
| 23 | s.stats.capacity = 2 | |
| 24 | s.enemies[0]!.pos = v(6, 0) | |
| 25 | s.enemies[0]!.plan = [v(6, 1)] | |
| 26 | s.enemies[1]!.pos = v(6, 4) | |
| 27 | s.enemies[1]!.plan = [v(6, 3)] | |
| 28 | return s | |
| 29 | } | |
| 30 | const safeOf = (s: GameState) => { | |
| 31 | const danger = predictDanger(s) | |
| 32 | return (p: Vec) => safeStep(s, danger, p) | |
| 33 | } | |
| 34 | const coin = v(0, 1) | |
| 35 | const above = v(0, 0) | |
| 36 | ||
| 37 | describe('Q75: a coin in a pocket', () => { | |
| 38 | it('the logic is right: a walk, a faded tile, no deadly first step', () => { | |
| 39 | const s = scene() | |
| 40 | expect(tapMeaning(s, coin)).toBe('walk') | |
| 41 | expect(farTiles(s, safeOf(s)).some((f) => eq(f.tile, coin))).toBe(true) | |
| 42 | expect(isDeadly(s, { id: 'move', target: v(2, 1) })).toBe(false) | |
| 43 | }) | |
| 44 | ||
| 45 | it('the RPG tile above the coin is bright, Move greyed on it', () => { | |
| 46 | const s = scene() | |
| 47 | expect(actionableTiles(s).some((t) => eq(t, above))).toBe(true) | |
| 48 | expect(tileActions(s, above).find((a) => a.button === 'move')!.possible).toBe(false) | |
| 49 | expect(tileActions(s, above).find((a) => a.button === 'bomb')!.possible).toBe(true) | |
| 50 | }) | |
| 51 | ||
| 52 | it('d-pad Down from the RPG tile reaches the coin, with nothing picked and with the coin as destination', () => { | |
| 53 | const s = scene() | |
| 54 | for (const dest of [null, coin]) { | |
| 55 | const tiles = [...actionableTiles(s), ...farTiles(s, safeOf(s), dest).map((f) => f.tile)] | |
| 56 | expect(eq(moveCursor(tiles, above, 0, 1), coin)).toBe(true) | |
| 57 | } | |
| 58 | }) | |
| 59 | ||
| 60 | it('with a destination elsewhere, the coin is no candidate (only the destination is far)', () => { | |
| 61 | const s = scene() | |
| 62 | const dest = v(0, 6) | |
| 63 | const far = farTiles(s, safeOf(s), dest).map((f) => f.tile) | |
| 64 | expect(far.some((t) => eq(t, coin))).toBe(false) | |
| 65 | }) | |
| 66 | }) | |
| 67 | ||
| 68 | // Q75b: a tile that is both an RPG target and a walk away offers Move (the walk) and Bomb. | |
| 69 | describe('Q75b: Move on a tile that is also a bomb target', () => { | |
| 70 | // The RPG flies to the end of the line: a wall at column 6 makes (5,1) the target. | |
| 71 | const open = (): GameState => { | |
| 72 | const s = board(` | |
| 73 | ....... | |
| 74 | ...P..# | |
| 75 | .......`) | |
| 76 | s.powers.rpg = true | |
| 77 | s.stats.capacity = 2 | |
| 78 | s.enemies = [] | |
| 79 | return s | |
| 80 | } | |
| 81 | const twoAway = v(5, 1) | |
| 82 | const get = (s: GameState, tile: Vec, button: string) => cursorActions(s, safeOf(s), tile, null).find((a) => a.button === button)! | |
| 83 | ||
| 84 | it('the old behaviour: the tile is bright, Move is greyed in tileActions', () => { | |
| 85 | const s = open() | |
| 86 | expect(actionableTiles(s).some((t) => eq(t, twoAway))).toBe(true) | |
| 87 | expect(tileActions(s, twoAway).find((a) => a.button === 'move')!.possible).toBe(false) | |
| 88 | expect(tileActions(s, twoAway).find((a) => a.button === 'bomb')!.possible).toBe(true) | |
| 89 | expect(farTiles(s, safeOf(s)).some((f) => eq(f.tile, twoAway))).toBe(false) | |
| 90 | }) | |
| 91 | ||
| 92 | it('the cursor offers Bomb and a walk of 2 steps', () => { | |
| 93 | const s = open() | |
| 94 | const move = get(s, twoAway, 'move') | |
| 95 | expect(move.possible).toBe(true) | |
| 96 | expect(move.far).toBe(true) | |
| 97 | expect(move.label).toBe('Walk here · 2 steps') | |
| 98 | expect(get(s, twoAway, 'bomb').possible).toBe(true) | |
| 99 | const lines = cursorLines(cursorActions(s, safeOf(s), twoAway, null)).map((l) => l.button) | |
| 100 | expect(lines).toEqual(['move', 'bomb']) | |
| 101 | }) | |
| 102 | ||
| 103 | it('"Keep walking" when it is the destination', () => { | |
| 104 | const s = open() | |
| 105 | const move = cursorActions(s, safeOf(s), twoAway, twoAway).find((a) => a.button === 'move')! | |
| 106 | expect(move.label).toBe('Keep walking · 2 steps') | |
| 107 | }) | |
| 108 | ||
| 109 | it('Move walks (a step toward it), not an RPG', () => { | |
| 110 | const s = open() | |
| 111 | expect(tapMeaning(s, twoAway)).toBe('walk') | |
| 112 | const action = autoMoveAction(s, twoAway, safeOf(s))! | |
| 113 | const next = step(s, action).state | |
| 114 | expect(next.player.x).toBeGreaterThan(s.player.x) | |
| 115 | expect(next.bombs.length).toBe(0) | |
| 116 | }) | |
| 117 | ||
| 118 | it('the same for a tile 3+ away', () => { | |
| 119 | const s = open() | |
| 120 | s.player = v(1, 1) | |
| 121 | const far = v(5, 1) | |
| 122 | expect(actionableTiles(s).some((t) => eq(t, far))).toBe(true) | |
| 123 | expect(get(s, far, 'bomb').possible).toBe(true) | |
| 124 | expect(get(s, far, 'move').far).toBe(true) | |
| 125 | expect(get(s, far, 'move').label).toBe('Walk here · 4 steps') | |
| 126 | }) | |
| 127 | ||
| 128 | it('a plain tile next to you keeps its ordinary Move, and your own tile has no walk', () => { | |
| 129 | const s = open() | |
| 130 | const near = get(s, v(4, 1), 'move') | |
| 131 | expect(near.possible).toBe(true) | |
| 132 | expect(near.far).toBeUndefined() | |
| 133 | expect(get(s, s.player, 'move').possible).toBe(false) | |
| 134 | }) | |
| 135 | ||
| 136 | it('the coin case: the faded tile still offers only the walk', () => { | |
| 137 | const s = scene() | |
| 138 | const acts = cursorActions(s, safeOf(s), coin, null) | |
| 139 | expect(acts.find((a) => a.button === 'move')!.far).toBe(true) | |
| 140 | expect(acts.filter((a) => a.possible).length).toBe(1) | |
| 141 | }) | |
| 142 | }) |