import { beforeEach, describe, expect, it, onTestFailed } from "vitest"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { encodeSave } from "../src/storage/saves"; import { CRYSTAL_ROM } from "../src/rom/validate"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import type { Direction } from "../src/game/world/types"; import { C } from "../src/game/battle/constants"; import { findPath } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const NEW_BARK = "24:4", ELMS_LAB = "24:5", ROUTE_29 = "24:3", CHERRYGROVE = "26:3", ROUTE_30 = "26:1", MR_POKEMONS_HOUSE = "26:10", CHERRYGROVE_CENTER = "26:5", ROUTE_31 = "26:2", ROUTE_31_GATE = "26:11", VIOLET = "10:5", VIOLET_CENTER = "10:10", VIOLET_GYM = "10:7", SPROUT_1F = "3:1", SPROUT_2F = "3:2", SPROUT_3F = "3:3", KYLES_HOUSE = "10:11"; const DARK_CAVE = "3:78", EARLS_ACADEMY = "10:8"; const PHONE_BUG_CATCHER_WADE = 16; const FLASH = 148, HM05 = 0xf7; const TOGEPI = 175, BELLSPROUT = 69, ONIX = 95; const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40, EVENT_GOT_HM05_FLASH = 0x14, EVENT_BEAT_FALKNER = 0x4bd, EVENT_GOT_TM31_MUD_SLAP = 0x8, EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d, ENGINE_ZEPHYRBADGE = 0x1b; /** The acceptance run for PLAN section 2: a new game played from the * introduction to the catching tutorial through the world's own inputs, * without fixtures or story flags set by hand. Every stage ends with a * save serialized to JSON and loaded into a fresh world. The stages share * one world and run in order, so run the file rather than a single stage. */ describe.skipIf(!existsSync(romPath))( "a continuous opening playthrough", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const now = () => new Date("2026-09-22T12:00:00"); let seed = 0x2a; const random = () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }; const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined; let world: GameWorld; const log: string[] = []; /** A wild species to throw Poké Balls at instead of fighting. */ let wanted = 0; /** The usable move with the most power, or the first with PP. */ const strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => { let best = -1; mon.moves.forEach((move, i) => { if (!move || !(mon.pp[i] & 63)) return; if (best < 0 || assets!.pokemon.move(move).power > assets!.pokemon.move(mon.moves[best]).power) best = i; }); return Math.max(0, best); }; /** 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, as * a cautious player would. New moves are learned when there is room, * and other questions take the queued answers or their defaults. */ const play = (answers: (number | string)[] = []) => { 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.state.bag.balls.findIndex((slot) => slot.item === C.POKE_BALL); if ( engine.battleMode === C.WILD_BATTLE && engine.enemy.mon.species === wanted && !world.state.party.some((member) => member.species === wanted) && ball >= 0 ) world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball }); else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp) world.battleAction({ action: "run" }); else world.battleAction({ action: "fight", move: strongest(mon) }); } 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). else if (prompt?.kind === "party") world.battleChoose( prompt.cancel ? -1 : world.state.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}`); }; const STEP: Record = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], }; /** 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. */ const walk = (x: number, y: number) => { const map = world.mapId, warp = world.map.warps.some((warp) => warp.x === x && warp.y === y); for (let attempt = 0; attempt < 40; attempt++) { play(); if (warp && world.mapId !== map) return; expect(world.mapId, `walking to ${x},${y}`).toBe(map); if (world.x === x && world.y === y) return; for (const direction of findPath(world, x, y)) { const [dx, dy] = STEP[direction]; const from = [world.x, world.y]; if (!world.move(direction)) { for (let tick = 0; tick < 64; tick++) world.tick(); break; } play(); if ( world.mapId !== map || world.x !== from[0] + dx || world.y !== from[1] + dy ) break; } } throw new Error( `Could not reach ${x},${y} on ${map} from ${world.x},${world.y}`, ); }; /** Step across a map connection from the nearest usable edge tile. */ const cross = (direction: Direction, target: string) => { const 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]) => { try { return [{ x, y, length: findPath(world, x, y).length }]; } catch { return []; } }); edge.sort((a, b) => a.length - b.length); for (const { x, y } of edge) { walk(x, y); if (world.move(direction)) { 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. */ const enter = (target: string, direction: Direction) => { const warp = world.map.warps.find((warp) => warp.targetMap === target)!; expect(warp, `a warp to ${target}`).toBeDefined(); const [dx, dy] = STEP[direction]; walk(warp.x - dx, warp.y - dy); expect(world.move(direction)).toBe(true); play(); expect(world.mapId).toBe(target); }; /** Leave through an exit mat, which needs the exit direction pressed. */ const exit = (target: string, direction: Direction = "down") => { const warp = world.map.warps.find((warp) => warp.targetMap === target)!; walk(warp.x, warp.y); if (world.mapId !== target) { expect(world.move(direction)).toBe(true); play(); } expect(world.mapId).toBe(target); }; const reload = () => { const saved = JSON.parse(JSON.stringify(world.save())); world = new GameWorld(assets!, now, random); world.restore(saved); expect(world.save()).toEqual(saved); }; const lead = () => 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). */ const 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(world.save())), }), ); }; /** PokecenterNurseScript from in front of the counter, then back out. */ const heal = (center: string, city: string) => { enter(center, "up"); walk(3, 3); world.move("up"); // the counter: this only turns the player world.interact(); play(); expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true); exit(city); }; /** Pace between two neighbouring grass tiles (the nearest pair) until * the lead reaches `level`. `rest` heals and comes back here. */ const train = (level: number, rest: () => void) => { const 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 && lead().level < level; round++) { if (lead().hp * 2 < lead().stats[0]) { rest(); expect(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]; walk(x, y); walk(x + 1, y); } expect(lead().level).toBeGreaterThanOrEqual(level); }; /** Take the stairs (or another warp) to `target`, trying each. */ const stairs = (target: string) => { for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) { try { findPath(world, warp.x, warp.y); } catch { continue; } walk(warp.x, warp.y); 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. */ const route = ( target: string, maps: string[], depth = 6, goal?: { x: number; y: number }, ): boolean => { const reachable = () => { if (!goal) return true; try { findPath(world, goal.x, goal.y); 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); } catch { continue; } const from = world.mapId; walk(warp.x, warp.y); if (world.mapId !== from && route(target, maps, depth - 1, goal)) return true; world.restore(here); } return false; }; /** Advance to the next menu choice, keeping its pages in the log. */ const toChoice = () => { 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). */ const choose = (label: string | RegExp) => { const choice = 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); world.choose(index); }; /** Choose an item in the pack. */ const choosePack = (item: number) => { const choice = 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 world.choosePackEntry(pocket, index); } throw new Error(`Item ${item} is not in the pack.`); }; /** From Route 31: through the gate to Violet's Pokémon Center and back. */ const restInViolet = () => { exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); heal(VIOLET_CENTER, VIOLET); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); }; beforeEach(() => { log.length = 0; }); const traceFailures = () => onTestFailed(() => { console.log( `${world.mapId} ${world.x},${world.y}; party ${JSON.stringify( world.state.party.map(({ species, level, hp }) => ({ species, level, hp, })), )}\n${log.slice(-30).join("\n---\n")}`, ); }); it("plays the introduction and Mom's scene", () => { world = new GameWorld(assets!, now, random); traceFailures(); world.beginIntro(); play([0, "", 1, "", 1, 0, "KAWA"]); expect(world.intro).toBe(false); expect(world.name).toBe("KAWA"); reload(); walk(7, 0); expect(world.mapId).toBe("24:6"); walk(9, 3); expect(world.move("down")).toBe(true); play([1, 1, 1, 1, 1]); expect(log).toContain("KAWA received\nPOKéGEAR."); reload(); }); it("meets Elm, takes Cyndaquil and the aide's Potion", () => { traceFailures(); walk(6, 7); expect(world.move("down")).toBe(true); play(); expect(world.mapId).toBe(NEW_BARK); enter(ELMS_LAB, "up"); play([0, 1]); walk(6, 4); world.move("up"); world.interact(); play([1, 1, ""]); expect(world.state.party.map((mon) => mon.species)).toEqual([155]); walk(4, 7); expect(world.move("down")).toBe(true); play(); expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]); reload(); }); it("walks to Mr. Pokémon's house through Route 29, Cherrygrove and Route 30", () => { traceFailures(); exit(NEW_BARK); cross("left", ROUTE_29); cross("left", CHERRYGROVE); reload(); cross("up", ROUTE_30); enter(MR_POKEMONS_HOUSE, "up"); play(); expect(world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG expect(world.state.flags.has(11)).toBe(true); // the Pokédex reload(); }); it("takes Elm's call outside and meets the rival in Cherrygrove", () => { traceFailures(); exit(ROUTE_30); expect(log.join("\n")).toContain("It's a disaster!"); expect(world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON reload(); cross("down", CHERRYGROVE); // The rival's scene blocks the way east to Route 29. cross("right", ROUTE_29); const rival = log.join("\n"); expect(rival).toContain("RIVAL ?"); expect(rival).toContain("My name's ???."); expect(world.state.scenes[CHERRYGROVE]).toBe(0); reload(); }); it("names the rival for the officer and returns the Egg to Elm", () => { traceFailures(); cross("right", NEW_BARK); enter(ELMS_LAB, "up"); walk(4, 6); expect(world.move("up")).toBe(true); play(["SILVER"]); expect(world.state.rivalName).toBe("SILVER"); reload(); walk(5, 3); world.move("up"); // Elm stands at 5,2: this only turns the player. world.interact(); play(); expect(world.state.bag.keyItems).toEqual([]); walk(4, 8); expect(world.state.bag.balls).toEqual([ { item: C.POKE_BALL, quantity: 5 }, ]); reload(); }); it("watches the catching tutorial on Route 29 and reaches Cherrygrove", () => { traceFailures(); exit(NEW_BARK); cross("left", ROUTE_29); cross("left", CHERRYGROVE); const pages = log.join("\n").replace(/\n/g, " "); expect(pages).toContain("show you how to catch POKéMON?"); expect(pages).toContain("That's how you do it"); expect(world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON expect(world.state.scenes[ROUTE_29]).toBe(0); reload(); const save = world.save(); expect(save.party[0].species).toBe(155); expect(save.pokedex.caught).toContain(155); expect(save.rivalName).toBe("SILVER"); expect(save.intro).toBe(false); }); it("trains and walks Route 30 and Route 31 to Violet City", () => { traceFailures(); const restInCherrygrove = () => { cross("left", CHERRYGROVE); heal(CHERRYGROVE_CENTER, CHERRYGROVE); cross("right", ROUTE_29); }; heal(CHERRYGROVE_CENTER, CHERRYGROVE); cross("right", ROUTE_29); train(10, restInCherrygrove); cross("left", CHERRYGROVE); heal(CHERRYGROVE_CENTER, CHERRYGROVE); cross("up", ROUTE_30); cross("up", ROUTE_31); exit(ROUTE_31_GATE, "left"); // Mom worries when the player has not come home since the Egg; the // call rings on a step after Route 31's callback queued it. expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true); exit(VIOLET, "left"); expect(world.state.flags.size).toBeGreaterThan(0); reload(); writeTestSave("violet-city"); }, 120_000); it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => { traceFailures(); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); train(14, restInViolet); restInViolet(); exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); enter(SPROUT_1F, "up"); expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true); walk(10, 3); world.move("up"); // Elder Li: this only turns the player world.interact(); play(); const pages = log.join("\n").replace(/\n/g, " "); expect(pages).toContain("FLASH"); expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true); reload(); }, 120_000); it("beats Falkner for the ZEPHYRBADGE and TM31, then hears from Elm's aide", () => { traceFailures(); expect(route(SPROUT_1F, [SPROUT_1F, SPROUT_2F, SPROUT_3F], 6, { x: 9, y: 15 })).toBe(true); exit(VIOLET); heal(VIOLET_CENTER, VIOLET); enter(VIOLET_GYM, "up"); walk(5, 2); world.move("up"); // Falkner: this only turns the player world.interact(); play(); expect(world.state.events.has(EVENT_BEAT_FALKNER), log.join("|")).toBe(true); expect(world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true); expect(world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true); expect(world.state.bag.tmsHms[30]).toBe(1); // TM31 reload(); exit(VIOLET); // SPECIALCALL_ASSISTANT rings on a step: the aide waits in the // Pokémon Center with the Egg. enter(VIOLET_CENTER, "up"); expect(log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER"); // The aide's object flag (EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER). const aide = world.assets.map(VIOLET_CENTER).objects[4]; expect(world.state.events.has(aide.eventFlag)).toBe(false); reload(); }, 120_000); it("takes the Egg from Elm's aide", () => { traceFailures(); walk(4, 4); world.move("up"); // the aide: this only turns the player world.interact(); play([1]); expect(world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true); const egg = world.state.party.at(-1)!; expect(egg).toMatchObject({ species: TOGEPI, egg: true, nickname: "EGG", hp: 0, happiness: world.assets.pokemon.baseData(TOGEPI).eggCycles, }); // An Egg is neither seen nor caught. expect(world.state.pokedex.caught.has(TOGEPI)).toBe(false); reload(); }); it("catches a Bellsprout on Route 31 and trades it to Kyle for Onix", () => { traceFailures(); exit(VIOLET); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); wanted = BELLSPROUT; try { for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++) { if (!world.state.bag.balls.length) break; train(lead().level + 1, restInViolet); } } finally { wanted = 0; } const bellsprout = world.state.party.findIndex((mon) => mon.species === BELLSPROUT); expect(bellsprout, log.slice(-20).join("|")).toBeGreaterThan(0); exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); enter(KYLES_HOUSE, "up"); const kyle = world.objects().find((object) => object.sprite === world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === world.assets.map(KYLES_HOUSE).objects[1].id)!; walk(kyle.x - 1, kyle.y); world.move("right"); world.interact(); play([1, bellsprout]); const onix = world.state.party.at(-1)!; expect(onix).toMatchObject({ species: ONIX, nickname: "ROCKY", ot: "KYLE", otId: 48926, dvs: 0x9666 }); expect(world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false); expect(world.state.tradeFlags).toBe(1 << 1); expect(world.state.pokedex.caught.has(ONIX)).toBe(true); reload(); }, 120_000); it("teaches HM05 to a new Bellsprout and lights up Dark Cave with FLASH", () => { traceFailures(); exit(VIOLET); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); wanted = BELLSPROUT; try { for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++) train(lead().level + 1, restInViolet); } finally { wanted = 0; } const slot = world.state.party.findIndex((mon) => mon.species === BELLSPROUT); expect(slot).toBeGreaterThan(0); const nickname = world.state.party[slot].nickname; // PACK, the TM/HM pocket's HM05, USE, YES, then the Bellsprout. expect(world.openStartMenu()).toBe(true); choose("PACK"); choosePack(HM05); choose("USE"); expect(toChoice().kind).toBe("yesno"); world.choose(1); const party = toChoice(); expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE"); world.choose(slot); if (world.state.party[slot].moves.every(Boolean)) { // A full move list asks which move to forget: the first one. expect(toChoice().kind).toBe("yesno"); world.choose(1); toChoice(); world.choose(0); } for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++) toChoice(); expect(log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!"); // 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(); } play(); expect(world.state.party[slot].moves).toContain(FLASH); expect(world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept // Dark Cave is dark until FLASH, which the ZEPHYRBADGE allows. enter(DARK_CAVE, "up"); expect(world.state.flash).toBe(false); expect(world.openStartMenu()).toBe(true); choose("POKéMON"); choose(nickname); choose("FLASH"); play(); expect(world.state.flash, log.slice(-6).join("|")).toBe(true); reload(); }, 120_000); it("registers Bug Catcher Wade's number and reads Earl's blackboard", () => { traceFailures(); exit(ROUTE_31); // Talk to Wade wherever he stands (he walks up to battle on sight). // A trainer who spots the player on the way battles and moves, so // the approach is planned again. const talkTo = (id: number) => { 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)!; const x = target.x + dx, y = target.y + dy; try { if (!(world.x === x && world.y === y)) 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}`); }; talkTo(world.map.objects[2].id); play([1]); const pages = log.join("\n").replace(/\n/g, " "); expect(pages).toContain("BUG CATCHER WADE"); expect(world.state.phone).toContain(PHONE_BUG_CATCHER_WADE); reload(); restInViolet(); exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); enter(EARLS_ACADEMY, "up"); walk(3, 1); world.move("up"); world.interact(); choose("PSN"); toChoice(); expect(log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP"); // B leaves the blackboard. world.choose(-1); play(); expect(world.busy).toBe(false); reload(); }, 120_000); }, );