fuzzyworld

explodorama / files / src / sim / path.ts

path.ts

Blame: the commit that last changed each line · file · history

a313eb7d 2026-10-25 Pavel Safronov1import { diagonalAllowed, isAvailable, isValidTarget } from './actions'
36c5d6a2 2026-10-24 Pavel Safronov2import type { Danger } from './danger'
565a6873 2026-10-25 Pavel Safronov3import { enemyAt, moveTiles } from './enemies'
1f0038ac 2026-11-01 Pavel Safronov4import { add, eq, exitOpen, flameSafe, idx, oilSlide, playerCanPass } from './state'
28670c3e 2026-10-24 Pavel Safronov5import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
82adee18 2026-10-20 Pavel Safronov6
e6bb7c42 2026-10-25 Pavel Safronov7/** The four diagonal steps, for a walk with Diagonal. */
8const 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 Safronov10/**
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 */
16export 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 Safronov21/**
27f24fed 2026-10-20 Pavel Safronov22 * 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 Safronov25 * 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 Safronov27 * 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 Safronov29 * With Diagonal, a diagonal step counts as one too, where the action allows it (no squeezing; user
e6bb7c42 2026-10-25 Pavel Safronov30 * report, 2026-10-25: auto-move went round two sides of a corner it could cut).
82adee18 2026-10-20 Pavel Safronov31 */
32export function playerPath(s: GameState, to: Vec): Vec[] | null {
27f24fed 2026-10-20 Pavel Safronov33 if (eq(s.player, to) || !playerCanPass(s, to)) return null
82adee18 2026-10-20 Pavel Safronov34 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 Safronov36 const avoidExit = exitOpen(s) && !eq(s.exit, to)
82adee18 2026-10-20 Pavel Safronov37 const queue = [s.player]
e6bb7c42 2026-10-25 Pavel Safronov38 const steps = s.powers.diagonal ? [...DIR_LIST.map((d) => DIRS[d]), ...DIAGONALS] : DIR_LIST.map((d) => DIRS[d])
82adee18 2026-10-20 Pavel Safronov39 for (let i = 0; i < queue.length; i++) {
40 const p = queue[i]!
e6bb7c42 2026-10-25 Pavel Safronov41 for (const d of steps) {
a313eb7d 2026-10-25 Pavel Safronov42 if (d.x && d.y && !diagonalAllowed(s, p, d)) continue
e6bb7c42 2026-10-25 Pavel Safronov43 const q = add(p, d)
1f0038ac 2026-11-01 Pavel Safronov44 if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit)) || slips(s, p, q)) continue
82adee18 2026-10-20 Pavel Safronov45 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 Safronov56
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 Safronov60 * 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 Safronov63 * unless it is `to`. Null if nothing gets closer.
64 */
65export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean): Action | null {
66 const path = playerPath(s, to)
e6bb7c42 2026-10-25 Pavel Safronov67 // 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 Safronov71 if (path?.length === 1) return step
fcf44f32 2026-10-25 Pavel Safronov72 // (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 Safronov73 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 Safronov76 if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after) && !slips(s, next, after)) {
28670c3e 2026-10-24 Pavel Safronov77 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 Safronov86
87/**
565a6873 2026-10-25 Pavel Safronov88 * 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 Safronov90 */
91export 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 Safronov93 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 Safronov94 return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p)))
95}
e6bb7c42 2026-10-25 Pavel Safronov96
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 */
104export 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}