Files
Crystal/tests/world-driver.ts
T
kawaandClaude Opus 5.5 b5c2b19094
Checks / web (push) Successful in 1m46s
Private ROM checks / private-rom (push) Failing after 39m32s
Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm,
Olivine City and its buildings, the Lighthouse's six floors, Routes 40
and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's
house, the Poke Seer and the Lugia speech house. Olivine's port, the
Battle Tower gate and Whirl Islands stay closed. The story runs from the
rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the
SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to
Amphy's cure, Jasmine, the MINERALBADGE and TM23.

Port GiveShuckle and ReturnShuckie with every return code and Mania's
wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and
reused caught level, PhotoStudio's no-printer path (Printer Error 2),
and PlaySlowCry. wMooMooBerries joins the saved script bytes.

Port trainer item AI: the 13 healing, status and X items, the
highest-level gate, item use while Mean Look or Wrap prevents
switching, and the original's random and state quirks, checked against
the ROM over 5,632 states. Leaders with items are no longer refused.

Game Corner sprites match the original frame by frame: cursor and reel
positions, Golem's whole-frame flips, frame timing, the frame a removed
sprite is still drawn, and the Golem roll's screen shake. The Burned
Tower rival's garbage trainer header is documented as a boundary: its
seen text dispatches text command $7f into home code before any battle.

The playthrough continues for six stages to Jasmine; its driver can
Surf, fish and travel through floors joined by holes. New tests cover
trainer items, Olivine and Cianwood side content, and reference checks
for trainer items, trainer sight and Game Corner animations.

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

189 lines
6.7 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;
/** Shortest walkable path on the current map, avoiding other warps.
* `water` also crosses open water, for a player who can Surf. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
{ water = false } = {},
): Direction[] {
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, dx, dy] of DIRECTIONS) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
const tile = world.assets.collision(world.map, x, y);
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile)))
continue;
if (
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
edges(tile).includes(OPPOSITE[direction])
)
continue;
if (world.objects().some((object) => object.x === x && object.y === y))
continue;
if (
isWarpHazard(tile) &&
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, 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 };
}