- Day-Care and breeding: deposits, withdrawal pricing and growth, compatibility, egg species, DVs and moves, DayCareStep, egg collection and the Odd Egg, compared with the cartridge's routines. - CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep in the original's order; HatchEggs with the ROM's hatch animation, compared frame by frame with the original. - MAIL: all ten stationery types and the Portrait MAIL picture, compared with the cartridge's tilemaps, graphics and palettes. - Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio take the original's absent-printer path. - Hall of Fame records (saved apart from the League flag, ready for the League to call) and the Hall of Fame PC, compared with the cartridge's _HallOfFamePC on 255 pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
12 KiB
TypeScript
395 lines
12 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 { generatePartyMon } from "../src/game/party/pokemon";
|
|
import {
|
|
mailMessage,
|
|
validMailMessage,
|
|
type Mail,
|
|
} from "../src/game/party/mail";
|
|
import { close, menuItems, open, pick, pickItem, until } from "./menu-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const POKEMON_CENTER = "26:5",
|
|
CYNDAQUIL = 155,
|
|
PIDGEY = 16;
|
|
const POTION = 0x12,
|
|
BERRY = 0xad,
|
|
FLOWER_MAIL = 0x9e,
|
|
SURF_MAIL = 0xb5;
|
|
|
|
describe("MAIL messages", () => {
|
|
it("hold up to sixteen characters a line, the second after a full first", () => {
|
|
expect(validMailMessage("")).toBe(true);
|
|
expect(validMailMessage("HELLO, CHRIS!")).toBe(true);
|
|
expect(validMailMessage("x".repeat(17))).toBe(false);
|
|
expect(validMailMessage("SHORT\nLINE")).toBe(false);
|
|
expect(validMailMessage(`${"A".repeat(16)}\n${"b".repeat(16)}`)).toBe(true);
|
|
expect(validMailMessage(`${"A".repeat(16)}\nB\nC`)).toBe(false);
|
|
// The mail entry's own characters only.
|
|
expect(validMailMessage("é♂♀¥…× “”[]:;&()")).toBe(true);
|
|
expect(validMailMessage("<PK>")).toBe(false);
|
|
expect(validMailMessage("tab\there")).toBe(false);
|
|
// The second line pads the first to a full line.
|
|
expect(mailMessage("HI", "THERE")).toBe(`HI${" ".repeat(14)}\nTHERE`);
|
|
expect(mailMessage("HI", "")).toBe("HI");
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("MAIL", () => {
|
|
/** In front of Cherrygrove's PC with two Pokémon, MAIL and a Potion. */
|
|
function fixture() {
|
|
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
const world = new GameWorld(
|
|
assets,
|
|
() => new Date("2026-09-25T12:00:00"),
|
|
() => 0x42,
|
|
);
|
|
const mon = (species: number, level: number) =>
|
|
generatePartyMon(assets.pokemon, {
|
|
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: 1 },
|
|
{ item: FLOWER_MAIL, quantity: 2 },
|
|
];
|
|
world.restore({
|
|
...world.save(),
|
|
map: POKEMON_CENTER,
|
|
x: 9,
|
|
y: 2,
|
|
direction: "up",
|
|
objects: [],
|
|
});
|
|
return world;
|
|
}
|
|
const letter = (
|
|
world: GameWorld,
|
|
message: string,
|
|
type = FLOWER_MAIL,
|
|
): Mail => ({
|
|
message,
|
|
author: world.name,
|
|
authorId: world.playerId,
|
|
species: CYNDAQUIL,
|
|
type,
|
|
});
|
|
/** GIVE the MAIL from the pack to the first Pokémon, up to its entry. */
|
|
function give(world: GameWorld) {
|
|
open(world);
|
|
pick(world, "PACK");
|
|
pickItem(world, FLOWER_MAIL);
|
|
pick(world, "GIVE");
|
|
return pick(world, "CYNDAQUIL");
|
|
}
|
|
/** The first Pokémon's MAIL submenu. */
|
|
function mailMenu(world: GameWorld) {
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
return pick(world, "MAIL");
|
|
}
|
|
/** Turn on the PC and open the player's MAIL BOX. */
|
|
function mailbox(world: GameWorld) {
|
|
world.interact();
|
|
until(world);
|
|
pick(world, `${world.name}'s PC`);
|
|
return pick(world, "MAIL BOX");
|
|
}
|
|
|
|
it("writes a message on MAIL given from the pack", () => {
|
|
const world = fixture();
|
|
expect(give(world)).toEqual(["Made CYNDAQUIL hold FLOWER MAIL."]);
|
|
const entry = world.dialogue?.choice;
|
|
expect(entry?.kind === "name" && [entry.mail, entry.max]).toEqual([
|
|
true,
|
|
16,
|
|
]);
|
|
// The entry keeps its lines; a short first line cannot have a second.
|
|
world.choose("SHORT\nLINE");
|
|
expect(world.dialogue?.choice?.kind).toBe("name");
|
|
world.choose(mailMessage("HELLO FROM", "NEW BARK TOWN!"));
|
|
close(world);
|
|
const mon = world.state.party[0];
|
|
expect(mon.item).toBe(FLOWER_MAIL);
|
|
expect(mon.mail).toEqual(
|
|
letter(world, `HELLO FROM${" ".repeat(6)}\nNEW BARK TOWN!`),
|
|
);
|
|
expect(world.state.bag.items.at(-1)).toEqual({
|
|
item: FLOWER_MAIL,
|
|
quantity: 1,
|
|
});
|
|
// The submenu offers MAIL instead of ITEM, and SWITCH keeps it with
|
|
// the Pokémon.
|
|
open(world);
|
|
pick(world, "POKéMON");
|
|
pick(world, "CYNDAQUIL");
|
|
expect(menuItems(world)).toEqual([
|
|
"STATS",
|
|
"SWITCH",
|
|
"MOVE",
|
|
"MAIL",
|
|
"CANCEL",
|
|
]);
|
|
pick(world, "SWITCH");
|
|
pick(world, "PIDGEY");
|
|
close(world);
|
|
expect(
|
|
world.state.party.map((mon) => [mon.species, mon.mail?.type]),
|
|
).toEqual([
|
|
[PIDGEY, undefined],
|
|
[CYNDAQUIL, FLOWER_MAIL],
|
|
]);
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
});
|
|
|
|
it("swaps a held item for MAIL, which then gets its message", () => {
|
|
const world = fixture();
|
|
world.state.party[0].item = BERRY;
|
|
expect(give(world)).toEqual([
|
|
"CYNDAQUIL is already holding",
|
|
"BERRY. Switch items?",
|
|
]);
|
|
world.choose(1);
|
|
expect(until(world)).toEqual([
|
|
"Took CYNDAQUIL's BERRY and",
|
|
"made it hold FLOWER MAIL.",
|
|
]);
|
|
expect(world.dialogue?.choice?.kind).toBe("name");
|
|
world.choose("");
|
|
close(world);
|
|
expect(world.state.party[0].mail).toEqual(letter(world, ""));
|
|
expect(world.state.bag.items.some((slot) => slot.item === BERRY)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("reads MAIL, and takes it to the PC or into the pack", () => {
|
|
const world = fixture();
|
|
const mon = world.state.party[0];
|
|
mon.item = FLOWER_MAIL;
|
|
mon.mail = letter(world, "SEE YOU SOON!");
|
|
mailMenu(world);
|
|
expect(menuItems(world)).toEqual(["READ", "TAKE", "QUIT"]);
|
|
world.choose(0); // READ
|
|
expect(world.mailView).toEqual(mon.mail);
|
|
world.advanceDialogue();
|
|
until(world);
|
|
expect(world.mailView).toBeUndefined();
|
|
// B on the letter goes back to the party menu.
|
|
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
|
|
close(world);
|
|
|
|
// TAKE: NO, then NO again keeps it.
|
|
mailMenu(world);
|
|
expect(pick(world, "TAKE")).toEqual(["Send the removed MAIL to your PC?"]);
|
|
world.choose(0);
|
|
expect(until(world)[0]).toBe("The MAIL will lose its message. OK?");
|
|
world.choose(0);
|
|
close(world);
|
|
expect(world.state.party[0].mail?.message).toBe("SEE YOU SOON!");
|
|
|
|
// To the PC.
|
|
mailMenu(world);
|
|
pick(world, "TAKE");
|
|
world.choose(1);
|
|
expect(until(world)[0]).toBe("The MAIL was sent to your PC.");
|
|
close(world);
|
|
expect(world.state.party[0]).not.toHaveProperty("mail");
|
|
expect(world.state.party[0].item).toBe(0);
|
|
expect(world.state.mailbox).toEqual([letter(world, "SEE YOU SOON!")]);
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
});
|
|
|
|
it("refuses a full MAILBOX, and loses the message in the pack", () => {
|
|
const world = fixture();
|
|
const mon = world.state.party[0];
|
|
mon.item = FLOWER_MAIL;
|
|
mon.mail = letter(world, "HI");
|
|
world.state.mailbox = Array.from({ length: 10 }, () =>
|
|
letter(world, "OLD"),
|
|
);
|
|
mailMenu(world);
|
|
pick(world, "TAKE");
|
|
world.choose(1);
|
|
expect(until(world)[0]).toBe("Your PC's MAILBOX is full.");
|
|
close(world);
|
|
expect(world.state.party[0].mail?.message).toBe("HI");
|
|
|
|
// A full pack keeps it on the Pokémon.
|
|
world.state.bag.items = Array.from({ length: 20 }, (_, i) => ({
|
|
item: i + 1,
|
|
quantity: 1,
|
|
}));
|
|
mailMenu(world);
|
|
pick(world, "TAKE");
|
|
world.choose(0);
|
|
until(world);
|
|
world.choose(1);
|
|
expect(until(world)[0]).toBe("There's no space for removing MAIL.");
|
|
close(world);
|
|
expect(world.state.party[0].item).toBe(FLOWER_MAIL);
|
|
|
|
world.state.bag.items = [];
|
|
mailMenu(world);
|
|
pick(world, "TAKE");
|
|
world.choose(0);
|
|
until(world);
|
|
world.choose(1);
|
|
expect(until(world)[0]).toBe("MAIL detached from CYNDAQUIL.");
|
|
close(world);
|
|
expect(world.state.party[0]).not.toHaveProperty("mail");
|
|
expect(world.state.bag.items).toEqual([{ item: FLOWER_MAIL, quantity: 1 }]);
|
|
});
|
|
|
|
it("prints with Start, waits for B on the error and restores the letter", () => {
|
|
const world = fixture();
|
|
const mon = world.state.party[0];
|
|
mon.item = FLOWER_MAIL;
|
|
mon.mail = letter(world, "“HELLO…”");
|
|
mailMenu(world);
|
|
world.choose(0);
|
|
const before = world.dialogue?.choice;
|
|
expect(before?.kind).toBe("screen");
|
|
world.choose("down");
|
|
expect(world.mailView).toEqual(mon.mail);
|
|
world.choose("start");
|
|
const printer = world.dialogue?.choice;
|
|
expect(printer?.kind === "screen" && printer.screen.label).toContain("Printer Error 2");
|
|
expect(world.mailView).toBeUndefined();
|
|
world.choose("a");
|
|
expect(world.dialogue?.choice).toEqual(printer);
|
|
world.choose("b");
|
|
expect(world.mailView).toEqual(mon.mail);
|
|
expect(world.dialogue?.choice).toEqual(before);
|
|
world.choose("a");
|
|
until(world);
|
|
close(world);
|
|
expect(world.state.party[0].mail).toEqual(mon.mail);
|
|
});
|
|
|
|
it("keeps a Pokémon holding MAIL out of the boxes", () => {
|
|
const world = fixture();
|
|
const mon = world.state.party[1];
|
|
mon.item = FLOWER_MAIL;
|
|
mon.mail = { ...letter(world, "HI"), species: PIDGEY };
|
|
world.interact();
|
|
until(world);
|
|
pick(world, "BILL's PC");
|
|
until(world);
|
|
pick(world, "DEPOSIT POKéMON");
|
|
pick(world, /^PIDGEY/);
|
|
expect(pick(world, "DEPOSIT")).toContain("Remove MAIL.");
|
|
close(world);
|
|
expect(world.state.party).toHaveLength(2);
|
|
});
|
|
|
|
it("reads, packs and attaches MAIL from the PC's MAIL BOX", () => {
|
|
const world = fixture();
|
|
world.state.mailbox = [
|
|
letter(world, "FIRST"),
|
|
{ ...letter(world, "SECOND", SURF_MAIL), author: "KRIS" },
|
|
letter(world, "THIRD"),
|
|
];
|
|
world.state.party.push({
|
|
...structuredClone(world.state.party[1]),
|
|
egg: true,
|
|
nickname: "EGG",
|
|
});
|
|
mailbox(world);
|
|
expect(menuItems(world)).toEqual([
|
|
world.name,
|
|
"KRIS",
|
|
world.name,
|
|
"CANCEL",
|
|
]);
|
|
pick(world, "KRIS");
|
|
expect(menuItems(world)).toEqual([
|
|
"READ MAIL",
|
|
"PUT IN PACK",
|
|
"ATTACH MAIL",
|
|
"CANCEL",
|
|
]);
|
|
world.choose(0); // READ MAIL
|
|
expect(world.mailView?.message).toBe("SECOND");
|
|
world.advanceDialogue();
|
|
until(world);
|
|
|
|
// PUT IN PACK asks first, then the message is lost.
|
|
pick(world, "KRIS");
|
|
expect(pick(world, "PUT IN PACK")).toEqual([
|
|
"The MAIL's message will be lost. OK?",
|
|
]);
|
|
world.choose(1);
|
|
expect(until(world)[0]).toBe("The cleared MAIL was put away.");
|
|
expect(menuItems(world)).toEqual([world.name, world.name, "CANCEL"]);
|
|
|
|
// ATTACH MAIL: not to an Egg or a Pokémon holding an item.
|
|
pick(world, /./);
|
|
expect(pick(world, "ATTACH MAIL")).toEqual(["Choose a POKéMON."]);
|
|
world.choose(2);
|
|
expect(until(world)[0]).toBe("An EGG can't hold any MAIL.");
|
|
world.choose(0);
|
|
expect(until(world)[0]).toBe("The MAIL was moved from the MAILBOX.");
|
|
close(world);
|
|
expect(world.state.party[0].mail).toEqual(letter(world, "FIRST"));
|
|
expect(world.state.party[0].item).toBe(FLOWER_MAIL);
|
|
expect(world.state.mailbox).toEqual([letter(world, "THIRD")]);
|
|
expect(world.state.bag.items.at(-1)).toEqual({
|
|
item: SURF_MAIL,
|
|
quantity: 1,
|
|
});
|
|
|
|
mailbox(world);
|
|
pick(world, world.name);
|
|
pick(world, "ATTACH MAIL");
|
|
world.choose(0);
|
|
expect(until(world)[0]).toBe("It's already hold- ing an item.");
|
|
close(world);
|
|
expect(world.state.mailbox).toHaveLength(1);
|
|
});
|
|
|
|
it("saves MAIL with its holder, and refuses MAIL without one", () => {
|
|
const world = fixture();
|
|
const mon = world.state.party[0];
|
|
mon.item = FLOWER_MAIL;
|
|
mon.mail = letter(world, "HI");
|
|
world.state.mailbox = [letter(world, "BOX")];
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
const { mailbox: _, ...legacy } = saved;
|
|
expect(migrateSave(legacy, () => 0)?.mailbox).toEqual([]);
|
|
const party = (change: (mon: (typeof saved.party)[number]) => void) => {
|
|
const copy = structuredClone(saved);
|
|
change(copy.party[0]);
|
|
return copy;
|
|
};
|
|
for (const invalid of [
|
|
party((mon) => delete mon.mail),
|
|
party((mon) => (mon.item = POTION)),
|
|
party((mon) => (mon.mail!.message = "x".repeat(17))),
|
|
party((mon) => (mon.mail!.type = POTION)),
|
|
{
|
|
...saved,
|
|
mailbox: Array.from({ length: 11 }, () => letter(world, "")),
|
|
},
|
|
])
|
|
expect(() => world.restore(invalid)).toThrow("Invalid native save");
|
|
});
|
|
});
|