Files
Sébastien QUEROLandClaude Opus 5.5 397dcef44c Port the fishing rods and wild Magikarp
The Old, Good and Super Rods run FishFunction facing water: the map's fish
group and Fish pick no fish, no bite, or a Pokémon from FishGroups and the
time of day's TimeFishGroups. The player casts and jiggles with the
fishing frames drawn from LoadFishingGFX's tiles, shows "!" on a bite, and
the hooked Pokémon attacks as BATTLETYPE_FISH. Facing pieces now use
signed offsets, which the rod above and left of the player needs.

Wild Magikarp no longer stop a battle: LoadEnemyMon redraws their DVs
through CalcMagikarpLength and its filters, as the original does. The
length routine matches the original in the reference emulator.

Part of #27

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

187 lines
6.3 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 { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import {
Battle,
UnsupportedBattle,
type BattleEffect,
type BattleInput,
} from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import {
calcStats,
generatePartyMon,
type PartyMon,
} from "../src/game/party/pokemon";
import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"native battle engine with the local ROM",
() => {
let data: CrystalAssets["pokemon"];
beforeAll(() => {
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
});
const PIDGEY = 16,
CYNDAQUIL = 155,
TACKLE = 33;
const starter = (level = 5, exp?: number): PartyMon => {
const mon = generatePartyMon(data, {
species: CYNDAQUIL,
level,
item: 0,
otId: 1234,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
if (exp !== undefined) mon.exp = exp;
return mon;
};
/** A reproducible stand-in for the hardware Random routine. */
const seeded = (seed: number) => () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const battle = (
party: PartyMon[],
wild: { species: number; level: number },
seed = 1,
) => {
const pokedex = { seen: new Set<number>(), caught: new Set<number>() };
return {
pokedex,
battle: new Battle({
data,
party,
pokedex,
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
random: seeded(seed),
wild,
text: () => defaultTextContext("CHRIS"),
}),
};
};
/** Play a battle, answering the menu and prompts; returns texts and result. */
const play = (
battle: Battle,
answer: (effect: BattleEffect) => BattleInput = (effect) =>
effect.kind === "action"
? { action: "fight", move: 0 }
: effect.kind === "yesno"
? 1
: undefined,
) => {
const texts: string[] = [];
const effects: BattleEffect[] = [];
const run = battle.run();
let input: BattleInput;
for (let step = 0; step < 2000; step++) {
const next = run.next(input);
if (next.done) return { texts, effects, result: next.value.result };
effects.push(next.value);
if (next.value.kind === "text")
texts.push(next.value.text.replace(/\n/g, " "));
input = answer(next.value);
}
throw new Error("Battle did not end.");
};
it("wins a wild battle with the original messages, experience and stat experience", () => {
const mon = starter();
const { battle: game, pokedex } = battle([mon], {
species: PIDGEY,
level: 3,
});
const { texts, result } = play(game);
expect(texts.slice(0, 3)).toEqual([
"Wild PIDGEY appeared!",
"Go! CYNDAQUIL!",
"CYNDAQUIL used TACKLE!",
]);
expect(texts).toContain("Enemy PIDGEY fainted!");
// Base experience 55 × level 3 ÷ 7.
expect(texts.at(-1)).toBe("CYNDAQUIL gained 23 EXP. Points!");
expect(result).toBe(C.WIN);
expect(mon.exp).toBe(135 + 23);
expect(mon.statExp).toEqual([40, 45, 40, 56, 35]);
expect(mon.pp[0]).toBeLessThan(35);
expect(pokedex.seen.has(PIDGEY)).toBe(true);
expect(mon.hp).toBeLessThan(mon.stats[0]);
});
it("escapes when the player's Pokémon is faster", () => {
const mon = starter(20);
const { battle: game } = battle([mon], { species: PIDGEY, level: 2 });
const { texts, result } = play(game, (effect) =>
effect.kind === "action" ? { action: "run" } : undefined,
);
expect(texts.slice(-1)).toEqual(["Got away safely!"]);
expect(result).toBe(C.DRAW);
expect(mon.exp).toBe(data.expAtLevel(3, 20));
});
it("loses when the only Pokémon faints", () => {
const mon = starter();
mon.hp = 1;
const { battle: game } = battle([mon], { species: 19, level: 10 }, 5);
const { texts, result } = play(game, (effect) =>
effect.kind === "action" ? { action: "fight", move: 1 } : undefined,
);
expect(texts).toContain("CYNDAQUIL fainted!");
expect(result).toBe(C.LOSE);
expect(mon.hp).toBe(0);
expect(mon.status).toBe(0);
});
it("levels up, shows the stat box and learns the level's move", () => {
const mon = starter(5, 178);
const { battle: game } = battle([mon], { species: PIDGEY, level: 3 });
const { texts, effects } = play(game);
expect(texts).toContain("CYNDAQUIL grew to level 6!");
const box = effects.find((effect) => effect.kind === "stats");
expect(box).toMatchObject({
labels: ["ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED"],
});
expect(texts).toContain("CYNDAQUIL learned SMOKESCREEN!");
expect(mon.level).toBe(6);
expect(mon.moves).toEqual([TACKLE, 43, 108, 0]);
// Level-up recalculates stats with the new stat experience.
expect(mon.stats).toEqual(
calcStats(data, CYNDAQUIL, mon.dvs, 6, mon.statExp),
);
});
it("asks which move to forget when four moves are known", () => {
const mon = starter(5, 178);
mon.moves = [TACKLE, 43, 52, 98];
mon.pp = [35, 30, 25, 30];
const { battle: game } = battle([mon], { species: PIDGEY, level: 3 });
const { texts } = play(game, (effect) =>
effect.kind === "action"
? { action: "fight", move: 0 }
: effect.kind === "yesno"
? 1
: effect.kind === "forget"
? 1
: undefined,
);
expect(texts).toContain("Which move should be forgotten?");
expect(texts.some((text) => text.startsWith("1, 2 and…"))).toBe(true);
expect(mon.moves).toEqual([TACKLE, 108, 52, 98]);
expect(mon.pp[1]).toBe(data.move(108).pp);
});
it("refuses battles that need untranslated rules", () => {
const { battle: game } = battle([starter()], { species: 201, level: 5 });
expect(() => play(game)).toThrow(UnsupportedBattle);
});
},
);