Files
Crystal/tests/bug-contest.test.ts
T
kawaandClaude Opus 5.5 2640fa2725 Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates,
Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's
room and both floors of the Burned Tower. The story runs from Floria,
the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the
rival in the Burned Tower and the beasts' release to Ecruteak Gym,
Morty, the FOGBADGE and TM30.

Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons,
CheckEncounterRoamMon) with their state in the save; only Continue moves
them on restore, so a failed import's rollback keeps them in place.
Port the Bug-Catching Contest: registration on contest days, Park Balls,
contestants, the timer, QUIT, the catch-and-swap prompt, judging from
the ROM's contestant table, prizes and the saved contest state.

Port the Game Corner's SlotMachine and CardFlip on a new realtime tile
screen that advances two frames per tick with buffered buttons: reel
strips and scrolling, bias tables, the seven streak, Golem and Chansey,
payouts, the Card Flip board, cursor and navigation. Their tilemaps are
checked against the ROM's own data.

Also port getnum, a plain warp that continues its script on the new map,
and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the
Burned Tower rival) is no longer sighted once its own flag is set, as
the original's read of bit 1 of wNumKeyItems gives with the usual key
items.

The playthrough walks through the National Park and continues to Morty;
its driver chooses moves by type matchup, buys at Marts and uses Super
Potions and Awakenings in trainer battles. New tests cover roaming, the
contest, the Game Corner and Ecruteak's side content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:10:17 +02:00

