- 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>
789 lines
31 KiB
TypeScript
789 lines
31 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;
|
|
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;
|
|
let best = -1;
|
|
mon.moves.forEach((move, i) => {
|
|
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
|
if (best < 0 || score(move) > score(mon.moves[best])) best = i;
|
|
});
|
|
return Math.max(0, best);
|
|
};
|
|
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
|
/** 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,
|
|
);
|
|
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 (
|
|
engine.battleMode !== C.WILD_BATTLE &&
|
|
!prompt.noItems &&
|
|
mon.hp * 2 < mon.maxHp &&
|
|
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) {
|
|
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}`);
|
|
};
|
|
|
|
/** 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. */
|
|
walk = (x: number, y: number) => {
|
|
const world = this.world,
|
|
map = world.mapId,
|
|
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
this.play();
|
|
if (warp && world.mapId !== map) 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.
|
|
if (water) {
|
|
try {
|
|
this.sail(warp.x, warp.y, { whirlpools: true });
|
|
} catch {
|
|
if (world.mapId === from) 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) continue;
|
|
}
|
|
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) 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")}`;
|
|
}
|