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 { magikarpLength } from "../src/game/battle/battle"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("Mahogany and the Lake of Rage with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const MAGIKARP_HOUSE = "9:2", ROUTE_43 = "9:5", ROUTE_43_GATE = "9:4", LAKE_OF_RAGE = "9:6"; const MAGIKARP = 129, TYPHLOSION = 157, ELIXER = 0x41; const EVENT_CLEARED_ROCKET_HIDEOUT = 0x22, EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP = 0x38, EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP = 0x39, EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY = 0x3a, EVENT_ROUTE_43_GATE_ROCKETS = 0x6dc; 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, { dvs = 0x8888, otId = game.playerId, ot = "KAWA", egg = false } = {}, ) => Object.assign( generatePartyMon(game.assets.pokemon, { species, level: 20, item: 0, otId, ot, dvs, caughtData: 0, }), egg ? { egg } : {}, ); /** 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[] = [], ) => { game.warpTo(map, x, y); settle(game); // The guru spins in place; keep him where his script expects. game.restore({ ...game.save(), x, y, direction: face, objects: [] }); game.interact(); const shown = settle(game, answers).join(" "); expect(game.notice, game.diagnostic).toBe(""); return shown; }; /** The fishing guru stands at (2, 3); talk to him from below. */ const guru = (game: GameWorld, answers: number[] = []) => talk(game, MAGIKARP_HOUSE, 2, 4, "up", answers); /** The record sign outside his house: only its name until the Rocket * Hideout is cleared. */ const sign = (game: GameWorld) => talk(game, LAKE_OF_RAGE, 25, 32, "up"); /** DVs whose Magikarp, for this OT ID, measures longer or shorter than * the starting 3′6″ record. */ const dvsFor = (game: GameWorld, otId: number, longer: boolean) => { for (let dvs = 0; dvs < 0x10000; dvs += 0x0101) { const { feet, inches } = magikarpLength(game.assets.reader, dvs, otId); if (longer ? feet * 12 + inches > 42 : feet * 12 + inches < 42) return { dvs, length: `${feet}′${inches}″` }; } throw new Error("No such Magikarp."); }; it("measures a longer Magikarp as the new record, with its OT as the holder", () => { const game = world(); expect(sign(game)).not.toContain("RECORD"); game.state.events.add(EVENT_CLEARED_ROCKET_HIDEOUT); expect(sign(game)).toContain("3′6″ caught by\nRALPH"); game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP); const otId = 4321; const { dvs, length } = dvsFor(game, otId, true); game.state.party = [ mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs, otId, ot: "ZOE" }), ]; const shown = guru(game, [1]); expect(shown).toContain(`it measures\n${length}.`); expect(shown).toContain("Take this as a\nmemento!"); expect(game.state.bag.items).toContainEqual({ item: ELIXER, quantity: 1 }); // The holder is the Magikarp's own OT, not the player. expect(sign(game)).toContain(`${length} caught by\nZOE`); // The record is saved and survives a reload. const saved = JSON.parse(JSON.stringify(game.save())); expect(saved.magikarpRecord).toMatchObject({ holder: "ZOE" }); const reloaded = world(); reloaded.restore(saved); expect(sign(reloaded)).toContain(`${length} caught by\nZOE`); // The same length again is not longer. expect(guru(game, [1])).toContain("seen a bigger one"); }); it("turns away a shorter Magikarp, another Pokémon, a refusal and an Egg", () => { const game = world(); game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP); const { dvs } = dvsFor(game, game.playerId, false); game.state.party = [mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs })]; expect(guru(game, [1])).toContain("seen a bigger one"); expect(guru(game, [0])).toContain("That's not a\nMAGIKARP!"); expect(guru(game, [-1])).toContain("didn't\nget one good"); expect(game.save().magikarpRecord).toEqual({ feet: 3, inches: 6, holder: "RALPH" }); // FindPartyMonThatSpecies reads wPartySpecies, where an Egg is EGG: // the guru only repeats his request. game.state.party = [mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs, egg: true })]; expect(guru(game)).toContain("world's\nlargest MAGIKARP."); }); it("keeps the Elixer on standby when the pack is full", () => { const game = world(); game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP); game.state.events.add(EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP); const { dvs } = dvsFor(game, game.playerId, true); game.state.party = [mon(game, MAGIKARP, { dvs })]; // Twenty different items fill the Items pocket. game.state.bag.items = Array.from({ length: 20 }, (_, i) => ({ item: 0x10 + i, quantity: 1 })); guru(game, [0]); expect(game.state.events.has(EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY)).toBe(true); game.state.bag.items = []; expect(guru(game)).toContain("Take this as a\nmemento!"); expect(game.state.events.has(EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY)).toBe(false); }); it("charges the Route 43 toll on every pass until the Rocket Hideout is cleared", () => { const game = world(); game.state.party = [mon(game, TYPHLOSION)]; game.state.money = 2500; /** Walk into the gate from Route 43's south door, facing up. */ const pass = () => { game.warpTo(ROUTE_43, 17, 36); settle(game); game.restore({ ...game.save(), x: 17, y: 36, direction: "up", objects: [] }); expect(game.move("up")).toBe(true); const shown = settle(game).join(" "); expect(game.notice, game.diagnostic).toBe(""); expect(game.mapId).toBe(ROUTE_43_GATE); return shown; }; expect(pass()).toContain("Thank you very\nmuch!"); expect(game.state.money).toBe(1500); // Route 43's callback sets the gate's scene again on each visit (its // own setmapscene to scene-less Route 43 changes nothing). expect(pass()).toContain("The toll is ¥1000"); expect(game.state.money).toBe(500); // Short of the toll, the Rockets take what there is. expect(pass()).toContain("pay what you\nhave"); expect(game.state.money).toBe(0); game.state.events.add(EVENT_CLEARED_ROCKET_HIDEOUT); game.state.events.add(EVENT_ROUTE_43_GATE_ROCKETS); game.state.money = 1000; expect(pass()).not.toContain("toll"); expect(game.state.money).toBe(1000); }); });