import { describe, expect, it } from "vitest"; import { existsSync, mkdirSync, readFileSync, writeFileSync } 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 { addHallOfFameEntry } from "../src/game/party/hall-of-fame"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { CRYSTAL_ROM } from "../src/rom/validate"; import { encodeSave } from "../src/storage/saves"; import { foundationFixture } from "./foundation-fixture"; import { settle } from "./world-driver"; // Backups for the manual checks on the project board's "A tester" column, // built from the local ROM and imported through Savegames → Import backup // (then "Add as a new save"). They hold names read from the ROM, so they // stay out of the repository (*.crystal-save.json is ignored). // // MAKE_TEST_SAVES=1 npx vitest run tests/human-test-saves.test.ts const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const OUT = "test-saves"; /** NativeRuntime.id, which an imported backup must name. */ const CORE_ID = "crystal-native-world-v1"; describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)( "saves for manual testing", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); function write(name: string, world: GameWorld) { const state = new TextEncoder().encode(JSON.stringify(world.save())); // The backup must load as the app would load it. new GameWorld(assets()).restore( JSON.parse(new TextDecoder().decode(state)), ); mkdirSync(OUT, { recursive: true }); writeFileSync( `${OUT}/${name}.crystal-save.json`, encodeSave({ version: 1, romHash: CRYSTAL_ROM.sha1, coreId: CORE_ID, savedAt: new Date().toISOString(), state, }), ); } it("writes fly-teleport: New Bark Town with Fly, Teleport and Escape Ropes", () => { const a = assets(); // Pidgeot with Fly, the Storm Badge, New Bark and Cherrygrove visited. const world = foundationFixture(a, "fly"); const abra = generatePartyMon(a.pokemon, { species: 63, // ABRA level: 20, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }); abra.moves = [0x64, 0, 0, 0]; // TELEPORT abra.pp = [20, 0, 0, 0]; world.state.party.push(abra); // Violet City too, whose map is not ported yet. world.state.flags.add(a.spawnEngineFlag(16)!); world.state.lastSpawnMap = "26:3"; // Cherrygrove City world.state.bag.items = [{ item: 0x13, quantity: 3 }]; // ESCAPE ROPE expect(world.mapId).toBe("24:4"); write("fly-teleport", world); }); it("writes field-items: New Bark Town's shore with rods, MAIL and a Repel", () => { const a = assets(); // Cyndaquil facing the sea with the Old Rod. const world = foundationFixture(a, "fishing"); world.state.party.push( generatePartyMon(a.pokemon, { species: 16, // PIDGEY level: 4, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }), ); world.state.bag.items = [ { item: 0x12, quantity: 5 }, // POTION { item: 0x9e, quantity: 2 }, // FLOWER_MAIL { item: 0x14, quantity: 1 }, // REPEL ]; world.state.bag.keyItems.push(0x3b, 0x3d); // GOOD_ROD, SUPER_ROD expect(world.mapId).toBe("24:4"); write("field-items", world); }); it("writes headbutt: Route 29 facing a Headbutt tree", () => { // Totodile with Headbutt and Tackle, below the first reachable tree. const world = foundationFixture(assets(), "headbutt"); expect(world.mapId).toBe("24:3"); write("headbutt", world); }); /** A Pokémon of the player's, from (species, level, DVs). */ const caught = ( a: CrystalAssets, world: GameWorld, species: number, level: number, dvs = 0x9abc, ) => generatePartyMon(a.pokemon, { species, level, item: 0, otId: world.playerId, ot: "CHRIS", dvs, caughtData: 0, }); it("writes day-care: inside the Day-Care with a pair that breeds", () => { const a = assets(); const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00")); // A female Cyndaquil and a male Typhlosion (Attack and Speed DVs 0 // and 15), and a Totodile to keep. The Odd Egg is still to come. world.state.party = [ caught(a, world, 155, 20, 0x0201), // CYNDAQUIL caught(a, world, 157, 40, 0xf7f2), // TYPHLOSION caught(a, world, 158, 15), // TOTODILE ]; world.state.money = 50000; world.warpTo("11:24", 2, 4); // facing the man settle(world); world.restore({ ...world.save(), direction: "up" }); expect(world.mapId).toBe("11:24"); write("day-care", world); }); it("writes egg-hatching: Route 29 with two Eggs two steps from hatching", () => { const a = assets(); const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00")); const egg = (species: number, dvs: number, happiness: number) => ({ ...caught(a, world, species, 5, dvs), egg: true as const, nickname: "EGG", happiness, hp: 0, }); // The Togepi Egg reaches zero on step 128 and stops DoEggStep; the // shiny Pichu's is already zero, so both hatch. world.state.party = [ caught(a, world, 155, 20), egg(175, 0x8888, 1), // TOGEPI egg(172, 0x2aaa, 0), // PICHU ]; world.restore({ ...world.save(), map: "24:3", x: 12, y: 3, direction: "left", stepCount: 0x7e, }); settle(world); write("egg-hatching", world); }); it("writes hall-of-fame-ceremony: the Hall of Fame's entrance, where Lance's scene and the ceremony start", () => { const a = assets(); const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00")); world.state.flags.add(ENGINE_FLAGS.pokedex); for (let badge = 0x1b; badge < 0x1b + 8; badge++) world.state.flags.add(badge); world.state.party = [ caught(a, world, 157, 60, 0x9abc), // TYPHLOSION caught(a, world, 25, 40, 0x2aaa), // PIKACHU, shiny caught(a, world, 201, 40, 0xffff), // UNOWN { ...caught(a, world, 175, 5), egg: true }, // an Egg, left out ]; for (const species of [157, 25, 201]) { world.state.pokedex.seen.add(species); world.state.pokedex.caught.add(species); } // The Hall of Fame's own warp; continuing runs HallOfFameEnterScene. world.restore({ ...world.save(), map: "16:8", x: 4, y: 13, direction: "up", objects: [] }); write("hall-of-fame-ceremony", world); }); it("writes hall-of-fame: a Pokémon Center whose PC has three teams", () => { const a = assets(); const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00")); world.state.flags.add(ENGINE_FLAGS.pokedex); const record = (party: [number, number, number][]) => addHallOfFameEntry( world.state, party.map(([species, level, dvs]) => caught(a, world, species, level, dvs), ), ); record([[201, 50, 0xffff]]); // UNOWN record([ [157, 55, 0x9abc], // TYPHLOSION [29, 30, 0x0000], // NIDORAN♀ [32, 31, 0xffff], // NIDORAN♂ [25, 100, 0x2aaa], // PIKACHU, shiny [175, 12, 0x9abc], // TOGEPI [249, 70, 0x9abc], // LUGIA ]); record([[158, 42, 0x9abc]]); // TOTODILE world.state.party = [caught(a, world, 155, 20)]; world.restore({ ...world.save(), map: "26:5", x: 9, y: 2, direction: "up", objects: [], }); settle(world); write("hall-of-fame", world); }); }, );