Files
Crystal/tests/battle-types.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 8fae1f0c0f
Checks / web (push) Canceled after 0s
Port the special battle types and contest battles
Run the forced-shiny, trap, forced-item, Celebi, Suicune, roaming and Bug-Catching Contest battle types. Roaming battles keep their struct's DVs and HP byte, and every other battle makes CleanUpBattleRAM's roaming roll. The contest shows PARKBALL×, counts down Park Balls, ends when they run out, keeps its catch outside the party and asks before switching. Scripts load the special types with loadvar, and while a contest runs, encounters use BugCatchingContestBattleScript and ContestMons.

Validated with 596 unit tests, 155 browser tests (including the contest menu and switch screen), native/reference builds and reference/vendor/release checks. National Park's contest event, the roaming release and the Ilex Forest Celebi event come with their maps.

Closes #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:56:30 +02:00

311 lines
13 KiB
TypeScript
Raw 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 { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { RomReader } from "../src/game/rom/reader";
import {
Battle,
BATTLERESULT_CAUGHT_CELEBI,
UnsupportedBattle,
type BattleEffect,
type BattleInput,
type BattleOptions,
type BattleOutcome,
type BugContest,
type RoamMon,
} from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { ScriptRun, emptyScriptState } from "../src/game/scripts/interpreter";
import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const CATERPIE = 10,
SCYTHER = 123,
GYARADOS = 130,
SNORLAX = 143,
TYPHLOSION = 157,
RAIKOU = 243,
SUICUNE = 245,
CELEBI = 251,
TACKLE = 33;
describe("loadvar VAR_BATTLETYPE", () => {
const host = {
mapId: "1:1",
lastTalked: 0,
sceneOwner: () => undefined,
timeMask: () => 2,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
clockDay: () => 0,
text: () => defaultTextContext("KAWA"),
};
const battle = (type: number) => {
// loadvar VAR_BATTLETYPE, type; loadwildmon GYARADOS, 30; startbattle;
// startbattle; end. CleanUpBattleRAM clears the type for the second.
const bytes = Uint8Array.from([0x1e, 3, type, 0x5d, GYARADOS, 30, 0x5f, 0x5f, 0x91]);
const run = new ScriptRun(new RomReader(bytes), 0, emptyScriptState(), host);
const first = run.resume();
const second = run.resume(0);
return [first, second] as const;
};
it("passes the event battle types to the battle and clears them afterwards", () => {
for (const type of [
C.BATTLETYPE_CONTEST,
C.BATTLETYPE_FORCESHINY,
C.BATTLETYPE_TRAP,
C.BATTLETYPE_FORCEITEM,
C.BATTLETYPE_SUICUNE,
]) {
const [first, second] = battle(type);
expect(first).toMatchObject({ kind: "battle", canLose: false, battleType: type });
expect(second).toMatchObject({ kind: "battle", battleType: undefined });
}
expect(battle(C.BATTLETYPE_CANLOSE)[0]).toMatchObject({ canLose: true, battleType: undefined });
});
it("refuses battle types that only the engine's own routines set", () => {
for (const type of [C.BATTLETYPE_TUTORIAL, C.BATTLETYPE_ROAMING, C.BATTLETYPE_CELEBI, 13])
expect(() => battle(type)).toThrow("battle type");
});
});
describe.skipIf(!existsSync(romPath))("special battle types with the local ROM", () => {
let data: CrystalAssets["pokemon"];
beforeAll(() => {
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
});
const strong = (): PartyMon => {
const mon = generatePartyMon(data, {
species: TYPHLOSION, level: 100, item: 0, otId: 1234, ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.moves = [TACKLE, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
return mon;
};
/** A battle whose Random bytes come from `bytes` first, then an LCG. */
const setup = (options: Partial<BattleOptions> & { seed?: number; bytes?: number[] } = {}) => {
let seed = options.seed ?? 1;
const bytes = [...(options.bytes ?? [])];
const draws: number[] = [];
const party = options.party ?? [strong()];
const battle = new Battle({
data,
party,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
const value = bytes.length ? bytes.shift()! : (seed >>> 16) & 255;
draws.push(value);
return value;
},
wild: { species: CATERPIE, level: 5 },
text: () => defaultTextContext("CHRIS"),
bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 5 }], keyItems: [] },
hasPokedex: true,
caughtData: () => 0x1234,
...options,
});
return { battle, party, draws };
};
/** Answer menus with `actions` and choices with `answers`; stops at a
* menu with no action left. */
const play = (battle: Battle, actions: BattleInput[], answers: BattleInput[] = []) => {
const run = battle.run();
const texts: string[] = [];
const prompts: BattleEffect[] = [];
let input: BattleInput;
for (let guard = 0; guard < 2000; guard++) {
const next = run.next(input);
input = undefined;
if (next.done) return { texts, prompts, outcome: next.value as BattleOutcome };
const effect = next.value;
if (effect.kind === "text") texts.push(effect.text.replace(/\n/g, " "));
else if (effect.kind === "action") {
prompts.push(effect);
if (!actions.length) return { texts, prompts, outcome: undefined };
input = actions.shift();
} else if (effect.kind === "yesno" || effect.kind === "name" || effect.kind === "contestswitch") {
prompts.push(effect);
input = answers.shift();
} else if (effect.kind === "forget") input = 4;
}
throw new Error("The battle did not finish.");
};
const master = { action: "item" as const, item: C.MASTER_BALL, slot: 0 };
const run = { action: "run" as const };
const park = { action: "item" as const, item: C.PARK_BALL, slot: -1 };
it("refuses unknown types and event battles missing their state", () => {
const cases: Partial<BattleOptions>[] = [
{ battleType: 13 },
{ battleType: C.BATTLETYPE_TUTORIAL },
{ battleType: C.BATTLETYPE_CONTEST },
{ battleType: C.BATTLETYPE_ROAMING },
{ battleType: C.BATTLETYPE_ROAMING, roamer: { species: SUICUNE, level: 40, mapId: "1:1", hp: 0, dvs: 0 } },
];
for (const options of cases) {
const { battle, draws } = setup(options);
expect(() => battle.run().next()).toThrow(UnsupportedBattle);
expect(draws).toEqual([]);
}
});
it("makes the Red Gyarados shiny without drawing its DVs, and keeps the player in", () => {
const { battle, draws } = setup({ battleType: C.BATTLETYPE_FORCESHINY, wild: { species: GYARADOS, level: 30 }, bytes: [0] });
const { texts } = play(battle, [run]);
expect(battle.enemy.mon.dvs).toBe(0xeaaa);
// Only the item roll (no item) precedes the menu.
expect(draws).toEqual([0]);
expect(texts).toContain("Can't escape!");
});
it("refuses to let the player run from the trap, Celebi and Suicune battles", () => {
for (const battleType of [C.BATTLETYPE_TRAP, C.BATTLETYPE_CELEBI, C.BATTLETYPE_SUICUNE]) {
const { battle } = setup({ battleType });
const { texts, outcome } = play(battle, [run]);
expect(texts).toContain("Can't escape!");
expect(outcome).toBeUndefined();
}
});
it("forces Snorlax's first held item without the item rolls", () => {
const { battle, draws } = setup({ battleType: C.BATTLETYPE_FORCEITEM, wild: { species: SNORLAX, level: 50 }, bytes: [0x12, 0x34] });
play(battle, []);
expect(battle.enemy.mon.item).toBe(data.baseData(SNORLAX).items[0]);
expect(battle.enemy.mon.dvs).toBe(0x1234);
expect(draws.slice(0, 2)).toEqual([0x12, 0x34]);
// Catching it reloads the item without a roll either.
const caught = setup({ battleType: C.BATTLETYPE_FORCEITEM, wild: { species: SNORLAX, level: 50 } });
const { outcome } = play(caught.battle, [master], [0]);
expect(outcome?.result).toBe(C.WIN);
expect(caught.draws).toHaveLength(3); // DVs and CleanUpBattleRAM's roaming roll
expect(caught.party[1].item).toBe(data.baseData(SNORLAX).items[0]);
});
it("announces Celebi and records its capture in the battle result", () => {
const { battle } = setup({ battleType: C.BATTLETYPE_CELEBI, wild: { species: CELEBI, level: 30 } });
const { texts, outcome } = play(battle, [master], [0]);
expect(texts[0]).toBe("Wild CELEBI appeared!");
expect(outcome!.result).toBe(BATTLERESULT_CAUGHT_CELEBI | C.WIN);
});
it("moves the roaming Pokémon after one battle in sixteen", () => {
const seen = new Set<boolean>();
for (let seed = 1; seed < 200 && seen.size < 2; seed++) {
const { battle, draws } = setup({ seed });
const { outcome } = play(battle, [master], [0]);
// CleanUpBattleRAM's BattleRandom is the battle's last draw.
const moved = !(draws.at(-1)! & 0x0f);
expect(outcome!.updateRoamMons).toBe(moved ? true : undefined);
seen.add(moved);
}
expect(seen.size).toBe(2);
});
it("keeps a roaming Pokémon's DVs and HP between encounters, and removes it once caught", () => {
const roamer: RoamMon = { species: RAIKOU, level: 40, mapId: "10:1", hp: 0, dvs: 0 };
// The item roll (no item), then the DVs: Speed/Special first.
const first = setup({ battleType: C.BATTLETYPE_ROAMING, roamer, wild: { species: RAIKOU, level: 40 }, bytes: [0, 0x34, 0x12] });
const fled = play(first.battle, [run]);
const maxHp = first.battle.enemy.mon.maxHp;
expect(first.battle.enemy.mon.dvs).toBe(0x1234);
expect(roamer).toMatchObject({ species: RAIKOU, dvs: 0x1234, hp: maxHp & 0xff });
expect(fled.outcome).toMatchObject({ result: C.DRAW, updateRoamMons: true });
// Damaged HP is kept as its low byte, and the DVs are not drawn again.
roamer.hp = 50;
const second = setup({ battleType: C.BATTLETYPE_ROAMING, roamer, wild: { species: RAIKOU, level: 40 }, bytes: [0] });
const caught = play(second.battle, [master], [0]);
expect(second.battle.enemy.mon.dvs).toBe(0x1234);
expect(second.party[1]).toMatchObject({ species: RAIKOU, dvs: 0x1234, hp: (maxHp & 0xff00) | 50 });
expect(caught.outcome!.result).toBe(C.WIN);
expect(caught.outcome!.updateRoamMons).toBeUndefined();
expect(roamer).toEqual({ species: 0, level: 40, mapId: null, hp: 0, dvs: 0x1234 });
});
describe("the Bug-Catching Contest", () => {
const contestBattle = (contest: BugContest, options: Partial<BattleOptions> & { seed?: number } = {}) =>
setup({ battleType: C.BATTLETYPE_CONTEST, contest, ...options });
it("shows PARKBALL×, lets the player go, and leaves the pack alone", () => {
const contest: BugContest = { parkBalls: 20, mon: null };
const { battle } = contestBattle(contest);
const { texts, prompts, outcome } = play(battle, [run]);
expect(prompts[0]).toEqual({ kind: "action", parkBalls: 20 });
expect(texts).toContain("Got away safely!");
expect(outcome!.result).toBe(C.DRAW);
});
it("keeps its first catch for the contest, outside the party", () => {
for (let seed = 1; seed < 100; seed++) {
const contest: BugContest = { parkBalls: 20, mon: null };
const { battle, party } = contestBattle(contest, { seed });
const { texts, outcome } = play(battle, [park, park, park, park, park]);
if (!contest.mon) continue;
expect(outcome!.result).toBe(C.WIN);
expect(texts).toContain("CHRIS used the PARK BALL.");
expect(texts).toContain("Caught CATERPIE!");
expect(party).toHaveLength(1);
const thrown = texts.filter((text) => text === "CHRIS used the PARK BALL.").length;
expect(contest.parkBalls).toBe(20 - thrown);
const enemy = battle.enemy.mon;
expect(contest.mon).toMatchObject({
species: CATERPIE, level: 5, dvs: enemy.dvs, hp: enemy.hp, caughtData: 0, happiness: C.BASE_HAPPINESS,
});
return;
}
throw new Error("No seed caught the Pokémon.");
});
it("asks before replacing the stocked Pokémon", () => {
for (const answer of [0, 1]) {
const stock = strong();
stock.species = SCYTHER;
stock.level = 14;
let done = false;
for (let seed = 1; seed < 100 && !done; seed++) {
const contest: BugContest = { parkBalls: 20, mon: stock };
const { battle } = contestBattle(contest, { seed });
const { texts, prompts } = play(battle, [park, park, park, park, park], [answer]);
const ask = prompts.find((prompt) => prompt.kind === "contestswitch");
if (!ask) continue;
done = true;
expect(texts).toContain("You already caught a SCYTHER.");
expect(ask).toEqual({
kind: "contestswitch",
text: "Switch #MON?".replace("#", "POKé"),
stock: { name: "SCYTHER", level: 14, maxHp: stock.stats[0] },
caught: { name: "CATERPIE", level: 5, maxHp: battle.enemy.mon.maxHp },
});
if (answer) {
expect(contest.mon!.species).toBe(CATERPIE);
expect(texts).toContain("Caught CATERPIE!");
} else {
expect(contest.mon).toBe(stock);
expect(texts).not.toContain("Caught CATERPIE!");
}
}
expect(done).toBe(true);
}
});
it("ends the battle as a draw when the last Park Ball misses", () => {
for (let seed = 1; seed < 100; seed++) {
const contest: BugContest = { parkBalls: 1, mon: null };
const { battle } = contestBattle(contest, { seed, wild: { species: SCYTHER, level: 14 } });
const { texts, outcome } = play(battle, [park, park]);
if (contest.mon) continue;
expect(contest.parkBalls).toBe(0);
// The wild Pokémon still takes its turn first.
expect(texts.some((text) => text.startsWith("Wild SCYTHER used") || text.startsWith("Enemy SCYTHER used"))).toBe(true);
expect(outcome!.result).toBe(C.DRAW);
return;
}
throw new Error("Every seed caught Scyther.");
});
});
});