import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld, migrateSave } from "../src/game/world/world"; import { emptyScriptState } from "../src/game/scripts/interpreter"; import { generatePartyMon } from "../src/game/party/pokemon"; import type { Direction } from "../src/game/world/types"; import { settle, findPath } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; // Item IDs read from constants/item_constants.asm, not reference-checked // here (only ROM/RAM addresses in profile.ts are). const SUN_STONE = 169, EVERSTONE = 112, BERRY = 173, GOLD_BERRY = 174; // CATERPIE and WEEDLE (constants/pokemon_constants.asm): ContestMons' // first two entries, each 20%. const CATERPIE = 10, WEEDLE = 13; const ROUTE35_GATE = "10:15", ROUTE36_GATE = "10:17", NATIONAL_PARK = "3:15", CONTEST_PARK = "3:16"; // Date#getDay() matches the ROM's SUNDAY..SATURDAY (0-6) directly, as the // rest of the native clock already assumes (world.ts's weekday math). const CONTEST_DAY = "2026-09-22T12:00:00"; // Tuesday const OFF_DAY = "2026-09-25T12:00:00"; // Friday // A COLL_LONG_GRASS patch reachable from the Route 36 gate's warp-in // point (33, 18); the Route 35 gate's own (10, 47) lands in a separate, // grass-less walkable region of this map. const GRASS = { x: 31, y: 17 }; const GRASS2 = { x: 30, y: 17 }; function fixture(options: { day?: string; party?: number[] } = {}) { const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); let now = new Date(options.day ?? CONTEST_DAY); let seed = 7; const forced: number[] = []; const random = () => { if (forced.length) return forced.shift()!; seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }; const world = new GameWorld(assets, () => now, random); const species = options.party ?? [155]; // Cyndaquil: strong enough that nothing faints. world.state.party = species.map((sp) => generatePartyMon(assets.pokemon, { species: sp, level: 60, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0, }), ); world.state.money = 3000; return { world, forced, advance: (ms: number) => { now = new Date(now.getTime() + ms); }, }; } /** Teleport straight into a gate or the park, bypassing Route 35/36/37 * (opened separately; see the task brief). */ function enterAt( world: GameWorld, map: string, x: number, y: number, direction: Direction = "down", ) { world.restore({ ...world.save(), map, x, y, direction, objects: [] }); } /** Walk to a tile, stopping early if a step starts a battle (findPath's * BFS keeps walking around the contest's roaming NPCs). */ function walkToOrBattle(world: GameWorld, x: number, y: number) { for (const direction of findPath(world, x, y)) { if (world.battle) return; if (!world.move(direction)) throw new Error(`Blocked moving ${direction}.`); for (let tick = 0; tick < 40 && world.busy && !world.battle; tick++) world.tick(); if (world.battle) return; } } /** Drive a Bug-Catching Contest battle: PARKBALL× on every action prompt, * as the contest's own battle menu requires. */ function driveContestBattle(world: GameWorld, switchIn = true) { for (let tick = 0; tick < 20_000; tick++) { const view = world.battle; if (!view) return; const prompt = view.prompt; if (prompt?.kind === "action") world.battleAction({ action: "item", item: 0, slot: 0 }); else if (prompt?.kind === "yesno") world.battleChoose(1); else if (prompt?.kind === "contestswitch") world.battleChoose(switchIn ? 1 : 0); else if (prompt?.kind === "name") world.battleName(""); else if (prompt?.kind === "stats" || view.message?.prompt) world.interact(); else world.tick(); } throw new Error("The contest battle did not settle."); } /** Registers at the Route 35 gate (a one-Pokémon party skips the "leave * the rest behind" question) and lands in the contest map. */ function register(world: GameWorld) { enterAt(world, ROUTE35_GATE, 2, 2, "up"); world.interact(); settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.mapId).toBe(CONTEST_PARK); } describe.skipIf(!existsSync(romPath))("the Bug-Catching Contest", () => { it("the gate's officer offers the contest on Tuesday/Thursday/Saturday", () => { const { world } = fixture({ day: CONTEST_DAY }); register(world); expect(world.state.bugContest).toEqual({ parkBalls: 20, mon: null }); expect(world.state.bugContestEnd).toBeGreaterThan(Date.parse(CONTEST_DAY)); expect(world.state.flags.has(17)).toBe(true); // ENGINE_BUG_CONTEST_TIMER expect(world.x).toBe(10); expect(world.y).toBe(47); }); it("an off day only explains the contest, without starting one", () => { const { world } = fixture({ day: OFF_DAY }); enterAt(world, ROUTE35_GATE, 0, 2, "down"); world.interact(); const shown = settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/PARK/); expect(world.state.bugContest).toBeUndefined(); expect(world.mapId).toBe(ROUTE35_GATE); }); it("leaves the rest of the party with the contest helper when it has more than one Pokémon", () => { const { world } = fixture({ party: [155, 16] }); // Cyndaquil, Pidgey enterAt(world, ROUTE35_GATE, 2, 2, "up"); world.interact(); settle(world); // "Would you like to try?" / "use the first Pokémon?" / promptbuttons expect(world.notice, world.diagnostic).toBe(""); expect(world.state.party).toHaveLength(1); expect(world.state.party[0].species).toBe(155); expect(world.mapId).toBe(CONTEST_PARK); }); it("refuses to register with a fainted or an Egg first Pokémon", () => { { const { world } = fixture({ party: [155, 16] }); world.state.party[0].hp = 0; enterAt(world, ROUTE35_GATE, 2, 2, "up"); world.interact(); const shown = settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/can't battle/); expect(world.state.bugContest).toBeUndefined(); } { const { world } = fixture({ party: [155, 16] }); world.state.party[0].egg = true; enterAt(world, ROUTE35_GATE, 2, 2, "up"); world.interact(); const shown = settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/EGG/); expect(world.state.bugContest).toBeUndefined(); } }); it("catches a bug, catches a second and swaps, then quits early for judging and a prize", () => { const { world, forced } = fixture({ day: CONTEST_DAY }); // The Route 36 gate's own registration lands at (33, 18), which (unlike // the Route 35 gate's (10, 47)) can reach this map's grass. enterAt(world, ROUTE36_GATE, 0, 4, "up"); world.interact(); settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.mapId).toBe(CONTEST_PARK); // SetUpFiveStepWildEncounterCooldown: walk off the five-step cooldown // from entering the map on safe (non-grass) ground before approaching it. walkToOrBattle(world, 28, 18); expect(world.battle).toBeUndefined(); // Force the first encounter (long grass: 40% of 255) onto Caterpie // (ContestMons' first 20% entry) at its minimum level. forced.push(0, 0, 0); walkToOrBattle(world, GRASS.x, GRASS.y); expect(world.battle).toBeDefined(); driveContestBattle(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.bugContest?.mon?.species).toBe(CATERPIE); expect(world.state.bugContest?.parkBalls).toBeLessThan(20); const ballsAfterFirst = world.state.bugContest!.parkBalls; // A second catch (the next ContestMons entry, Weedle) offers a swap. forced.push(0, 50, 0); walkToOrBattle(world, GRASS2.x, GRASS2.y); if (!world.battle) walkToOrBattle(world, GRASS.x, GRASS.y); expect(world.battle).toBeDefined(); driveContestBattle(world, true); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.bugContest?.mon?.species).toBe(WEEDLE); expect(world.state.bugContest!.parkBalls).toBeLessThan(ballsAfterFirst); // QUIT replaces SAVE in the start menu, and PACK is not offered. world.openStartMenu(); const menu = world.dialogue!.choice as { kind: "menu"; items: string[] }; expect(menu.kind).toBe("menu"); expect(menu.items.some((label) => /PACK/.test(label))).toBe(false); const quitIndex = menu.items.findIndex((label) => /QUIT/.test(label)); expect(quitIndex).toBeGreaterThanOrEqual(0); world.choose(quitIndex); expect(world.dialogue?.choice?.kind).toBe("yesno"); world.choose(1); // "Do you want to finish now?" // BugContestResultsScript: the ready-to-judge text, third/second/first // place announcements, the player's own prize (or consolation Berry), // the returned party (none left behind here) and the nickname offer. const shown = settle(world, [1, ""]); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/Weedle|WEEDLE/i); expect(world.state.bugContest).toBeUndefined(); expect(world.state.bugContestEnd).toBeUndefined(); expect(world.state.flags.has(17)).toBe(false); expect(world.mapId).toBe(ROUTE36_GATE); // The judged Weedle joined the party (which had room) with the // National Park as its caught location. const caught = world.state.party.at(-1)!; expect(caught.species).toBe(WEEDLE); expect(caught.caughtData & 0x7f).toBe(0x13); // LANDMARK_NATIONAL_PARK }); it("ends the contest and prints the judging when the twenty minutes run out", () => { const { world, advance } = fixture({ day: CONTEST_DAY }); register(world); advance(21 * 60_000); const shown = settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/time|Time/); expect(world.state.bugContest).toBeUndefined(); expect(world.mapId).toBe(ROUTE36_GATE); }); it("awards first place and the Sun Stone to an outstanding catch", () => { const { world } = fixture({ day: CONTEST_DAY }); register(world); // A maximum-level, maximum-stat catch outscores every AI contestant. world.state.bugContest = { parkBalls: 15, mon: generatePartyMon(world.assets.pokemon, { species: 127, // Pinsir: a real ContestMons rare entry, high stats. level: 100, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0, }), }; const before = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0; world.openStartMenu(); const menu = world.dialogue!.choice as { kind: "menu"; items: string[] }; world.choose(menu.items.findIndex((label) => /QUIT/.test(label))); world.choose(1); const shown = settle(world, [1, ""]); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/winner/i); const after = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0; expect(after).toBe(before + 1); }); it("boxes the catch when the party is full, with the National Park landmark", () => { const { world } = fixture({ day: CONTEST_DAY, party: [155, 16, 19, 1, 4, 7] }); // Registering with more than one Pokémon leaves the other five with // the helper (ContestDropOffMons); ContestReturnMons restores them // during judging, which is where the party is actually full again. register(world); expect(world.state.party).toHaveLength(1); expect(world.state.contestLeftParty).toHaveLength(5); world.state.bugContest = { parkBalls: 10, mon: generatePartyMon(world.assets.pokemon, { species: CATERPIE, level: 7, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0, caughtData: 0, }), }; world.openStartMenu(); const menu = world.dialogue!.choice as { kind: "menu"; items: string[] }; world.choose(menu.items.findIndex((label) => /QUIT/.test(label))); world.choose(1); const shown = settle(world, [1, ""]); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.party).toHaveLength(6); // unchanged; went to the box const box = world.state.storage.boxes[world.state.storage.current]; expect(box[0]?.species).toBe(CATERPIE); expect(box[0]?.caughtData & 0x7f).toBe(0x13); expect(shown.join("\n")).toMatch(/PC|BOX/i); }); it("saves and restores mid-contest, keeping the Park Balls, caught mon and deadline", () => { const { world } = fixture({ day: CONTEST_DAY }); register(world); world.state.bugContest = { parkBalls: 12, mon: generatePartyMon(world.assets.pokemon, { species: WEEDLE, level: 9, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x1234, caughtData: 0, }), }; const saved = world.save(); expect(saved.bugContest).toEqual({ parkBalls: 12, mon: world.state.bugContest.mon }); expect(saved.bugContestEnd).toBe(world.state.bugContestEnd); const assets2 = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const reloaded = new GameWorld(assets2, () => new Date(CONTEST_DAY), () => 0); reloaded.restore(saved); expect(reloaded.notice, reloaded.diagnostic).toBe(""); expect(reloaded.state.bugContest).toEqual(world.state.bugContest); expect(reloaded.state.bugContestEnd).toBe(world.state.bugContestEnd); // The deadline survives the reload and still ends the contest on time. const laterSave = { ...reloaded.save() }; const assets3 = new CrystalAssets(new Uint8Array(readFileSync(romPath))); let later = new Date(Date.parse(CONTEST_DAY) + 21 * 60_000); const reloaded2 = new GameWorld(assets3, () => later, () => 0); reloaded2.restore(laterSave); settle(reloaded2, [1, ""]); // accept the judged Weedle's nickname offer expect(reloaded2.notice, reloaded2.diagnostic).toBe(""); expect(reloaded2.state.bugContest).toBeUndefined(); }); it("migrates an older save with no contest fields into no contest running", () => { const { world } = fixture(); const save = world.save() as Record; delete save.bugContest; delete save.bugContestEnd; const migrated = migrateSave(save, () => 1); expect(migrated).toBeDefined(); expect(migrated!.bugContest).toBeUndefined(); expect(migrated!.bugContestEnd).toBeUndefined(); world.restore(migrated); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.bugContest).toBeUndefined(); }); it("the National Park's own NPCs and items work outside the contest", () => { const { world } = fixture({ day: OFF_DAY }); enterAt(world, NATIONAL_PARK, 27, 41, "up"); // facing Teacher1 at (27,40) world.interact(); const shown = settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/QUICK CLAW/); const item = world.assets.pokemon.itemName(73); // QUICK_CLAW expect(world.state.bag.items.some((slot) => world.assets.pokemon.itemName(slot.item) === item)).toBe(true); }); });