import { describe, expect, it } from "vitest"; import { readFileSync, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { RomReader } from "../src/game/rom/reader"; import { decompress } from "../src/game/rom/lz"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import { NATIVE_MAPS, SYMBOLS } from "../src/game/rom/profile"; import { ScriptRun, emptyScriptState, runScript, } from "../src/game/scripts/interpreter"; import { generatePartyMon } from "../src/game/party/pokemon"; import type { Evolution } from "../src/game/rom/pokemon"; import { readMovement } from "../src/game/scripts/movement"; import { defaultTextContext, readText } from "../src/game/scripts/text"; import { settle, walkTo, withoutPlayTime } from "./world-driver"; import { drawTile } from "../src/game/rendering/pixels"; import { CRYSTAL_ROM } from "../src/rom/validate"; import { VECTORS, type Direction } from "../src/game/world/types"; describe("native ROM formats", () => { it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => { const reader = new RomReader(new Uint8Array(0xc000)); expect(reader.address(2, 0x4321)).toBe(0x8321); expect(reader.address(2, 0x1234)).toBe(0x1234); expect(() => reader.address(3, 0x4000)).toThrow(); expect(() => reader.word(0xbfff)).toThrow(); expect(() => reader.slice(-1, 1)).toThrow(); }); it("decodes all LZ commands, long runs, overlapping copies and termination", () => { const decode = (input: number[]) => [ ...decompress(new RomReader(Uint8Array.from(input)), 0), ]; expect(decode([0x02, 1, 2, 3, 0x22, 7, 0x43, 8, 9, 0x61, 0xff])).toEqual([ 1, 2, 3, 7, 7, 7, 8, 9, 8, 9, 0, 0, ]); expect(decode([0, 5, 0x83, 0x80, 0xff])).toEqual([5, 5, 5, 5, 5]); expect(decode([0, 0b10010000, 0xa0, 0, 0, 0xff])).toEqual([144, 9]); expect(decode([2, 1, 2, 3, 0xc2, 0, 2, 0xff])).toEqual([1, 2, 3, 3, 2, 1]); expect(decode([0xec, 39, 0xff])).toEqual(Array(40).fill(0)); expect(() => decode([0x80, 0x80, 0xff])).toThrow("back-reference"); expect(() => decompress(new RomReader(new Uint8Array([0xec, 99, 0xff])), 0, 10), ).toThrow("oversized"); expect(() => decode([0x20, 4])).toThrow(); }); it("decodes planar pixels with transparency and flips", () => { const pixels = new Uint8ClampedArray(8 * 8 * 4); const tile = new Uint8Array(16); tile[0] = 0x80; tile[1] = 0x40; const palette = [ [0, 0, 0], [255, 0, 0], [0, 255, 0], [0, 0, 255], ] as const; drawTile(pixels, 8, 8, tile, 0, 0, 0, palette, { transparent: true, flipX: true, }); expect([...pixels.slice(7 * 4, 8 * 4)]).toEqual([255, 0, 0, 255]); expect([...pixels.slice(6 * 4, 7 * 4)]).toEqual([0, 255, 0, 255]); expect(pixels[3]).toBe(0); }); it("aborts unknown script commands without committing earlier event writes", () => { const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0])); const state = emptyScriptState(); expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported"); expect(state.events.size).toBe(0); }); it("bounds looping scripts", () => { const reader = new RomReader(Uint8Array.from([0x03, 0, 0])); expect(() => runScript(reader, 0, emptyScriptState(), "TEST")).toThrow( "budget", ); }); it("resumes scripts after choices and writes only to a private draft", () => { const bytes = new Uint8Array(0x100); // writetext $40; yesorno; iftrue $10; end; $10: setevent 7; end bytes.set([0x4c, 0x40, 0x00, 0x4e, 0x09, 0x10, 0x00, 0x91], 0); bytes.set([0x33, 7, 0, 0x91], 0x10); // "HI" then the player's name through text_ram, then done. bytes.set([0x00, 0x87, 0x88, 0x7f, 0x52, 0x57], 0x40); const state = emptyScriptState(); const host = { mapId: "1:1", lastTalked: 0, sceneOwner: () => undefined, timeMask: () => 2, clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), clockDay: () => 0, text: () => defaultTextContext("KAWA"), }; const yes = new ScriptRun(new RomReader(bytes), 0, state, host); expect(yes.resume()).toEqual({ kind: "yesno", pages: ["HI KAWA"] }); expect(yes.resume(1)).toBeUndefined(); expect(yes.state.events.has(7)).toBe(true); expect(state.events.size).toBe(0); const no = new ScriptRun(new RomReader(bytes), 0, state, host); no.resume(); expect(no.resume(0)).toBeUndefined(); expect(no.state.events.size).toBe(0); }); it("decodes movement data and refuses unported movement commands", () => { const reader = new RomReader( Uint8Array.from([ 0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x5a, 0x47, // turn_step, turn_in, set_sliding, step_dig 16, skyfall, // step_shake $54, fish_got_bite, step_bump, step_wait_end 5 0x05, 0x26, 0x39, 0x4f, 16, 0x4e, 0x55, 0x54, 0x51, 0x50, 0x48, 5, ]), ); expect(readMovement(reader, 0)).toEqual([ { kind: "turn", direction: "left", ticks: 1 }, { kind: "step", direction: "right", ticks: 8, animate: true }, { kind: "step", direction: "down", ticks: 16, animate: true }, { kind: "jump", direction: "up", ticks: 8 }, { kind: "fix-facing", fixed: true }, { kind: "sleep", ticks: 3 }, { kind: "sleep", ticks: 12 }, ]); expect(() => readMovement(reader, 9)).toThrow("not ported"); expect(readMovement(reader, 11)).toEqual([ { kind: "turn-step", direction: "up", ticks: 3 }, { kind: "step", direction: "left", ticks: 8, animate: true, spin: true }, { kind: "sliding", sliding: true }, { kind: "pose", pose: "spin", ticks: 16 }, { kind: "pose", pose: "skyfall", ticks: 31 }, { kind: "shake", displacement: 0x54 }, { kind: "pose", pose: "fish-bite", ticks: 8 }, { kind: "bump", ticks: 1 }, { kind: "wait-end", ticks: 5 }, ]); }); it("reads RAM-backed names and string buffers only from native values", () => { // text_ram wStringBuffer4, " ", text_buffer 0, done const bytes = Uint8Array.from([ 0x01, 0xac, 0xd0, 0x00, 0x7f, 0x50, 0x14, 0x00, 0x50, 0x50, ]); const context = { ...defaultTextContext("KAWA"), strings: { 0xd0ac: "POKéGEAR", 0xd099: "ELM" }, }; expect(readText(new RomReader(bytes), 0, context)).toBe("POKéGEAR ELM"); expect(() => readText(new RomReader(bytes), 0, defaultTextContext("KAWA")), ).toThrow("no native value"); }); }); const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))( "native engine with local user ROM (never checked in)", () => { const load = () => { const bytes = readFileSync(romPath); expect(createHash("sha1").update(bytes).digest("hex")).toBe( CRYSTAL_ROM.sha1, ); return new CrystalAssets(new Uint8Array(bytes)); }; it("decodes every supported map, tileset, event table and sprite", () => { const assets = load(); for (const id of Object.keys(NATIVE_MAPS)) { const map = assets.map(id), tileset = assets.tileset(map); expect(map.blocks.length).toBe((map.width * map.height) / 4); expect(tileset.graphics.length).toBeGreaterThanOrEqual(0x600); expect(assets.palettes(map, 1).length).toBe(8); for (const warp of map.warps) { expect(warp.x).toBeLessThan(map.width); expect(warp.y).toBeLessThan(map.height); // -1 returns through the saved entrance warp (e.g. elevators). if (warp.targetWarp !== 0xff && warp.targetMap in NATIVE_MAPS) expect( assets.map(warp.targetMap).warps[warp.targetWarp - 1], `${map.id} -> ${warp.targetMap}, warp ${warp.targetWarp}`, ).toBeDefined(); } } expect(assets.sprite(1)?.graphics.length).toBe(384); expect(assets.sprite(0x60)?.graphics.length).toBe(384); expect(assets.map("24:7").warps[0]).toEqual({ x: 7, y: 0, targetMap: "24:6", targetWarp: 3, }); }); it.skipIf( !existsSync(".reference/pokecrystal/gfx/tilesets/players_room.2bpp"), )( "matches decompressed graphics against the locally rebuilt PRET reference", () => { const path = ".reference/pokecrystal/gfx/tilesets/players_room.2bpp"; const assets = load(); expect(assets.tileset(assets.map("24:7")).graphics).toEqual( new Uint8Array(readFileSync(path)), ); }, ); it("walks with the original timing: eight overworld ticks per tile", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); world.restore({ ...world.save(), x: 3, y: 2, direction: "right" }); const ticks: number[] = []; for (let tick = 1; tick <= 40 && world.x < 7; tick++) { const x = world.x; world.tick("right"); if (world.x !== x) ticks.push(tick); } // Steps start on ticks 1, 9, 17... and continue without a gap. expect(ticks).toEqual([1, 9, 17, 25]); // Two pixels per tick: the step started on tick 25, now two ticks in. world.tick(); expect(world.poses().find((pose) => pose.id === 0)?.x).toBe(6 * 16 + 4); }); it("decodes ROM location names and draws connected maps beyond the edges", () => { const assets = load(); const town = assets.map("24:4"); expect(town.location).toBe("New Bark Town"); expect(assets.map("24:5").name).toBe("Elm’s lab"); expect(assets.map("24:5").location).toBe("New Bark Town"); const west = town.connections.find((c) => c.direction === "left")!; const route = assets.map(west.targetMap); expect(route.location).toBe("Route 29"); expect(assets.isPorted(route.id)).toBe(true); for (let y = 0; y < town.height; y++) expect(assets.visibleBlock(town, -1, y)).toBe( assets.block(route, west.x, y + west.y), ); expect(assets.visibleBlock(town, -1, -1)).toBe(town.border); expect(() => assets.map("24:99")).toThrow("Unknown map"); expect(() => assets.map("27:1")).toThrow("Unknown map"); }); it("plays Mom's opening scene with native choices, then walks outside", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); // The bedroom's NEWMAP callback ran the original initialization script. expect(world.state.events.size).toBeGreaterThan(100); walkTo(world, 7, 0); expect(world.mapId).toBe("24:6"); expect([world.x, world.y]).toEqual([9, 1]); const flags = world.state.flags.size; // Monday, confirm, DST yes, confirm time, "know how to use the phone". const pages = walkTo(world, 9, 4, [1, 1, 1, 1, 1]); expect(pages[0]).toBe("Oh, CHRIS…! Our\nneighbor, PROF."); expect(pages).toContain("CHRIS received\nPOKéGEAR."); expect(pages).toContain("What day is it?"); expect(pages).toContain("MONDAY, is it?"); expect(pages.some((page) => /^12:00 PM DST,/.test(page))).toBe(true); expect(pages).toContain("Don't you just\nturn the POKéGEAR"); const save = world.save(); expect(save.scenes).toEqual({ "24:6": 1 }); expect(save.phone).toEqual([1]); // 2026-09-22 is a Tuesday; the chosen Monday is six days ahead. expect(save.clock).toEqual({ minuteOffset: 0, weekdayOffset: 6, dst: true }); expect(world.clock().weekday).toBe(1); expect(world.state.flags.size).toBe(flags + 2); expect(world.state.events.has(1735)).toBe(true); // Stepping on the tile again does not replay the scene. expect(world.move("up")).toBe(true); expect(settle(world)).toEqual([]); // Objects spawn when the map loads: the scene's Mom stays until the // player leaves, then the daytime Mom replaces her. expect(world.objects().find((object) => object.id === 2)).toMatchObject({ x: 7, y: 4, direction: "left", }); walkTo(world, 6, 7); expect(world.mapId).toBe("24:6"); expect(world.move("down")).toBe(true); settle(world); expect(world.mapId).toBe("24:4"); const home = world.map.warps.find((warp) => warp.targetMap === "24:6")!; walkTo(world, home.x, home.y); expect(world.objects().map((object) => object.id)).toEqual([4, 6]); walkTo(world, 6, 7); const restored = new GameWorld( world.assets, () => new Date("2026-09-22T12:00:00"), ); restored.restore(world.save()); expect(restored.save()).toEqual(world.save()); expect(() => world.restore({ ...world.save(), x: 127 })).toThrow( "position", ); expect(() => world.restore({ ...world.save(), scenes: { "24:4": 3 } }), ).not.toThrow(); expect(() => world.restore({ ...world.save(), scenes: { "1:1": 3 } }), ).toThrow("Invalid"); }); it("saves the committed state during a scene and replays it after restore", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); walkTo(world, 7, 0); walkTo(world, 9, 3); const before = world.save(); expect(world.move("down")).toBe(true); expect(world.busy).toBe(true); // A mid-scene save keeps the position before the triggering step. expect(world.save()).toEqual(before); const restored = new GameWorld( world.assets, () => new Date("2026-09-22T12:00:00"), ); restored.restore(world.save()); expect(restored.move("down")).toBe(true); expect(settle(restored, [1, 1, 1, 1, 1]).length).toBeGreaterThan(10); expect(restored.save().scenes).toEqual({ "24:6": 1 }); }); it("runs New Bark Town's teacher and rival scenes with scripted movement", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); world.restore({ ...world.save(), map: "24:4", x: 5, y: 8 }); walkTo(world, 2, 8); const events = world.save().events; expect(world.move("left")).toBe(true); const pages = settle(world); expect(pages[0]).toBe("Wait, CHRIS!"); expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!"); // The teacher runs over and leads the player back east. expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]); // Back home, SPRITEMOVEDATA_SPINRANDOM_SLOW spins her again at once. const teacher = world.objects().find((object) => object.id === 2)!; expect([teacher.x, teacher.y]).toEqual([6, 8]); expect(world.save().events).toEqual(events); walkTo(world, 2, 2); expect(world.move("right")).toBe(false); world.interact(); const rival = settle(world); expect(rival).toContain("…What are you\nstaring at?"); // Pushed one step and shoved two more with a jump. expect([world.x, world.y, world.direction]).toEqual([2, 5, "up"]); const silver = world.objects().find((object) => object.id === 4)!; expect([silver.x, silver.y, silver.direction]).toEqual([3, 2, "right"]); }); it("runs Elm's introduction, the lab's exit guard and a declined Poké Ball", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, scenes: { "24:6": 1 }, }); const door = world.map.warps.find((warp) => warp.targetMap === "24:5")!; walkTo(world, door.x, door.y + 1); expect(world.move("up")).toBe(true); expect(world.mapId).toBe("24:5"); // The OBJECTS callback moved Elm for the introduction. expect(world.objects().find((object) => object.id === 2)).toMatchObject({ x: 3, y: 4, }); const pages = settle(world, [0, 1]); expect(pages[0]).toBe("ELM: CHRIS!\nThere you are!"); expect(pages).toContain("But… Please, I\nneed your help!"); expect(pages.at(-1)).toBe("Go on. Pick one!"); expect(world.save().scenes).toMatchObject({ "24:5": 1 }); // The warp places the player on the mat at y=11; the ROM walks up seven tiles. expect([world.x, world.y, world.direction]).toEqual([4, 4, "right"]); // Elm stops the player from leaving without a Pokémon. walkTo(world, 4, 5); expect(world.move("down")).toBe(true); expect(settle(world).length).toBeGreaterThan(0); expect([world.x, world.y]).toEqual([4, 5]); walkTo(world, 6, 4); world.move("up"); const before = world.save(); world.interact(); // Declining a Poké Ball commits nothing. expect(settle(world, [0])).toEqual([ "", "ELM: You'll take\nCYNDAQUIL, the\nfire POKéMON?", "ELM: Think it over\ncarefully.", "Your partner is\nimportant.", ]); expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before)); }); it("jumps over representative Route 29 ledges from the actual collision data", () => { const assets = load(); const route = assets.map("24:3"); const world = new GameWorld(assets); const checked = new Set(); const directions: Record = { 0xa0: "right", 0xa1: "left", 0xa3: "down", 0xa4: "down", 0xa5: "down", }; for (let y = 1; y < route.height - 2; y++) { for (let x = 2; x < route.width - 2; x++) { const collision = assets.collision(route, x, y), direction = directions[collision]; if (!direction || checked.has(collision)) continue; const [dx, dy] = VECTORS[direction]; if ( assets.permission(assets.collision(route, x + dx, y + dy)) === 0 || assets.permission( assets.collision(route, x + dx * 2, y + dy * 2), ) !== 0 || route.objects.some((o) => o.x === x + dx * 2 && o.y === y + dy * 2) ) continue; world.restore({ ...world.save(), map: route.id, x, y, direction, objects: [], }); world.tick(direction); // The first half of the jump occupies the tile jumped over. expect([world.x, world.y]).toEqual([x + dx, y + dy]); expect( world.poses().find((pose) => pose.id === 0)?.lift, ).toBeLessThan(0); settle(world); expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]); expect(world.notice).toBe(""); checked.add(collision); } } expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]); }); it("gives the Route 29 item ball's Potion through FindItemInBallScript, hides the ball and commits atomically", () => { const assets = load(); const route = assets.map("24:3"); const world = new GameWorld(assets); const ball = route.objects.find((object) => object.kind === 1)!; // OBJECTTYPE_ITEMBALL // The tile west of the ball is the only walkable approach. world.restore({ ...world.save(), map: route.id, x: ball.x - 1, y: ball.y, direction: "right", objects: [], }); expect(world.objects().some((object) => object.id === ball.id)).toBe( true, ); world.interact(); expect(settle(world)).toEqual([ "CHRIS found\nPOTION!", "CHRIS put the\nPOTION in\nthe ITEM POCKET.", ]); expect(world.diagnostic).toBe(""); expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]); expect(world.state.events.has(ball.eventFlag)).toBe(true); // The ball disappears for the rest of the session... expect(world.objects().some((object) => object.id === ball.id)).toBe( false, ); // ...and the pickup survives save()/restore(). const saved = world.save(); const restored = new GameWorld(assets); restored.restore(saved); expect(restored.save()).toEqual(saved); expect(restored.objects().some((object) => object.id === ball.id)).toBe( false, ); }); it("shows the original no-room text and changes nothing when the item pocket is full", () => { const assets = load(); const route = assets.map("24:3"); const world = new GameWorld(assets); const ball = route.objects.find((object) => object.kind === 1)!; const full = world.save(); // Twenty full POTION stacks: PutItemInPocket finds no room for another. full.bag.items = Array.from({ length: 20 }, () => ({ item: 18, quantity: 99, })); world.restore({ ...full, map: route.id, x: ball.x - 1, y: ball.y, direction: "right", objects: [], }); world.interact(); expect(settle(world)).toEqual([ "CHRIS found\nPOTION!", "But CHRIS can't\ncarry any more\nitems.", ]); expect(world.diagnostic).toBe(""); expect(world.state.bag).toEqual(full.bag); expect(world.state.events.has(ball.eventFlag)).toBe(false); expect(world.objects().some((object) => object.id === ball.id)).toBe( true, ); }); it("refuses connections into unported maps and imports version 1 saves", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); const start = world.save(); // New Bark Town's east edge is water leading to Route 27, which // stays closed: a surfer is refused. const town = world.assets.map("24:4"), right = town.width - 1; const edge = Array.from({ length: town.height }, (_, y) => y).find( (y) => world.assets.permission(world.assets.collision(town, right, y)) === 1, )!; world.restore({ ...world.save(), map: "24:4", x: right, y: edge, playerState: 4, // PLAYER_SURF }); expect(world.move("right")).toBe(false); expect(world.notice).toBe("Route 27 is not ported yet."); world.restore(start); const { scenes, phone, clock, objects, playerId, party, bag, pokedex, ...legacy } = world.save(); void [scenes, phone, objects, playerId, party, bag, pokedex]; world.restore({ ...legacy, version: 1 }); expect(world.save()).toMatchObject({ version: 3, scenes: {}, phone: [], objects: [], party: [], pokedex: { seen: [], caught: [] }, }); world.restore({ ...legacy, version: 2, scenes: { "24:6": 1 }, phone: [1], clock, objects: [], }); expect(world.save()).toMatchObject({ version: 3, scenes: { "24:6": 1 }, phone: [1], }); expect(world.save().playerId).toBeLessThan(0x10000); expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid"); }); it("decodes species, moves, items and growth from the ROM", () => { const data = load().pokemon; expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([ "CHIKORITA", "CYNDAQUIL", "TOTODILE", ]); expect(data.speciesName(122)).toBe("MR.MIME"); expect(data.baseData(155)).toMatchObject({ species: 155, stats: [39, 52, 43, 65, 60, 50], growthRate: 3, picSize: 5, }); // Medium Slow: 135 at level 5, and the original level-1 underflow. expect(data.expAtLevel(3, 5)).toBe(135); expect(data.expAtLevel(3, 1)).toBe(16777162); expect(data.expAtLevel(0, 100)).toBe(1_000_000); expect(data.learnset(155).slice(0, 2)).toEqual([ { level: 1, move: 33 }, { level: 1, move: 43 }, ]); expect(data.moveName(33)).toBe("TACKLE"); expect(data.move(33)).toMatchObject({ power: 35, accuracy: 242, pp: 35 }); expect(data.itemName(18)).toBe("POTION"); expect(data.itemPocket(18)).toBe("item"); expect(data.pocketName("item")).toBe("ITEM POCKET"); expect(data.frontPic(155)).toHaveLength(49 * 16); expect(() => data.speciesName(252)).toThrow("Unknown"); }); it.skipIf( !existsSync( ".reference/pokecrystal/gfx/pokemon/cyndaquil/front.animated.2bpp", ), )( "matches front pictures against the locally rebuilt PRET reference", () => { const data = load().pokemon; for (const [species, name] of [ [152, "chikorita"], [155, "cyndaquil"], [158, "totodile"], ] as const) expect(data.frontPicData(species)).toEqual( new Uint8Array( readFileSync( `.reference/pokecrystal/gfx/pokemon/${name}/front.animated.2bpp`, ), ), ); }, ); it("decodes evolution lists, including stat and happiness triggers", () => { const data = load().pokemon; expect(data.evolutions(155)).toEqual([ { method: "level", level: 14, species: 156 }, ]); expect(data.evolutions(133).map((e) => e.method)).toEqual([ "item", "item", "item", "happiness", "happiness", ]); expect(data.evolutions(133)[4]).toEqual({ method: "happiness", time: "nite", species: 197, }); expect(data.evolutions(61)).toContainEqual({ method: "trade", item: 82, // KING'S ROCK species: 186, }); expect(data.evolutions(64)).toEqual([ { method: "trade", item: null, species: 65 }, ]); expect(data.evolutions(236)).toEqual([ { method: "stat", level: 20, attack: "lt", species: 107 }, { method: "stat", level: 20, attack: "gt", species: 106 }, { method: "stat", level: 20, attack: "eq", species: 237 }, ]); expect(data.evolutions(251)).toEqual([]); // The four-byte stat records must not shift the learnset. expect(data.learnset(236)[0]).toEqual({ level: 1, move: 33 }); }); it.skipIf( !existsSync(".reference/pokecrystal/data/pokemon/evos_attacks.asm"), )("matches every evolution list against the pinned PRET source", () => { const data = load().pokemon; const root = ".reference/pokecrystal"; const constants = (file: string, first: number) => { const ids = new Map(); let value = first; for (const line of readFileSync(`${root}/${file}`, "utf8").split( "\n", )) { if (/^DEF NUM_/.test(line)) break; const name = /^\s*const (\w+)/.exec(line)?.[1]; if (name) ids.set(name, value++); } return ids; }; const species = constants("constants/pokemon_constants.asm", 1), items = constants("constants/item_constants.asm", 0); const id = (map: Map, name: string) => { const value = map.get(name); if (value === undefined) throw new Error(`Unknown ${name}`); return value; }; const lists: Evolution[][] = []; let current: Evolution[] | null = null; for (const line of readFileSync( `${root}/data/pokemon/evos_attacks.asm`, "utf8", ).split("\n")) { if (/^\w+EvosAttacks:/.test(line)) lists.push((current = [])); const args = /^\s*db (EVOLVE_\w+), (.+)$/.exec(line); if (!args || !current) { if (/^\s*db 0 ; no more evolutions/.test(line)) current = null; continue; } const [a, b, c] = args[2].split(",").map((s) => s.trim()); if (args[1] === "EVOLVE_LEVEL") current.push({ method: "level", level: +a, species: id(species, b) }); else if (args[1] === "EVOLVE_ITEM") current.push({ method: "item", item: id(items, a), species: id(species, b), }); else if (args[1] === "EVOLVE_TRADE") current.push({ method: "trade", item: a === "-1" ? null : id(items, a), species: id(species, b), }); else if (args[1] === "EVOLVE_HAPPINESS") current.push({ method: "happiness", time: a.slice(3).toLowerCase() as "anytime", species: id(species, b), }); else current.push({ method: "stat", level: +a, attack: b.slice(4, 6).toLowerCase() as "gt", species: id(species, c), }); } expect(lists).toHaveLength(251); lists.forEach((list, i) => expect(data.evolutions(i + 1)).toEqual(list)); }); it("gives the chosen starter and the aide's Potion, then saves them", () => { let seed = 7; const random = () => (seed = (seed * 73 + 41) & 255); const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), random, ); world.restore({ ...world.save(), map: "24:5", x: 4, y: 3, scenes: { "24:6": 1, "24:5": 1 }, }); walkTo(world, 6, 4); expect(world.move("up")).toBe(false); world.interact(); // pokepic shows the ROM's front picture and waits for a button. expect(world.picture).toBe(155); expect(world.dialogue?.pages).toEqual([""]); world.advanceDialogue(); expect(world.picture).toBeUndefined(); expect(world.dialogue?.choice?.kind).toBe("yesno"); // Take it, then give it a nickname through the naming screen. const pages = settle(world, [1, 1]); expect(pages).toContain("CHRIS received\nCYNDAQUIL!"); expect(world.dialogue?.choice).toMatchObject({ kind: "name", current: "CYNDAQUIL", }); expect(world.dialogue?.pages).toEqual(["CYNDAQUIL'S\nNICKNAME?"]); // Characters the naming screen cannot enter keep the prompt open. world.choose("BAD~NAME"); expect(world.dialogue?.choice?.kind).toBe("name"); world.choose("BLAZE"); expect(settle(world)).toContain("CHRIS got ELM's\nphone number."); const save = world.save(); expect(save.party).toHaveLength(1); expect(save.party[0]).toMatchObject({ species: 155, nickname: "BLAZE", ot: "CHRIS", otId: world.playerId, level: 5, exp: 135, item: 173, moves: [33, 43, 0, 0], pp: [35, 30, 0, 0], happiness: 70, // Day, level 5; male player in New Bark Town. caughtData: 0x8501, }); expect(save.party[0].hp).toBe(save.party[0].stats[0]); expect(save.pokedex).toEqual({ seen: [155], caught: [155] }); expect(save.scenes).toEqual({ "24:6": 1, "24:5": 5, "24:4": 1 }); expect(save.events).toContain(26); // EVENT_GOT_A_POKEMON_FROM_ELM // The other balls now only describe themselves. walkTo(world, 7, 4); world.move("up"); world.interact(); expect(settle(world)).toHaveLength(1); expect(world.save().party).toHaveLength(1); walkTo(world, 4, 7); expect(world.move("down")).toBe(true); expect(settle(world)).toEqual([ "CHRIS, I want\nyou to have this\nfor your errand.", "CHRIS received\nPOTION.", "CHRIS put the\nPOTION in\nthe ITEM POCKET.", "There are only two\nof us, so we're\nalways busy.", ]); const final = world.save(); expect(final.bag.items).toEqual([{ item: 18, quantity: 1 }]); expect(final.scenes["24:5"]).toBe(2); const restored = new GameWorld( world.assets, () => new Date("2026-09-22T12:00:00"), ); restored.restore(final); expect(restored.save()).toEqual(final); // Stored stats must match the ROM's formula for the stored DVs. const tampered = structuredClone(final); tampered.party[0].stats[1] += 1; expect(() => restored.restore(tampered)).toThrow("Invalid"); const potion = structuredClone(final); potion.bag.keyItems = [18]; expect(() => restored.restore(potion)).toThrow("Invalid"); expect(restored.save()).toEqual(final); }); it("reads the bedroom radio through native event and text interpreters", () => { const world = new GameWorld(load()); world.restore({ ...world.save(), x: 3, y: 2, direction: "up" }); const previousEvents = world.state.events.size; world.interact(); expect(world.diagnostic).toBe(""); // Each radio line holds for 45 ticks (pause 45), ignoring buttons. expect(world.dialogue?.holdTicks).toBe(45); world.advanceDialogue(); expect(world.dialogue?.holdTicks).toBe(45); expect(world.state.events.size).toBe(previousEvents); expect(settle(world)).toHaveLength(4); expect(world.state.events.size).toBe(previousEvents + 1); world.interact(); expect(settle(world)).toHaveLength(1); }); it("heals a damaged, statused party through the Cherrygrove nurse at every time of day", () => { const greetings = { morning: ["Good morning!\nWelcome to our\nPOKéMON CENTER."], day: ["Hello!\nWelcome to our\nPOKéMON CENTER."], night: [ "Good evening!\nYou're out late.", "Welcome to our\nPOKéMON CENTER.", ], }; for (const [label, hour] of [ ["morning", 8], ["day", 13], ["night", 21], ] as const) { const assets = load(); const world = new GameWorld( assets, () => new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`), ); const mon = generatePartyMon(assets.pokemon, { species: 155, level: 10, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.moves = [45, 0, 0, 0]; // GROWL: base PP 40 mon.pp = [(3 << 6) | 2, 0, 0, 0]; // three PP Ups, nearly spent mon.hp = 1; mon.status = 0x08; // paralysis world.state.party.push(mon); world.restore({ ...world.save(), map: "26:5", x: 3, y: 3, direction: "up", }); // The counter at (3,2) lets interact() reach the nurse behind it. world.interact(); expect(world.dialogue?.pages).toEqual(greetings[label]); const pages = settle(world, [1]); expect(pages).toContain("Your POKéMON are\nfully healed."); const healed = world.save().party[0]; expect(healed.hp).toBe(healed.stats[0]); expect(healed.status).toBe(0); // ComputeMaxPP clamps each PP Up's bonus to 7, not 8: 40 + 3*7 = 61. expect(healed.pp).toEqual([(3 << 6) | 61, 0, 0, 0]); expect(world.diagnostic).toBe(""); } }); it("leaves the party unchanged when healing is declined", () => { const assets = load(); const world = new GameWorld( assets, () => new Date("2026-09-22T13:00:00"), ); const mon = generatePartyMon(assets.pokemon, { species: 155, level: 10, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.hp = 1; mon.status = 0x08; world.state.party.push(mon); world.restore({ ...world.save(), map: "26:5", x: 3, y: 3, direction: "up", }); world.interact(); const before = structuredClone(world.save().party); const pages = settle(world, [0]); expect(pages.at(-1)).toBe("We hope to see you\nagain."); expect(pages).not.toContain("Your POKéMON are\nfully healed."); expect(world.save().party).toEqual(before); expect(world.diagnostic).toBe(""); }); it("records the whiteout spawn by walking into the Cherrygrove Pokécenter, and keeps it through save/restore", () => { const assets = load(); const world = new GameWorld( assets, () => new Date("2026-09-22T13:00:00"), ); expect(world.save().lastSpawnMap).toBeNull(); world.restore({ ...world.save(), map: "26:3", x: 29, y: 4, direction: "up", }); // The Center's door is an automatic warp; EnterMapWarp's SetSpawn // records the outdoor map (Cherrygrove City) as the new blackout spot. expect(world.move("up")).toBe(true); expect(world.mapId).toBe("26:5"); expect([world.x, world.y]).toEqual([3, 7]); expect(world.save().lastSpawnMap).toBe("26:3"); const restored = new GameWorld( world.assets, () => new Date("2026-09-22T13:00:00"), ); restored.restore(world.save()); expect(restored.save()).toEqual(world.save()); }); it("honors the recorded spawn for the whiteout warp, and defaults to the player's house", () => { const assets = load(); const host = { mapId: "1:1", lastTalked: 0, sceneOwner: () => undefined, timeOfDay: () => 1, clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), clockDay: () => 0, text: () => defaultTextContext("CHRIS"), data: assets.pokemon, player: () => ({ id: 0, gender: 0 as const }), random: () => 0, landmark: () => 0, facing: () => 0, position: () => ({ x: 0, y: 0 }), objectPosition: () => undefined, }; // Runs the ROM's own battleWhiteoutScript end to end: HealParty, // GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter. const whiteoutWarp = (lastSpawnMap: string | null) => { const state = emptyScriptState(); state.lastSpawnMap = lastSpawnMap; const run = new ScriptRun( assets.reader, SYMBOLS.battleWhiteoutScript, state, host, ); let effect = run.resume(); while (effect && effect.kind !== "warp") effect = run.resume(); return effect; }; expect(whiteoutWarp("26:3")).toEqual({ kind: "warp", map: "26:3", x: 29, y: 4, }); // No recorded Center, or one the spawn table no longer lists: SPAWN_HOME. expect(whiteoutWarp(null)).toEqual({ kind: "warp", map: "24:7", x: 3, y: 3, }); expect(whiteoutWarp("1:1")).toEqual({ kind: "warp", map: "24:7", x: 3, y: 3, }); }); it("migrates a save without the spawn field to the player's house default", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T13:00:00"), ); const { lastSpawnMap, ...withoutSpawn } = world.save(); void lastSpawnMap; expect(() => world.restore(withoutSpawn)).not.toThrow(); expect(world.save().lastSpawnMap).toBeNull(); }); }, );