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 { close, 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", 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; 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.", ]); }); });