Files
Crystal/tests/world-driver.ts
T
kawaandClaude Opus 5.5 3fbc6798eb Open Tin Tower and Whirl Islands: Suicune, Ho-Oh and Lugia (#32)
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>
2026-10-13 14:47:45 +02:00

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 };
}