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 { generatePartyMon } from "../src/game/party/pokemon"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("Blackthorn City with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const MOVE_DELETERS_HOUSE = "5:7", DRAGON_SHRINE = "3:82"; const TYPHLOSION = 157, DRATINI = 147, SCENE_DRAGONSHRINE_NOOP = 1; const WRAP = 35, LEER = 43, THUNDER_WAVE = 86, TWISTER = 239, EXTREMESPEED = 245; const EVENT_GOT_DRATINI = 0xbd, EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1; const world = () => { let seed = 13; return new GameWorld( new CrystalAssets(bytes), () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); }; const mon = (game: GameWorld, species: number, level = 30) => generatePartyMon(game.assets.pokemon, { species, level, item: 0, otId: game.playerId, ot: "KAWA", dvs: 0x8888, caughtData: 0, }); /** Enter `map`, stand at (x, y) facing `face`, press A and answer. */ const talk = ( game: GameWorld, map: string, x: number, y: number, face: "up" | "down" | "left" | "right", answers: (number | string)[] = [], ) => { game.warpTo(map, x, y); settle(game); game.restore({ ...game.save(), x, y, direction: face }); game.interact(); const shown = settle(game, answers).join(" "); expect(game.notice, game.diagnostic).toBe(""); return shown; }; /** The Move Deleter stands at (2, 3). */ const deleter = (game: GameWorld, answers: number[]) => talk(game, MOVE_DELETERS_HOUSE, 2, 4, "up", answers); describe("the Move Deleter (MoveDeletion)", () => { it("deletes the chosen move and moves the later moves and PP up", () => { const game = world(); const typhlosion = mon(game, TYPHLOSION); typhlosion.moves = [33, 43, 108, 52]; typhlosion.pp = [35, 30 | 0xc0, 20, 25 | 0x40]; game.state.party = [typhlosion]; // YES, the first Pokémon, its second move (LEER), YES. const shown = deleter(game, [1, 0, 1, 1]); expect(shown).toContain("the MOVE DELETER."); expect(shown).toContain("Oh, make it forget\nLEER?"); expect(shown).toContain("forgot the move."); // The PP Up bits move with each move's PP. expect(game.state.party[0].moves).toEqual([33, 108, 52, 0]); expect(game.state.party[0].pp).toEqual([35, 20, 25 | 0x40, 0]); }); it("refuses a Pokémon with one move and an Egg, and says goodbye to every no", () => { const game = world(); const one = mon(game, TYPHLOSION); one.moves = [33, 0, 0, 0]; one.pp = [35, 0, 0, 0]; const egg = { ...mon(game, TYPHLOSION, 5), egg: true }; game.state.party = [one, egg, mon(game, TYPHLOSION)]; expect(deleter(game, [1, 0])).toContain("knows\nonly one move."); expect(deleter(game, [1, 1])).toContain("An EGG doesn't"); const before = structuredClone(game.state.party[2]); expect(deleter(game, [0])).toContain("Come visit me"); expect(deleter(game, [1, -1])).toContain("Come visit me"); expect(deleter(game, [1, 2, -1])).toContain("Come visit me"); expect(deleter(game, [1, 2, 0, 0])).toContain("Come visit me"); expect(game.state.party[2]).toEqual(before); }); }); describe("the Dragon Shrine elder's Dratini (GiveDratini)", () => { /** After the test (the shrine's scene done), talk to the elder. */ const elder = (game: GameWorld) => { game.state.scenes[DRAGON_SHRINE] = SCENE_DRAGONSHRINE_NOOP; return talk(game, DRAGON_SHRINE, 5, 2, "up", [0]); }; it("gives a Dratini with Extremespeed after a test without mistakes", () => { const game = world(); game.state.party = [mon(game, TYPHLOSION)]; const shown = elder(game); expect(shown).toContain("Take this DRATINI"); expect(game.state.events.has(EVENT_GOT_DRATINI)).toBe(true); const dratini = game.state.party[1]; expect(dratini).toMatchObject({ species: DRATINI, level: 15 }); expect(dratini.moves).toEqual([WRAP, THUNDER_WAVE, TWISTER, EXTREMESPEED]); // Each move's base PP, as .GiveMoves reads it from Moves. expect(dratini.pp).toEqual([20, 20, 20, 5]); // Re-entering clears EVENT_TEMPORARY_UNTIL_MAP_RELOAD_7. expect(elder(game)).toContain("CLAIR appears to"); }); it("gives the normal moves after a mistake, to the last Dratini only", () => { const game = world(); game.state.events.add(EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG); const earlier = mon(game, DRATINI, 20); game.state.party = [earlier, mon(game, TYPHLOSION)]; const before = structuredClone(earlier); elder(game); expect(game.state.party[2].moves).toEqual([WRAP, LEER, THUNDER_WAVE, TWISTER]); expect(game.state.party[2].pp).toEqual([20, 30, 20, 20]); expect(game.state.party[0]).toEqual(before); }); it("keeps the Dratini for later when the party is full", () => { const game = world(); game.state.party = Array.from({ length: 6 }, () => mon(game, TYPHLOSION)); expect(elder(game)).toContain("party is full."); expect(game.state.events.has(EVENT_GOT_DRATINI)).toBe(false); game.state.party.pop(); elder(game); expect(game.state.party[5].species).toBe(DRATINI); }); }); });