264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import {
|
|
Battle,
|
|
UnsupportedBattle,
|
|
catchRate,
|
|
type BattleInput,
|
|
type BattleOptions,
|
|
type BattleOutcome,
|
|
} from "../src/game/battle/battle";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { generatePartyMon, validPartyMon, type PartyMon } from "../src/game/party/pokemon";
|
|
import type { Bag } from "../src/game/party/bag";
|
|
import { defaultTextContext } from "../src/game/scripts/text";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
|
|
describe("catch rate formula", () => {
|
|
const none = { effect: 0, parameter: 0 };
|
|
const rate = (input: Partial<Parameters<typeof catchRate>[0]>) =>
|
|
catchRate({ rate: 255, ball: C.POKE_BALL, hp: 20, maxHp: 20, status: 0, held: none, ...input });
|
|
|
|
it("follows PokeBallEffect's HP term and ball multipliers", () => {
|
|
expect(rate({})).toBe(85); // 255 * (60 - 40) / 60
|
|
expect(rate({ hp: 1 })).toBe(246); // 255 * 58 / 60
|
|
expect(rate({ rate: 45, ball: C.GREAT_BALL, hp: 10 })).toBe(44); // 67 * 40 / 60
|
|
expect(rate({ rate: 200, ball: C.ULTRA_BALL })).toBe(85); // doubled, capped at 255
|
|
expect(rate({ rate: 1 })).toBe(1); // a zero quotient becomes 1
|
|
// Above 255 (3 * max HP), both terms are divided by four; a zero HP
|
|
// byte becomes 1.
|
|
expect(rate({ hp: 1, maxHp: 100 })).toBe(251); // 255 * (75 - 1) / 75
|
|
});
|
|
|
|
it("keeps the original status, held-item and high-HP bugs", () => {
|
|
expect(rate({ status: 1 })).toBe(95); // sleep: +10
|
|
expect(rate({ status: 1 << C.FRZ })).toBe(95);
|
|
for (const status of [1 << 3, 1 << 4, 1 << 6]) expect(rate({ status })).toBe(85); // PSN/BRN/PAR
|
|
expect(rate({ held: { effect: C.HELD_CATCH_CHANCE, parameter: 5 } })).toBe(90);
|
|
// Max HP 400: the low bytes wrap, so a full-HP Pokémon gets 67, not 85.
|
|
expect(rate({ hp: 400, maxHp: 400 })).toBe(67);
|
|
// These maximum HP values divide by zero in the original.
|
|
for (const maxHp of [342, 683]) expect(rate({ hp: maxHp, maxHp })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
|
|
let data: CrystalAssets["pokemon"];
|
|
beforeAll(() => {
|
|
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
|
|
});
|
|
const RATTATA = 19,
|
|
CYNDAQUIL = 155,
|
|
TACKLE = 33;
|
|
const starter = (): PartyMon => {
|
|
const mon = generatePartyMon(data, {
|
|
species: CYNDAQUIL, level: 5, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
|
});
|
|
mon.moves = [TACKLE, 0, 0, 0];
|
|
mon.pp = [35, 0, 0, 0];
|
|
return mon;
|
|
};
|
|
const setup = (options: Partial<BattleOptions> & { seed?: number } = {}) => {
|
|
let seed = options.seed ?? 1,
|
|
draws = 0;
|
|
const bag: Bag = options.bag ?? { items: [], balls: [{ item: C.POKE_BALL, quantity: 5 }], keyItems: [] };
|
|
const pokedex = { seen: new Set<number>(), caught: new Set<number>() };
|
|
const party = options.party ?? [starter()];
|
|
const battle = new Battle({
|
|
data,
|
|
party,
|
|
pokedex,
|
|
playerId: 1234,
|
|
playerName: "CHRIS",
|
|
timeOfDay: 1,
|
|
random: () => {
|
|
draws++;
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
},
|
|
wild: { species: RATTATA, level: 3 },
|
|
text: () => defaultTextContext("CHRIS"),
|
|
bag,
|
|
hasPokedex: true,
|
|
caughtData: (level) => 0x4000 | (level << 8) | 1,
|
|
...options,
|
|
});
|
|
return { battle, bag, pokedex, party, draws: () => draws };
|
|
};
|
|
/** Run a battle: `actions` answer the battle menu, `answers` the Yes/No
|
|
* and naming prompts. Returns the texts and the outcome. */
|
|
const play = (
|
|
battle: Battle,
|
|
actions: BattleInput[],
|
|
answers: BattleInput[] = [],
|
|
until?: () => boolean,
|
|
) => {
|
|
const run = battle.run();
|
|
const texts: string[] = [];
|
|
let input: BattleInput;
|
|
for (let guard = 0; guard < 2000; guard++) {
|
|
const next = run.next(input);
|
|
input = undefined;
|
|
if (next.done) return { texts, 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") {
|
|
if (until?.() || !actions.length) return { texts, outcome: undefined };
|
|
input = actions.shift();
|
|
} else if (effect.kind === "yesno" || effect.kind === "name") input = answers.shift();
|
|
else if (effect.kind === "forget") input = 4;
|
|
}
|
|
throw new Error("The battle did not finish.");
|
|
};
|
|
const ball = (slot = 0) => ({ action: "item" as const, item: C.POKE_BALL, slot });
|
|
|
|
it("catches a wild Pokémon into the party with its original moves, PP and current HP", () => {
|
|
// Weaken it first so the seeded throws succeed.
|
|
for (let seed = 1; seed < 50; seed++) {
|
|
const { battle, bag, pokedex, party } = setup({ seed });
|
|
const { texts, outcome } = play(
|
|
battle,
|
|
[{ action: "fight", move: 0 }, ball(), ball(), ball(), ball(), ball()],
|
|
[1, "RATTY"],
|
|
);
|
|
if (!outcome || party.length !== 2) continue;
|
|
expect(outcome.result).toBe(C.WIN);
|
|
expect(texts).toContain("CHRIS used the POKé BALL.");
|
|
expect(texts.some((text) => text.startsWith("Gotcha! RATTATA was caught!"))).toBe(true);
|
|
expect(texts).toContain("RATTATA's data was newly added to the #DEX.".replace("#", "POKé"));
|
|
expect(texts).toContain("Give a nickname to RATTATA?");
|
|
const caught = party[1];
|
|
const enemy = battle.enemy.mon;
|
|
expect(caught).toMatchObject({
|
|
species: RATTATA,
|
|
nickname: "RATTY",
|
|
level: 3,
|
|
otId: 1234,
|
|
ot: "CHRIS",
|
|
dvs: enemy.dvs,
|
|
hp: enemy.hp,
|
|
status: enemy.status,
|
|
item: enemy.item,
|
|
happiness: C.BASE_HAPPINESS,
|
|
caughtData: 0x4000 | (3 << 8) | 1,
|
|
exp: data.expAtLevel(data.baseData(RATTATA).growthRate, 3),
|
|
statExp: [0, 0, 0, 0, 0],
|
|
});
|
|
expect(caught.hp).toBeLessThan(caught.stats[0]);
|
|
// wWildMonMoves and wWildMonPP: full PP of the moves it appeared with.
|
|
expect(caught.pp).toEqual(caught.moves.map((move) => (move ? data.move(move).pp : 0)));
|
|
expect(validPartyMon(data, caught)).toBe(true);
|
|
expect(pokedex.caught.has(RATTATA)).toBe(true);
|
|
const thrown = texts.filter((text) => text === "CHRIS used the POKé BALL.").length;
|
|
expect(bag.balls).toEqual(thrown === 5 ? [] : [{ item: C.POKE_BALL, quantity: 5 - thrown }]);
|
|
return;
|
|
}
|
|
throw new Error("No seed caught the Pokémon.");
|
|
});
|
|
|
|
it("uses the turn when the Pokémon breaks free, with the wobble message", () => {
|
|
const messages = [
|
|
"Oh no! The POKéMON broke free!",
|
|
"Aww! It appeared to be caught!",
|
|
"Aargh! Almost had it!",
|
|
"Shoot! It was so close too!",
|
|
];
|
|
const seen = new Set<string>();
|
|
for (let seed = 1; seed < 200 && seen.size < 4; seed++) {
|
|
const { battle, bag, party } = setup({ seed });
|
|
const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4);
|
|
if (party.length !== 1) continue;
|
|
const failure = messages.find((message) => texts.includes(message));
|
|
expect(failure, texts.join(" | ")).toBeDefined();
|
|
seen.add(failure!);
|
|
// The wild Pokémon then moves, and the ball is gone.
|
|
expect(texts.some((text) => text.startsWith("Enemy RATTATA used")), texts.join(" | ")).toBe(true);
|
|
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 4 }]);
|
|
}
|
|
expect(seen.size).toBe(4);
|
|
});
|
|
|
|
it("catches with a Master Ball without drawing a catch or wobble random", () => {
|
|
const { battle, bag, party, draws } = setup({
|
|
bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] },
|
|
});
|
|
let before = 0;
|
|
const run = battle.run();
|
|
let input: BattleInput;
|
|
for (let guard = 0; guard < 100; guard++) {
|
|
const next = run.next(input);
|
|
input = undefined;
|
|
if (next.done) break;
|
|
if (next.value.kind === "action") {
|
|
before = draws();
|
|
input = { action: "item", item: C.MASTER_BALL, slot: 0 };
|
|
} else if (next.value.kind === "yesno") input = 0;
|
|
}
|
|
// Only LoadEnemyMon's wild item roll (one or two draws) remains.
|
|
expect(draws() - before).toBeGreaterThanOrEqual(1);
|
|
expect(draws() - before).toBeLessThanOrEqual(2);
|
|
expect(party).toHaveLength(2);
|
|
expect(party[1].nickname).toBe("RATTATA");
|
|
expect(bag.balls).toEqual([]);
|
|
});
|
|
|
|
it("keeps the species name for a blank nickname and skips the Pokédex text without a Pokédex", () => {
|
|
const { battle, party } = setup({
|
|
hasPokedex: false,
|
|
bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] },
|
|
});
|
|
const { texts } = play(battle, [{ action: "item", item: C.MASTER_BALL, slot: 0 }], [1, " "]);
|
|
expect(party[1].nickname).toBe("RATTATA");
|
|
expect(texts.join(" ")).not.toContain("newly added");
|
|
});
|
|
|
|
it("ignores a slot that does not hold the ball", () => {
|
|
const { battle, bag } = setup();
|
|
const { outcome } = play(battle, [{ action: "item", item: C.GREAT_BALL, slot: 0 }, { action: "run" }]);
|
|
expect(outcome).toBeDefined();
|
|
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]);
|
|
});
|
|
|
|
it("refuses to send a Pokémon to the PC with a full party", () => {
|
|
const { battle } = setup({ party: Array.from({ length: 6 }, starter) });
|
|
expect(() => play(battle, [ball()])).toThrow(UnsupportedBattle);
|
|
});
|
|
|
|
it("blocks balls in trainer battles and still uses the ball and turn", () => {
|
|
const joey = generatePartyMon(data, {
|
|
species: RATTATA, level: 2, item: 0, otId: 0, ot: "JOEY", dvs: 0x9888, caughtData: 0,
|
|
});
|
|
const { battle, bag, party } = setup({
|
|
trainer: {
|
|
name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, reward: 10,
|
|
},
|
|
});
|
|
const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4);
|
|
expect(texts).toContain("The trainer blocked the BALL!");
|
|
expect(texts).toContain("Don't be a thief!");
|
|
expect(texts.join(" ")).not.toContain("used the POKé BALL");
|
|
expect(texts.some((text) => text.includes("RATTATA used"))).toBe(true);
|
|
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 4 }]);
|
|
expect(party).toHaveLength(1);
|
|
});
|
|
|
|
it("runs the Dude's tutorial without touching the player's party or pack", () => {
|
|
const { battle, bag, party, pokedex } = setup({ tutorial: true, bag: undefined });
|
|
const { texts, outcome } = play(battle, []);
|
|
expect(outcome?.result).toBe(C.WIN);
|
|
expect(texts).toEqual([
|
|
"Wild RATTATA appeared!",
|
|
"DUDE used the POKé BALL.",
|
|
"Gotcha! RATTATA was caught!",
|
|
]);
|
|
expect(party).toHaveLength(1);
|
|
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]);
|
|
expect(pokedex.seen.has(RATTATA)).toBe(true);
|
|
expect(pokedex.caught.has(RATTATA)).toBe(false);
|
|
expect(battle.player.mon).toBeUndefined();
|
|
});
|
|
});
|