Search the playthrough's paths once per question [no-card]
findPath now takes the visible objects and warps once per search, and training and map crossings measure every candidate tile in one search instead of one search per tile. The routes chosen are the same; the 45-stage playthrough runs in seconds again, which the slower CI host needs (the Whitney stage hit its time limit there). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4aae3887e7
commit
aff8391e55
2 files changed
+93
-54
No files matched your search
@@ -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<Direction, [number, number]> = {
|
||||
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,13 +414,12 @@ 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];
|
||||
|
||||
+85
-43
@@ -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<string>;
|
||||
warps: Set<string>;
|
||||
}
|
||||
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<Direction, [number, number]> = {
|
||||
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 { 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 (ledges && (standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) {
|
||||
if ((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);
|
||||
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(world.assets.collision(world.map, lx, ly)) === 0 &&
|
||||
!occupied(x + dx, y + dy) &&
|
||||
!occupied(lx, ly) &&
|
||||
world.assets.permission(landingTile) === 0 &&
|
||||
!terrain.occupied.has(`${x + dx},${y + dy}`) &&
|
||||
!terrain.occupied.has(`${lx},${ly}`) &&
|
||||
!(
|
||||
isWarpHazard(world.assets.collision(world.map, lx, ly)) &&
|
||||
world.map.warps.some((warp) => warp.x === lx && warp.y === ly) &&
|
||||
isWarpHazard(landingTile) &&
|
||||
terrain.warps.has(`${lx},${ly}`) &&
|
||||
(lx !== target?.x || ly !== target?.y)
|
||||
)
|
||||
)
|
||||
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<string, Direction[]>();
|
||||
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<string>();
|
||||
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<string, number> {
|
||||
const lengths = new Map<string, number>();
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user