Files
Crystal/tests/battle-world.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

243 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
import type { GameWorld } from "../src/game/world/world";
import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
import { afterRestore, withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = (scenario: BattleScenario = "win") =>
battleFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), scenario);
function encounter(world: GameWorld) {
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
expect(world.battle).toBeDefined();
}
function finish(world: GameWorld, action: "fight" | "run" = "fight") {
const messages = new Set<string>();
for (let tick = 0; tick < 10000; tick++) {
if (world.battle) {
const view = world.battle;
if (view.message) messages.add(view.message.text);
if (view.prompt?.kind === "action") world.battleAction({ action, move: 0 });
else if (view.prompt?.kind === "yesno") world.battleChoose(1);
else if (view.prompt?.kind === "forget") world.battleChoose(3);
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
} else if (world.dialogue) {
messages.add(world.dialogue.pages[world.dialogue.index]);
world.advanceDialogue();
world.tick();
} else if (world.busy) world.tick();
else return [...messages].join("\n");
}
throw new Error(`Battle did not settle: ${world.diagnostic}`);
}
describe.skipIf(!existsSync(romPath))("world battle integration with the local ROM", () => {
it("runs a Bug-Catching Contest encounter through its battle script", () => {
const world = fixture("contest");
const party = structuredClone(world.state.party),
bag = structuredClone(world.state.bag);
// Pace on and off the test grass until the contest's 20% roll hits.
for (const direction of ENCOUNTER_WALK) if (!world.battle) world.move(direction);
for (let step = 0; step < 200 && !world.battle; step++) world.move(step % 2 ? "right" : "left");
expect(world.battle, world.diagnostic).toBeDefined();
const contest = world.state.bugContest!;
let asked = false;
const messages = new Set<string>();
for (let tick = 0; tick < 10000 && (world.battle || world.busy || world.dialogue); tick++) {
const view = world.battle;
if (!view) {
if (world.dialogue) world.advanceDialogue();
world.tick();
continue;
}
if (view.message) messages.add(view.message.text.replace(/\n/g, " "));
if (view.prompt?.kind === "action") {
expect(view.prompt.parkBalls).toBeGreaterThan(0);
world.battleAction({ action: "item", item: 0xb1, slot: -1 });
} else if (view.prompt?.kind === "contestswitch") {
asked = true;
expect(view.prompt.stock).toMatchObject({ name: "SCYTHER", level: 14 });
world.battleChoose(1);
} else if (view.message?.prompt) world.interact();
else world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
// Committed once the script ended: one Park Ball per throw, the new
// catch in the contest, and the party and pack untouched.
const thrown = [...messages].filter((text) => text.includes("used the PARK BALL")).length;
expect(thrown, [...messages].join(" | ")).toBeGreaterThan(0);
expect(world.state.bugContest!.parkBalls).toBe(20 - thrown);
expect(world.state.bugContest!.parkBalls).toBeLessThan(20);
expect(asked).toBe(true);
expect(world.state.bugContest!.mon!.species).not.toBe(123);
expect(world.state.party).toEqual(party);
expect(world.state.bag).toEqual(bag);
expect(contest).not.toBe(world.state.bugContest);
});
it("commits a victory, level-up, move learning and Pokédex sighting", () => {
const world = fixture();
encounter(world);
const before = world.save();
expect(before.party[0].level).toBe(5);
expect(before.pokedex.seen).toEqual([155]);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("SMOKESCREEN");
expect(world.state.party[0].level).toBe(6);
expect(world.state.party[0].moves).toContain(108);
expect(world.state.pokedex.seen.has(16)).toBe(true);
expect(world.state.pokedex.caught.has(16)).toBe(false);
const saved = world.save();
world.restore(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("evolves a Pokémon that levelled up once the battle is won", () => {
const world = fixture();
const data = world.assets.pokemon;
const mon = generatePartyMon(data, {
species: 155, level: 13, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.exp = data.expAtLevel(data.baseData(155).growthRate, 14) - 1;
world.state.party[0] = mon;
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("What? CYNDAQUIL\nis evolving!");
expect(messages).toContain("evolved into\nQUILAVA!");
const evolved = world.state.party[0];
expect([evolved.species, evolved.nickname, evolved.level]).toEqual([156, "QUILAVA", 14]);
expect(evolved.stats).toEqual(calcStats(data, 156, evolved.dvs, 14, evolved.statExp));
expect(world.state.pokedex.caught.has(156)).toBe(true);
const saved = world.save();
world.restore(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => {
const world = fixture();
const data = world.assets.pokemon;
// A level-14 Cyndaquil that does not take part could evolve, but
// EvolveAfterBattle only checks the one that grew to level 6.
world.state.party.push(generatePartyMon(data, {
species: 155, level: 14, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}));
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party[0].level).toBe(6);
expect(messages).not.toContain("evolving");
expect(world.state.party.map((mon) => mon.species)).toEqual([155, 155]);
});
it("struggles with recoil once every move is out of PP", () => {
const world = fixture();
world.state.party[0].pp = [0, 0, 0, 0];
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toMatch(/STRUGGLE/);
expect(messages).toMatch(/recoil/i);
});
it("escapes without awarding experience and returns to walking", () => {
const world = fixture();
encounter(world);
expect(finish(world, "run")).toMatch(/Got away safely/);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party[0].exp).toBe(178);
expect(world.state.pokedex.seen.has(16)).toBe(true);
expect(world.move("down")).toBe(true);
});
it("restores earned stat experience before the next stat recalculation", () => {
const world = fixture();
const mon = generatePartyMon(world.assets.pokemon, {
species: 155, level: 100, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.moves = [33, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
world.state.party = [mon];
encounter(world);
finish(world);
expect(world.notice, world.diagnostic).toBe("");
const saved = world.save();
expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true);
expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp));
expect(() => world.restore(saved)).not.toThrow();
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
const corrupt = structuredClone(saved);
corrupt.party[0].stats[1] = 999;
expect(() => world.restore(corrupt)).toThrow("Invalid native save");
});
it("whiteout heals the party, halves money and returns home", () => {
const world = fixture("loss");
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toMatch(/whited\s+out/i);
expect(world.save()).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 });
const mon = world.state.party[0];
expect(mon.hp).toBe(mon.stats[0]);
expect(mon.pp[0]).toBe(35);
expect(mon.status).toBe(0);
});
it("a mid-battle save restores the committed party and position", () => {
const world = fixture();
encounter(world);
const committed = world.save();
for (let tick = 0; tick < 5000; tick++) {
if (world.battle?.prompt?.kind === "action") {
world.battleAction({ action: "fight", move: 0 });
break;
}
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
world.restore(committed);
expect(world.battle).toBeUndefined();
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(committed, reloaded));
expect(world.move("right")).toBe(true);
});
it("a failed whiteout map callback cannot commit healing or money loss", () => {
const world = fixture("loss");
encounter(world);
const before = world.save();
// A malformed callback models a destination whose logic is not supported.
world.assets.map("24:7").callbacks.push({ kind: 5, script: world.assets.reader.bytes.length });
finish(world);
expect(world.notice).toContain("not been ported");
expect(world.save().money).toBe(before.money);
expect(world.save().party).toEqual(before.party);
expect(world.save().pokedex).toEqual(before.pokedex);
});
it("unsupported effects discard all battle party and Pokédex changes", () => {
const world = fixture("unsupported");
encounter(world);
const before = world.save();
finish(world);
expect(world.notice).toContain("not been ported");
expect(world.save().party).toEqual(before.party);
expect(world.save().pokedex).toEqual(before.pokedex);
expect(world.save().money).toBe(before.money);
});
});