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 } from "./world-driver"; const STEP: Record = { 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> = { 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; /** 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 && (mon.status & C.SLP_MASK) !== 0 && awakening >= 0 ) world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening }); else if ( engine.battleMode !== C.WILD_BATTLE && 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) { 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 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 []; try { return [{ x, y, length: findPath(world, x, y, { water, ledges: this.ledges }).length }]; } catch { return []; } }); 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 }[] = []; 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)) try { pairs.push({ x, y, length: findPath(world, x, y).length }); } catch { /* unreachable */ } 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(); 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")}`; }