Port Headbutt and Rock Smash encounters

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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 12:20:13 +02:00
1 parent ee55d344af
commit f259163da9
18 files changed
+789 -36

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
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("");
});
});