Tin Tower's floors and roof and the Whirl Islands are open. The Clear Bell gets the player past the sages to Suicune in Tin Tower; after the League, the Rainbow Wing leads up to Ho-Oh, and the Silver Wing to Lugia. New specials: MonCheck and BeastsCheck (CheckOwnMonAnywhere, with its five-letter OT comparison and no Day-Care search). The playthrough driver plans ledge jumps for the later stages, waterfall descents, warp panels and Surf between warps; the run gains two stages, Routes 45 and 46 back to Ecruteak and Tin Tower's Suicune. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import type { GameWorld } from "../src/game/world/world";
|
|
import type { Direction } from "../src/game/world/types";
|
|
|
|
/** Drive a world through scenes the way a player would: acknowledge pages,
|
|
* answer choices in order (default YES, the first weekday or menu item, or
|
|
* the clock's shown value) and wait out timed
|
|
* effects. Returns every page that was shown. */
|
|
export function settle(
|
|
world: GameWorld,
|
|
answers: (number | string)[] = [],
|
|
): string[] {
|
|
const shown: string[] = [];
|
|
let lastHeld = "";
|
|
for (let tick = 0; tick < 20_000; tick++) {
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index];
|
|
const last = dialogue.index === dialogue.pages.length - 1;
|
|
if (last && dialogue.choice) {
|
|
shown.push(page);
|
|
// The naming screen waits for the caller unless a name is queued.
|
|
if (dialogue.choice.kind === "name") {
|
|
if (typeof answers[0] !== "string") return shown;
|
|
world.choose(answers.shift()!);
|
|
continue;
|
|
}
|
|
const answer = answers.shift();
|
|
world.choose(
|
|
typeof answer === "number"
|
|
? answer
|
|
: dialogue.choice.kind === "yesno"
|
|
? 1
|
|
: dialogue.choice.kind === "clock"
|
|
? dialogue.choice.selected
|
|
: 0,
|
|
);
|
|
} else if (last && dialogue.holdTicks !== undefined) {
|
|
if (page !== lastHeld) shown.push(page);
|
|
lastHeld = page;
|
|
world.tick();
|
|
} else {
|
|
shown.push(page);
|
|
world.advanceDialogue();
|
|
}
|
|
continue;
|
|
}
|
|
lastHeld = "";
|
|
if (!world.busy) {
|
|
// Scene scripts start on an idle tick.
|
|
world.tick();
|
|
if (!world.busy) return shown;
|
|
continue;
|
|
}
|
|
world.tick();
|
|
}
|
|
throw new Error("The world did not settle.");
|
|
}
|
|
|
|
const DIRECTIONS = [
|
|
["up", 0, -1],
|
|
["down", 0, 1],
|
|
["left", -1, 0],
|
|
["right", 1, 0],
|
|
] as const;
|
|
|
|
/** .ledge_table's side-wall directions (GetMovementPermissions): side
|
|
* walls ($b0) and buoys ($c0) block these directions. */
|
|
const EDGES: readonly (readonly Direction[])[] = [
|
|
["right"],
|
|
["left"],
|
|
["up"],
|
|
["down"],
|
|
["down", "right"],
|
|
["down", "left"],
|
|
["up", "right"],
|
|
["up", "left"],
|
|
];
|
|
const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" } as const;
|
|
const edges = (tile: number): readonly Direction[] =>
|
|
(tile & 0xf0) === 0xb0 || (tile & 0xf0) === 0xc0 ? EDGES[tile & 7] : [];
|
|
|
|
/** CheckWarpCollision's own two hazard nybbles ($6x pits, $7x doors/
|
|
* stairs/carpets): a tile only needs avoiding here if stepping onto it
|
|
* could actually trigger a warp. Burned Tower B1F's fall/ladder warps
|
|
* (e.g. the coordinates `warpcheck` and a `changeblock`-revealed ladder
|
|
* read) sit on ordinary $00 floor and never fire from an ordinary step,
|
|
* so they are left walkable like any other floor tile. */
|
|
const isWarpHazard = (tile: number) =>
|
|
(tile & 0xf0) === 0x60 || (tile & 0xf0) === 0x70;
|
|
|
|
/** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses
|
|
* freely, unlike currents, waterfalls and whirlpools. */
|
|
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
|
|
/** COLL_WHIRLPOOL and COLL_WHIRLPOOL_2C: water once Whirlpool clears it. */
|
|
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;
|
|
|
|
/** 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,
|
|
fromX: number,
|
|
fromY: number,
|
|
direction: Direction,
|
|
water: boolean,
|
|
target?: { x: number; y: number },
|
|
whirlpools = false,
|
|
) {
|
|
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
|
const x = fromX + dx,
|
|
y = fromY + dy;
|
|
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
|
|
const tile = world.assets.collision(world.map, x, y);
|
|
if (
|
|
world.assets.permission(tile) !== 0 &&
|
|
!(water && (isOpenWater(tile) || (whirlpools && isWhirlpool(tile))))
|
|
)
|
|
return false;
|
|
if (
|
|
edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) ||
|
|
edges(tile).includes(OPPOSITE[direction])
|
|
)
|
|
return false;
|
|
if (world.objects().some((object) => object.x === x && object.y === y)) return false;
|
|
if (
|
|
isWarpHazard(tile) &&
|
|
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
|
|
(x !== target?.x || y !== target?.y)
|
|
)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
/** .ledge_table: the directions each COLL_HOP_* tile jumps toward. */
|
|
const LEDGES: readonly (readonly Direction[])[] = [
|
|
["right"],
|
|
["left"],
|
|
["up"],
|
|
["down"],
|
|
["down", "right"],
|
|
["down", "left"],
|
|
["up", "right"],
|
|
["up", "left"],
|
|
];
|
|
|
|
/** 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
|
|
* the step is blocked. */
|
|
export function landing(
|
|
world: GameWorld,
|
|
x: number,
|
|
y: number,
|
|
direction: Direction,
|
|
{
|
|
water = false,
|
|
target,
|
|
whirlpools = false,
|
|
ledges = false,
|
|
}: {
|
|
water?: boolean;
|
|
target?: { x: number; y: number };
|
|
whirlpools?: boolean;
|
|
ledges?: boolean;
|
|
} = {},
|
|
): { 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)
|
|
)
|
|
)
|
|
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 (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;
|
|
return { x, y: fy };
|
|
}
|
|
if (!open(world, 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)
|
|
) {
|
|
x += dx;
|
|
y += dy;
|
|
}
|
|
return { x, y };
|
|
}
|
|
|
|
/** 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`
|
|
* takes ledge jumps; a step onto ice slides on until blocked. */
|
|
export function findPath(
|
|
world: GameWorld,
|
|
targetX: number,
|
|
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}`);
|
|
}
|
|
|
|
/** Walk to a tile, settling any scene that a step triggers. A roaming NPC
|
|
* that steps into the way is waited out, then the route is planned again. */
|
|
export function walkTo(
|
|
world: GameWorld,
|
|
targetX: number,
|
|
targetY: number,
|
|
answers: (number | string)[] = [],
|
|
): string[] {
|
|
// Finish anything in progress first, such as the tick after a warp.
|
|
const shown: string[] = settle(world, answers);
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
let blocked = false;
|
|
for (const direction of findPath(world, targetX, targetY)) {
|
|
if (!world.move(direction)) {
|
|
const [, dx, dy] = DIRECTIONS.find(([name]) => name === direction)!;
|
|
const blocker = world
|
|
.objects()
|
|
.some((object) => object.x === world.x + dx && object.y === world.y + dy);
|
|
if (!blocker)
|
|
throw new Error(
|
|
`Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`,
|
|
);
|
|
for (let tick = 0; tick < 64; tick++) world.tick();
|
|
blocked = true;
|
|
break;
|
|
}
|
|
shown.push(...settle(world, answers));
|
|
}
|
|
// A warp target leaves the map, so a completed path is the success test.
|
|
if (!blocked) return shown;
|
|
}
|
|
throw new Error(`Could not reach ${targetX},${targetY}`);
|
|
}
|
|
|
|
/** A save without the play time, which keeps counting while refused or
|
|
* rolled-back actions leave everything else unchanged. */
|
|
export function withoutPlayTime<T extends { gameTime?: unknown }>(save: T) {
|
|
const { gameTime: _, ...rest } = save;
|
|
return rest;
|
|
}
|
|
|
|
/** Restoring a save always moves the roam-mon bookkeeping to the map just
|
|
* loaded (Continue's own farcall JumpRoamMons, then _BackUpMapIndices),
|
|
* even before any roaming Pokémon has been released; a round-trip
|
|
* comparison needs `reloaded`'s own `roamMons`/`roamMonsMap` in place of
|
|
* `saved`'s to still match everything else exactly. */
|
|
export function afterRestore<
|
|
T extends { roamMons?: unknown; roamMonsMap?: unknown },
|
|
>(saved: T, reloaded: T) {
|
|
return { ...saved, roamMons: reloaded.roamMons, roamMonsMap: reloaded.roamMonsMap };
|
|
}
|