Files
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- 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>
2026-10-17 08:52:30 +02:00

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");
});
});