Files
Crystal/tests/world-driver.ts
T
kawaandClaude Opus 5.5 7c12c81a27
Checks / web (push) Successful in 2m38s
Private ROM checks / private-rom (push) Successful in 20m37s
Ring with trainers' calls, rematch them and play the radio (Closes #30)
Every registered trainer's callee and caller scripts run, with the
missing specials and commands (RandomPhoneWildMon, RandomUnseenWildMon,
swarms, gettrainerclassname, Kenji's break, the Blue Card and Buena's
password variables). Fight counts are saved, so rematches and gifts work
on every open map, and the daily reset clears the phone's flags.

Random calls follow CheckPhoneCall and the 20/10/5/3-minute call timer;
received calls ring twice with the caller's name, and calls play their
ringing, hang-up and no-signal sounds. Grass and fishing swarms use the
swarm tables.

The radio ports PlayRadioShow and every station frame by frame, with its
music: the Pokégear's radio card tunes by knob, house radios play through
MapRadio, and Buena's show takes the day's password for Blue Card points
that her receptionist exchanges for prizes.

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

340 lines
12 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. */
/** RingTwice_StartCall's pages: the caller's name box and the cleared box
* between rings. */
export const isRinging = (page: string) => page.startsWith("☎") || page === " ";
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) {
// A received call's ringing (the caller's name box) is not text.
if (page !== lastHeld && !isRinging(page)) 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;
/** 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,
water: boolean,
target?: { x: number; y: number },
whirlpools = false,
) {
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;
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;
const key = `${x},${y}`;
if (terrain.occupied.has(key)) return false;
if (
isWarpHazard(tile) &&
terrain.warps.has(key) &&
(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"],
];
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
* the step is blocked. */
export function landing(
world: GameWorld,
x: number,
y: number,
direction: Direction,
options: PathOptions = {},
terrain = terrainOf(world),
): { x: number; y: number } | undefined {
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 ((standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) {
const lx = x + dx * 2,
ly = y + dy * 2;
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(landingTile) === 0 &&
!terrain.occupied.has(`${x + dx},${y + dy}`) &&
!terrain.occupied.has(`${lx},${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.
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, terrain, x, fy - 1, "down", water, target, whirlpools)) return undefined;
return { x, y: fy };
}
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, terrain, x, y, direction, water, target, whirlpools)
) {
x += dx;
y += dy;
}
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`
* 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 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
* 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 };
}