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
+92
-53
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 { C } from "../src/game/battle/constants";
|
||||||
import { applyTypeMatchups } from "../src/game/battle/battle";
|
import { applyTypeMatchups } from "../src/game/battle/battle";
|
||||||
import { BattleData } from "../src/game/battle/data";
|
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]> = {
|
const STEP: Record<Direction, [number, number]> = {
|
||||||
up: [0, -1],
|
up: [0, -1],
|
||||||
@@ -301,6 +301,7 @@ export class Playthrough {
|
|||||||
const world = this.world,
|
const world = this.world,
|
||||||
map = world.mapId;
|
map = world.mapId;
|
||||||
const [dx, dy] = STEP[direction];
|
const [dx, dy] = STEP[direction];
|
||||||
|
const lengths = pathLengths(world, { water, ledges: this.ledges });
|
||||||
const edge = Array.from(
|
const edge = Array.from(
|
||||||
{ length: dx ? world.map.height : world.map.width },
|
{ length: dx ? world.map.height : world.map.width },
|
||||||
(_, i) =>
|
(_, i) =>
|
||||||
@@ -310,11 +311,8 @@ export class Playthrough {
|
|||||||
).flatMap(([x, y]) => {
|
).flatMap(([x, y]) => {
|
||||||
// A warp on the edge (a gate's doorway) is not the connection.
|
// 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 [];
|
if (world.map.warps.some((warp) => warp.x === x && warp.y === y)) return [];
|
||||||
try {
|
const length = lengths.get(`${x},${y}`);
|
||||||
return [{ x, y, length: findPath(world, x, y, { water, ledges: this.ledges }).length }];
|
return length === undefined ? [] : [{ x, y, length }];
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
edge.sort((a, b) => a.length - b.length);
|
edge.sort((a, b) => a.length - b.length);
|
||||||
for (const { x, y } of edge) {
|
for (const { x, y } of edge) {
|
||||||
@@ -416,13 +414,12 @@ export class Playthrough {
|
|||||||
expect(this.world.mapId).toBe(map);
|
expect(this.world.mapId).toBe(map);
|
||||||
}
|
}
|
||||||
const pairs: { x: number; y: number; length: number }[] = [];
|
const pairs: { x: number; y: number; length: number }[] = [];
|
||||||
|
const lengths = pathLengths(world);
|
||||||
for (let y = 0; y < world.map.height; y++)
|
for (let y = 0; y < world.map.height; y++)
|
||||||
for (let x = 0; x < world.map.width; x++)
|
for (let x = 0; x < world.map.width; x++)
|
||||||
if (isGrass(x, y) && isGrass(x + 1, y))
|
if (isGrass(x, y) && isGrass(x + 1, y)) {
|
||||||
try {
|
const length = lengths.get(`${x},${y}`);
|
||||||
pairs.push({ x, y, length: findPath(world, x, y).length });
|
if (length !== undefined) pairs.push({ x, y, length });
|
||||||
} catch {
|
|
||||||
/* unreachable */
|
|
||||||
}
|
}
|
||||||
pairs.sort((a, b) => a.length - b.length);
|
pairs.sort((a, b) => a.length - b.length);
|
||||||
const { x, y } = pairs[0];
|
const { x, y } = pairs[0];
|
||||||
|
|||||||
+84
-42
@@ -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). */
|
/** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */
|
||||||
const isIce = (tile: number) => tile === 0x23 || tile === 0x2b;
|
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`
|
/** 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,
|
* is open: walkable (or open water for a surfer), no side wall, no object,
|
||||||
* and no warp other than the target. */
|
* and no warp other than the target. */
|
||||||
function open(
|
function open(
|
||||||
world: GameWorld,
|
world: GameWorld,
|
||||||
|
terrain: Terrain,
|
||||||
fromX: number,
|
fromX: number,
|
||||||
fromY: number,
|
fromY: number,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
@@ -109,7 +127,7 @@ function open(
|
|||||||
target?: { x: number; y: number },
|
target?: { x: number; y: number },
|
||||||
whirlpools = false,
|
whirlpools = false,
|
||||||
) {
|
) {
|
||||||
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
const [dx, dy] = VECTORS[direction];
|
||||||
const x = fromX + dx,
|
const x = fromX + dx,
|
||||||
y = fromY + dy;
|
y = fromY + dy;
|
||||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
|
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])
|
edges(tile).includes(OPPOSITE[direction])
|
||||||
)
|
)
|
||||||
return false;
|
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 (
|
if (
|
||||||
isWarpHazard(tile) &&
|
isWarpHazard(tile) &&
|
||||||
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
|
terrain.warps.has(key) &&
|
||||||
(x !== target?.x || y !== target?.y)
|
(x !== target?.x || y !== target?.y)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
@@ -146,6 +165,13 @@ const LEDGES: readonly (readonly Direction[])[] = [
|
|||||||
["up", "left"],
|
["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
|
/** 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,
|
* 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
|
* the last tile of the slide before something blocks it. Undefined when
|
||||||
@@ -155,56 +181,48 @@ export function landing(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
{
|
options: PathOptions = {},
|
||||||
water = false,
|
terrain = terrainOf(world),
|
||||||
target,
|
|
||||||
whirlpools = false,
|
|
||||||
ledges = false,
|
|
||||||
}: {
|
|
||||||
water?: boolean;
|
|
||||||
target?: { x: number; y: number };
|
|
||||||
whirlpools?: boolean;
|
|
||||||
ledges?: boolean;
|
|
||||||
} = {},
|
|
||||||
): { x: number; y: number } | undefined {
|
): { 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);
|
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,
|
const lx = x + dx * 2,
|
||||||
ly = y + dy * 2;
|
ly = y + dy * 2;
|
||||||
const occupied = (ox: number, oy: number) =>
|
const landingTile = world.assets.collision(world.map, lx, ly);
|
||||||
world.objects().some((object) => object.x === ox && object.y === oy);
|
|
||||||
if (
|
if (
|
||||||
lx >= 0 &&
|
lx >= 0 &&
|
||||||
ly >= 0 &&
|
ly >= 0 &&
|
||||||
lx < world.map.width &&
|
lx < world.map.width &&
|
||||||
ly < world.map.height &&
|
ly < world.map.height &&
|
||||||
world.assets.permission(world.assets.collision(world.map, lx, ly)) === 0 &&
|
world.assets.permission(landingTile) === 0 &&
|
||||||
!occupied(x + dx, y + dy) &&
|
!terrain.occupied.has(`${x + dx},${y + dy}`) &&
|
||||||
!occupied(lx, ly) &&
|
!terrain.occupied.has(`${lx},${ly}`) &&
|
||||||
!(
|
!(
|
||||||
isWarpHazard(world.assets.collision(world.map, lx, ly)) &&
|
isWarpHazard(landingTile) &&
|
||||||
world.map.warps.some((warp) => warp.x === lx && warp.y === ly) &&
|
terrain.warps.has(`${lx},${ly}`) &&
|
||||||
(lx !== target?.x || ly !== target?.y)
|
(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.
|
// COLL_WATERFALL: a surfer entering from above is carried down it.
|
||||||
const ahead = world.assets.collision(world.map, x + dx, y + dy);
|
if (water && world.assets.collision(world.map, x + dx, y + dy) === 0x33) {
|
||||||
if (water && ahead === 0x33) {
|
|
||||||
if (direction !== "down") return undefined;
|
if (direction !== "down") return undefined;
|
||||||
let fy = y + 1;
|
let fy = y + 1;
|
||||||
while (world.assets.collision(world.map, x, fy) === 0x33) fy++;
|
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 };
|
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;
|
x += dx;
|
||||||
y += dy;
|
y += dy;
|
||||||
while (
|
while (
|
||||||
isIce(world.assets.collision(world.map, x, y)) &&
|
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;
|
x += dx;
|
||||||
y += dy;
|
y += dy;
|
||||||
@@ -212,6 +230,26 @@ export function landing(
|
|||||||
return { x, y };
|
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.
|
/** Shortest walkable path on the current map, avoiding other warps.
|
||||||
* `water` also crosses open water, for a player who can Surf, and
|
* `water` also crosses open water, for a player who can Surf, and
|
||||||
* `whirlpools` the whirlpools a player with Whirlpool can clear; `ledges`
|
* `whirlpools` the whirlpools a player with Whirlpool can clear; `ledges`
|
||||||
@@ -222,21 +260,25 @@ export function findPath(
|
|||||||
targetY: number,
|
targetY: number,
|
||||||
{ water = false, whirlpools = false, ledges = false } = {},
|
{ water = false, whirlpools = false, ledges = false } = {},
|
||||||
): Direction[] {
|
): Direction[] {
|
||||||
const target = { x: targetX, y: targetY };
|
const path = search(world, {
|
||||||
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
water,
|
||||||
const visited = new Set<string>();
|
whirlpools,
|
||||||
for (let i = 0; i < queue.length; i++) {
|
ledges,
|
||||||
const node = queue[i],
|
target: { x: targetX, y: targetY },
|
||||||
key = `${node.x},${node.y}`;
|
}).get(`${targetX},${targetY}`);
|
||||||
if (visited.has(key)) continue;
|
if (!path) throw new Error(`No route to ${targetX},${targetY}`);
|
||||||
visited.add(key);
|
return path;
|
||||||
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}`);
|
/** 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
|
/** Walk to a tile, settling any scene that a step triggers. A roaming NPC
|
||||||
|
|||||||
Reference in new issue
Block a user