Files
Crystal/tests/world-driver.ts
T
kawaandClaude Opus 5.5 9c3579937c Open the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den (#32)
The Rocket-held Radio Tower, the Underground's switch room and warehouse,
Ice Path, Blackthorn City and its gym, Routes 45 and 46, Dark Cave's
Blackthorn entrance, Dragon's Den and the Dragon Shrine are open. The story
plays through the Basement Key, the switches and the director, the Clear
Bell, Ice Path's falling boulder, Clair's gym and the shrine's test for the
RISINGBADGE, TM24 and the elder's Dratini.

New specials: GiveDratini and MoveDeletion. A save keeps an object that a
script made disappear (or appear against its flag) as Continue does, so the
Radio Tower director no longer blocks the 5F stairs after a reload. A
whiteout from a battle started in an scall no longer refuses.

The playthrough driver pushes boulders into their holes (planned breadth
first), clears whirlpools, chooses the move a TM replaces and heals with
the strongest potion; the run gains six stages to the Rising Badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:19:02 +02:00

243 lines
8.4 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;
}
/** Where a step in `direction` from (x, y) ends: the next tile, 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,
}: { water?: boolean; target?: { x: number; y: number }; whirlpools?: boolean } = {},
): { x: number; y: number } | undefined {
if (!open(world, x, y, direction, water, target, whirlpools)) return undefined;
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
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; a step
* onto ice slides on until blocked. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
{ water = false, whirlpools = 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 });
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 };
}