Checks / web (push) Canceled after 0s
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>
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
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.");
|
||
});
|
||
});
|
||
});
|