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:
1 parent
ee55d344af
commit
f259163da9
18 files changed
+789
-36
No files matched your search
@@ -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
@@ -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.");
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user