Files
Crystal/tests/world-driver.ts
T
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:52:30 +02:00

350 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?.kind === "screen" && dialogue.choice.realtime) {
world.tick();
continue;
}
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;
}
if (dialogue.choice.kind === "screen") {
shown.push(dialogue.choice.screen.label);
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 };
}