Files
Crystal/tests/field-items.test.ts
T
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

612 lines
22 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { DecorationData } from "../src/game/world/decorations";
import { generatePartyMon } from "../src/game/party/pokemon";
import {
PLAYER,
ScriptRun,
emptyScriptState,
type ScriptHost,
} from "../src/game/scripts/interpreter";
import { StartMenuRun } from "../src/game/scripts/start-menu";
import { defaultTextContext } from "../src/game/scripts/text";
import { OVERWORLD } from "../src/game/rom/profile";
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",
ROUTE_36 = "10:3";
const CYNDAQUIL = 155,
PIDGEY = 16;
const POTION = 0x12,
REPEL = 0x14,
SUPER_REPEL = 0x2a,
MAX_REPEL = 0x2b,
COIN_CASE = 0x36,
BLUE_CARD = 0x74,
SACRED_ASH = 0x9c,
NORMAL_BOX = 0xa7,
GORGEOUS_BOX = 0xa8,
ITEMFINDER = 0x37,
CARD_KEY = 0x7f,
BASEMENT_KEY = 0x85,
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!";
describe.skipIf(!existsSync(romPath))("field items", () => {
/** The player's room, where every floor tile is grass for a test-only
* table of level-`wildLevel` Pidgey met on every step past the cooldown. */
function fixture(wildLevel = 3, map = PLAYERS_ROOM, x = 3, y = 3) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
assets.grassCollisions = () => [
assets.collision(assets.map(PLAYERS_ROOM), 3, 2),
];
assets.grassWildMons = (id) =>
id === PLAYERS_ROOM
? {
rates: [256, 256, 256],
slots: [0, 1, 2].map(() =>
Array.from({ length: 7 }, () => ({
species: PIDGEY,
level: wildLevel,
})),
),
}
: undefined;
let seed = 1;
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
const mon = (level: number) =>
generatePartyMon(assets.pokemon, {
species: CYNDAQUIL,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
world.state.party = [mon(5), mon(8)];
world.state.bag.items = [{ item: POTION, quantity: 1 }];
world.restore({ ...world.save(), map, x, y, objects: [] });
return world;
}
/** Step left and right until a wild battle or a page appears, at most
* `limit` steps; returns the number of steps taken. */
function walk(world: GameWorld, limit: number) {
for (let step = 1; step <= limit; step++) {
expect(world.move(step & 1 ? "left" : "right")).toBe(true);
if (world.battle || world.dialogue) return step;
}
return limit;
}
it("keeps weaker wild Pokémon away while a Repel lasts", () => {
const world = fixture();
world.state.bag.items.push({ item: REPEL, quantity: 2 });
expect(useItem(world, REPEL)).toEqual(["CHRIS used the REPEL."]);
// The pack stays open (ITEMMENU_CURRENT); a second one is refused.
expect(world.dialogue?.choice?.kind).toBe("pack");
world.advanceDialogue();
close(world);
expect(world.state.repel).toBe(100);
expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 });
expect(useItem(world, REPEL)).toEqual([
"The REPEL used earlier is still in effect.",
]);
close(world);
expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 });
// The Level 3 Pidgey never reaches the Level 5 lead.
expect(walk(world, 20)).toBe(20);
expect(world.battle).toBeUndefined();
expect(world.state.repel).toBe(80);
// A fainted lead hands the check to the next Pokémon (Level 8).
world.state.party[0].hp = 0;
expect(walk(world, 10)).toBe(10);
expect(world.battle).toBeUndefined();
});
it("lets wild Pokémon of the lead's level through", () => {
const world = fixture(5);
world.state.repel = 100;
expect(walk(world, 10)).toBe(5);
expect(world.battle).toBeDefined();
// The step that met the Pokémon replays after a reload, with its
// Repel step: the save made during the battle has the earlier count.
expect(world.save().repel).toBe(96);
expect(world.state.repel).toBe(95);
});
it("wears off on its last step, which meets no Pokémon", () => {
const world = fixture();
world.state.repel = 6;
// Five steps pass the cooldown under the Repel.
expect(walk(world, 5)).toBe(5);
expect(world.battle).toBeUndefined();
expect(world.move("left")).toBe(true);
expect(world.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]);
expect(world.battle).toBeUndefined();
expect(world.state.repel).toBe(0);
// A save during the message replays the Repel's last step.
const saved = world.save();
expect([saved.repel, saved.x]).toEqual([1, 2]);
settle(world);
expect(walk(world, 1)).toBe(1);
expect(world.battle).toBeDefined();
const replayed = fixture();
replayed.restore(saved);
expect(replayed.move("left")).toBe(true);
expect(replayed.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]);
});
it("gives Super and Max Repels 200 and 250 steps", () => {
for (const [item, steps, name] of [
[SUPER_REPEL, 200, "SUPER REPEL"],
[MAX_REPEL, 250, "MAX REPEL"],
] as const) {
const world = fixture();
world.state.bag.items.push({ item, quantity: 1 });
expect(useItem(world, item)).toEqual([`CHRIS used the ${name}.`]);
close(world);
expect(world.state.repel).toBe(steps);
expect(world.state.bag.items.some((slot) => slot.item === item)).toBe(
false,
);
}
});
it("shows the Coin Case's coins and the Blue Card's points", () => {
const world = fixture();
world.state.bag.keyItems = [COIN_CASE, BLUE_CARD];
world.state.coins = 1234;
world.state.blueCardBalance = 7;
expect(useItem(world, COIN_CASE)).toEqual(["Coins: 1234"]);
world.advanceDialogue();
close(world);
expect(useItem(world, BLUE_CARD)).toEqual(["You now have 7 points."]);
close(world);
expect(world.state.bag.keyItems).toEqual([COIN_CASE, BLUE_CARD]);
});
it("revives the whole party with Sacred Ash, only after a faint", () => {
const world = fixture();
world.state.bag.items.push({ item: SACRED_ASH, quantity: 2 });
// CheckAnyFaintedMon: nobody has fainted, so Oak objects.
expect(useItem(world, SACRED_ASH)).toEqual([OAK_WARNING]);
close(world);
expect(world.state.bag.items.at(-1)).toEqual({
item: SACRED_ASH,
quantity: 2,
});
const [first, second] = world.state.party;
first.hp = 0;
first.status = 0;
second.hp = 1;
second.status = 0x08;
second.pp[0] = 0;
// The menu closes and SacredAshScript runs.
expect(useItem(world, SACRED_ASH)).toEqual([
"CHRIS's POKéMON were all healed!",
]);
close(world);
for (const mon of world.state.party) {
expect([mon.hp, mon.status]).toEqual([mon.stats[0], 0]);
expect(mon.pp[0]).toBeGreaterThan(0);
}
expect(world.state.bag.items.at(-1)).toEqual({
item: SACRED_ASH,
quantity: 1,
});
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("sends the trophy in a Normal or Gorgeous Box home", () => {
for (const [item, trophy] of [
[NORMAL_BOX, "SILVER TROPHY"],
[GORGEOUS_BOX, "GOLD TROPHY"],
] as const) {
const world = fixture();
world.state.bag.items.push({ item, quantity: 1 });
const before = new Set(world.state.events);
expect(useItem(world, item)).toEqual([
"There was a trophy inside!",
"CHRIS sent the trophy home.",
]);
close(world);
const added = [...world.state.events].filter((id) => !before.has(id));
const decorations = new DecorationData(
world.assets.reader,
world.assets.pokemon,
);
const owned = Array.from({ length: 52 }, (_, i) => i + 1).filter((id) =>
added.includes(decorations.attribute(id).event),
);
expect(owned.map((id) => decorations.name(id))).toEqual([trophy]);
expect(world.state.bag.items.some((slot) => slot.item === item)).toBe(
false,
);
}
});
it("saves the Repel, coins and points, and migrates saves without them", () => {
const world = fixture();
Object.assign(world.state, { repel: 42, coins: 9999, blueCardBalance: 30 });
const saved = world.save();
expect(saved).toMatchObject({
repel: 42,
coins: 9999,
blueCardBalance: 30,
});
world.restore(saved);
expect(world.save()).toEqual(saved);
const { repel: _, coins: __, blueCardBalance: ___, ...legacy } = saved;
expect(migrateSave(legacy, () => 0)).toMatchObject({
repel: 0,
coins: 0,
blueCardBalance: 0,
});
for (const invalid of [
{ repel: 256 },
{ coins: 10000 },
{ blueCardBalance: -1 },
])
expect(() => world.restore({ ...saved, ...invalid })).toThrow(
"Invalid native save",
);
});
it("finds hidden items on the screen with the Itemfinder", () => {
// Route 30's hidden Potion at 14, 9 is seen from 9-18 across and 5-13
// down: four squares left of and above the player, five right of it
// and four below.
const nearby = "Yes! ITEMFINDER indicates there's an item nearby.",
nope = "Nope! ITEMFINDER isn't responding.";
for (const [x, y, pages] of [
[18, 9, [nearby]],
[19, 9, [nope]],
[9, 6, [nearby]],
[7, 6, [nope]],
[12, 5, [nearby]],
[12, 4, [nope]],
[12, 13, [nearby]],
[12, 14, [nope]],
] as const) {
const world = fixture(3, ROUTE_30, x, y);
world.state.bag.keyItems = [ITEMFINDER];
expect(useItem(world, ITEMFINDER), `${x}, ${y}`).toEqual(pages);
close(world);
expect(world.state.bag.keyItems).toEqual([ITEMFINDER]);
}
// Once found, the item no longer answers.
const world = fixture(3, ROUTE_30, 14, 10);
world.state.bag.keyItems = [ITEMFINDER];
world.state.events.add(
world.assets.reader.word(
world.map.backgroundEvents.find((event) => event.kind === 7)!.script,
),
);
expect(useItem(world, ITEMFINDER)).toEqual([nope]);
close(world);
});
it("uses the keys only at their doors", () => {
// Away from the Radio Tower and the Underground, Oak objects.
const world = fixture();
world.state.bag.keyItems = [CARD_KEY, BASEMENT_KEY];
for (const key of [CARD_KEY, BASEMENT_KEY]) {
expect(useItem(world, key)).toEqual([OAK_WARNING]);
close(world);
}
expect(world.state.bag.keyItems).toEqual([CARD_KEY, BASEMENT_KEY]);
// Those maps are not open yet: a menu run with their positions.
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const use = (
item: number,
mapId: string,
facing: number,
position: { x: number; y: number },
) => {
const state = emptyScriptState();
state.bag.keyItems = [item];
const run = new StartMenuRun(state, {
reader: assets.reader,
data: assets.pokemon,
text: () => defaultTextContext("CHRIS"),
scriptHost: () =>
({
mapId,
facing: () => facing,
position: () => position,
}) as ScriptHost,
} as unknown as ConstructorParameters<typeof StartMenuRun>[1]);
let effect = run.resume();
// PACK, the key, USE.
const menu = (label: string) => {
if (effect?.kind !== "menu")
throw new Error(`Expected a menu, got ${effect?.kind}`);
effect = run.resume(effect.items.indexOf(label));
};
menu("PACK");
effect = run.resume((2 << 8) | 0);
menu("USE");
return effect?.kind === "text"
? effect.pages.map((page) => page.replace(/\n/g, " "))
: run.deferred;
};
const UP = 1,
DOWN = 0,
LEFT = 2;
// _CardKey: facing up at the slot, 18, 6 with the map's border.
expect(use(CARD_KEY, RADIO_TOWER_3F, UP, { x: 14, y: 3 })).toBe(
OVERWORLD["_CardKey.CardKeyScript"],
);
expect(use(CARD_KEY, RADIO_TOWER_3F, LEFT, { x: 15, y: 2 })).toEqual([
OAK_WARNING,
]);
expect(use(CARD_KEY, ROUTE_36, UP, { x: 14, y: 3 })).toEqual([OAK_WARNING]);
// _BasementKey: facing the door at 22, 10 from any side.
expect(
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, DOWN, { x: 18, y: 5 }),
).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]);
expect(
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, LEFT, { x: 19, y: 6 }),
).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]);
expect(
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, UP, { x: 18, y: 5 }),
).toEqual([OAK_WARNING]);
});
it("sprinkles water with the Squirtbottle, which only Sudowoodo minds", () => {
const world = fixture();
world.state.bag.keyItems = [SQUIRTBOTTLE];
expect(useItem(world, SQUIRTBOTTLE)).toEqual([
"CHRIS sprinkled water.",
"But nothing happened…",
]);
close(world);
expect(world.state.bag.keyItems).toEqual([SQUIRTBOTTLE]);
// Route 36 is not open yet: the script against a host facing Sudowoodo.
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const facing = { id: 5, movement: SPRITEMOVEDATA_SUDOWOODO };
const host = {
mapId: ROUTE_36,
lastTalked: PLAYER,
text: () => defaultTextContext("CHRIS"),
data: assets.pokemon,
facingObject: () => facing,
sceneOwner: () => undefined,
} as unknown as ScriptHost;
const run = new ScriptRun(
assets.reader,
OVERWORLD["_Squirtbottle.SquirtbottleScript"],
emptyScriptState(),
host,
{},
);
const first = run.resume();
expect(first?.kind === "text" && first.pages).toEqual([
"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);
});
});
});