diff --git a/tests/playthrough-driver.ts b/tests/playthrough-driver.ts index 7e7615f..daf11f8 100644 --- a/tests/playthrough-driver.ts +++ b/tests/playthrough-driver.ts @@ -8,7 +8,7 @@ import type { Direction } from "../src/game/world/types"; import { C } from "../src/game/battle/constants"; import { applyTypeMatchups } from "../src/game/battle/battle"; import { BattleData } from "../src/game/battle/data"; -import { afterRestore, findPath, isWhirlpool, landing } from "./world-driver"; +import { afterRestore, findPath, isWhirlpool, landing, pathLengths } from "./world-driver"; const STEP: Record = { up: [0, -1], @@ -301,6 +301,7 @@ export class Playthrough { const world = this.world, map = world.mapId; const [dx, dy] = STEP[direction]; + const lengths = pathLengths(world, { water, ledges: this.ledges }); const edge = Array.from( { length: dx ? world.map.height : world.map.width }, (_, i) => @@ -310,11 +311,8 @@ export class Playthrough { ).flatMap(([x, y]) => { // A warp on the edge (a gate's doorway) is not the connection. if (world.map.warps.some((warp) => warp.x === x && warp.y === y)) return []; - try { - return [{ x, y, length: findPath(world, x, y, { water, ledges: this.ledges }).length }]; - } catch { - return []; - } + const length = lengths.get(`${x},${y}`); + return length === undefined ? [] : [{ x, y, length }]; }); edge.sort((a, b) => a.length - b.length); for (const { x, y } of edge) { @@ -416,14 +414,13 @@ export class Playthrough { expect(this.world.mapId).toBe(map); } const pairs: { x: number; y: number; length: number }[] = []; + const lengths = pathLengths(world); for (let y = 0; y < world.map.height; y++) for (let x = 0; x < world.map.width; x++) - if (isGrass(x, y) && isGrass(x + 1, y)) - try { - pairs.push({ x, y, length: findPath(world, x, y).length }); - } catch { - /* unreachable */ - } + if (isGrass(x, y) && isGrass(x + 1, y)) { + const length = lengths.get(`${x},${y}`); + if (length !== undefined) pairs.push({ x, y, length }); + } pairs.sort((a, b) => a.length - b.length); const { x, y } = pairs[0]; this.walk(x, y); diff --git a/tests/world-driver.ts b/tests/world-driver.ts index edfd477..744fbe0 100644 --- a/tests/world-driver.ts +++ b/tests/world-driver.ts @@ -97,11 +97,29 @@ export const isWhirlpool = (tile: number) => tile === 0x24 || tile === 0x2c; /** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */ const isIce = (tile: number) => tile === 0x23 || tile === 0x2b; +/** What one path search reads many times: the visible objects' tiles and + * the map's warp tiles, taken once per search. */ +interface Terrain { + occupied: Set; + warps: Set; +} +const terrainOf = (world: GameWorld): Terrain => ({ + occupied: new Set(world.objects().map((object) => `${object.x},${object.y}`)), + warps: new Set(world.map.warps.map((warp) => `${warp.x},${warp.y}`)), +}); +const VECTORS: Record = { + up: [0, -1], + down: [0, 1], + left: [-1, 0], + right: [1, 0], +}; + /** Whether a step from (fromX, fromY) onto the next tile in `direction` * is open: walkable (or open water for a surfer), no side wall, no object, * and no warp other than the target. */ function open( world: GameWorld, + terrain: Terrain, fromX: number, fromY: number, direction: Direction, @@ -109,7 +127,7 @@ function open( target?: { x: number; y: number }, whirlpools = false, ) { - const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!; + const [dx, dy] = VECTORS[direction]; const x = fromX + dx, y = fromY + dy; if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false; @@ -124,10 +142,11 @@ function open( edges(tile).includes(OPPOSITE[direction]) ) return false; - if (world.objects().some((object) => object.x === x && object.y === y)) return false; + const key = `${x},${y}`; + if (terrain.occupied.has(key)) return false; if ( isWarpHazard(tile) && - world.map.warps.some((warp) => warp.x === x && warp.y === y) && + terrain.warps.has(key) && (x !== target?.x || y !== target?.y) ) return false; @@ -146,6 +165,13 @@ const LEDGES: readonly (readonly Direction[])[] = [ ["up", "left"], ]; +interface PathOptions { + water?: boolean; + target?: { x: number; y: number }; + whirlpools?: boolean; + ledges?: boolean; +} + /** Where a step in `direction` from (x, y) ends: the next tile, two tiles * on when standing on a ledge that jumps that way (.TryJump), or, on ice, * the last tile of the slide before something blocks it. Undefined when @@ -155,56 +181,48 @@ export function landing( x: number, y: number, direction: Direction, - { - water = false, - target, - whirlpools = false, - ledges = false, - }: { - water?: boolean; - target?: { x: number; y: number }; - whirlpools?: boolean; - ledges?: boolean; - } = {}, + options: PathOptions = {}, + terrain = terrainOf(world), ): { x: number; y: number } | undefined { - const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!; - const standing = world.assets.collision(world.map, x, y); - if (ledges && (standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) { - const lx = x + dx * 2, - ly = y + dy * 2; - const occupied = (ox: number, oy: number) => - world.objects().some((object) => object.x === ox && object.y === oy); - if ( - lx >= 0 && - ly >= 0 && - lx < world.map.width && - ly < world.map.height && - world.assets.permission(world.assets.collision(world.map, lx, ly)) === 0 && - !occupied(x + dx, y + dy) && - !occupied(lx, ly) && - !( - isWarpHazard(world.assets.collision(world.map, lx, ly)) && - world.map.warps.some((warp) => warp.x === lx && warp.y === ly) && - (lx !== target?.x || ly !== target?.y) + const { water = false, target, whirlpools = false, ledges = false } = options; + const [dx, dy] = VECTORS[direction]; + if (ledges) { + const standing = world.assets.collision(world.map, x, y); + if ((standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) { + const lx = x + dx * 2, + ly = y + dy * 2; + const landingTile = world.assets.collision(world.map, lx, ly); + if ( + lx >= 0 && + ly >= 0 && + lx < world.map.width && + ly < world.map.height && + world.assets.permission(landingTile) === 0 && + !terrain.occupied.has(`${x + dx},${y + dy}`) && + !terrain.occupied.has(`${lx},${ly}`) && + !( + isWarpHazard(landingTile) && + terrain.warps.has(`${lx},${ly}`) && + (lx !== target?.x || ly !== target?.y) + ) ) - ) - return { x: lx, y: ly }; + return { x: lx, y: ly }; + } } // COLL_WATERFALL: a surfer entering from above is carried down it. - const ahead = world.assets.collision(world.map, x + dx, y + dy); - if (water && ahead === 0x33) { + if (water && world.assets.collision(world.map, x + dx, y + dy) === 0x33) { if (direction !== "down") return undefined; let fy = y + 1; while (world.assets.collision(world.map, x, fy) === 0x33) fy++; - if (!open(world, x, fy - 1, "down", water, target, whirlpools)) return undefined; + if (!open(world, terrain, x, fy - 1, "down", water, target, whirlpools)) return undefined; return { x, y: fy }; } - if (!open(world, x, y, direction, water, target, whirlpools)) return undefined; + if (!open(world, terrain, x, y, direction, water, target, whirlpools)) return undefined; x += dx; y += dy; while ( isIce(world.assets.collision(world.map, x, y)) && - open(world, x, y, direction, water, target) + open(world, terrain, x, y, direction, water, target, whirlpools) ) { x += dx; y += dy; @@ -212,6 +230,26 @@ export function landing( return { x, y }; } +/** Breadth first from the player: each reached tile's path, in the order + * the search reaches them. Stops at `target` when one is given. */ +function search(world: GameWorld, options: PathOptions) { + const terrain = terrainOf(world); + const paths = new Map(); + const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; + for (let i = 0; i < queue.length; i++) { + const node = queue[i], + key = `${node.x},${node.y}`; + if (paths.has(key)) continue; + paths.set(key, node.path); + if (node.x === options.target?.x && node.y === options.target?.y) break; + for (const [direction] of DIRECTIONS) { + const end = landing(world, node.x, node.y, direction, options, terrain); + if (end) queue.push({ ...end, path: [...node.path, direction] }); + } + } + return paths; +} + /** Shortest walkable path on the current map, avoiding other warps. * `water` also crosses open water, for a player who can Surf, and * `whirlpools` the whirlpools a player with Whirlpool can clear; `ledges` @@ -222,21 +260,25 @@ export function findPath( targetY: number, { water = false, whirlpools = false, ledges = false } = {}, ): Direction[] { - const target = { x: targetX, y: targetY }; - const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; - const visited = new Set(); - for (let i = 0; i < queue.length; i++) { - const node = queue[i], - key = `${node.x},${node.y}`; - if (visited.has(key)) continue; - visited.add(key); - if (node.x === targetX && node.y === targetY) return node.path; - for (const [direction] of DIRECTIONS) { - const end = landing(world, node.x, node.y, direction, { water, target, whirlpools, ledges }); - if (end) queue.push({ ...end, path: [...node.path, direction] }); - } - } - throw new Error(`No route to ${targetX},${targetY}`); + const path = search(world, { + water, + whirlpools, + ledges, + target: { x: targetX, y: targetY }, + }).get(`${targetX},${targetY}`); + if (!path) throw new Error(`No route to ${targetX},${targetY}`); + return path; +} + +/** Every tile the player can reach (not stepping onto warps), with its + * shortest path's length: one search for many candidate targets. */ +export function pathLengths( + world: GameWorld, + { water = false, ledges = false } = {}, +): Map { + const lengths = new Map(); + for (const [key, path] of search(world, { water, ledges })) lengths.set(key, path.length); + return lengths; } /** Walk to a tile, settling any scene that a step triggers. A roaming NPC