Files
Crystal/tests/headbutt-rock-smash.test.ts
Sébastien QUEROLandClaude Opus 5.5 f259163da9 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>
2026-10-02 12:20:13 +02:00

339 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("");
});
});