import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("Ecruteak side content with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const ROUTE_36 = "10:3", ROUTE_37 = "10:4", ECRUTEAK = "4:9", ECRUTEAK_ITEMFINDER_HOUSE = "4:8", ECRUTEAK_CENTER = "4:3", ECRUTEAK_GYM = "4:7", BURNED_TOWER_1F = "3:13", BURNED_TOWER_B1F = "3:14"; const HARD_STONE = 0x7d, MAGNET = 0x6c, ITEMFINDER = 0x37, ETHER = 0x3f, ULTRA_BALL = 2, HP_UP = 0x1a; const EVENT_GOT_HARD_STONE_FROM_ARTHUR = 0x67, EVENT_GOT_MAGNET_FROM_SUNNY = 0x69, EVENT_GOT_ITEMFINDER = 0x5a; // def_scene_scripts numbers each map's own scenes from 0 in file order: // EcruteakGymForcedToLeaveScene is 0 (the default), EcruteakGymNoopScene // (opened by BurnedTowerB1F's ReleaseTheBeasts) is 1. const SCENE_ECRUTEAKGYM_NOOP = 1; const EVENT_RELEASED_THE_BEASTS = 0x7b, EVENT_BEAT_MORTY = 0x4c0, ENGINE_FOGBADGE = 30; /** A world on `date`. */ const world = (date: string) => { let seed = 13; return new GameWorld( new CrystalAssets(bytes), () => new Date(date), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); }; /** Stand beside the object at (x, y) on `map` (across a counter if one * is in between), face it and press A. The first warpTo runs the map's * OBJECTS callback (which the weekday gifts depend on) and settles any * entry scene; repositioning after that with restore(), rather than a * second settled move, reaches the approach tile in the same tick, so a * wandering object (radius > 0) has no window to step away first. * restore() also resets object positions to home, since wandering is * not saved. */ const talk = (game: GameWorld, map: string, x: number, y: number) => { const target = game.assets.map(map); const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], } as const; for (const [face, [dx, dy]] of Object.entries(STEP) as [ keyof typeof STEP, readonly [number, number], ][]) { const across = game.assets.collision(target, x - dx, y - dy) === 0x90 ? 2 : 1; const px = x - dx * across, py = y - dy * across; if ( px < 0 || py < 0 || px >= target.width || py >= target.height || game.assets.permission(game.assets.collision(target, px, py)) !== 0 ) continue; try { game.warpTo(map, px, py); } catch { continue; } settle(game); game.restore({ ...game.save(), x: px, y: py, direction: face }); game.interact(); return; } throw new Error(`No place to talk to ${x},${y} on ${map}`); }; /** Advance to the next choice, keeping the pages shown. */ const next = (game: GameWorld, pages: string[] = []) => { for (let tick = 0; tick < 4000; tick++) { const dialogue = game.dialogue; if (dialogue) { const page = dialogue.pages[dialogue.index].replace(/\n/g, " "); if (page && pages.at(-1) !== page) pages.push(page); if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) return dialogue.choice; if (dialogue.index === dialogue.pages.length - 1 && dialogue.holdTicks !== undefined) game.tick(); else game.advanceDialogue(); } else if (game.busy) game.tick(); else return undefined; } throw new Error(`No choice: ${game.diagnostic}`); }; /** Accept yes/no prompts and advance text until the interaction ends. */ const finish = (game: GameWorld, pages: string[] = []) => { for (let step = 0; step < 200; step++) { const choice = next(game, pages); if (!choice) { expect(game.notice, game.diagnostic).toBe(""); return pages.join(" "); } if (choice.kind === "name") game.choose(""); else game.choose(choice.kind === "yesno" ? 1 : 0); } throw new Error("Interaction did not finish."); }; it("gives Arthur's Hard Stone only on Thursday, once met", () => { const notThursday = world("2026-09-22T12:00:00"); // Tuesday talk(notThursday, ROUTE_36, 46, 6); // Arthur is hidden on a non-Thursday. expect(notThursday.notice).toBe("Nothing to interact with here."); const game = world("2026-09-24T12:00:00"); // Thursday talk(game, ROUTE_36, 46, 6); let pages: string[] = []; let text = finish(game, pages); expect(text).toMatch(/Who are\s+you/); expect(game.state.events.has(EVENT_GOT_HARD_STONE_FROM_ARTHUR)).toBe(true); expect(game.state.bag.items).toContainEqual({ item: HARD_STONE, quantity: 1 }); // Talking again on the same day shows the "already have it" text. talk(game, ROUTE_36, 46, 6); pages = []; text = finish(game, pages); expect(text).toMatch(/second son/i); }); it("gives Sunny's Magnet only on Sunday, once met", () => { const game = world("2026-09-20T12:00:00"); // Sunday talk(game, ROUTE_37, 16, 8); const pages: string[] = []; const text = finish(game, pages); expect(text).toMatch(/SUNNY: Hi!/); expect(game.state.events.has(EVENT_GOT_MAGNET_FROM_SUNNY)).toBe(true); expect(game.state.bag.items).toContainEqual({ item: MAGNET, quantity: 1 }); }); it("gives the Itemfinder from the house south of Tin Tower", () => { const game = world("2026-09-22T12:00:00"); talk(game, ECRUTEAK_ITEMFINDER_HOUSE, 2, 3); const pages: string[] = []; const text = finish(game, pages); expect(text).toMatch(/true spirit/i); expect(game.state.events.has(EVENT_GOT_ITEMFINDER)).toBe(true); // ITEMFINDER is a key item (ItemAttributes' pocket byte). expect(game.state.bag.keyItems).toContain(ITEMFINDER); }); it("answers CheckMobileAdapterStatusSpecial's non-mobile branch at the Pokémon Center", () => { const game = world("2026-09-22T12:00:00"); // The default scene (EcruteakPokecenter1FMeetBillScene) plays out and // moves the player on the first visit; let it finish before walking // over to the PokéFan. game.warpTo(ECRUTEAK_CENTER, 3, 7); finish(game); talk(game, ECRUTEAK_CENTER, 7, 6); const text = finish(game); // CheckMobileAdapterStatusSpecial always clears wScriptVar in this // revision (CheckMobileAdapterStatus is the Japanese version's // routine), so the non-mobile KIMONO GIRLS text always shows. expect(text).toMatch(/KIMONO GIRLS/); expect(text).not.toMatch(/OLIVINE CITY/); }); it("keeps Morty's gym closed until the Burned Tower beasts are released", () => { // EcruteakGymForcedToLeaveScene is the default scene (0); only // BurnedTowerB1F's ReleaseTheBeasts (board #32's roaming increment, // out of scope here) sets SCENE_ECRUTEAKGYM_NOOP to open it. Morty's // own dialogue in Burned Tower 1F explains why: he is investigating // the tower with Eusine instead of running the gym. const game = world("2026-09-22T12:00:00"); game.warpTo(ECRUTEAK_GYM, 5, 17); const pages = settle(game); expect(game.notice, game.diagnostic).toBe(""); expect(pages.join(" ")).toMatch(/is absent/i); expect(game.mapId).toBe(ECRUTEAK); expect(game.battle).toBeUndefined(); }); it("finds Burned Tower 1F's hidden items and HP Up, and cannot smash its rock yet", () => { const game = world("2026-09-22T12:00:00"); game.warpTo(BURNED_TOWER_1F, 9, 15); settle(game); // the Eusine scene (sdefer) runs on the first idle tick expect(game.notice, game.diagnostic).toBe(""); game.restore({ ...game.save(), x: 7, y: 7, direction: "right" }); game.interact(); finish(game); expect(game.state.bag.items).toContainEqual({ item: ETHER, quantity: 1 }); game.restore({ ...game.save(), x: 13, y: 12, direction: "up" }); game.interact(); finish(game); expect(game.state.bag.balls).toContainEqual({ item: ULTRA_BALL, quantity: 1 }); game.restore({ ...game.save(), x: 14, y: 3, direction: "up" }); game.interact(); finish(game); expect(game.state.bag.items).toContainEqual({ item: HP_UP, quantity: 1 }); game.restore({ ...game.save(), x: 15, y: 5, direction: "up" }); game.interact(); expect(finish(game)).toMatch(/Maybe a.*POKéMON.*break/i); }); it("asks for Strength at Burned Tower B1F's boulder, which is not obtainable yet", () => { // The TM Endure behind it stays unreachable until a much later card // gives Strength; AskStrengthScript (jumpstd StrengthBoulderScript) // should still answer sensibly rather than refusing outright. const game = world("2026-09-22T12:00:00"); game.warpTo(BURNED_TOWER_B1F, 17, 9); settle(game); game.restore({ ...game.save(), x: 17, y: 9, direction: "up" }); game.interact(); expect(finish(game)).toMatch(/may be able to move this/i); }); it("opens Ecruteak Gym's guide and statue text once Morty is reachable, then again once he is beaten", () => { // BurnedTowerB1F's ReleaseTheBeasts sets SCENE_ECRUTEAKGYM_NOOP; drive // that directly, as the playthrough's own roaming-beast stage (out of // scope for this focused file) otherwise would. const game = world("2026-09-22T12:00:00"); game.warpTo(ECRUTEAK_GYM, 5, 17); game.state.scenes[ECRUTEAK_GYM] = SCENE_ECRUTEAKGYM_NOOP; settle(game); expect(game.mapId).toBe(ECRUTEAK_GYM); game.restore({ ...game.save(), x: 7, y: 16, direction: "up" }); // the Gym Guide game.interact(); let text = finish(game); expect(text).toMatch(/secret mo-?\s*tives/i); game.restore({ ...game.save(), x: 3, y: 16, direction: "up" }); // the left statue game.interact(); text = finish(game); expect(text).toMatch(/#MON GYM|POKéMON GYM/); expect(text).not.toMatch(/WINNING TRAINERS/); // EcruteakGymGuideScript checks EVENT_BEAT_MORTY; EcruteakGymStatue // checks ENGINE_FOGBADGE instead. Beating Morty sets both together, // but this isolated test drives each script without a real battle. game.state.events.add(EVENT_BEAT_MORTY); game.state.flags.add(ENGINE_FOGBADGE); game.restore({ ...game.save(), x: 7, y: 16, direction: "up" }); game.interact(); text = finish(game); expect(text).toMatch(/cowering in/i); game.restore({ ...game.save(), x: 6, y: 16, direction: "up" }); // the right statue game.interact(); text = finish(game); expect(text).toMatch(/LEADER:\s*MORTY/i); expect(text).toMatch(/WINNING TRAINERS/); }); it("shows Ecruteak City's beasts-released text once they are released", () => { const before = world("2026-09-22T12:00:00"); talk(before, ECRUTEAK, 3, 9); // EcruteakCityLass2 (the old tower) expect(finish(before)).toMatch(/tower that.*used to be here/i); const after = world("2026-09-22T12:00:00"); after.state.events.add(EVENT_RELEASED_THE_BEASTS); talk(after, ECRUTEAK, 3, 9); expect(finish(after)).toMatch(/ran off in differ-?\s*ent directions/i); }); });