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

+28 -1
View File
@@ -18,7 +18,8 @@ async function start(
| "candy"
| "pokegear"
| "mom"
| "fly" = false,
| "fly"
| "headbutt" = false,
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
@@ -480,6 +481,32 @@ test("Fly picks a visited town on the Fly map and lands there", async ({
expect([state.map, state.x, state.y]).toEqual(["26:3", 29, 4]);
});
test("Headbutt shakes a tree and a sleeping Hoothoot falls out", async ({
page,
}) => {
await start(page, "headbutt");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
screen = page.locator("#game-screen");
await page.keyboard.press("KeyX");
await expect(text).toHaveText(/A POKéMON could be\s+in this tree\./);
await page.keyboard.press("KeyX");
await expect(text).toHaveText(/Want to HEADBUTT\s+it\?/);
await choices.getByRole("button", { name: "YES" }).click();
await expect(text).toHaveText(/TOTODILE did a\s+HEADBUTT!/);
await page.keyboard.press("KeyX");
await expect(page.locator("#native-dialogue")).toBeHidden();
await page.clock.runFor(5_000);
await expect(page.locator("#battle")).toBeVisible();
await expect(page.locator("#battle-text")).toHaveText(
/HOOTHOOT fell\s+out of the tree!/,
);
await expect(page.locator("#battle-foe-name")).toHaveText("HOOTHOOT");
await expect(page.locator("#battle-foe-status")).toHaveText("SLP");
await expect(page.locator("#game-notice")).toHaveText("");
await expect(screen).toHaveAttribute("data-map", "24:3");
});
test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom");
const text = page.locator("#dialogue-text"),
+53 -11
View File
@@ -9,10 +9,21 @@ import { generatePartyMon } from "../src/game/party/pokemon";
* Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom"
* faces Mom in the living room after the Mystery Egg, with her bank open
* and ¥2300 saved. "fly" stands outdoors with a Pidgeot that knows Fly,
* the Storm Badge, and New Bark Town and Cherrygrove City visited. */
* the Storm Badge, and New Bark Town and Cherrygrove City visited.
* "headbutt" faces Route 29's first reachable Headbutt tree from below
* with a Pokémon that knows Headbutt; Random always returning 0 drops a
* sleeping Hoothoot. */
export function foundationFixture(
assets: CrystalAssets,
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" | "fly",
room:
| boolean
| "poster"
| "center"
| "candy"
| "pokegear"
| "mom"
| "fly"
| "headbutt",
) {
const world = new GameWorld(
assets,
@@ -73,6 +84,21 @@ export function foundationFixture(
world.state.flags.add(assets.spawnEngineFlag(spawn)!);
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "headbutt") {
const mon = generatePartyMon(assets.pokemon, {
species: 158, // TOTODILE
level: 30,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
mon.moves = [OW.HEADBUTT, 33, 0, 0]; // TACKLE
mon.pp = [15, 35, 0, 0];
world.state.party = [mon];
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "mom") {
// EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM
// and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST.
@@ -83,15 +109,17 @@ export function foundationFixture(
}
const save = world.save();
const [map, x, y] =
room === "center" || room === "candy"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
: room === "mom"
? ["24:6", 7, 3]
: room && room !== "pokegear" && room !== "fly"
? ["24:7", 2, 2]
: ["24:4", 9, 6];
room === "headbutt"
? headbuttSpot(assets)
: room === "center" || room === "candy"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
: room === "mom"
? ["24:6", 7, 3]
: room && room !== "pokegear" && room !== "fly"
? ["24:7", 2, 2]
: ["24:4", 9, 6];
world.restore({
...save,
map,
@@ -102,3 +130,17 @@ export function foundationFixture(
});
return world;
}
/** Below Route 29's first Headbutt tree with walkable ground under it. */
function headbuttSpot(assets: CrystalAssets): [string, number, number] {
const map = assets.map("24:3");
for (let y = 1; y < map.height - 1; y++)
for (let x = 0; x < map.width; x++)
if (
assets.collision(map, x, y) === OW.COLL_HEADBUTT_TREE &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y <= y + 1 && o.y >= y)
)
return ["24:3", x, y + 1];
throw new Error("No reachable Headbutt tree.");
}
+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("");
});
});
+7
View File
@@ -65,5 +65,12 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
expect(world.mapId).toBe("24:4");
write("fly-teleport", world);
});
it("writes headbutt: Route 29 facing a Headbutt tree", () => {
// Totodile with Headbutt and Tackle, below the first reachable tree.
const world = foundationFixture(assets(), "headbutt");
expect(world.mapId).toBe("24:3");
write("headbutt", world);
});
},
);
+14 -5
View File
@@ -537,7 +537,7 @@ describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
closeMenus(world);
});
it("recognises a smashable rock for Rock Smash, which is not ported further", () => {
it("recognises a smashable rock for Rock Smash and smashes it", () => {
// A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6.
const spot = { map: "3:78", x: 27, y: 6 };
const { assets, world } = fixture([spot.map]);
@@ -552,10 +552,19 @@ describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
expect(useFromMenu(world, "ROCK SMASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
useFromMenu(world, "ROCK SMASH");
expect(world.notice).toBe("Smashing rocks is not ported yet.");
closeMenus(world);
expect(world.diagnostic).toBe("");
const shown = useFromMenu(world, "ROCK SMASH");
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
// Random always returns 0 here, so the rock hides a Krabby.
for (let tick = 0; tick < 500 && !world.battle; tick++) {
if (world.dialogue) {
shown.push(world.dialogue.pages[world.dialogue.index]);
world.advanceDialogue();
} else world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nROCK SMASH!");
expect(world.objects().some((o) => o.x === spot.x && o.y === spot.y)).toBe(false);
expect(world.battle?.engine.enemy.mon.species).toBe(98); // KRABBY
});
it("draws a wild Pokémon with Sweet Scent in grass, and nothing elsewhere", () => {