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 { emptyScriptState } from "../src/game/scripts/interpreter"; import { generatePartyMon } from "../src/game/party/pokemon"; import { rockMonEncounter, treeMonEncounter, treeScore, } from "../src/game/scripts/treemons"; import type { Direction } from "../src/game/world/types"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const NEW_BARK_TOWN = "24:4", ROUTE_29 = "24:3", DARK_CAVE_VIOLET_ENTRANCE = "3:78"; const HEADBUTT = 29, ROCK_SMASH = 0xf9, TACKLE = 33; const EXEGGCUTE = 102, KRABBY = 98, HOOTHOOT = 163, SPINARAK = 167, PINECO = 204, SHUCKLE = 213, TOTODILE = 158; const COLL_HEADBUTT_TREE = 0x15; /** A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6. */ const ROCK = { x: 27, y: 6 }; describe("GetTreeScore", () => { it("compares the facing tile's score with the trainer ID's last digit", () => { // (5 × 5 + 5 + 5) / 5 = 7. expect(treeScore(5, 5, 7)).toBe(2); // equal: rare expect(treeScore(5, 5, 12343)).toBe(1); // 7 - 3 = 4: good expect(treeScore(5, 5, 2)).toBe(0); // 5: bad // (2 × 4 + 2 + 4) / 5 = 2, below the ID's digit: ten is added. expect(treeScore(2, 4, 9)).toBe(1); // 2 - 9 + 10 = 3 expect(treeScore(2, 4, 6)).toBe(0); // 2 - 6 + 10 = 6 // The largest coordinates still fit the routine's 16 bits. expect(treeScore(255, 255, 3)).toBe(1); // 65535 / 5 = 13107: 7 - 3 = 4 }); }); /** RandomRange answers in order, each checked against its range. */ function draws(...values: number[]) { const ranges: number[] = []; const next = (range: number) => { ranges.push(range); const value = values.shift(); if (value === undefined) throw new Error("No draw left."); expect(value).toBeLessThan(range); return value; }; return { next, ranges, left: () => values.length }; } describe.skipIf(!existsSync(romPath))("Headbutt and Rock Smash", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); it("draws tree Pokémon from the map's set by score", () => { const r = assets().reader; // (5, 5) scores 7: trainer IDs ending in 2, 3 and 7 are bad, good and rare. const tree = (id: number, ...values: number[]) => { const random = draws(...values); const mon = treeMonEncounter(r, ROUTE_29, { x: 5, y: 5 }, id, random.next); expect(random.left()).toBe(0); return { mon, ranges: random.ranges }; }; // Bad: only a zero out of ten finds one; the roll then picks the slot. expect(tree(2, 0, 0)).toEqual({ mon: { species: HOOTHOOT, level: 10 }, ranges: [10, 100], }); expect(tree(2, 1).mon).toBeUndefined(); // Good: five out of ten; 64 passes Hoothoot's 50 into Spinarak's 15. expect(tree(3, 4, 64).mon).toEqual({ species: SPINARAK, level: 10 }); expect(tree(3, 5).mon).toBeUndefined(); // Rare: eight out of ten, from the rare table after the common one. expect(tree(7, 7, 64).mon).toEqual({ species: PINECO, level: 10 }); expect(tree(7, 7, 99).mon).toEqual({ species: EXEGGCUTE, level: 10 }); expect(tree(7, 8).mon).toBeUndefined(); // New Bark Town's set is TREEMON_SET_NONE; Dark Cave is not listed. for (const map of [NEW_BARK_TOWN, DARK_CAVE_VIOLET_ENTRANCE]) { const random = draws(); expect(treeMonEncounter(r, map, { x: 5, y: 5 }, 2, random.next)).toBeUndefined(); expect(random.ranges).toEqual([]); } }); it("finds a rock Pokémon four times out of ten where the map has a set", () => { const r = assets().reader; const rock = (map: string, ...values: number[]) => { const random = draws(...values); const mon = rockMonEncounter(r, map, random.next); expect(random.left()).toBe(0); return mon; }; expect(rock(DARK_CAVE_VIOLET_ENTRANCE, 3, 89)).toEqual({ species: KRABBY, level: 15 }); expect(rock(DARK_CAVE_VIOLET_ENTRANCE, 3, 90)).toEqual({ species: SHUCKLE, level: 15 }); expect(rock(DARK_CAVE_VIOLET_ENTRANCE, 4)).toBeUndefined(); expect(rock(ROUTE_29)).toBeUndefined(); }); /** A world whose Random always returns `random`, at `hour`, with one * Pokémon that knows `moves`. */ function fixture(moves: number[], random = 0, hour = 12) { const crystal = assets(); const ported = crystal.isPorted.bind(crystal); crystal.isPorted = (id) => ported(id) || id === DARK_CAVE_VIOLET_ENTRANCE; const time = `2026-09-22T${String(hour).padStart(2, "0")}:00:00`; const world = new GameWorld(crystal, () => new Date(time), () => random); world.state = emptyScriptState(); const mon = generatePartyMon(crystal.pokemon, { species: TOTODILE, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0, }); mon.moves = [...moves, TACKLE, 0, 0, 0].slice(0, 4) as typeof mon.moves; mon.pp = [10, 10, 10, 10]; mon.nickname = "BITEY"; world.state.party = [mon]; world.state.scenes[NEW_BARK_TOWN] = 1; return { assets: crystal, world }; } /** Stand below a Route 29 Headbutt tree, facing it. */ function underTree(world: GameWorld) { const map = world.assets.map(ROUTE_29); for (let y = 1; y < map.height - 1; y++) for (let x = 0; x < map.width; x++) if ( world.assets.collision(map, x, y) === COLL_HEADBUTT_TREE && world.assets.permission(world.assets.collision(map, x, y + 1)) === 0 && !map.objects.some((o) => o.x === x && o.y <= y + 1 && o.y >= y) ) { world.restore({ ...world.save(), map: ROUTE_29, x, y: y + 1, direction: "up", objects: [] }); return { x, y }; } throw new Error("No reachable Headbutt tree."); } function atRock(world: GameWorld, direction: Direction = "up") { world.restore({ ...world.save(), map: DARK_CAVE_VIOLET_ENTRANCE, x: ROCK.x, y: ROCK.y + 1, direction, }); } const rockVisible = (world: GameWorld) => world.objects().some((o) => o.x === ROCK.x && o.y === ROCK.y); /** Drive pages and Yes/No boxes (YES) until a battle starts or the * world settles; returns the pages and the shake's ticks and square. */ function play(world: GameWorld) { const pages: string[] = []; let shakeTicks = 0, shaken: { x: number; y: number } | undefined; for (let tick = 0; tick < 2000; tick++) { if (world.battle) break; const dialogue = world.dialogue; const tree = world.headbuttTree; if (tree) { shakeTicks++; shaken = { x: tree.x, y: tree.y }; expect(dialogue).toBeUndefined(); } if (dialogue) { const page = dialogue.pages[dialogue.index]; if (pages.at(-1) !== page) pages.push(page); const last = dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice && dialogue.choice.kind !== "yesno") break; if (last && dialogue.choice?.kind === "yesno") world.choose(1); else if (last && dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); continue; } if (!world.busy) break; world.tick(); } return { text: pages.join("\n"), shakeTicks, shaken }; } /** Run from the battle and let the script finish. */ function runAway(world: GameWorld) { const messages: string[] = []; for (let tick = 0; tick < 5000; tick++) { const view = world.battle; if (view) { if (view.message && messages.at(-1) !== view.message.text) messages.push(view.message.text); if (view.prompt?.kind === "action") world.battleAction({ action: "run", move: 0 }); else if (view.message?.prompt) world.interact(); else world.tick(); } else if (world.dialogue) world.advanceDialogue(); else if (world.busy) world.tick(); else return messages.join("\n"); } throw new Error(`The battle did not end: ${world.diagnostic}`); } it("asks to Headbutt a tree, shakes it and finds nothing", () => { // Nine out of ten fails at any score. const { world } = fixture([HEADBUTT], 9); const tree = underTree(world); world.interact(); const { text, shakeTicks, shaken } = play(world); expect(world.notice, world.diagnostic).toBe(""); expect(text).toContain("A POKéMON could be\nin this tree."); expect(text).toContain("BITEY did a\nHEADBUTT!"); expect(text).toContain("Nope. Nothing…"); expect(text.indexOf("HEADBUTT!")).toBeLessThan(text.indexOf("Nope")); expect(shaken).toEqual(tree); expect(shakeTicks).toBe(20); expect(world.battle).toBeUndefined(); expect(world.busy).toBe(false); }); it("does nothing at a tree without a Pokémon that knows Headbutt", () => { const { world } = fixture([]); underTree(world); world.interact(); expect(world.dialogue).toBeUndefined(); expect(world.busy).toBe(false); }); it("drops a Pokémon from the tree into a tree battle, asleep in the day", () => { const { world } = fixture([HEADBUTT]); underTree(world); world.interact(); play(world); expect(world.battle, world.diagnostic).toBeDefined(); const enemy = world.battle!.engine.enemy.mon; expect(enemy.species).toBe(HOOTHOOT); expect(enemy.level).toBe(10); expect(enemy.status).toBe(7); // TREEMON_SLEEP_TURNS expect(world.battle!.engine.battleType).toBe(8); // BATTLETYPE_TREE const messages = runAway(world); expect(messages).toContain("HOOTHOOT fell\nout of the tree!"); expect(world.diagnostic).toBe(""); expect(world.state.pokedex.seen.has(HOOTHOOT)).toBe(true); }); it("leaves Hoothoot awake at night, when it is not in the asleep list", () => { const { world } = fixture([HEADBUTT], 0, 22); underTree(world); world.interact(); play(world); expect(world.battle, world.diagnostic).toBeDefined(); expect(world.battle!.engine.enemy.mon.species).toBe(HOOTHOOT); expect(world.battle!.engine.enemy.mon.status).toBe(0); runAway(world); }); /** Start → POKéMON → BITEY → `move`, then the queued script. */ function useFromMenu(world: GameWorld, move: string) { expect(world.openStartMenu()).toBe(true); const pick = (label: string) => { for (let tick = 0; tick < 200 && world.dialogue?.choice?.kind !== "menu"; tick++) { if (world.dialogue) world.advanceDialogue(); else world.tick(); } const choice = world.dialogue?.choice; if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`); const index = choice.items.findIndex((item) => item.startsWith(label)); expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0); world.choose(index); }; pick("POKéMON"); pick("BITEY"); pick(move); const { text } = play(world); // A failure prints its message and returns to the party menu. if (world.dialogue?.choice) { for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue(); settle(world); } return [text]; } it("uses Headbutt from the party menu only when facing a tree", () => { const { world } = fixture([HEADBUTT], 9); underTree(world); world.restore({ ...world.save(), direction: "down" }); expect(useFromMenu(world, "HEADBUTT")[0]).toMatch(/^Can't use that\nhere.\n/); world.restore({ ...world.save(), direction: "up" }); const [text] = useFromMenu(world, "HEADBUTT"); expect(world.notice, world.diagnostic).toBe(""); // HeadbuttFromMenuScript skips the question. expect(text).not.toContain("in this tree"); expect(text).toContain("BITEY did a\nHEADBUTT!"); expect(text).toContain("Nope. Nothing…"); }); it("explains a rock to a party without Rock Smash", () => { const { world } = fixture([]); atRock(world); world.interact(); expect(settle(world)).toEqual(["Maybe a POKéMON\ncan break this."]); expect(rockVisible(world)).toBe(true); }); it("smashes a rock that hides nothing", () => { const { world } = fixture([ROCK_SMASH], 9); atRock(world); expect(rockVisible(world)).toBe(true); world.interact(); const { text } = play(world); expect(world.notice, world.diagnostic).toBe(""); expect(text).toContain("This rock looks\nbreakable."); expect(text).toContain("BITEY used\nROCK SMASH!"); expect(world.battle).toBeUndefined(); expect(rockVisible(world)).toBe(false); }); it("smashes a rock from the party menu and meets the Krabby under it", () => { const { world } = fixture([ROCK_SMASH]); atRock(world, "down"); expect(useFromMenu(world, "ROCK SMASH")[0]).toMatch(/^Can't use that\nhere.\n/); world.restore({ ...world.save(), direction: "up" }); const [text] = useFromMenu(world, "ROCK SMASH"); expect(world.notice, world.diagnostic).toBe(""); expect(text).not.toContain("breakable"); expect(text).toContain("BITEY used\nROCK SMASH!"); expect(rockVisible(world)).toBe(false); expect(world.battle).toBeDefined(); const enemy = world.battle!.engine.enemy.mon; expect([enemy.species, enemy.level, enemy.status]).toEqual([KRABBY, 15, 0]); expect(world.battle!.engine.battleType).toBe(0); expect(runAway(world)).toContain("Wild KRABBY\nappeared!"); expect(world.diagnostic).toBe(""); }); });