Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate, Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's room and the Hall of Fame are open. - halloffame (game/scripts/league.ts): the music fades as SAVING RECORD shows, the win and the team (without Eggs) are recorded, each Pokémon's back picture passes and its front picture slides in for its "New Hall of Famer!" page and cry, then the player's card with the play time and Professor Oak's rating; the play time stops meanwhile. The world then continues at New Bark as Continue does after the cartridge's reset. - credits and the credits after the Hall of Fame (CreditsMachine): the ROM's script, strings, banner Pokémon and THE END, frame by frame as VBlank_Credits presents them, with the frames each command group runs over measured on the cartridge. B shortens credits seen before. - wait, appear/disappear of the player, and rival names in battle texts (PlaceEnemysName prints wRivalName alone: "???" before he is named). - The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows their own hSCX. - Reference: the credits match the cartridge on every frame (BG map, banner tiles, CRAM palettes, LY overrides, parser), with and without B; the ceremony's pages match for every species and the player's card for Chris and Kris. - Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four and Lance); the driver follows warps within a map and fights carefully from here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
835 lines
33 KiB
TypeScript
835 lines
33 KiB
TypeScript
import { expect } from "vitest";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { encodeSave } from "../src/storage/saves";
|
|
import { CRYSTAL_ROM } from "../src/rom/validate";
|
|
import type { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld, type NativeSave } from "../src/game/world/world";
|
|
import type { Direction } from "../src/game/world/types";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { applyTypeMatchups } from "../src/game/battle/battle";
|
|
import { BattleData } from "../src/game/battle/data";
|
|
import { afterRestore, findPath, isWhirlpool, landing, pathLengths } from "./world-driver";
|
|
|
|
const STEP: Record<Direction, [number, number]> = {
|
|
up: [0, -1],
|
|
down: [0, 1],
|
|
left: [-1, 0],
|
|
right: [1, 0],
|
|
};
|
|
|
|
/** Warp mats and edges that need their direction pressed (.CheckWarp). */
|
|
const WARP_EXITS: Partial<Record<number, Direction>> = {
|
|
0x70: "down",
|
|
0x76: "left",
|
|
0x78: "up",
|
|
0x7e: "right",
|
|
};
|
|
|
|
/** PLAYER_SURF (constants/wram_constants.asm). */
|
|
const PLAYER_SURF = 4;
|
|
|
|
/** Plays the game through the world's own inputs, as tests/playthrough
|
|
* does: scripts, battles and walking, with a seeded Random. A stage's save
|
|
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
|
|
export class Playthrough {
|
|
world!: GameWorld;
|
|
readonly log: string[] = [];
|
|
/** A wild species to throw Poké Balls at instead of fighting. */
|
|
wanted = 0;
|
|
/** Plan ledge jumps (Tin Tower, Route 45). Off for the earlier stages,
|
|
* whose seeded runs were recorded walking around ledges. */
|
|
ledges = false;
|
|
/** Fight as a player facing the League would (off for the stages
|
|
* recorded before it): weigh each move by its accuracy, cure poison,
|
|
* paralysis and burns with a Full Heal in a trainer battle, and heal
|
|
* below two thirds of the HP rather than half. */
|
|
careful = false;
|
|
seed = 0x2a;
|
|
private stage = 0;
|
|
readonly now = () => new Date("2026-09-22T12:00:00");
|
|
readonly random = () => {
|
|
this.seed = (this.seed * 1103515245 + 12345) >>> 0;
|
|
return (this.seed >>> 16) & 255;
|
|
};
|
|
constructor(readonly assets: CrystalAssets) {}
|
|
|
|
/** A new world for the introduction. */
|
|
start() {
|
|
this.world = new GameWorld(this.assets, this.now, this.random);
|
|
}
|
|
/** Continue from a dumped stage. */
|
|
resume(dump: { save: NativeSave; seed: number }) {
|
|
this.seed = dump.seed;
|
|
this.world = new GameWorld(this.assets, this.now, this.random);
|
|
this.world.restore(dump.save);
|
|
}
|
|
|
|
/** The usable move that hits hardest for its power, STAB and type
|
|
* matchups against `targetTypes`, or the first with PP; a move Disable
|
|
* blocks (its slot in the side's disable count) is skipped. */
|
|
strongest = (
|
|
mon: {
|
|
moves: readonly number[];
|
|
pp: readonly number[];
|
|
types: readonly number[];
|
|
},
|
|
targetTypes: readonly number[],
|
|
disableCount = 0,
|
|
) => {
|
|
const disabled = disableCount ? ((disableCount >> 4) & 0xf) - 1 : -1;
|
|
this.matchups ??= new BattleData(this.assets.reader).typeMatchups();
|
|
const score = (move: number) =>
|
|
applyTypeMatchups(this.matchups!, {
|
|
damage: this.assets.pokemon.move(move).power * 10,
|
|
modifier: 0,
|
|
moveType: this.assets.pokemon.move(move).type,
|
|
userTypes: mon.types,
|
|
targetTypes,
|
|
identified: false,
|
|
}).damage;
|
|
const weighed = (move: number) =>
|
|
this.careful ? score(move) * this.assets.pokemon.move(move).accuracy : score(move);
|
|
let best = -1;
|
|
mon.moves.forEach((move, i) => {
|
|
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
|
if (best < 0 || weighed(move) > weighed(mon.moves[best])) best = i;
|
|
});
|
|
return Math.max(0, best);
|
|
};
|
|
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
|
private screenLabel = "";
|
|
private screenStill = 0;
|
|
/** Run scripts and battles until the player is free again. Battles use
|
|
* the strongest move with PP; a wild battle below half HP is run from,
|
|
* and a trainer battle (which cannot be run from) instead reaches for
|
|
* the bag through the battle's own item action (BattleMenu_Pack), as a
|
|
* cautious player who restocked before a hard fight would: a carried
|
|
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
|
|
* it out of a whole fight, and the strongest carried potion heals it
|
|
* below half HP. New moves are learned when there is room, and other
|
|
* questions take the queued answers or their defaults. */
|
|
play = (answers: (number | string)[] = []) => {
|
|
const world = this.world,
|
|
log = this.log;
|
|
for (let tick = 0; tick < 100_000; tick++) {
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
const battle = world.battle;
|
|
if (battle) {
|
|
if (battle.message && log.at(-1) !== battle.message.text)
|
|
log.push(battle.message.text);
|
|
const prompt = battle.prompt;
|
|
if (prompt?.kind === "action") {
|
|
const { engine } = battle,
|
|
mon = engine.player.mon;
|
|
const ball = world.liveState.bag.balls.findIndex(
|
|
(slot) => slot.item === C.POKE_BALL || slot.item === C.GREAT_BALL,
|
|
);
|
|
// The strongest potion carried: Max, then Hyper, then Super.
|
|
const potion = [C.MAX_POTION, C.HYPER_POTION, C.SUPER_POTION]
|
|
.map((item) => world.liveState.bag.items.findIndex((slot) => slot.item === item))
|
|
.find((index) => index >= 0) ?? -1;
|
|
const awakening = world.liveState.bag.items.findIndex(
|
|
(slot) => slot.item === C.AWAKENING,
|
|
);
|
|
const fullHeal = world.liveState.bag.items.findIndex(
|
|
(slot) => slot.item === C.FULL_HEAL,
|
|
);
|
|
if (
|
|
engine.battleMode === C.WILD_BATTLE &&
|
|
engine.enemy.mon.species === this.wanted &&
|
|
!world.liveState.party.some((member) => member.species === this.wanted) &&
|
|
ball >= 0
|
|
)
|
|
world.battleAction({
|
|
action: "item",
|
|
item: world.liveState.bag.balls[ball].item,
|
|
slot: ball,
|
|
});
|
|
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
|
world.battleAction({ action: "run" });
|
|
else if (
|
|
engine.battleMode !== C.WILD_BATTLE &&
|
|
!prompt.noItems &&
|
|
(mon.status & C.SLP_MASK) !== 0 &&
|
|
awakening >= 0
|
|
)
|
|
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
|
|
else if (
|
|
this.careful &&
|
|
engine.battleMode !== C.WILD_BATTLE &&
|
|
!prompt.noItems &&
|
|
mon.status & ((1 << C.PSN) | (1 << C.PAR) | (1 << C.BRN)) &&
|
|
fullHeal >= 0
|
|
)
|
|
world.battleAction({ action: "item", item: C.FULL_HEAL, slot: fullHeal });
|
|
else if (
|
|
engine.battleMode !== C.WILD_BATTLE &&
|
|
!prompt.noItems &&
|
|
mon.hp * (this.careful ? 3 : 2) < mon.maxHp * (this.careful ? 2 : 1) &&
|
|
potion >= 0
|
|
)
|
|
world.battleAction({
|
|
action: "item",
|
|
item: world.liveState.bag.items[potion].item,
|
|
slot: potion,
|
|
});
|
|
else
|
|
world.battleAction({
|
|
action: "fight",
|
|
move: this.strongest(
|
|
mon,
|
|
engine.enemy.mon.types,
|
|
engine.player.disableCount,
|
|
),
|
|
});
|
|
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
|
// SHIFT's party menu: keep the Pokémon out (or send the first
|
|
// that can fight when one must go). UseItem_SelectMon shows the
|
|
// same prompt kind for the item action just above, distinguished
|
|
// by its own text (BattlePack's RestoreHPEffect heals whichever
|
|
// slot is chosen; the active Pokémon is the one that asked for it).
|
|
else if (prompt?.kind === "party")
|
|
world.battleChoose(
|
|
prompt.text === "Use on which POKéMON?"
|
|
? battle.engine.curBattleMon
|
|
: prompt.cancel
|
|
? -1
|
|
: world.liveState.party.findIndex(
|
|
(mon, i) =>
|
|
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
|
|
),
|
|
);
|
|
else if (prompt?.kind === "forget") world.battleChoose(4);
|
|
else if (prompt?.kind === "name") world.battleName("");
|
|
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
|
world.interact();
|
|
else world.tick();
|
|
continue;
|
|
}
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index];
|
|
const last = dialogue.index === dialogue.pages.length - 1;
|
|
if (log.at(-1) !== page) log.push(page);
|
|
if (last && dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
|
// A player watching a screen that runs on its own (the Hall of
|
|
// Fame's rating, THE END) presses A once it has stood still for
|
|
// four seconds.
|
|
const label = dialogue.choice.screen.label;
|
|
if (label !== this.screenLabel) [this.screenLabel, this.screenStill] = [label, 0];
|
|
else if (++this.screenStill >= 120) {
|
|
world.bufferScreenButton("a");
|
|
this.screenStill = 0;
|
|
}
|
|
world.tick();
|
|
continue;
|
|
}
|
|
if (last && dialogue.choice) {
|
|
const answer = answers.shift();
|
|
if (dialogue.choice.kind === "name")
|
|
world.choose(typeof answer === "string" ? answer : "");
|
|
else
|
|
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) world.tick();
|
|
else world.advanceDialogue();
|
|
continue;
|
|
}
|
|
if (!world.busy) {
|
|
world.tick(); // scene scripts and phone calls start on idle ticks
|
|
if (!world.busy) return;
|
|
continue;
|
|
}
|
|
world.tick();
|
|
}
|
|
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
|
};
|
|
|
|
/** Where a warp of the current map lands when it leads elsewhere on the
|
|
* same map (Victory Road's ladders, a building's inner stairs). */
|
|
private sameMapArrival = (x: number, y: number) => {
|
|
const world = this.world;
|
|
const warp = world.map.warps.find((warp) => warp.x === x && warp.y === y);
|
|
return warp?.targetMap === world.mapId ? world.map.warps[warp.targetWarp - 1] : undefined;
|
|
};
|
|
/** Walk to a tile on the current map, replanning after ledges, roaming
|
|
* NPCs and scenes that move the player. A warp target leaves the map, or
|
|
* lands at its destination on the same map. */
|
|
walk = (x: number, y: number) => {
|
|
const world = this.world,
|
|
map = world.mapId,
|
|
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y),
|
|
arrival = this.sameMapArrival(x, y);
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
this.play();
|
|
if (warp && world.mapId !== map) return;
|
|
if (arrival && world.x === arrival.x && world.y === arrival.y) return;
|
|
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
|
if (world.x === x && world.y === y) return;
|
|
// A surfer crosses water until it steps ashore.
|
|
const water = world.state.playerState === PLAYER_SURF;
|
|
for (const direction of findPath(world, x, y, { water, ledges: this.ledges })) {
|
|
const end = landing(world, world.x, world.y, direction, {
|
|
water,
|
|
target: { x, y },
|
|
ledges: this.ledges,
|
|
});
|
|
if (!world.move(direction)) {
|
|
for (let tick = 0; tick < 64; tick++) world.tick();
|
|
break;
|
|
}
|
|
this.play();
|
|
if (world.mapId !== map || world.x !== end?.x || world.y !== end.y) break;
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
|
|
);
|
|
};
|
|
/** Walk and Surf to a tile: where a step leaves the shore, face the
|
|
* water and answer AskSurfScript's question with YES; a whirlpool in
|
|
* the way is faced and cleared (TryWhirlpoolOW, then YES) when
|
|
* `whirlpools` is set. */
|
|
sail = (x: number, y: number, { whirlpools = false } = {}) => {
|
|
const world = this.world,
|
|
map = world.mapId;
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
this.play();
|
|
expect(world.mapId, `sailing to ${x},${y}`).toBe(map);
|
|
if (world.x === x && world.y === y) return;
|
|
for (const direction of findPath(world, x, y, {
|
|
water: true,
|
|
whirlpools,
|
|
ledges: this.ledges,
|
|
})) {
|
|
const [dx, dy] = STEP[direction];
|
|
const from = [world.x, world.y];
|
|
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
|
|
if (isWhirlpool(ahead)) {
|
|
// Stepping onto a whirlpool spins the player back: turn first.
|
|
if (world.save().direction !== direction) world.move(direction);
|
|
world.interact();
|
|
this.play([1]);
|
|
break;
|
|
}
|
|
if (world.state.playerState !== PLAYER_SURF && world.assets.permission(ahead) === 1) {
|
|
world.move(direction); // only turns to face the water
|
|
world.interact();
|
|
this.play([1]);
|
|
expect(world.state.playerState, "surfing").toBe(PLAYER_SURF);
|
|
} else if (!world.move(direction)) {
|
|
for (let tick = 0; tick < 64; tick++) world.tick();
|
|
break;
|
|
} else this.play();
|
|
if (
|
|
world.mapId !== map ||
|
|
world.x !== from[0] + dx ||
|
|
world.y !== from[1] + dy
|
|
)
|
|
break;
|
|
}
|
|
}
|
|
throw new Error(`Could not sail to ${x},${y} on ${map} from ${world.x},${world.y}`);
|
|
};
|
|
/** Step across a map connection from the nearest usable edge tile;
|
|
* `water` lets a player who can Surf reach it over the sea. */
|
|
cross = (direction: Direction, target: string, { water = false } = {}) => {
|
|
const world = this.world,
|
|
map = world.mapId;
|
|
const [dx, dy] = STEP[direction];
|
|
const lengths = pathLengths(world, { water, ledges: this.ledges });
|
|
const edge = Array.from(
|
|
{ length: dx ? world.map.height : world.map.width },
|
|
(_, i) =>
|
|
dx
|
|
? [dx < 0 ? 0 : world.map.width - 1, i]
|
|
: [i, dy < 0 ? 0 : world.map.height - 1],
|
|
).flatMap(([x, y]) => {
|
|
// A warp on the edge (a gate's doorway) is not the connection.
|
|
if (world.map.warps.some((warp) => warp.x === x && warp.y === y)) return [];
|
|
const length = lengths.get(`${x},${y}`);
|
|
return length === undefined ? [] : [{ x, y, length }];
|
|
});
|
|
edge.sort((a, b) => a.length - b.length);
|
|
for (const { x, y } of edge) {
|
|
if (water) this.sail(x, y);
|
|
else this.walk(x, y);
|
|
if (world.move(direction)) {
|
|
this.play();
|
|
if (world.mapId === target) return;
|
|
}
|
|
if (world.mapId !== map) break;
|
|
}
|
|
throw new Error(
|
|
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
|
|
);
|
|
};
|
|
/** Stand in front of a warp leading to `target` and take it. */
|
|
enter = (target: string, direction: Direction) => {
|
|
const world = this.world;
|
|
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
|
expect(warp, `a warp to ${target}`).toBeDefined();
|
|
const [dx, dy] = STEP[direction];
|
|
this.walk(warp.x - dx, warp.y - dy);
|
|
expect(world.move(direction)).toBe(true);
|
|
this.play();
|
|
expect(world.mapId).toBe(target);
|
|
};
|
|
/** Leave through an exit mat, which needs the exit direction pressed. */
|
|
exit = (target: string, direction: Direction = "down") => {
|
|
const world = this.world;
|
|
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
|
this.walk(warp.x, warp.y);
|
|
if (world.mapId !== target) {
|
|
expect(world.move(direction)).toBe(true);
|
|
this.play();
|
|
}
|
|
expect(world.mapId).toBe(target);
|
|
};
|
|
/** Serialize the save to JSON and load it into a fresh world. */
|
|
reload = () => {
|
|
const saved = JSON.parse(JSON.stringify(this.world.save()));
|
|
// PLAYTHROUGH_SAVES=dir keeps every stage's save, for debugging a
|
|
// later stage from where the run reached.
|
|
if (process.env.PLAYTHROUGH_SAVES) {
|
|
mkdirSync(process.env.PLAYTHROUGH_SAVES, { recursive: true });
|
|
writeFileSync(
|
|
`${process.env.PLAYTHROUGH_SAVES}/${String(++this.stage).padStart(3, "0")}.json`,
|
|
JSON.stringify({ save: saved, seed: this.seed }),
|
|
);
|
|
}
|
|
this.world = new GameWorld(this.assets, this.now, this.random);
|
|
this.world.restore(saved);
|
|
const reloaded = this.world.save();
|
|
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
|
};
|
|
lead = () => this.world.state.party[0];
|
|
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
|
* tests/human-test-saves.test.ts writes them (kept out of git). */
|
|
writeTestSave = (name: string) => {
|
|
if (!process.env.MAKE_TEST_SAVES) return;
|
|
mkdirSync("test-saves", { recursive: true });
|
|
writeFileSync(
|
|
`test-saves/${name}.crystal-save.json`,
|
|
encodeSave({
|
|
version: 1,
|
|
romHash: CRYSTAL_ROM.sha1,
|
|
coreId: "crystal-native-world-v1",
|
|
savedAt: new Date().toISOString(),
|
|
state: new TextEncoder().encode(JSON.stringify(this.world.save())),
|
|
}),
|
|
);
|
|
};
|
|
/** PokecenterNurseScript from in front of the counter, then back out. */
|
|
heal = (center: string, city: string) => {
|
|
this.enter(center, "up");
|
|
this.walk(3, 3);
|
|
this.world.move("up"); // the counter: this only turns the player
|
|
this.world.interact();
|
|
this.play();
|
|
expect(
|
|
this.world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0]),
|
|
).toBe(true);
|
|
this.exit(city);
|
|
};
|
|
/** Pace between two neighbouring grass tiles (the nearest pair) until
|
|
* the lead reaches `level`. `rest` heals and comes back here. */
|
|
train = (level: number, rest: () => void) => {
|
|
const world = this.world,
|
|
map = world.mapId,
|
|
grass = world.assets.grassCollisions();
|
|
const isGrass = (x: number, y: number) =>
|
|
x >= 0 &&
|
|
y >= 0 &&
|
|
x < world.map.width &&
|
|
y < world.map.height &&
|
|
grass.includes(world.assets.collision(world.map, x, y));
|
|
for (let round = 0; round < 2000 && this.lead().level < level; round++) {
|
|
if (this.lead().hp * 2 < this.lead().stats[0]) {
|
|
rest();
|
|
expect(this.world.mapId).toBe(map);
|
|
}
|
|
const pairs: { x: number; y: number; length: number }[] = [];
|
|
const lengths = pathLengths(world);
|
|
for (let y = 0; y < world.map.height; y++)
|
|
for (let x = 0; x < world.map.width; x++)
|
|
if (isGrass(x, y) && isGrass(x + 1, y)) {
|
|
const length = lengths.get(`${x},${y}`);
|
|
if (length !== undefined) pairs.push({ x, y, length });
|
|
}
|
|
pairs.sort((a, b) => a.length - b.length);
|
|
const { x, y } = pairs[0];
|
|
this.walk(x, y);
|
|
this.walk(x + 1, y);
|
|
}
|
|
expect(this.lead().level).toBeGreaterThanOrEqual(level);
|
|
};
|
|
|
|
/** Take the stairs (or another warp) to `target`, trying each. A warp
|
|
* mat or cave edge needs its exit direction pressed (.CheckWarp). */
|
|
stairs = (target: string) => {
|
|
const world = this.world;
|
|
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
|
|
try {
|
|
findPath(world, warp.x, warp.y, { ledges: this.ledges });
|
|
} catch {
|
|
continue;
|
|
}
|
|
this.walk(warp.x, warp.y);
|
|
if (world.mapId === target) return;
|
|
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
|
if (exit && world.move(exit)) {
|
|
this.play();
|
|
if (world.mapId === target) return;
|
|
}
|
|
}
|
|
throw new Error(`No way from ${world.mapId} to ${target}`);
|
|
};
|
|
/** Find a way through warps among `maps` to `target`, as a player
|
|
* trying staircases would: depth first, backing out of dead ends. */
|
|
route = (
|
|
target: string,
|
|
maps: string[],
|
|
depth = 6,
|
|
goal?: { x: number; y: number },
|
|
): boolean => {
|
|
const world = this.world;
|
|
const reachable = () => {
|
|
if (!goal) return true;
|
|
try {
|
|
findPath(world, goal.x, goal.y, { ledges: this.ledges });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
if (world.mapId === target && reachable()) return true;
|
|
if (!depth) return false;
|
|
const here = world.save();
|
|
for (const warp of world.map.warps) {
|
|
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
|
try {
|
|
findPath(world, warp.x, warp.y, { ledges: this.ledges });
|
|
} catch {
|
|
continue;
|
|
}
|
|
const from = world.mapId;
|
|
this.walk(warp.x, warp.y);
|
|
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
|
if (world.mapId === from && exit && world.move(exit)) this.play();
|
|
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
|
|
return true;
|
|
world.restore(here);
|
|
}
|
|
return false;
|
|
};
|
|
/** The fewest warps (stairs, holes, doors) among `maps` to `target`:
|
|
* breadth first over the places each warp lands, so a maze of floors
|
|
* whose holes drop into cut-off corners is solved without looping.
|
|
* `water` lets a player with Surf and Whirlpool sail to the warps. */
|
|
travel = (target: string, maps: string[], { water = false } = {}) => {
|
|
const world = this.world;
|
|
const queue = [world.save()];
|
|
const seen = new Set([`${world.mapId} ${world.x},${world.y}`]);
|
|
for (let i = 0; i < queue.length && i < 1000; i++) {
|
|
world.restore(queue[i]);
|
|
const from = world.mapId;
|
|
for (const warp of world.map.warps) {
|
|
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
|
world.restore(queue[i]);
|
|
try {
|
|
findPath(world, warp.x, warp.y, { water, whirlpools: water, ledges: this.ledges });
|
|
} catch {
|
|
continue;
|
|
}
|
|
// A surfer reaches a cave's stairs over water, then steps on.
|
|
const landedHere = () => {
|
|
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
|
return Boolean(arrival && world.x === arrival.x && world.y === arrival.y);
|
|
};
|
|
if (water) {
|
|
try {
|
|
this.sail(warp.x, warp.y, { whirlpools: true });
|
|
} catch {
|
|
if (world.mapId === from && !landedHere()) continue;
|
|
}
|
|
} else
|
|
try {
|
|
this.walk(warp.x, warp.y);
|
|
} catch {
|
|
// A warp panel on the way can land elsewhere on the same map.
|
|
if (world.mapId === from && !landedHere()) continue;
|
|
}
|
|
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
|
if (world.mapId === from && exit && world.move(exit)) this.play();
|
|
// A warp to another place on the same map lands at its partner.
|
|
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
|
const moved = arrival ? world.x === arrival.x && world.y === arrival.y : world.mapId !== from;
|
|
if (!moved) continue;
|
|
if (world.mapId === target) return;
|
|
const key = `${world.mapId} ${world.x},${world.y}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
queue.push(world.save());
|
|
}
|
|
}
|
|
throw new Error(`No way to ${target} through ${maps.join(", ")}`);
|
|
};
|
|
/** Advance to the next menu choice, keeping its pages in the log. */
|
|
toChoice = () => {
|
|
const world = this.world,
|
|
log = this.log;
|
|
for (let tick = 0; tick < 2000; tick++) {
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index];
|
|
if (log.at(-1) !== page) log.push(page);
|
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
|
return dialogue.choice;
|
|
if (dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
} else world.tick();
|
|
}
|
|
throw new Error(`No choice appeared: ${world.diagnostic}`);
|
|
};
|
|
/** Choose a menu item by its label (or a pattern). */
|
|
choose = (label: string | RegExp) => {
|
|
const choice = this.toChoice();
|
|
if (choice.kind !== "menu")
|
|
throw new Error(`Expected a menu, got ${choice.kind}`);
|
|
const index = choice.items.findIndex((item) =>
|
|
typeof label === "string" ? item === label : label.test(item),
|
|
);
|
|
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
|
this.world.choose(index);
|
|
};
|
|
/** Choose an item in the pack. */
|
|
choosePack = (item: number) => {
|
|
const choice = this.toChoice();
|
|
if (choice.kind !== "pack")
|
|
throw new Error(`Expected the pack, got ${choice.kind}`);
|
|
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
|
const index = entries.findIndex((entry) => entry.item === item);
|
|
if (index >= 0) return this.world.choosePackEntry(pocket, index);
|
|
}
|
|
throw new Error(`Item ${item} is not in the pack.`);
|
|
};
|
|
/** Talk to an object wherever it stands. A trainer who spots the player
|
|
* on the way battles and moves, so the approach is planned again. */
|
|
talkTo = (id: number) => {
|
|
const world = this.world;
|
|
const COUNTER = 0x90;
|
|
for (let attempt = 0; attempt < 4; attempt++)
|
|
for (const [dx, dy, face] of [
|
|
[-1, 0, "right"],
|
|
[1, 0, "left"],
|
|
[0, 1, "up"],
|
|
[0, -1, "down"],
|
|
] as const) {
|
|
const target = world.objects().find((object) => object.id === id)!;
|
|
// Across a counter the player stands one tile further away.
|
|
const across =
|
|
world.assets.collision(world.map, target.x + dx, target.y + dy) === COUNTER;
|
|
const x = target.x + dx * (across ? 2 : 1),
|
|
y = target.y + dy * (across ? 2 : 1);
|
|
try {
|
|
if (!(world.x === x && world.y === y)) this.walk(x, y);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const now = world.objects().find((object) => object.id === id)!;
|
|
if (now.x !== target.x || now.y !== target.y) break;
|
|
world.move(face);
|
|
world.interact();
|
|
return;
|
|
}
|
|
throw new Error(`No way to talk to object ${id}`);
|
|
};
|
|
/** Talk to the clerk at `clerk` and buy `quantity` of `item` through
|
|
* the world's own BuyMenu (BuyMenuLoop, SelectQuantityToBuy): BUY, the
|
|
* item in the stock list, the quantity, YES, back out of the list,
|
|
* then QUIT, as tests/mart.test.ts and tests/goldenrod.test.ts drive the
|
|
* martmenu/martlist/martquantity choices directly. A pharmacy
|
|
* (MARTTYPE_PHARMACY) opens straight onto its list, with no menu. */
|
|
buy = (clerk: number, item: number, quantity: number) => {
|
|
const world = this.world;
|
|
this.talkTo(clerk);
|
|
const menu = this.toChoice().kind === "martmenu";
|
|
if (menu) world.choose(0); // BUY
|
|
const list = this.toChoice();
|
|
if (list.kind !== "martlist")
|
|
throw new Error(`Expected a mart list, got ${list.kind}`);
|
|
const index = list.entries.findIndex((entry) => entry.item === item);
|
|
expect(index, `item ${item} in the mart's stock`).toBeGreaterThanOrEqual(0);
|
|
world.choose(index);
|
|
expect(this.toChoice().kind).toBe("martquantity");
|
|
world.choose(quantity);
|
|
expect(this.toChoice().kind).toBe("yesno");
|
|
world.choose(1);
|
|
// "Here you are!", then the stock list again (BuyMenuLoop repeats).
|
|
expect(this.toChoice().kind).toBe("martlist");
|
|
world.choose(-1); // back to the mart's own menu
|
|
if (menu) {
|
|
expect(this.toChoice().kind).toBe("martmenu");
|
|
world.choose(2); // QUIT
|
|
}
|
|
this.play();
|
|
};
|
|
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
|
* a full move list forgets move `forget` (the first by default). */
|
|
teach = (item: number, slot: number, forget = 0) => {
|
|
const world = this.world;
|
|
expect(world.openStartMenu()).toBe(true);
|
|
this.choose("PACK");
|
|
this.choosePack(item);
|
|
this.choose("USE");
|
|
expect(this.toChoice().kind).toBe("yesno");
|
|
world.choose(1);
|
|
const party = this.toChoice();
|
|
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
|
|
world.choose(slot);
|
|
if (world.liveState.party[slot].moves.every(Boolean)) {
|
|
expect(this.toChoice().kind).toBe("yesno");
|
|
world.choose(1);
|
|
this.toChoice();
|
|
world.choose(forget);
|
|
}
|
|
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
|
|
this.toChoice();
|
|
// The menus work on a draft that is kept once they close.
|
|
for (let i = 0; i < 10 && world.busy; i++) {
|
|
if (world.dialogue?.choice) world.advanceDialogue();
|
|
else world.tick();
|
|
}
|
|
this.play();
|
|
};
|
|
/** Push Strength boulder `id` one tile in `direction` from its far
|
|
* side: the first bump only turns the player, a boulder Strength is not
|
|
* yet active for asks first (AskStrengthScript, then YES), and the
|
|
* boulder moves on its own movement function a few frames later. */
|
|
push = (id: number, direction: Direction) => {
|
|
const world = this.world;
|
|
const boulder = () => world.objects().find((object) => object.id === id);
|
|
const from = boulder()!;
|
|
const [dx, dy] = STEP[direction];
|
|
this.walk(from.x - dx, from.y - dy);
|
|
const moved = () => {
|
|
const now = boulder();
|
|
return !now || now.x !== from.x || now.y !== from.y;
|
|
};
|
|
for (let attempt = 0; attempt < 3 && !moved(); attempt++) {
|
|
world.move(direction);
|
|
for (let tick = 0; tick < 40; tick++) world.tick();
|
|
this.play();
|
|
if (moved()) break;
|
|
world.interact();
|
|
this.play([1]);
|
|
}
|
|
expect(moved(), `boulder ${id} pushed ${direction}`).toBe(true);
|
|
};
|
|
/** Push Strength boulders into their holes: each goal boulder must reach
|
|
* its own stone-table warp (a boulder on another pit only stops there),
|
|
* and `movable` boulders may be pushed out of the way. The pushes are
|
|
* planned breadth first over boulder positions and the player's reachable
|
|
* area, then made one by one with `push`. */
|
|
boulders = (goals: { id: number; x: number; y: number }[], movable: number[] = []) => {
|
|
const world = this.world,
|
|
map = world.map;
|
|
const ids = [...goals.map((goal) => goal.id), ...movable];
|
|
const land = (x: number, y: number) =>
|
|
x >= 0 &&
|
|
y >= 0 &&
|
|
x < map.width &&
|
|
y < map.height &&
|
|
world.assets.permission(world.assets.collision(map, x, y)) === 0;
|
|
const warps = new Set(map.warps.map((warp) => `${warp.x},${warp.y}`));
|
|
const fixed = new Set(
|
|
world
|
|
.objects()
|
|
.filter((object) => !ids.includes(object.id))
|
|
.map((object) => `${object.x},${object.y}`),
|
|
);
|
|
const at = (id: number) => {
|
|
const object = world.objects().find((candidate) => candidate.id === id);
|
|
return object ? `${object.x},${object.y}` : "gone";
|
|
};
|
|
/** The player's reachable tiles, walking around boulders and pits. */
|
|
const reach = (x: number, y: number, boulders: string[]) => {
|
|
const blocked = new Set([...boulders, ...fixed]);
|
|
const seen = new Set([`${x},${y}`]);
|
|
const queue = [[x, y]];
|
|
for (let i = 0; i < queue.length; i++)
|
|
for (const [dx, dy] of Object.values(STEP)) {
|
|
const nx = queue[i][0] + dx,
|
|
ny = queue[i][1] + dy,
|
|
key = `${nx},${ny}`;
|
|
if (seen.has(key) || !land(nx, ny) || blocked.has(key) || warps.has(key)) continue;
|
|
seen.add(key);
|
|
queue.push([nx, ny]);
|
|
}
|
|
return seen;
|
|
};
|
|
type Node = { boulders: string[]; x: number; y: number; pushes: [number, Direction][] };
|
|
const queue: Node[] = [{ boulders: ids.map(at), x: world.x, y: world.y, pushes: [] }];
|
|
const seen = new Set<string>();
|
|
let plan: Node | undefined;
|
|
for (let i = 0; i < queue.length && i < 500_000 && !plan; i++) {
|
|
const node = queue[i];
|
|
const area = reach(node.x, node.y, node.boulders);
|
|
const key = `${node.boulders.join(";")}|${[...area].sort()[0]}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
if (goals.every((_, index) => node.boulders[index] === "gone")) plan = node;
|
|
node.boulders.forEach((boulder, index) => {
|
|
if (boulder === "gone") return;
|
|
const [bx, by] = boulder.split(",").map(Number);
|
|
for (const [direction, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
|
|
const target = `${bx + dx},${by + dy}`;
|
|
if (!area.has(`${bx - dx},${by - dy}`)) continue;
|
|
if (!land(bx + dx, by + dy) || node.boulders.includes(target) || fixed.has(target)) continue;
|
|
const goal = goals[index];
|
|
const falls = goal && goal.x === bx + dx && goal.y === by + dy;
|
|
if (warps.has(target) && !falls) continue;
|
|
const boulders = [...node.boulders];
|
|
boulders[index] = falls ? "gone" : target;
|
|
queue.push({ boulders, x: bx, y: by, pushes: [...node.pushes, [ids[index], direction]] });
|
|
}
|
|
});
|
|
}
|
|
expect(plan, "a way to push the boulders into their holes").toBeDefined();
|
|
for (const [id, direction] of plan!.pushes) this.push(id, direction);
|
|
};
|
|
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
|
|
cut = (x: number, y: number) => {
|
|
const world = this.world;
|
|
for (const [face, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
|
|
try {
|
|
this.walk(x - dx, y - dy);
|
|
} catch {
|
|
continue;
|
|
}
|
|
world.move(face);
|
|
world.interact();
|
|
this.play([1]);
|
|
return;
|
|
}
|
|
throw new Error(`No side to cut ${x},${y} from`);
|
|
};
|
|
/** The current location and party, then the last pages, for a failure. */
|
|
trace = () =>
|
|
`${this.world.mapId} ${this.world.x},${this.world.y}; party ${JSON.stringify(
|
|
this.world.state.party.map(({ species, level, hp }) => ({
|
|
species,
|
|
level,
|
|
hp,
|
|
})),
|
|
)}\n${this.log.slice(-30).join("\n---\n")}`;
|
|
}
|