Route 42, Mt. Mortar, Mahogany Town, Route 43 and its gates, the Lake of Rage and its houses, the Rocket Hideout and Route 44 are open. The story plays through the toll, the red Gyarados, Lance, the hideout and Pryce's GLACIERBADGE, which starts the Radio Tower takeover. The fishing guru measures Magikarp (CheckMagikarpLength, MagikarpHouseSign, FindPartyMonThatSpecies); the record is saved. takemoney now leaves a short account at zero as TakeMoney does, and setmapscene/setscene on a map without scenes consume the byte instead of refusing. The playthrough's path search slides on ice and the run gains five stages to Pryce. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
8.0 KiB
TypeScript
231 lines
8.0 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_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 },
|
|
) {
|
|
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))) 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 }: { water?: boolean; target?: { x: number; y: number } } = {},
|
|
): { x: number; y: number } | undefined {
|
|
if (!open(world, x, y, direction, water, target)) 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; a step onto
|
|
* ice slides on until blocked. */
|
|
export function findPath(
|
|
world: GameWorld,
|
|
targetX: number,
|
|
targetY: number,
|
|
{ water = 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 });
|
|
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 };
|
|
}
|