Files
Crystal/tests/playthrough-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

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")}`;
}