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>
612 lines
22 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|