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 { DecorationData } from "../src/game/world/decorations"; import { generatePartyMon } from "../src/game/party/pokemon"; import { PLAYER, ScriptRun, emptyScriptState, type ScriptHost, } from "../src/game/scripts/interpreter"; import { StartMenuRun } from "../src/game/scripts/start-menu"; import { defaultTextContext } from "../src/game/scripts/text"; import { OVERWORLD } from "../src/game/rom/profile"; import { fish } from "../src/game/scripts/fish"; import { Battle, magikarpLength } from "../src/game/battle/battle"; import { close, open, pick, pickItem, useItem } from "./menu-driver"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const PLAYERS_ROOM = "24:7", NEW_BARK_TOWN = "24:4", ROUTE_30 = "26:1", RADIO_TOWER_3F = "3:19", GOLDENROD_UNDERGROUND = "3:53", ROUTE_36 = "10:3"; const CYNDAQUIL = 155, PIDGEY = 16; const POTION = 0x12, REPEL = 0x14, SUPER_REPEL = 0x2a, MAX_REPEL = 0x2b, COIN_CASE = 0x36, BLUE_CARD = 0x74, SACRED_ASH = 0x9c, NORMAL_BOX = 0xa7, GORGEOUS_BOX = 0xa8, ITEMFINDER = 0x37, CARD_KEY = 0x7f, BASEMENT_KEY = 0x85, SQUIRTBOTTLE = 0xaf, OLD_ROD = 0x3a, GOOD_ROD = 0x3b, SUPER_ROD = 0x3d; const MAGIKARP = 129, KRABBY = 98, CORSOLA = 222, STARYU = 120; const FISHGROUP_SHORE = 1; const FACING_FISH_RIGHT = 0x13; const SPRITEMOVEDATA_SUDOWOODO = 0x17; const OAK_WARNING = "OAK: CHRIS! This isn't the time to use that!"; describe.skipIf(!existsSync(romPath))("field items", () => { /** The player's room, where every floor tile is grass for a test-only * table of level-`wildLevel` Pidgey met on every step past the cooldown. */ function fixture(wildLevel = 3, map = PLAYERS_ROOM, x = 3, y = 3) { const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); assets.grassCollisions = () => [ assets.collision(assets.map(PLAYERS_ROOM), 3, 2), ]; assets.grassWildMons = (id) => id === PLAYERS_ROOM ? { rates: [256, 256, 256], slots: [0, 1, 2].map(() => Array.from({ length: 7 }, () => ({ species: PIDGEY, level: wildLevel, })), ), } : undefined; let seed = 1; const world = new GameWorld( assets, () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); const mon = (level: number) => generatePartyMon(assets.pokemon, { species: CYNDAQUIL, level, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); world.state.party = [mon(5), mon(8)]; world.state.bag.items = [{ item: POTION, quantity: 1 }]; world.restore({ ...world.save(), map, x, y, objects: [] }); return world; } /** Step left and right until a wild battle or a page appears, at most * `limit` steps; returns the number of steps taken. */ function walk(world: GameWorld, limit: number) { for (let step = 1; step <= limit; step++) { expect(world.move(step & 1 ? "left" : "right")).toBe(true); if (world.battle || world.dialogue) return step; } return limit; } it("keeps weaker wild Pokémon away while a Repel lasts", () => { const world = fixture(); world.state.bag.items.push({ item: REPEL, quantity: 2 }); expect(useItem(world, REPEL)).toEqual(["CHRIS used the REPEL."]); // The pack stays open (ITEMMENU_CURRENT); a second one is refused. expect(world.dialogue?.choice?.kind).toBe("pack"); world.advanceDialogue(); close(world); expect(world.state.repel).toBe(100); expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 }); expect(useItem(world, REPEL)).toEqual([ "The REPEL used earlier is still in effect.", ]); close(world); expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 }); // The Level 3 Pidgey never reaches the Level 5 lead. expect(walk(world, 20)).toBe(20); expect(world.battle).toBeUndefined(); expect(world.state.repel).toBe(80); // A fainted lead hands the check to the next Pokémon (Level 8). world.state.party[0].hp = 0; expect(walk(world, 10)).toBe(10); expect(world.battle).toBeUndefined(); }); it("lets wild Pokémon of the lead's level through", () => { const world = fixture(5); world.state.repel = 100; expect(walk(world, 10)).toBe(5); expect(world.battle).toBeDefined(); // The step that met the Pokémon replays after a reload, with its // Repel step: the save made during the battle has the earlier count. expect(world.save().repel).toBe(96); expect(world.state.repel).toBe(95); }); it("wears off on its last step, which meets no Pokémon", () => { const world = fixture(); world.state.repel = 6; // Five steps pass the cooldown under the Repel. expect(walk(world, 5)).toBe(5); expect(world.battle).toBeUndefined(); expect(world.move("left")).toBe(true); expect(world.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]); expect(world.battle).toBeUndefined(); expect(world.state.repel).toBe(0); // A save during the message replays the Repel's last step. const saved = world.save(); expect([saved.repel, saved.x]).toEqual([1, 2]); settle(world); expect(walk(world, 1)).toBe(1); expect(world.battle).toBeDefined(); const replayed = fixture(); replayed.restore(saved); expect(replayed.move("left")).toBe(true); expect(replayed.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]); }); it("gives Super and Max Repels 200 and 250 steps", () => { for (const [item, steps, name] of [ [SUPER_REPEL, 200, "SUPER REPEL"], [MAX_REPEL, 250, "MAX REPEL"], ] as const) { const world = fixture(); world.state.bag.items.push({ item, quantity: 1 }); expect(useItem(world, item)).toEqual([`CHRIS used the ${name}.`]); close(world); expect(world.state.repel).toBe(steps); expect(world.state.bag.items.some((slot) => slot.item === item)).toBe( false, ); } }); it("shows the Coin Case's coins and the Blue Card's points", () => { const world = fixture(); world.state.bag.keyItems = [COIN_CASE, BLUE_CARD]; world.state.coins = 1234; world.state.blueCardBalance = 7; expect(useItem(world, COIN_CASE)).toEqual(["Coins: 1234"]); world.advanceDialogue(); close(world); expect(useItem(world, BLUE_CARD)).toEqual(["You now have 7 points."]); close(world); expect(world.state.bag.keyItems).toEqual([COIN_CASE, BLUE_CARD]); }); it("revives the whole party with Sacred Ash, only after a faint", () => { const world = fixture(); world.state.bag.items.push({ item: SACRED_ASH, quantity: 2 }); // CheckAnyFaintedMon: nobody has fainted, so Oak objects. expect(useItem(world, SACRED_ASH)).toEqual([OAK_WARNING]); close(world); expect(world.state.bag.items.at(-1)).toEqual({ item: SACRED_ASH, quantity: 2, }); const [first, second] = world.state.party; first.hp = 0; first.status = 0; second.hp = 1; second.status = 0x08; second.pp[0] = 0; // The menu closes and SacredAshScript runs. expect(useItem(world, SACRED_ASH)).toEqual([ "CHRIS's POKéMON were all healed!", ]); close(world); for (const mon of world.state.party) { expect([mon.hp, mon.status]).toEqual([mon.stats[0], 0]); expect(mon.pp[0]).toBeGreaterThan(0); } expect(world.state.bag.items.at(-1)).toEqual({ item: SACRED_ASH, quantity: 1, }); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); }); it("sends the trophy in a Normal or Gorgeous Box home", () => { for (const [item, trophy] of [ [NORMAL_BOX, "SILVER TROPHY"], [GORGEOUS_BOX, "GOLD TROPHY"], ] as const) { const world = fixture(); world.state.bag.items.push({ item, quantity: 1 }); const before = new Set(world.state.events); expect(useItem(world, item)).toEqual([ "There was a trophy inside!", "CHRIS sent the trophy home.", ]); close(world); const added = [...world.state.events].filter((id) => !before.has(id)); const decorations = new DecorationData( world.assets.reader, world.assets.pokemon, ); const owned = Array.from({ length: 52 }, (_, i) => i + 1).filter((id) => added.includes(decorations.attribute(id).event), ); expect(owned.map((id) => decorations.name(id))).toEqual([trophy]); expect(world.state.bag.items.some((slot) => slot.item === item)).toBe( false, ); } }); it("saves the Repel, coins and points, and migrates saves without them", () => { const world = fixture(); Object.assign(world.state, { repel: 42, coins: 9999, blueCardBalance: 30 }); const saved = world.save(); expect(saved).toMatchObject({ repel: 42, coins: 9999, blueCardBalance: 30, }); world.restore(saved); expect(world.save()).toEqual(saved); const { repel: _, coins: __, blueCardBalance: ___, ...legacy } = saved; expect(migrateSave(legacy, () => 0)).toMatchObject({ repel: 0, coins: 0, blueCardBalance: 0, }); for (const invalid of [ { repel: 256 }, { coins: 10000 }, { blueCardBalance: -1 }, ]) expect(() => world.restore({ ...saved, ...invalid })).toThrow( "Invalid native save", ); }); it("finds hidden items on the screen with the Itemfinder", () => { // Route 30's hidden Potion at 14, 9 is seen from 9-18 across and 5-13 // down: four squares left of and above the player, five right of it // and four below. const nearby = "Yes! ITEMFINDER indicates there's an item nearby.", nope = "Nope! ITEMFINDER isn't responding."; for (const [x, y, pages] of [ [18, 9, [nearby]], [19, 9, [nope]], [9, 6, [nearby]], [7, 6, [nope]], [12, 5, [nearby]], [12, 4, [nope]], [12, 13, [nearby]], [12, 14, [nope]], ] as const) { const world = fixture(3, ROUTE_30, x, y); world.state.bag.keyItems = [ITEMFINDER]; expect(useItem(world, ITEMFINDER), `${x}, ${y}`).toEqual(pages); close(world); expect(world.state.bag.keyItems).toEqual([ITEMFINDER]); } // Once found, the item no longer answers. const world = fixture(3, ROUTE_30, 14, 10); world.state.bag.keyItems = [ITEMFINDER]; world.state.events.add( world.assets.reader.word( world.map.backgroundEvents.find((event) => event.kind === 7)!.script, ), ); expect(useItem(world, ITEMFINDER)).toEqual([nope]); close(world); }); it("uses the keys only at their doors", () => { // Away from the Radio Tower and the Underground, Oak objects. const world = fixture(); world.state.bag.keyItems = [CARD_KEY, BASEMENT_KEY]; for (const key of [CARD_KEY, BASEMENT_KEY]) { expect(useItem(world, key)).toEqual([OAK_WARNING]); close(world); } expect(world.state.bag.keyItems).toEqual([CARD_KEY, BASEMENT_KEY]); // Those maps are not open yet: a menu run with their positions. const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const use = ( item: number, mapId: string, facing: number, position: { x: number; y: number }, ) => { const state = emptyScriptState(); state.bag.keyItems = [item]; const run = new StartMenuRun(state, { reader: assets.reader, data: assets.pokemon, text: () => defaultTextContext("CHRIS"), scriptHost: () => ({ mapId, facing: () => facing, position: () => position, }) as ScriptHost, } as unknown as ConstructorParameters[1]); let effect = run.resume(); // PACK, the key, USE. const menu = (label: string) => { if (effect?.kind !== "menu") throw new Error(`Expected a menu, got ${effect?.kind}`); effect = run.resume(effect.items.indexOf(label)); }; menu("PACK"); effect = run.resume((2 << 8) | 0); menu("USE"); return effect?.kind === "text" ? effect.pages.map((page) => page.replace(/\n/g, " ")) : run.deferred; }; const UP = 1, DOWN = 0, LEFT = 2; // _CardKey: facing up at the slot, 18, 6 with the map's border. expect(use(CARD_KEY, RADIO_TOWER_3F, UP, { x: 14, y: 3 })).toBe( OVERWORLD["_CardKey.CardKeyScript"], ); expect(use(CARD_KEY, RADIO_TOWER_3F, LEFT, { x: 15, y: 2 })).toEqual([ OAK_WARNING, ]); expect(use(CARD_KEY, ROUTE_36, UP, { x: 14, y: 3 })).toEqual([OAK_WARNING]); // _BasementKey: facing the door at 22, 10 from any side. expect( use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, DOWN, { x: 18, y: 5 }), ).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]); expect( use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, LEFT, { x: 19, y: 6 }), ).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]); expect( use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, UP, { x: 18, y: 5 }), ).toEqual([OAK_WARNING]); }); it("sprinkles water with the Squirtbottle, which only Sudowoodo minds", () => { const world = fixture(); world.state.bag.keyItems = [SQUIRTBOTTLE]; expect(useItem(world, SQUIRTBOTTLE)).toEqual([ "CHRIS sprinkled water.", "But nothing happened…", ]); close(world); expect(world.state.bag.keyItems).toEqual([SQUIRTBOTTLE]); // Route 36 is not open yet: the script against a host facing Sudowoodo. const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const facing = { id: 5, movement: SPRITEMOVEDATA_SUDOWOODO }; const host = { mapId: ROUTE_36, lastTalked: PLAYER, text: () => defaultTextContext("CHRIS"), data: assets.pokemon, facingObject: () => facing, sceneOwner: () => undefined, } as unknown as ScriptHost; const run = new ScriptRun( assets.reader, OVERWORLD["_Squirtbottle.SquirtbottleScript"], emptyScriptState(), host, {}, ); const first = run.resume(); expect(first?.kind === "text" && first.pages).toEqual([ "CHRIS used the\nSQUIRTBOTTLE.", ]); }); it("fishes from FishGroups by rod and time of day", () => { const r = new CrystalAssets(new Uint8Array(readFileSync(romPath))).reader; const cast = (rod: number, time: number, ...draws: number[]) => fish(r, FISHGROUP_SHORE, rod, time, () => { const draw = draws.shift(); if (draw === undefined) throw new Error("No draw left."); return draw; }); // A bite below 50 percent + 1 ($80), then the first entry the roll // does not exceed: 70 percent + 1, 85 percent + 1, 100 percent. expect(cast(0, 1, 127, 0)).toEqual({ species: MAGIKARP, level: 10 }); expect(cast(0, 1, 128)).toBeUndefined(); expect(cast(0, 1, 0, 179)).toEqual({ species: MAGIKARP, level: 10 }); expect(cast(0, 1, 0, 255)).toEqual({ species: KRABBY, level: 10 }); // The Good Rod's last entry is TimeFishGroups 0: Corsola by day, // Staryu from the night on. expect(cast(1, 0, 0, 255)).toEqual({ species: CORSOLA, level: 20 }); expect(cast(1, 1, 0, 255)).toEqual({ species: CORSOLA, level: 20 }); expect(cast(1, 2, 0, 255)).toEqual({ species: STARYU, level: 20 }); expect(cast(2, 1, 0, 0)).toEqual({ species: KRABBY, level: 40 }); }); it("draws a wild Magikarp's DVs again below four feet, except at the Lake of Rage", () => { const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const id = 0x1234; const feet = (dvs: number) => magikarpLength(assets.reader, dvs, id).feet; // Nonzero, unlike the draws that follow. const short = [...Array(0x10000).keys()].find( (dvs) => dvs > 0x100 && feet(dvs) < 4, )!, long = [...Array(0x10000).keys()].find( (dvs) => dvs > 0x100 && feet(dvs) >= 4, )!; /** The wild Magikarp's DVs for these Random draws (then zeros). */ const dvs = (mapId: string, ...draws: number[]) => { const game = new Battle({ data: assets.pokemon, party: [ generatePartyMon(assets.pokemon, { species: 155, level: 5, item: 0, otId: id, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }), ], playerId: id, playerName: "CHRIS", timeOfDay: 1, pokedex: { seen: new Set(), caught: new Set() }, random: () => draws.shift() ?? 0, wild: { species: MAGIKARP, level: 10 }, fish: true, mapId, text: () => defaultTextContext("CHRIS"), }); const first = game.run().next().value; expect(first && "text" in first && first.text).toBe( "The hooked\nMAGIKARP\nattacked!", ); return game.enemy.mon.dvs; }; const hi = (value: number) => value >> 8, lo = (value: number) => value & 0xff; // No item (0), the DVs, then 39 percent + 1 (100) skips the floor. expect(dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 99)).toBe(short); expect( dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 100, hi(long), lo(long), 100), ).toBe(long); // The Lake of Rage (9:6) skips it without a draw, and so does any map // in group 9 or numbered 6. for (const map of ["9:6", "9:1", "24:6"]) expect(dvs(map, 0, hi(short), lo(short), 255)).toBe(short); }); describe("fishing in New Bark Town", () => { /** Facing the sea, with a rod and a constant random byte. */ function shore(random: number, rod = OLD_ROD) { const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const world = new GameWorld( assets, () => new Date("2026-09-22T12:00:00"), () => random, ); world.state.party = [ generatePartyMon(assets.pokemon, { species: 155, level: 5, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }), ]; world.state.bag.keyItems = [rod]; world.state.scenes[NEW_BARK_TOWN] = 1; world.restore({ ...world.save(), map: NEW_BARK_TOWN, x: 17, y: 7, direction: "right", objects: [], }); return world; } /** USE from the pack, then tick until a page or a battle appears, * recording the player's facings and emotes meanwhile. */ function cast(world: GameWorld, rod = OLD_ROD) { open(world); pick(world, "PACK"); pickItem(world, rod); const items = world.dialogue?.choice?.kind === "menu" ? world.dialogue.choice.items : []; world.choose(items.indexOf("USE")); const facings = new Set(), emotes = new Set(); let ticks = 0; for (; ticks < 400 && !world.dialogue && !world.battle; ticks++) { world.tick(); facings.add(world.poses().find((pose) => pose.id === 0)?.facing); if (world.emote) emotes.add(world.emote.emote); } return { facings, emotes, ticks }; } it("hooks a Pokémon that attacks", () => { const world = shore(0); const { facings, emotes } = cast(world); // Cast, four bites with the "!" bubble (EMOTE_SHOCK), then the rod // is pulled in. expect(facings).toContain(FACING_FISH_RIGHT); expect([...emotes]).toEqual([0]); expect(world.dialogue?.pages).toEqual(["Oh!\nA bite!"]); // .Movement_RestoreRod hid the bubble before the text. expect(world.emote).toBeUndefined(); expect(world.poses().find((pose) => pose.id === 0)?.facing).toBe( FACING_FISH_RIGHT, ); world.advanceDialogue(); // PutTheRodAway, then the battle: BATTLETYPE_FISH. for (let tick = 0; tick < 100 && !world.battle?.message; tick++) world.tick(); expect( world.poses().find((pose) => pose.id === 0)?.facing, ).toBeUndefined(); expect(world.battle?.message?.text).toBe( "The hooked\nMAGIKARP\nattacked!", ); expect(world.diagnostic).toBe(""); }); it("gets no bite, or nothing at all where the map has no fish", () => { for (const group of [undefined, 0]) { const world = shore(200, SUPER_ROD); if (group !== undefined) world.map.fishGroup = group; const { facings, emotes } = cast(world, SUPER_ROD); expect(facings).toContain(FACING_FISH_RIGHT); expect(emotes.size).toBe(0); expect(world.dialogue?.pages).toEqual(["Not even a nibble!"]); expect(close(world)).toEqual(["Not even a nibble!"]); expect( world.poses().find((pose) => pose.id === 0)?.facing, ).toBeUndefined(); expect(world.battle).toBeUndefined(); expect(world.state.bag.keyItems).toEqual([SUPER_ROD]); } }); it("needs water ahead and no surfing, or Oak objects", () => { const turned = shore(0, GOOD_ROD); turned.restore({ ...turned.save(), direction: "left" }); expect(useItem(turned, GOOD_ROD)).toEqual([OAK_WARNING]); close(turned); const surfing = shore(0, GOOD_ROD); surfing.restore({ ...surfing.save(), x: 18, playerState: 4 }); expect(useItem(surfing, GOOD_ROD)).toEqual([OAK_WARNING]); close(surfing); }); }); });