Facing a Headbutt tree asks to HEADBUTT it, and HEADBUTT from the party menu skips the question. ShakeHeadbuttTree shakes the tree as a sprite over grass, then TreeMonEncounter scores the facing tile against the trainer ID and draws from the map's common or rare tree table. A Pokémon that falls fights as BATTLETYPE_TREE: "fell out of the tree!", and the time of day's AsleepTreeMons species start asleep. Smashable rocks run SmashRockScript, and ROCK SMASH from the party menu acts on the facing rock; after the rock breaks, RockMonEncounter gives the map's rock set a 40% chance of a wild battle. Closes #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
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("");
|
||
});
|
||
});
|