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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 00:49:35 +02:00
1 parent 4cb4676c7d
commit 397dcef44c
20 files changed
+681 -47

No files matched your search

+200 -2
View File
@@ -13,13 +13,16 @@ import {
import { StartMenuRun } from "../src/game/scripts/start-menu";
import { defaultTextContext } from "../src/game/scripts/text";
import { OVERWORLD } from "../src/game/rom/profile";
import { close, useItem } from "./menu-driver";
import { fish } from "../src/game/scripts/fish";
import { Battle, magikarpLength } from "../src/game/battle/battle";
import { close, open, pick, pickItem, useItem } from "./menu-driver";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PLAYERS_ROOM = "24:7",
NEW_BARK_TOWN = "24:4",
ROUTE_30 = "26:1",
RADIO_TOWER_3F = "3:19",
GOLDENROD_UNDERGROUND = "3:53",
@@ -38,7 +41,16 @@ const POTION = 0x12,
ITEMFINDER = 0x37,
CARD_KEY = 0x7f,
BASEMENT_KEY = 0x85,
SQUIRTBOTTLE = 0xaf;
SQUIRTBOTTLE = 0xaf,
OLD_ROD = 0x3a,
GOOD_ROD = 0x3b,
SUPER_ROD = 0x3d;
const MAGIKARP = 129,
KRABBY = 98,
CORSOLA = 222,
STARYU = 120;
const FISHGROUP_SHORE = 1;
const FACING_FISH_RIGHT = 0x13;
const SPRITEMOVEDATA_SUDOWOODO = 0x17;
const OAK_WARNING = "OAK: CHRIS! This isn't the time to use that!";
@@ -410,4 +422,190 @@ describe.skipIf(!existsSync(romPath))("field items", () => {
"CHRIS used the\nSQUIRTBOTTLE.",
]);
});
it("fishes from FishGroups by rod and time of day", () => {
const r = new CrystalAssets(new Uint8Array(readFileSync(romPath))).reader;
const cast = (rod: number, time: number, ...draws: number[]) =>
fish(r, FISHGROUP_SHORE, rod, time, () => {
const draw = draws.shift();
if (draw === undefined) throw new Error("No draw left.");
return draw;
});
// A bite below 50 percent + 1 ($80), then the first entry the roll
// does not exceed: 70 percent + 1, 85 percent + 1, 100 percent.
expect(cast(0, 1, 127, 0)).toEqual({ species: MAGIKARP, level: 10 });
expect(cast(0, 1, 128)).toBeUndefined();
expect(cast(0, 1, 0, 179)).toEqual({ species: MAGIKARP, level: 10 });
expect(cast(0, 1, 0, 255)).toEqual({ species: KRABBY, level: 10 });
// The Good Rod's last entry is TimeFishGroups 0: Corsola by day,
// Staryu from the night on.
expect(cast(1, 0, 0, 255)).toEqual({ species: CORSOLA, level: 20 });
expect(cast(1, 1, 0, 255)).toEqual({ species: CORSOLA, level: 20 });
expect(cast(1, 2, 0, 255)).toEqual({ species: STARYU, level: 20 });
expect(cast(2, 1, 0, 0)).toEqual({ species: KRABBY, level: 40 });
});
it("draws a wild Magikarp's DVs again below four feet, except at the Lake of Rage", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const id = 0x1234;
const feet = (dvs: number) => magikarpLength(assets.reader, dvs, id).feet;
// Nonzero, unlike the draws that follow.
const short = [...Array(0x10000).keys()].find(
(dvs) => dvs > 0x100 && feet(dvs) < 4,
)!,
long = [...Array(0x10000).keys()].find(
(dvs) => dvs > 0x100 && feet(dvs) >= 4,
)!;
/** The wild Magikarp's DVs for these Random draws (then zeros). */
const dvs = (mapId: string, ...draws: number[]) => {
const game = new Battle({
data: assets.pokemon,
party: [
generatePartyMon(assets.pokemon, {
species: 155,
level: 5,
item: 0,
otId: id,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
}),
],
playerId: id,
playerName: "CHRIS",
timeOfDay: 1,
pokedex: { seen: new Set(), caught: new Set() },
random: () => draws.shift() ?? 0,
wild: { species: MAGIKARP, level: 10 },
fish: true,
mapId,
text: () => defaultTextContext("CHRIS"),
});
const first = game.run().next().value;
expect(first && "text" in first && first.text).toBe(
"The hooked\nMAGIKARP\nattacked!",
);
return game.enemy.mon.dvs;
};
const hi = (value: number) => value >> 8,
lo = (value: number) => value & 0xff;
// No item (0), the DVs, then 39 percent + 1 (100) skips the floor.
expect(dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 99)).toBe(short);
expect(
dvs(NEW_BARK_TOWN, 0, hi(short), lo(short), 100, hi(long), lo(long), 100),
).toBe(long);
// The Lake of Rage (9:6) skips it without a draw, and so does any map
// in group 9 or numbered 6.
for (const map of ["9:6", "9:1", "24:6"])
expect(dvs(map, 0, hi(short), lo(short), 255)).toBe(short);
});
describe("fishing in New Bark Town", () => {
/** Facing the sea, with a rod and a constant random byte. */
function shore(random: number, rod = OLD_ROD) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => random,
);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155,
level: 5,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
}),
];
world.state.bag.keyItems = [rod];
world.state.scenes[NEW_BARK_TOWN] = 1;
world.restore({
...world.save(),
map: NEW_BARK_TOWN,
x: 17,
y: 7,
direction: "right",
objects: [],
});
return world;
}
/** USE from the pack, then tick until a page or a battle appears,
* recording the player's facings and emotes meanwhile. */
function cast(world: GameWorld, rod = OLD_ROD) {
open(world);
pick(world, "PACK");
pickItem(world, rod);
const items =
world.dialogue?.choice?.kind === "menu"
? world.dialogue.choice.items
: [];
world.choose(items.indexOf("USE"));
const facings = new Set<number | undefined>(),
emotes = new Set<number>();
let ticks = 0;
for (; ticks < 400 && !world.dialogue && !world.battle; ticks++) {
world.tick();
facings.add(world.poses().find((pose) => pose.id === 0)?.facing);
if (world.emote) emotes.add(world.emote.emote);
}
return { facings, emotes, ticks };
}
it("hooks a Pokémon that attacks", () => {
const world = shore(0);
const { facings, emotes } = cast(world);
// Cast, four bites with the "!" bubble (EMOTE_SHOCK), then the rod
// is pulled in.
expect(facings).toContain(FACING_FISH_RIGHT);
expect([...emotes]).toEqual([0]);
expect(world.dialogue?.pages).toEqual(["Oh!\nA bite!"]);
// .Movement_RestoreRod hid the bubble before the text.
expect(world.emote).toBeUndefined();
expect(world.poses().find((pose) => pose.id === 0)?.facing).toBe(
FACING_FISH_RIGHT,
);
world.advanceDialogue();
// PutTheRodAway, then the battle: BATTLETYPE_FISH.
for (let tick = 0; tick < 100 && !world.battle?.message; tick++)
world.tick();
expect(
world.poses().find((pose) => pose.id === 0)?.facing,
).toBeUndefined();
expect(world.battle?.message?.text).toBe(
"The hooked\nMAGIKARP\nattacked!",
);
expect(world.diagnostic).toBe("");
});
it("gets no bite, or nothing at all where the map has no fish", () => {
for (const group of [undefined, 0]) {
const world = shore(200, SUPER_ROD);
if (group !== undefined) world.map.fishGroup = group;
const { facings, emotes } = cast(world, SUPER_ROD);
expect(facings).toContain(FACING_FISH_RIGHT);
expect(emotes.size).toBe(0);
expect(world.dialogue?.pages).toEqual(["Not even a nibble!"]);
expect(close(world)).toEqual(["Not even a nibble!"]);
expect(
world.poses().find((pose) => pose.id === 0)?.facing,
).toBeUndefined();
expect(world.battle).toBeUndefined();
expect(world.state.bag.keyItems).toEqual([SUPER_ROD]);
}
});
it("needs water ahead and no surfing, or Oak objects", () => {
const turned = shore(0, GOOD_ROD);
turned.restore({ ...turned.save(), direction: "left" });
expect(useItem(turned, GOOD_ROD)).toEqual([OAK_WARNING]);
close(turned);
const surfing = shore(0, GOOD_ROD);
surfing.restore({ ...surfing.save(), x: 18, playerState: 4 });
expect(useItem(surfing, GOOD_ROD)).toEqual([OAK_WARNING]);
close(surfing);
});
});
});