import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { PokemonData } from "../src/game/rom/pokemon"; import { JOHTO } from "../src/game/rom/profile"; import { RomReader } from "../src/game/rom/reader"; import { generatePartyMon, updateUnownDex } from "../src/game/party/pokemon"; import { unownUnlocked } from "../src/game/battle/battle"; import { initialPieces, moveCursor, solved, } from "../src/game/scripts/unown-puzzle"; import { GameWorld } from "../src/game/world/world"; import { OW } from "../src/game/world/constants"; import { battleFixtureWorld } from "./battle-fixture"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const UNOWN = 201; describe("GetUnownLetter", () => { it("takes the middle two bits of each DV, divided by 10, plus one", () => { expect(PokemonData.unownLetter(0x0000)).toBe(1); // Every middle bit set: $ff / 10 = 25, letter Z. expect(PokemonData.unownLetter(0x6666)).toBe(26); // Attack %0110 0000 alone: $c0 / 10 = 19, letter T. expect(PokemonData.unownLetter(0x6000)).toBe(20); // Special's bits are the lowest: 3 / 10 = 0. expect(PokemonData.unownLetter(0x0006)).toBe(1); for (let dvs = 0; dvs < 0x10000; dvs += 37) { const letter = PokemonData.unownLetter(dvs); expect(letter).toBeGreaterThanOrEqual(1); expect(letter).toBeLessThanOrEqual(26); } }); it("records each new letter once, and the first in the party", () => { const record = { unownDex: [] as number[], firstUnownSeen: 0 }; updateUnownDex(record, { species: UNOWN, dvs: 0x6666 }, false); expect(record).toEqual({ unownDex: [26], firstUnownSeen: 0 }); updateUnownDex(record, { species: UNOWN, dvs: 0x0000 }); updateUnownDex(record, { species: UNOWN, dvs: 0x6666 }); updateUnownDex(record, { species: 16, dvs: 0x1234 }); expect(record).toEqual({ unownDex: [26, 1], firstUnownSeen: 1 }); }); }); describe("the Unown puzzle's rules", () => { it("moves the cursor around START and CANCEL", () => { expect(moveCursor(0, "up")).toBeUndefined(); expect(moveCursor(0, "left")).toBeUndefined(); expect(moveCursor(5, "right")).toBeUndefined(); expect(moveCursor(7, "down")).toBe(13); // Below the inner pieces' last row lies START/CANCEL. expect(moveCursor(25, "down")).toBeUndefined(); expect(moveCursor(24, "down")).toBe(30); expect(moveCursor(30, "right")).toBe(35); expect(moveCursor(35, "left")).toBe(30); expect(moveCursor(30, "down")).toBeUndefined(); }); }); describe.skipIf(!existsSync(romPath))("the Ruins of Alph with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const assets = () => new CrystalAssets(bytes); const KABUTO_CHAMBER = "3:24", INNER_CHAMBER = "3:27"; const EVENT_SOLVED_KABUTO_PUZZLE = 0x2a1; it("places the sixteen pieces on the border, and knows the solution", () => { const reader = new RomReader(bytes); let seed = 3; const pieces = initialPieces({ reader, random: () => (seed = (seed * 75 + 74) % 65537) & 0xff, }); expect(pieces.filter(Boolean).sort((a, b) => a - b)).toEqual( Array.from({ length: 16 }, (_, i) => i + 1), ); // Only the outer ring and the corners of the bottom row are used. pieces.forEach((piece, place) => { const row = Math.floor(place / 6), column = place % 6; if (piece) expect(row === 0 || row === 5 || column === 0 || column === 5).toBe(true); }); const done = Array(36).fill(0); for (let piece = 1; piece <= 16; piece++) done[7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3)] = piece; expect(solved(reader, done)).toBe(true); expect(solved(reader, pieces)).toBe(false); }); it("only lets wild Unown appear with letters from the unlocked sets", () => { const data = new PokemonData(new RomReader(bytes)); for (let dvs = 0; dvs < 0x10000; dvs += 7) { const letter = PokemonData.unownLetter(dvs); expect(unownUnlocked(data, 0, dvs)).toBe(false); expect(unownUnlocked(data, 1, dvs)).toBe(letter <= 11); expect(unownUnlocked(data, 1 << 3, dvs)).toBe(letter >= 24); expect(unownUnlocked(data, 0x0f, dvs)).toBe(true); } }); /** Solve the puzzle on screen as a player would: read the pieces from * the tilemap, then carry each to its place. */ const solvePuzzle = (world: GameWorld) => { const r = world.assets.reader; const corners = Array.from({ length: 17 }, (_, piece) => r.byte(JOHTO["GetCurrentPuzzlePieceVTileCorner.Corners"] + piece), ); const placeCell = (place: number) => { const cell = r.word(JOHTO.UnownPuzzleCoordData + place * 6 + 2) - 0xc4a0; return cell; }; const screen = () => { const choice = world.dialogue?.choice; if (choice?.kind !== "screen") throw new Error(`No puzzle on screen: ${world.diagnostic}`); return choice.screen; }; const pieces = () => Array.from({ length: 36 }, (_, place) => { const tile = screen().map[placeCell(place)]; const piece = corners.indexOf(tile, 1); return piece > 0 ? piece : 0; }); let cursor = 0; const goTo = (target: number) => { // Breadth first over the cursor's moves. const previous = new Map([[cursor, [-1, ""]]]); const queue = [cursor]; while (queue.length) { const at = queue.shift()!; for (const direction of ["up", "down", "left", "right"]) { const next = moveCursor(at, direction); if (next === undefined || previous.has(next)) continue; previous.set(next, [at, direction]); queue.push(next); } } const path: string[] = []; for (let at = target; at !== cursor; at = previous.get(at)![0]) path.unshift(previous.get(at)![1]); for (const direction of path) world.choose(direction); cursor = target; }; for (let piece = 1; piece <= 16; piece++) { const from = pieces().indexOf(piece); const to = 7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3); goTo(from); world.choose("a"); goTo(to); world.choose("a"); } }; it("solves the Kabuto puzzle, unlocks A to K and drops into the inner chamber", () => { const world = battleFixtureWorld(assets()); world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 3, direction: "up" }); settle(world); // The floor is closed until the puzzle is solved. expect(world.assets.permission(world.assets.collision(world.map, 3, 3))).toBe(0); world.interact(); for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick(); // START quits without solving. world.openStartMenu(); settle(world); expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(false); world.interact(); for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick(); solvePuzzle(world); // The finished picture waits for A or B. expect(world.dialogue?.choice?.kind).toBe("screen"); world.choose("a"); settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.mapId).toBe(INNER_CHAMBER); expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(true); expect(world.state.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K)).toBe(true); }); it("shows the wall's word in Unown letters", () => { const world = battleFixtureWorld(assets()); world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 1, direction: "up" }); settle(world); world.interact(); for (let i = 0; i < 40 && world.dialogue?.choice?.kind !== "screen"; i++) { if (world.dialogue && !world.dialogue.choice) world.advanceDialogue(); else world.tick(); } const choice = world.dialogue?.choice; expect(choice?.kind).toBe("screen"); if (choice?.kind !== "screen") return; expect(choice.screen.overlay).toBe(true); // UNOWNWORDS_ESCAPE: six letters, two tiles wide, in bank 1. const drawn = choice.screen.map.filter((tile) => tile >= 0x100); expect(drawn.length).toBe(6 * 4); world.choose("b"); settle(world); expect(world.busy).toBe(false); }); it("meets Unown in the inner chamber once a set is unlocked, and records them", () => { // The battle fixture's encounter tables would replace the chamber's. let seed = 5; const world = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }); world.state.party.push( generatePartyMon(world.assets.pokemon, { species: 157, level: 50, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0, }), ); world.state.flags.add(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K); world.state.bag.balls = [{ item: 1, quantity: 30 }]; // MASTER BALL world.restore({ ...world.save(), map: INNER_CHAMBER, x: 10, y: 8 }); settle(world); let caught = false; for (let step = 0; step < 400 && !caught; step++) { world.move(step & 1 ? "up" : "down"); for (let tick = 0; tick < 4000; tick++) { const battle = world.battle; if (battle) { if (battle.prompt?.kind === "action") { const ball = world.liveState.bag.balls.findIndex((slot) => slot.item === 1); if (battle.engine.enemy.mon.species === UNOWN && ball >= 0) world.battleAction({ action: "item", item: 1, slot: ball }); else world.battleAction({ action: "run" }); } else if (battle.prompt?.kind === "name") world.battleName(""); else if (battle.prompt?.kind === "yesno") world.battleChoose(0); else if (battle.message?.prompt) world.interact(); else world.tick(); continue; } if (world.dialogue) { if (world.dialogue.choice) world.choose(0); else world.advanceDialogue(); continue; } if (!world.busy) break; world.tick(); } expect(world.notice, world.diagnostic).toBe(""); caught = world.state.party.some((member) => member.species === UNOWN); } expect(caught).toBe(true); const unown = world.state.party.find((member) => member.species === UNOWN)!; const letter = PokemonData.unownLetter(unown.dvs); expect(letter).toBeLessThanOrEqual(11); expect(world.state.unownDex).toEqual([letter]); expect(world.state.firstUnownSeen).toBeGreaterThanOrEqual(1); // VAR_UNOWNCOUNT counts the letters. expect(world.state.unownDex.length).toBe(1); }); });