explodorama / files / src / sim / path.ts
path.ts
a313eb7d 2026-10-25 Pavel Safronov | 1 | import { diagonalAllowed, isAvailable, isValidTarget } from './actions' |
36c5d6a2 2026-10-24 Pavel Safronov | 2 | import type { Danger } from './danger' |
565a6873 2026-10-25 Pavel Safronov | 3 | import { enemyAt, moveTiles } from './enemies' |
1f0038ac 2026-11-01 Pavel Safronov | 4 | import { add, eq, exitOpen, flameSafe, idx, oilSlide, playerCanPass } from './state' |
28670c3e 2026-10-24 Pavel Safronov | 5 | import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types' |
82adee18 2026-10-20 Pavel Safronov | 6 | |
e6bb7c42 2026-10-25 Pavel Safronov | 7 | /** The four diagonal steps, for a walk with Diagonal. */ |
| 8 | const DIAGONALS: readonly Vec[] = [{ x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: 1 }] | |
| 9 | ||
1f0038ac 2026-11-01 Pavel Safronov | 10 | /** |
| 11 | * Whether a step from `from` to its neighbour `q` (or a run landing on `q`) would start an oil | |
| 12 | * slide: the player would not stay on `q`. Auto-move keeps off such steps (reviewer, 2026-10-31): | |
| 13 | * it plans on tiles it lands on, and a slide can carry the player past the destination, back and | |
| 14 | * forth for ever, or onto the open exit. A tap on a single tile away still moves, and slides. | |
| 15 | */ | |
| 16 | export function slips(s: GameState, from: Vec, q: Vec): boolean { | |
| 17 | const d = { x: Math.sign(q.x - from.x), y: Math.sign(q.y - from.y) } | |
| 18 | return !(d.x && d.y) && oilSlide(s, q, d, (p) => playerCanPass(s, p)).length > 0 | |
| 19 | } | |
| 20 | ||
82adee18 2026-10-20 Pavel Safronov | 21 | /** |
27f24fed 2026-10-20 Pavel Safronov | 22 | * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free |
| 23 | * floor, plus crates or bombs with Wall or Bomb Pass). The result leaves out the player's own | |
| 24 | * tile and ends at `to`; null if `to` can't be reached. | |
82adee18 2026-10-20 Pavel Safronov | 25 | * Enemies don't block the path, because they will have moved by the time the player gets there; |
| 26 | * the UI refuses a step onto an enemy instead. | |
52d68b59 2026-10-24 Pavel Safronov | 27 | * An open exit is only walked onto as the destination: passing over it would end the level on |
| 28 | * the way somewhere else (user report, 2026-10-23). | |
a313eb7d 2026-10-25 Pavel Safronov | 29 | * With Diagonal, a diagonal step counts as one too, where the action allows it (no squeezing; user |
e6bb7c42 2026-10-25 Pavel Safronov | 30 | * report, 2026-10-25: auto-move went round two sides of a corner it could cut). |
82adee18 2026-10-20 Pavel Safronov | 31 | */ |
| 32 | export function playerPath(s: GameState, to: Vec): Vec[] | null { | |
27f24fed 2026-10-20 Pavel Safronov | 33 | if (eq(s.player, to) || !playerCanPass(s, to)) return null |
82adee18 2026-10-20 Pavel Safronov | 34 | const key = (p: Vec) => p.y * s.width + p.x |
| 35 | const prev = new Map<number, Vec>([[key(s.player), s.player]]) | |
52d68b59 2026-10-24 Pavel Safronov | 36 | const avoidExit = exitOpen(s) && !eq(s.exit, to) |
82adee18 2026-10-20 Pavel Safronov | 37 | const queue = [s.player] |
e6bb7c42 2026-10-25 Pavel Safronov | 38 | const steps = s.powers.diagonal ? [...DIR_LIST.map((d) => DIRS[d]), ...DIAGONALS] : DIR_LIST.map((d) => DIRS[d]) |
82adee18 2026-10-20 Pavel Safronov | 39 | for (let i = 0; i < queue.length; i++) { |
| 40 | const p = queue[i]! | |
e6bb7c42 2026-10-25 Pavel Safronov | 41 | for (const d of steps) { |
a313eb7d 2026-10-25 Pavel Safronov | 42 | if (d.x && d.y && !diagonalAllowed(s, p, d)) continue |
e6bb7c42 2026-10-25 Pavel Safronov | 43 | const q = add(p, d) |
1f0038ac 2026-11-01 Pavel Safronov | 44 | if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit)) || slips(s, p, q)) continue |
82adee18 2026-10-20 Pavel Safronov | 45 | prev.set(key(q), p) |
| 46 | if (eq(q, to)) { | |
| 47 | const path = [q] | |
| 48 | for (let r = p; !eq(r, s.player); r = prev.get(key(r))!) path.unshift(r) | |
| 49 | return path | |
| 50 | } | |
| 51 | queue.push(q) | |
| 52 | } | |
| 53 | } | |
| 54 | return null | |
| 55 | } | |
28670c3e 2026-10-24 Pavel Safronov | 56 | |
| 57 | /** | |
| 58 | * Auto-move's next action toward `to`, also the first one when a far tile is tapped (user | |
| 59 | * request, 2026-10-23): a tile next to the player is a step; otherwise whichever of a step along | |
fcf44f32 2026-10-25 Pavel Safronov | 60 | * the way or a Run (2 straight along it) leaves the player closest to `to`, counted as the walk |
| 61 | * still left; on a tie, a Run. (Teleport took part until it became a charge, 2026-10-25.) Then it | |
| 62 | * is worked out again next turn. Only tiles `safe` allows are landed on, and never the open exit | |
28670c3e 2026-10-24 Pavel Safronov | 63 | * unless it is `to`. Null if nothing gets closer. |
| 64 | */ | |
| 65 | export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean): Action | null { | |
| 66 | const path = playerPath(s, to) | |
e6bb7c42 2026-10-25 Pavel Safronov | 67 | // The first step along it: a plain step, or a diagonal one (with Diagonal). |
| 68 | const first = path?.[0] | |
| 69 | const stepId = first && Math.abs(first.x - s.player.x) + Math.abs(first.y - s.player.y) === 2 ? 'diagonal' : 'move' | |
| 70 | const step: Action | null = first && safe(first) && isValidTarget(s, stepId, first) ? { id: stepId, target: first } : null | |
28670c3e 2026-10-24 Pavel Safronov | 71 | if (path?.length === 1) return step |
fcf44f32 2026-10-25 Pavel Safronov | 72 | // (Teleport was an option too until 2026-10-25; as a charge it isn't auto-move's to spend.) |
28670c3e 2026-10-24 Pavel Safronov | 73 | const options: { action: Action; left: number }[] = [] |
| 74 | const [next, after] = [path?.[0], path?.[1]] | |
| 75 | const straight = next && after && after.x - next.x === next.x - s.player.x && after.y - next.y === next.y - s.player.y | |
1f0038ac 2026-11-01 Pavel Safronov | 76 | if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after) && !slips(s, next, after)) { |
28670c3e 2026-10-24 Pavel Safronov | 77 | options.push({ action: { id: 'run', target: after }, left: path!.length - 2 }) |
| 78 | } | |
| 79 | if (step) options.push({ action: step, left: path!.length - 1 }) | |
| 80 | // The closest; the first listed wins a tie. | |
| 81 | let best: { action: Action; left: number } | null = null | |
| 82 | for (const o of options) if (!best || o.left < best.left) best = o | |
| 83 | const now = path?.length ?? Infinity | |
| 84 | return best && best.left < now ? best.action : null | |
| 85 | } | |
36c5d6a2 2026-10-24 Pavel Safronov | 86 | |
| 87 | /** | |
565a6873 2026-10-25 Pavel Safronov | 88 | * Whether auto-move may take this step: not onto an enemy, not onto a tile an enemy moves to or |
| 89 | * dashes through next, and not into flame this turn. Those would be deaths the player didn't choose. | |
36c5d6a2 2026-10-24 Pavel Safronov | 90 | */ |
| 91 | export function safeStep(s: GameState, danger: Danger, p: Vec): boolean { | |
| 92 | // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt. | |
565a6873 2026-10-25 Pavel Safronov | 93 | const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && moveTiles(e.pos, e.plan[0]).some((t) => eq(t, p))) |
36c5d6a2 2026-10-24 Pavel Safronov | 94 | return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p))) |
| 95 | } | |
e6bb7c42 2026-10-25 Pavel Safronov | 96 | |
| 97 | /** | |
| 98 | * Where auto-move will land, move by move, from here to `to` (user report, 2026-10-25: with Run, | |
| 99 | * every tile of the way lit up though only every other one is landed on): `autoMoveAction` played | |
| 100 | * out on the player's place alone, the first move only where `safe` allows (later turns' dangers | |
| 101 | * aren't known yet). A Run or Teleport skips tiles; a step lands on each. Empty if the first move | |
| 102 | * can't be made now. | |
| 103 | */ | |
| 104 | export function autoRoute(s: GameState, to: Vec, safe: (p: Vec) => boolean): Vec[] { | |
| 105 | const route: Vec[] = [] | |
| 106 | let at = s | |
| 107 | for (let i = 0; i < s.width * s.height && !eq(at.player, to); i++) { | |
| 108 | const action = autoMoveAction(at, to, i === 0 ? safe : () => true) | |
| 109 | if (!action || !('target' in action) || !action.target) break | |
| 110 | route.push(action.target) | |
| 111 | at = { ...at, player: action.target } | |
| 112 | } | |
| 113 | return route | |
| 114 | } |