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>
861 lines
30 KiB
TypeScript
861 lines
30 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld } from "../src/game/world/world";
|
|
import { calcStats, generatePartyMon, maxPP } from "../src/game/party/pokemon";
|
|
import { ENGINE_FLAGS } from "../src/game/rom/profile";
|
|
import { settle } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const NEW_BARK_TOWN = "24:4",
|
|
PLAYERS_HOUSE = "24:6",
|
|
CYNDAQUIL = 155,
|
|
QUILAVA = 156,
|
|
PIDGEY = 16,
|
|
PIKACHU = 25,
|
|
RAICHU = 26;
|
|
const BICYCLE = 7,
|
|
ANTIDOTE = 9,
|
|
FULL_RESTORE = 0x0e,
|
|
POTION = 0x12,
|
|
THUNDERSTONE = 0x17,
|
|
HP_UP = 0x1a,
|
|
RARE_CANDY = 0x20,
|
|
REVIVE = 0x27,
|
|
PP_UP = 0x3e,
|
|
ETHER = 0x3f,
|
|
ELIXER = 0x41,
|
|
EVERSTONE = 0x70,
|
|
ENERGYPOWDER = 0x79,
|
|
LEFTOVERS = 0x92,
|
|
FLOWER_MAIL = 0x9e,
|
|
BERRY = 0xad;
|
|
const SOFTBOILED = 0x87;
|
|
const CUT = 0x0f,
|
|
GUST = 0x10,
|
|
SAND_ATTACK = 0x1c,
|
|
TACKLE = 0x21,
|
|
LEER = 0x2b,
|
|
SMOKESCREEN = 0x6c;
|
|
const POISONED = 0x08;
|
|
|
|
describe.skipIf(!existsSync(romPath))("the start menu", () => {
|
|
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
/** Standing in New Bark Town (outdoors) with two Pokémon and a pack. */
|
|
function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) {
|
|
const world = new GameWorld(
|
|
assets(),
|
|
() => new Date("2026-09-25T12:00:00"),
|
|
() => 0x42,
|
|
);
|
|
const data = world.assets.pokemon;
|
|
const mon = (species: number, level: number) =>
|
|
generatePartyMon(data, {
|
|
species,
|
|
level,
|
|
item: 0,
|
|
otId: world.playerId,
|
|
ot: "CHRIS",
|
|
dvs: 0x9abc,
|
|
caughtData: 0,
|
|
});
|
|
world.state.party = [mon(CYNDAQUIL, 8), mon(PIDGEY, 4)];
|
|
world.state.bag.items = [
|
|
{ item: POTION, quantity: 5 },
|
|
{ item: ANTIDOTE, quantity: 1 },
|
|
];
|
|
world.state.bag.keyItems = [BICYCLE];
|
|
world.state.scenes[NEW_BARK_TOWN] = 1;
|
|
world.restore({
|
|
...world.save(),
|
|
map,
|
|
x,
|
|
y,
|
|
direction: "down",
|
|
objects: [],
|
|
});
|
|
return world;
|
|
}
|
|
/** Acknowledge pages up to the next choice; returns the pages seen. */
|
|
function until(world: GameWorld) {
|
|
const pages: string[] = [];
|
|
for (let tick = 0; tick < 500; tick++) {
|
|
const dialogue = world.dialogue;
|
|
if (!dialogue) {
|
|
if (!world.busy) return pages;
|
|
world.tick();
|
|
continue;
|
|
}
|
|
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
|
if (page && pages.at(-1) !== page) pages.push(page);
|
|
const last = dialogue.index === dialogue.pages.length - 1;
|
|
if (last && dialogue.choice) return pages;
|
|
if (last && dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
}
|
|
throw new Error("No choice appeared.");
|
|
}
|
|
function menuItems(world: GameWorld) {
|
|
const choice = world.dialogue?.choice;
|
|
return choice?.kind === "menu" ? choice.items : [];
|
|
}
|
|
/** Choose the menu item matching `label` (the whole label, or a regex). */
|
|
function pick(world: GameWorld, label: string | RegExp) {
|
|
const items = menuItems(world);
|
|
const index = items.findIndex((item) =>
|
|
typeof label === "string" ? item === label : label.test(item),
|
|
);
|
|
expect(index, `${label} in ${items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
|
world.choose(index);
|
|
return until(world);
|
|
}
|
|
/** Choose the pack entry for `item` in its pocket. */
|
|
function pickItem(world: GameWorld, item: number) {
|
|
const choice = world.dialogue?.choice;
|
|
if (choice?.kind !== "pack") throw new Error(`Expected the pack, got ${choice?.kind}`);
|
|
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
|
const index = entries.findIndex((entry) => entry.item === item);
|
|
if (index >= 0) {
|
|
world.choosePackEntry(pocket, index);
|
|
return until(world);
|
|
}
|
|
}
|
|
throw new Error(`Item ${item} is not in the pack.`);
|
|
}
|
|
function open(world: GameWorld) {
|
|
expect(world.openStartMenu()).toBe(true);
|
|
return until(world);
|
|
}
|
|
/** B until every menu has closed. */
|
|
function close(world: GameWorld) {
|
|
for (let i = 0; i < 20 && world.busy; i++) {
|
|
if (world.dialogue?.choice) world.advanceDialogue();
|
|
until(world);
|
|
}
|
|
settle(world);
|
|
expect(world.diagnostic).toBe("");
|
|
}
|
|
|
|
it("lists the original items for the player's progress", () => {
|
|
const world = fixture();
|
|
open(world);
|
|
expect(menuItems(world)).toEqual([
|
|
"POKéMON",
|
|
"PACK",
|
|
"CHRIS",
|
|
"SAVE",
|
|
"OPTION",
|
|
"EXIT",
|
|
]);
|
|
const choice = world.dialogue?.choice;
|
|
expect(choice?.kind === "menu" && choice.details?.[1]).toBe("Contains items");
|
|
pick(world, "EXIT");
|
|
expect(world.busy).toBe(false);
|
|
|
|
world.state.flags.add(ENGINE_FLAGS.pokedex);
|
|
world.state.flags.add(ENGINE_FLAGS.pokegear);
|
|
open(world);
|
|
expect(menuItems(world)).toEqual([
|
|
"POKéDEX",
|
|
"POKéMON",
|
|
"PACK",
|
|
"POKéGEAR",
|
|
"CHRIS",
|
|
"SAVE",
|
|
"OPTION",
|
|
"EXIT",
|
|
]);
|
|
// The cursor stays on the last choice; B leaves the Pokégear's clock.
|
|
expect(pick(world, "POKéGEAR")).toEqual(["Press any button to exit."]);
|
|
expect(world.dialogue?.choice?.kind).toBe("pokegear");
|
|
world.advanceDialogue();
|
|
until(world);
|
|
expect(world.dialogue?.choice?.kind).toBe("menu");
|
|
close(world);
|
|
open(world);
|
|
const reopened = world.dialogue?.choice;
|
|
expect(reopened?.kind === "menu" && reopened.items[reopened.selected]).toBe(
|
|
"POKéGEAR",
|
|
);
|
|
close(world);
|
|
});
|
|
|
|
it("opens only while the player stands still", () => {
|
|
const world = fixture();
|
|
world.tick("left");
|
|
expect(world.activity).toBe("walking");
|
|
expect(world.openStartMenu()).toBe(false);
|
|
});
|
|
|
|
it("shows the Pokémon submenu with field moves first", () => {
|
|
const world = fixture();
|
|
world.state.party[1].moves = [TACKLE, CUT, 0, 0];
|
|
open(world);
|
|
expect(pick(world, "POKéMON")).toEqual(["Choose a POKéMON."]);
|
|
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
|
|
pick(world, "CYNDAQUIL");
|
|
expect(menuItems(world)).toEqual(["STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
|
|
pick(world, "CANCEL");
|
|
pick(world, "PIDGEY");
|
|
expect(menuItems(world)).toEqual(["CUT", "STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
|
|
// CutFunction's CheckBadge, then the party menu again.
|
|
expect(pick(world, "CUT")).toEqual([
|
|
"Sorry! A new BADGE is required.",
|
|
"Choose a POKéMON.",
|
|
]);
|
|
// STATS shows the stats screen, and B returns to the party.
|
|
pick(world, "PIDGEY");
|
|
world.choose(menuItems(world).indexOf("STATS"));
|
|
expect(world.statsScreen?.species).toBe(PIDGEY);
|
|
world.advanceDialogue();
|
|
expect(until(world)).toEqual(["Choose a POKéMON."]);
|
|
expect(world.statsScreen).toBeUndefined();
|
|
close(world);
|
|
});
|
|
|
|
it("shares HP with Softboiled, which needs more than a fifth of it", () => {
|
|
const world = fixture();
|
|
const data = world.assets.pokemon;
|
|
const chansey = generatePartyMon(data, {
|
|
species: 113,
|
|
level: 20,
|
|
item: 0,
|
|
otId: world.playerId,
|
|
ot: "CHRIS",
|
|
dvs: 0x9abc,
|
|
caughtData: 0,
|
|
});
|
|
chansey.moves = [SOFTBOILED, 0, 0, 0];
|
|
world.state.party.push(chansey);
|
|
world.state.party[0].hp = 3;
|
|
const fifth = Math.floor(chansey.stats[0] / 5);
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CHANSEY");
|
|
expect(pick(world, "SOFTBOILED")).toEqual(["Use on which POKéMON?"]);
|
|
// Not on itself, and not on a Pokémon at full HP.
|
|
expect(pick(world, "CHANSEY")).toEqual([
|
|
"That can't be used on this POKéMON.",
|
|
"Use on which POKéMON?",
|
|
]);
|
|
expect(pick(world, "PIDGEY")[0]).toBe("That can't be used on this POKéMON.");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual([
|
|
`CYNDAQUIL recovered ${Math.min(fifth, world.liveState.party[0].stats[0] - 3)}HP!`,
|
|
"Choose a POKéMON.",
|
|
]);
|
|
// The cursor returns to the user.
|
|
const party = world.dialogue?.choice;
|
|
expect(party?.kind === "menu" && party.selected).toBe(2);
|
|
close(world);
|
|
expect(world.state.party[2].hp).toBe(chansey.stats[0] - fifth);
|
|
expect(world.state.party[0].hp).toBe(
|
|
Math.min(world.state.party[0].stats[0], 3 + fifth),
|
|
);
|
|
|
|
world.state.party[2].hp = fifth;
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CHANSEY");
|
|
expect(pick(world, "SOFTBOILED")).toEqual([
|
|
"Not enough HP!",
|
|
"Choose a POKéMON.",
|
|
]);
|
|
close(world);
|
|
});
|
|
|
|
it("switches two Pokémon, and B cancels the move", () => {
|
|
const world = fixture();
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
expect(pick(world, "SWITCH")).toEqual(["Move to where?"]);
|
|
expect(menuItems(world)).toEqual(["▷CYNDAQUIL", "PIDGEY"]);
|
|
world.advanceDialogue();
|
|
until(world);
|
|
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
|
|
pick(world, "CYNDAQUIL");
|
|
pick(world, "SWITCH");
|
|
pick(world, "PIDGEY");
|
|
expect(menuItems(world)).toEqual(["PIDGEY", "CYNDAQUIL"]);
|
|
// Nothing commits until the menu closes.
|
|
expect(world.save().party?.[0].species).toBe(CYNDAQUIL);
|
|
close(world);
|
|
expect(world.state.party.map((mon) => mon.species)).toEqual([PIDGEY, CYNDAQUIL]);
|
|
// wPartyMenuCursor stays on the row chosen last, in the next menu too.
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
const party = world.dialogue?.choice;
|
|
expect(party?.kind === "menu" && party.selected).toBe(1);
|
|
close(world);
|
|
});
|
|
|
|
it("reorders moves with their PP", () => {
|
|
const world = fixture();
|
|
const mon = world.state.party[0];
|
|
expect(mon.moves).toEqual([TACKLE, LEER, SMOKESCREEN, 0]);
|
|
mon.pp[0] = 30;
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
expect(pick(world, "MOVE")).toEqual(["CYNDAQUIL :L8"]);
|
|
expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]);
|
|
expect(pick(world, "TACKLE")).toEqual(["Where?"]);
|
|
// B while choosing where cancels only the move being moved.
|
|
world.advanceDialogue();
|
|
until(world);
|
|
pick(world, "TACKLE");
|
|
pick(world, "SMOKESCREEN");
|
|
expect(menuItems(world)).toEqual(["SMOKESCREEN", "LEER", "TACKLE"]);
|
|
close(world);
|
|
expect(world.state.party[0].moves).toEqual([SMOKESCREEN, LEER, TACKLE, 0]);
|
|
expect(world.state.party[0].pp).toEqual([20, 30, 30, 0]);
|
|
});
|
|
|
|
it("gives, swaps and takes held items", () => {
|
|
const world = fixture();
|
|
world.state.bag.items.push({ item: LEFTOVERS, quantity: 1 });
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
pick(world, "ITEM");
|
|
expect(menuItems(world)).toEqual(["GIVE", "TAKE"]);
|
|
expect(pick(world, "TAKE")).toEqual([
|
|
"CYNDAQUIL isn't holding anything.",
|
|
"Choose a POKéMON.",
|
|
]);
|
|
pick(world, "CYNDAQUIL");
|
|
pick(world, "ITEM");
|
|
pick(world, "GIVE");
|
|
// Key items cannot be held; the pack stays open.
|
|
expect(pickItem(world, BICYCLE)).toEqual(["This item can't be held."]);
|
|
expect(pickItem(world, POTION)).toEqual([
|
|
"Made CYNDAQUIL hold POTION.",
|
|
"Choose a POKéMON.",
|
|
]);
|
|
pick(world, "CYNDAQUIL");
|
|
pick(world, "ITEM");
|
|
pick(world, "GIVE");
|
|
expect(pickItem(world, LEFTOVERS)).toEqual([
|
|
"CYNDAQUIL is already holding",
|
|
"POTION. Switch items?",
|
|
]);
|
|
world.choose(1);
|
|
expect(until(world)).toEqual([
|
|
"Took CYNDAQUIL's POTION and",
|
|
"made it hold LEFTOVERS.",
|
|
"Choose a POKéMON.",
|
|
]);
|
|
close(world);
|
|
expect(world.state.party[0].item).toBe(LEFTOVERS);
|
|
expect(world.state.bag.items).toEqual([
|
|
{ item: POTION, quantity: 5 },
|
|
{ item: ANTIDOTE, quantity: 1 },
|
|
]);
|
|
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
pick(world, "ITEM");
|
|
expect(pick(world, "TAKE")[0]).toBe("Took LEFTOVERS from CYNDAQUIL.");
|
|
close(world);
|
|
expect(world.state.party[0].item).toBe(0);
|
|
expect(world.state.bag.items.at(-1)).toEqual({ item: LEFTOVERS, quantity: 1 });
|
|
});
|
|
|
|
it("keeps a held item when the pack is full", () => {
|
|
const world = fixture();
|
|
world.state.party[0].item = LEFTOVERS;
|
|
world.state.bag.items = Array.from({ length: 20 }, (_, i) => ({
|
|
item: [POTION, ANTIDOTE, BERRY][i % 3],
|
|
quantity: 99,
|
|
}));
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
pick(world, "ITEM");
|
|
expect(pick(world, "TAKE")[0]).toBe("Item storage space full.");
|
|
close(world);
|
|
expect(world.state.party[0].item).toBe(LEFTOVERS);
|
|
});
|
|
|
|
it("refuses to compose MAIL without changing anything", () => {
|
|
const world = fixture();
|
|
world.state.bag.items.push({ item: FLOWER_MAIL, quantity: 1 });
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, FLOWER_MAIL);
|
|
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
|
|
expect(pick(world, "GIVE")).toEqual(["To which POKéMON?"]);
|
|
pick(world, "CYNDAQUIL");
|
|
expect(world.notice).toBe("Writing MAIL is not ported yet.");
|
|
close(world);
|
|
expect(world.state.party[0].item).toBe(0);
|
|
expect(world.state.bag.items.at(-1)).toEqual({ item: FLOWER_MAIL, quantity: 1 });
|
|
});
|
|
|
|
it("uses healing items from the pack on the party", () => {
|
|
const world = fixture();
|
|
const [cyndaquil, pidgey] = world.state.party;
|
|
cyndaquil.hp = 5;
|
|
pidgey.status = POISONED;
|
|
world.state.bag.items.push(
|
|
{ item: REVIVE, quantity: 1 },
|
|
{ item: FULL_RESTORE, quantity: 1 },
|
|
);
|
|
open(world);
|
|
pick(world, "PACK");
|
|
// The pack opens on the Items pocket, with the ROM's pocket names.
|
|
const pack = world.dialogue?.choice;
|
|
expect(
|
|
pack?.kind === "pack" && [pack.pocket, pack.pockets.map((pocket) => pocket.name)],
|
|
).toEqual([0, ["ITEM POCKET", "BALL POCKET", "KEY POCKET", "TM POCKET"]]);
|
|
pickItem(world, POTION);
|
|
expect(menuItems(world)).toEqual(["USE", "GIVE", "TOSS", "QUIT"]);
|
|
expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]);
|
|
// Full HP: no effect, the Potion stays and the pack is shown again.
|
|
expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]);
|
|
expect(world.dialogue?.choice?.kind).toBe("pack");
|
|
pickItem(world, POTION);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL recovered 20HP!"]);
|
|
pickItem(world, ANTIDOTE);
|
|
pick(world, "USE");
|
|
expect(pick(world, "PIDGEY")).toEqual(["PIDGEY's cured of poison."]);
|
|
// A Revive on a Pokémon that has not fainted has no effect.
|
|
pickItem(world, REVIVE);
|
|
pick(world, "USE");
|
|
expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]);
|
|
close(world);
|
|
const [healed, cured] = world.state.party;
|
|
expect(healed.hp).toBe(25);
|
|
expect(cured.status).toBe(0);
|
|
expect(world.state.bag.items).toEqual([
|
|
{ item: POTION, quantity: 4 },
|
|
{ item: REVIVE, quantity: 1 },
|
|
{ item: FULL_RESTORE, quantity: 1 },
|
|
]);
|
|
|
|
world.state.party[1].hp = 0;
|
|
world.state.party[0].hp = 1;
|
|
world.state.party[0].status = POISONED;
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, REVIVE);
|
|
pick(world, "USE");
|
|
expect(pick(world, "PIDGEY")).toEqual(["PIDGEY is revitalized."]);
|
|
pickItem(world, FULL_RESTORE);
|
|
pick(world, "USE");
|
|
const max = world.state.party[0].stats[0];
|
|
expect(pick(world, "CYNDAQUIL")).toEqual([`CYNDAQUIL recovered ${max - 1}HP!`]);
|
|
close(world);
|
|
expect(world.state.party[1].hp).toBe(world.state.party[1].stats[0] >> 1);
|
|
expect(world.state.party[0].hp).toBe(max);
|
|
expect(world.state.party[0].status).toBe(0);
|
|
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 4 }]);
|
|
});
|
|
|
|
it("makes bitter herbs cost happiness", () => {
|
|
const world = fixture();
|
|
world.state.party[0].hp = 1;
|
|
world.state.bag.items.push({ item: ENERGYPOWDER, quantity: 1 });
|
|
const happiness = world.state.party[0].happiness;
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, ENERGYPOWDER);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual([
|
|
"CYNDAQUIL recovered 24HP!",
|
|
"It looks bitter…",
|
|
]);
|
|
close(world);
|
|
expect(world.state.party[0].happiness).toBe(happiness - 5);
|
|
});
|
|
|
|
it("restores and raises PP", () => {
|
|
const world = fixture();
|
|
world.state.party[0].pp = [10, 30, 3, 0];
|
|
world.state.bag.items.push(
|
|
{ item: ETHER, quantity: 1 },
|
|
{ item: ELIXER, quantity: 1 },
|
|
{ item: PP_UP, quantity: 2 },
|
|
);
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, ETHER);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["Restore the PP of which move?"]);
|
|
expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]);
|
|
// B returns to the party menu.
|
|
world.advanceDialogue();
|
|
expect(until(world)).toEqual(["Use on which POKéMON?"]);
|
|
pick(world, "CYNDAQUIL");
|
|
// Full PP: no effect.
|
|
expect(pick(world, "LEER")).toEqual(["It won't have any effect."]);
|
|
pickItem(world, ETHER);
|
|
pick(world, "USE");
|
|
pick(world, "CYNDAQUIL");
|
|
expect(pick(world, "TACKLE")).toEqual(["PP was restored."]);
|
|
pickItem(world, ELIXER);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["PP was restored."]);
|
|
pickItem(world, PP_UP);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["Raise the PP of which move?"]);
|
|
expect(pick(world, "TACKLE")).toEqual(["TACKLE's PP increased."]);
|
|
close(world);
|
|
// Tackle: 10, +10 (Ether), +10 (Elixer), then a PP Up adds a fifth of
|
|
// the base PP (7 of 35) to both the current PP and the maximum.
|
|
const pp = world.state.party[0].pp;
|
|
expect(pp).toEqual([0x40 | 37, 30, 13, 0]);
|
|
expect(maxPP(world.assets.pokemon, TACKLE, 1) & 63).toBe(42);
|
|
const items = world.state.bag.items;
|
|
expect(items.find((slot) => slot.item === PP_UP)?.quantity).toBe(1);
|
|
expect(items.some((slot) => slot.item === ETHER || slot.item === ELIXER)).toBe(false);
|
|
});
|
|
|
|
it("refuses a PP Up on a move that has had three", () => {
|
|
const world = fixture();
|
|
world.state.party[0].pp[1] = 0xc0 | 30;
|
|
world.state.bag.items.push({ item: PP_UP, quantity: 1 });
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, PP_UP);
|
|
pick(world, "USE");
|
|
pick(world, "CYNDAQUIL");
|
|
// PPIsMaxedOutText, then the move list again.
|
|
expect(pick(world, "LEER")).toEqual([
|
|
"LEER's PP is maxed out.",
|
|
"Raise the PP of which move?",
|
|
]);
|
|
close(world);
|
|
expect(world.state.party[0].pp[1]).toBe(0xc0 | 30);
|
|
expect(world.state.bag.items.at(-1)).toEqual({ item: PP_UP, quantity: 1 });
|
|
});
|
|
|
|
it("tosses items after confirming the quantity", () => {
|
|
const world = fixture();
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, POTION);
|
|
pick(world, "TOSS");
|
|
const choice = world.dialogue?.choice;
|
|
expect(choice?.kind === "martquantity" && [choice.prompt, choice.max]).toEqual([
|
|
"Throw away how\nmany?",
|
|
5,
|
|
]);
|
|
world.choose(3);
|
|
expect(until(world)).toEqual(["Throw away 3 POTION(S)?"]);
|
|
world.choose(0);
|
|
until(world);
|
|
pickItem(world, POTION);
|
|
pick(world, "TOSS");
|
|
world.choose(3);
|
|
until(world);
|
|
world.choose(1);
|
|
expect(until(world)).toEqual(["Threw away POTION(S)."]);
|
|
close(world);
|
|
expect(world.state.bag.items[0]).toEqual({ item: POTION, quantity: 2 });
|
|
});
|
|
|
|
it("gets on the Bicycle from the Key pocket, or gives Oak's warning indoors", () => {
|
|
const world = fixture();
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, BICYCLE);
|
|
expect(menuItems(world)).toEqual(["USE", "SEL", "QUIT"]);
|
|
world.choose(0);
|
|
expect(settle(world).join(" ")).toContain("got on the\nBICYCLE.");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.playerState).toBe(1);
|
|
|
|
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
|
|
open(indoors);
|
|
pick(indoors, "PACK");
|
|
pickItem(indoors, BICYCLE);
|
|
expect(pick(indoors, "USE")).toEqual([
|
|
"OAK: CHRIS! This isn't the time to use that!",
|
|
]);
|
|
close(indoors);
|
|
expect(indoors.state.playerState).toBe(0);
|
|
});
|
|
|
|
it("registers the Bicycle with SEL, and Select rides it without a message", () => {
|
|
const world = fixture();
|
|
// Nothing registered yet.
|
|
expect(world.useSelect()).toBe(true);
|
|
expect(until(world)).toEqual([
|
|
"An item in your PACK may be",
|
|
"registered for use on SELECT Button.",
|
|
]);
|
|
close(world);
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, BICYCLE);
|
|
expect(pick(world, "SEL")).toEqual(["Registered the BICYCLE."]);
|
|
close(world);
|
|
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
|
|
// The registration is saved.
|
|
world.restore(world.save());
|
|
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
|
|
expect(world.useSelect()).toBe(true);
|
|
expect(settle(world)).toEqual([]);
|
|
expect(world.diagnostic).toBe("");
|
|
expect(world.state.playerState).toBe(1);
|
|
expect(world.useSelect()).toBe(true);
|
|
expect(settle(world)).toEqual([]);
|
|
expect(world.state.playerState).toBe(0);
|
|
|
|
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
|
|
indoors.state.registeredItem = { which: 0x81, item: BICYCLE };
|
|
indoors.useSelect();
|
|
expect(until(indoors)).toEqual(["OAK: CHRIS! This isn't the time to use that!"]);
|
|
close(indoors);
|
|
expect(indoors.state.playerState).toBe(0);
|
|
});
|
|
|
|
it("forgets a registered item that left its slot", () => {
|
|
const world = fixture();
|
|
world.state.registeredItem = { which: 0x02, item: POTION };
|
|
world.useSelect();
|
|
expect(until(world)[0]).toBe("An item in your PACK may be");
|
|
close(world);
|
|
expect(world.state.registeredItem).toEqual({ which: 0, item: 0 });
|
|
});
|
|
|
|
it("offers no USE without a field menu", () => {
|
|
const world = fixture();
|
|
world.state.bag.balls = [{ item: 5, quantity: 3 }];
|
|
open(world);
|
|
pick(world, "PACK");
|
|
// Poké Balls have no field menu: GIVE and TOSS only.
|
|
pickItem(world, 5);
|
|
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
|
|
pick(world, "QUIT");
|
|
close(world);
|
|
});
|
|
|
|
it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => {
|
|
const world = fixture();
|
|
const pidgey = world.state.party[1];
|
|
pidgey.moves = [TACKLE, CUT, LEER, GUST];
|
|
pidgey.pp = [35, 30, 30, 35];
|
|
pidgey.hp = 0;
|
|
world.state.bag.items.push({ item: RARE_CANDY, quantity: 2 });
|
|
const data = world.assets.pokemon;
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, RARE_CANDY);
|
|
pick(world, "USE");
|
|
const pages = pick(world, "PIDGEY");
|
|
expect(pages.slice(0, 2)).toEqual([
|
|
"PIDGEY grew to level 5!",
|
|
pages[1],
|
|
]);
|
|
expect(pages[1]).toMatch(/^ATTACK \d+ DEFENSE \d+ SPCL\.ATK \d+ SPCL\.DEF \d+ SPEED \d+$/);
|
|
expect(pages.slice(2)).toEqual([
|
|
"PIDGEY is trying to learn SAND-ATTACK.",
|
|
"But PIDGEY can't learn more than four moves.",
|
|
"Delete an older move to make room for SAND-ATTACK?",
|
|
]);
|
|
world.choose(1);
|
|
expect(until(world)).toEqual(["Which move should be forgotten?"]);
|
|
expect(menuItems(world)).toEqual(["TACKLE", "CUT", "LEER", "GUST"]);
|
|
expect(pick(world, "CUT")).toEqual([
|
|
"HM moves can't be forgotten now.",
|
|
"Which move should be forgotten?",
|
|
]);
|
|
expect(pick(world, "LEER")).toEqual([
|
|
"1, 2 and… Poof!",
|
|
"PIDGEY forgot LEER.",
|
|
"And…",
|
|
"PIDGEY learned SAND-ATTACK!",
|
|
]);
|
|
close(world);
|
|
const mon = world.state.party[1];
|
|
expect(mon.level).toBe(5);
|
|
expect(mon.exp).toBe(data.expAtLevel(data.baseData(PIDGEY).growthRate, 5));
|
|
expect(mon.moves).toEqual([TACKLE, CUT, SAND_ATTACK, GUST]);
|
|
expect(mon.pp[2]).toBe(data.move(SAND_ATTACK).pp);
|
|
// The Rare Candy adds the maximum HP's rise even to a fainted Pokémon.
|
|
expect(mon.hp).toBe(mon.stats[0] - pidgey.stats[0]);
|
|
expect(mon.hp).toBeGreaterThan(0);
|
|
expect(world.state.bag.items.at(-1)).toEqual({ item: RARE_CANDY, quantity: 1 });
|
|
});
|
|
|
|
it("can decline to learn the move", () => {
|
|
const world = fixture();
|
|
world.state.party[1].moves = [TACKLE, CUT, LEER, GUST];
|
|
world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 });
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, RARE_CANDY);
|
|
pick(world, "USE");
|
|
pick(world, "PIDGEY");
|
|
world.choose(0);
|
|
expect(until(world)).toEqual(["Stop learning SAND-ATTACK?"]);
|
|
world.choose(1);
|
|
expect(until(world)).toEqual(["PIDGEY did not learn SAND-ATTACK."]);
|
|
close(world);
|
|
expect(world.state.party[1].moves).toEqual([TACKLE, CUT, LEER, GUST]);
|
|
expect(world.state.party[1].level).toBe(5);
|
|
});
|
|
|
|
it("evolves after a Rare Candy, and B can stop it", () => {
|
|
for (const stop of [false, true]) {
|
|
const world = fixture();
|
|
const data = world.assets.pokemon;
|
|
world.state.party[0] = generatePartyMon(data, {
|
|
species: CYNDAQUIL,
|
|
level: 13,
|
|
item: 0,
|
|
otId: world.playerId,
|
|
ot: "CHRIS",
|
|
dvs: 0x9abc,
|
|
caughtData: 0,
|
|
});
|
|
world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 });
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, RARE_CANDY);
|
|
pick(world, "USE");
|
|
world.choose(0);
|
|
// Acknowledge pages until the evolution animation starts.
|
|
const pages: string[] = [];
|
|
for (let i = 0; i < 200 && !world.evolution; i++) {
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
pages.push(dialogue.pages[dialogue.index].replace(/\n/g, " "));
|
|
if (dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
} else world.tick();
|
|
}
|
|
expect(pages[0]).toBe("CYNDAQUIL grew to level 14!");
|
|
expect(pages.at(-1)).toBe("What? CYNDAQUIL is evolving!");
|
|
expect(world.picture).toBe(CYNDAQUIL);
|
|
// B does nothing before the flashes begin.
|
|
world.advanceDialogue();
|
|
expect(world.evolution).toBeDefined();
|
|
for (let tick = 0; tick < 40; tick++) world.tick();
|
|
if (stop) {
|
|
world.advanceDialogue();
|
|
expect(until(world)).toEqual(["Huh? CYNDAQUIL stopped evolving!"]);
|
|
} else {
|
|
expect(until(world)).toEqual([
|
|
"Congratulations! Your CYNDAQUIL",
|
|
"evolved into QUILAVA!",
|
|
]);
|
|
}
|
|
close(world);
|
|
const mon = world.state.party[0];
|
|
expect(mon.level).toBe(14);
|
|
expect([mon.species, mon.nickname]).toEqual(
|
|
stop ? [CYNDAQUIL, "CYNDAQUIL"] : [QUILAVA, "QUILAVA"],
|
|
);
|
|
expect(mon.stats).toEqual(
|
|
calcStats(data, mon.species, mon.dvs, 14, mon.statExp),
|
|
);
|
|
expect(world.state.pokedex.caught.has(QUILAVA)).toBe(!stop);
|
|
expect(world.state.bag.items.some((slot) => slot.item === RARE_CANDY)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("evolves with a stone, which B cannot stop, unless held back by an Everstone", () => {
|
|
const world = fixture();
|
|
const data = world.assets.pokemon;
|
|
world.state.party[1] = generatePartyMon(data, {
|
|
species: PIKACHU,
|
|
level: 10,
|
|
item: EVERSTONE,
|
|
otId: world.playerId,
|
|
ot: "CHRIS",
|
|
dvs: 0x9abc,
|
|
caughtData: 0,
|
|
});
|
|
world.state.party[1].nickname = "SPARKY";
|
|
world.state.bag.items.push({ item: THUNDERSTONE, quantity: 2 });
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, THUNDERSTONE);
|
|
expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]);
|
|
const party = world.dialogue?.choice;
|
|
expect(party?.kind === "menu" && party.details).toEqual(["NOT ABLE", "ABLE"]);
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]);
|
|
pickItem(world, THUNDERSTONE);
|
|
pick(world, "USE");
|
|
expect(pick(world, "SPARKY")).toEqual(["It won't have any effect."]);
|
|
close(world);
|
|
world.state.party[1].item = 0;
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, THUNDERSTONE);
|
|
pick(world, "USE");
|
|
world.choose(1);
|
|
for (let i = 0; i < 20 && !world.evolution; i++) {
|
|
if (world.dialogue?.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
}
|
|
for (let tick = 0; tick < 60; tick++) world.tick();
|
|
world.advanceDialogue();
|
|
expect(until(world)).toEqual([
|
|
"Congratulations! Your SPARKY",
|
|
"evolved into RAICHU!",
|
|
]);
|
|
close(world);
|
|
const mon = world.state.party[1];
|
|
// A nickname stays.
|
|
expect([mon.species, mon.nickname]).toEqual([RAICHU, "SPARKY"]);
|
|
expect(world.state.bag.items.at(-1)).toEqual({ item: THUNDERSTONE, quantity: 1 });
|
|
});
|
|
|
|
it("raises stat experience with a vitamin, up to its limit", () => {
|
|
const world = fixture();
|
|
const data = world.assets.pokemon;
|
|
const mon = world.state.party[0];
|
|
const hp = mon.hp,
|
|
happiness = mon.happiness;
|
|
world.state.bag.items.push({ item: HP_UP, quantity: 2 });
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, HP_UP);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL's HEALTH rose."]);
|
|
close(world);
|
|
const raised = world.state.party[0];
|
|
expect(raised.statExp[0]).toBe(2560);
|
|
expect(raised.stats).toEqual(calcStats(data, CYNDAQUIL, raised.dvs, 8, raised.statExp));
|
|
expect(raised.hp).toBe(hp);
|
|
expect(raised.happiness).toBeGreaterThan(happiness);
|
|
|
|
raised.statExp[0] = 100 << 8;
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, HP_UP);
|
|
pick(world, "USE");
|
|
expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]);
|
|
close(world);
|
|
expect(world.state.party[0].statExp[0]).toBe(100 << 8);
|
|
expect(world.state.bag.items.at(-1)).toEqual({ item: HP_UP, quantity: 1 });
|
|
});
|
|
|
|
it("saves the state from before the menu while it is open", () => {
|
|
const world = fixture();
|
|
world.state.party[0].hp = 5;
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, POTION);
|
|
pick(world, "USE");
|
|
pick(world, "CYNDAQUIL");
|
|
const saved = world.save();
|
|
expect(saved.party?.[0].hp).toBe(5);
|
|
expect(saved.bag?.items[0].quantity).toBe(5);
|
|
const reloaded = fixture();
|
|
reloaded.restore(saved);
|
|
expect(reloaded.busy).toBe(false);
|
|
expect(reloaded.state.party[0].hp).toBe(5);
|
|
close(world);
|
|
expect(world.state.party[0].hp).toBe(25);
|
|
});
|
|
});
|