374 lines
15 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, migrateSave } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle, findPath } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
// Item IDs read from constants/item_constants.asm, not reference-checked
// here (only ROM/RAM addresses in profile.ts are).
const SUN_STONE = 169,
EVERSTONE = 112,
BERRY = 173,
GOLD_BERRY = 174;
// CATERPIE and WEEDLE (constants/pokemon_constants.asm): ContestMons'
// first two entries, each 20%.
const CATERPIE = 10,
WEEDLE = 13;
const ROUTE35_GATE = "10:15",
ROUTE36_GATE = "10:17",
NATIONAL_PARK = "3:15",
CONTEST_PARK = "3:16";
// Date#getDay() matches the ROM's SUNDAY..SATURDAY (0-6) directly, as the
// rest of the native clock already assumes (world.ts's weekday math).
const CONTEST_DAY = "2026-09-22T12:00:00"; // Tuesday
const OFF_DAY = "2026-09-25T12:00:00"; // Friday
// A COLL_LONG_GRASS patch reachable from the Route 36 gate's warp-in
// point (33, 18); the Route 35 gate's own (10, 47) lands in a separate,
// grass-less walkable region of this map.
const GRASS = { x: 31, y: 17 };
const GRASS2 = { x: 30, y: 17 };
function fixture(options: { day?: string; party?: number[] } = {}) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
let now = new Date(options.day ?? CONTEST_DAY);
let seed = 7;
const forced: number[] = [];
const random = () => {
if (forced.length) return forced.shift()!;
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const world = new GameWorld(assets, () => now, random);
const species = options.party ?? [155]; // Cyndaquil: strong enough that nothing faints.
world.state.party = species.map((sp) =>
generatePartyMon(assets.pokemon, {
species: sp,
level: 60,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xffff,
caughtData: 0,
}),
);
world.state.money = 3000;
return {
world,
forced,
advance: (ms: number) => {
now = new Date(now.getTime() + ms);
},
};
}
/** Teleport straight into a gate or the park, bypassing Route 35/36/37
* (opened separately; see the task brief). */
function enterAt(
world: GameWorld,
map: string,
x: number,
y: number,
direction: Direction = "down",
) {
world.restore({ ...world.save(), map, x, y, direction, objects: [] });
}
/** Walk to a tile, stopping early if a step starts a battle (findPath's
* BFS keeps walking around the contest's roaming NPCs). */
function walkToOrBattle(world: GameWorld, x: number, y: number) {
for (const direction of findPath(world, x, y)) {
if (world.battle) return;
if (!world.move(direction)) throw new Error(`Blocked moving ${direction}.`);
for (let tick = 0; tick < 40 && world.busy && !world.battle; tick++)
world.tick();
if (world.battle) return;
}
}
/** Drive a Bug-Catching Contest battle: PARKBALL× on every action prompt,
* as the contest's own battle menu requires. */
function driveContestBattle(world: GameWorld, switchIn = true) {
for (let tick = 0; tick < 20_000; tick++) {
const view = world.battle;
if (!view) return;
const prompt = view.prompt;
if (prompt?.kind === "action")
world.battleAction({ action: "item", item: 0, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(1);
else if (prompt?.kind === "contestswitch")
world.battleChoose(switchIn ? 1 : 0);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
}
throw new Error("The contest battle did not settle.");
}
/** Registers at the Route 35 gate (a one-Pokémon party skips the "leave
* the rest behind" question) and lands in the contest map. */
function register(world: GameWorld) {
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.mapId).toBe(CONTEST_PARK);
}
describe.skipIf(!existsSync(romPath))("the Bug-Catching Contest", () => {
it("the gate's officer offers the contest on Tuesday/Thursday/Saturday", () => {
const { world } = fixture({ day: CONTEST_DAY });
register(world);
expect(world.state.bugContest).toEqual({ parkBalls: 20, mon: null });
expect(world.state.bugContestEnd).toBeGreaterThan(Date.parse(CONTEST_DAY));
expect(world.state.flags.has(17)).toBe(true); // ENGINE_BUG_CONTEST_TIMER
expect(world.x).toBe(10);
expect(world.y).toBe(47);
});
it("an off day only explains the contest, without starting one", () => {
const { world } = fixture({ day: OFF_DAY });
enterAt(world, ROUTE35_GATE, 0, 2, "down");
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/PARK/);
expect(world.state.bugContest).toBeUndefined();
expect(world.mapId).toBe(ROUTE35_GATE);
});
it("leaves the rest of the party with the contest helper when it has more than one Pokémon", () => {
const { world } = fixture({ party: [155, 16] }); // Cyndaquil, Pidgey
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
settle(world); // "Would you like to try?" / "use the first Pokémon?" / promptbuttons
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party).toHaveLength(1);
expect(world.state.party[0].species).toBe(155);
expect(world.mapId).toBe(CONTEST_PARK);
});
it("refuses to register with a fainted or an Egg first Pokémon", () => {
{
const { world } = fixture({ party: [155, 16] });
world.state.party[0].hp = 0;
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/can't battle/);
expect(world.state.bugContest).toBeUndefined();
}
{
const { world } = fixture({ party: [155, 16] });
world.state.party[0].egg = true;
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/EGG/);
expect(world.state.bugContest).toBeUndefined();
}
});
it("catches a bug, catches a second and swaps, then quits early for judging and a prize", () => {
const { world, forced } = fixture({ day: CONTEST_DAY });
// The Route 36 gate's own registration lands at (33, 18), which (unlike
// the Route 35 gate's (10, 47)) can reach this map's grass.
enterAt(world, ROUTE36_GATE, 0, 4, "up");
world.interact();
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.mapId).toBe(CONTEST_PARK);
// SetUpFiveStepWildEncounterCooldown: walk off the five-step cooldown
// from entering the map on safe (non-grass) ground before approaching it.
walkToOrBattle(world, 28, 18);
expect(world.battle).toBeUndefined();
// Force the first encounter (long grass: 40% of 255) onto Caterpie
// (ContestMons' first 20% entry) at its minimum level.
forced.push(0, 0, 0);
walkToOrBattle(world, GRASS.x, GRASS.y);
expect(world.battle).toBeDefined();
driveContestBattle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bugContest?.mon?.species).toBe(CATERPIE);
expect(world.state.bugContest?.parkBalls).toBeLessThan(20);
const ballsAfterFirst = world.state.bugContest!.parkBalls;
// A second catch (the next ContestMons entry, Weedle) offers a swap.
forced.push(0, 50, 0);
walkToOrBattle(world, GRASS2.x, GRASS2.y);
if (!world.battle) walkToOrBattle(world, GRASS.x, GRASS.y);
expect(world.battle).toBeDefined();
driveContestBattle(world, true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bugContest?.mon?.species).toBe(WEEDLE);
expect(world.state.bugContest!.parkBalls).toBeLessThan(ballsAfterFirst);
// QUIT replaces SAVE in the start menu, and PACK is not offered.
world.openStartMenu();
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
expect(menu.kind).toBe("menu");
expect(menu.items.some((label) => /PACK/.test(label))).toBe(false);
const quitIndex = menu.items.findIndex((label) => /QUIT/.test(label));
expect(quitIndex).toBeGreaterThanOrEqual(0);
world.choose(quitIndex);
expect(world.dialogue?.choice?.kind).toBe("yesno");
world.choose(1); // "Do you want to finish now?"
// BugContestResultsScript: the ready-to-judge text, third/second/first
// place announcements, the player's own prize (or consolation Berry),
// the returned party (none left behind here) and the nickname offer.
const shown = settle(world, [1, ""]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/Weedle|WEEDLE/i);
expect(world.state.bugContest).toBeUndefined();
expect(world.state.bugContestEnd).toBeUndefined();
expect(world.state.flags.has(17)).toBe(false);
expect(world.mapId).toBe(ROUTE36_GATE);
// The judged Weedle joined the party (which had room) with the
// National Park as its caught location.
const caught = world.state.party.at(-1)!;
expect(caught.species).toBe(WEEDLE);
expect(caught.caughtData & 0x7f).toBe(0x13); // LANDMARK_NATIONAL_PARK
});
it("ends the contest and prints the judging when the twenty minutes run out", () => {
const { world, advance } = fixture({ day: CONTEST_DAY });
register(world);
advance(21 * 60_000);
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/time|Time/);
expect(world.state.bugContest).toBeUndefined();
expect(world.mapId).toBe(ROUTE36_GATE);
});
it("awards first place and the Sun Stone to an outstanding catch", () => {
const { world } = fixture({ day: CONTEST_DAY });
register(world);
// A maximum-level, maximum-stat catch outscores every AI contestant.
world.state.bugContest = {
parkBalls: 15,
mon: generatePartyMon(world.assets.pokemon, {
species: 127, // Pinsir: a real ContestMons rare entry, high stats.
level: 100,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xffff,
caughtData: 0,
}),
};
const before = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0;
world.openStartMenu();
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
world.choose(menu.items.findIndex((label) => /QUIT/.test(label)));
world.choose(1);
const shown = settle(world, [1, ""]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/winner/i);
const after = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0;
expect(after).toBe(before + 1);
});
it("boxes the catch when the party is full, with the National Park landmark", () => {
const { world } = fixture({ day: CONTEST_DAY, party: [155, 16, 19, 1, 4, 7] });
// Registering with more than one Pokémon leaves the other five with
// the helper (ContestDropOffMons); ContestReturnMons restores them
// during judging, which is where the party is actually full again.
register(world);
expect(world.state.party).toHaveLength(1);
expect(world.state.contestLeftParty).toHaveLength(5);
world.state.bugContest = {
parkBalls: 10,
mon: generatePartyMon(world.assets.pokemon, {
species: CATERPIE,
level: 7,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
}),
};
world.openStartMenu();
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
world.choose(menu.items.findIndex((label) => /QUIT/.test(label)));
world.choose(1);
const shown = settle(world, [1, ""]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party).toHaveLength(6); // unchanged; went to the box
const box = world.state.storage.boxes[world.state.storage.current];
expect(box[0]?.species).toBe(CATERPIE);
expect(box[0]?.caughtData & 0x7f).toBe(0x13);
expect(shown.join("\n")).toMatch(/PC|BOX/i);
});
it("saves and restores mid-contest, keeping the Park Balls, caught mon and deadline", () => {
const { world } = fixture({ day: CONTEST_DAY });
register(world);
world.state.bugContest = {
parkBalls: 12,
mon: generatePartyMon(world.assets.pokemon, {
species: WEEDLE,
level: 9,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x1234,
caughtData: 0,
}),
};
const saved = world.save();
expect(saved.bugContest).toEqual({ parkBalls: 12, mon: world.state.bugContest.mon });
expect(saved.bugContestEnd).toBe(world.state.bugContestEnd);
const assets2 = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const reloaded = new GameWorld(assets2, () => new Date(CONTEST_DAY), () => 0);
reloaded.restore(saved);
expect(reloaded.notice, reloaded.diagnostic).toBe("");
expect(reloaded.state.bugContest).toEqual(world.state.bugContest);
expect(reloaded.state.bugContestEnd).toBe(world.state.bugContestEnd);
// The deadline survives the reload and still ends the contest on time.
const laterSave = { ...reloaded.save() };
const assets3 = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
let later = new Date(Date.parse(CONTEST_DAY) + 21 * 60_000);
const reloaded2 = new GameWorld(assets3, () => later, () => 0);
reloaded2.restore(laterSave);
settle(reloaded2, [1, ""]); // accept the judged Weedle's nickname offer
expect(reloaded2.notice, reloaded2.diagnostic).toBe("");
expect(reloaded2.state.bugContest).toBeUndefined();
});
it("migrates an older save with no contest fields into no contest running", () => {
const { world } = fixture();
const save = world.save() as Record<string, unknown>;
delete save.bugContest;
delete save.bugContestEnd;
const migrated = migrateSave(save, () => 1);
expect(migrated).toBeDefined();
expect(migrated!.bugContest).toBeUndefined();
expect(migrated!.bugContestEnd).toBeUndefined();
world.restore(migrated);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bugContest).toBeUndefined();
});
it("the National Park's own NPCs and items work outside the contest", () => {
const { world } = fixture({ day: OFF_DAY });
enterAt(world, NATIONAL_PARK, 27, 41, "up"); // facing Teacher1 at (27,40)
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/QUICK CLAW/);
const item = world.assets.pokemon.itemName(73); // QUICK_CLAW
expect(world.state.bag.items.some((slot) => world.assets.pokemon.itemName(slot.item) === item)).toBe(true);
});
});