Port MAIL in the party menu and the PC's MAIL BOX

Giving MAIL opens the mail entry: two lines of up to sixteen characters,
kept with the player's name and ID, the holder's species and the MAIL.
The party submenu's MAIL reads the letter or takes it, either to the PC's
MAILBOX (ten at most) or into the pack without its message. The player's
PC lists the MAILBOX by author to read, put in the pack or attach, and
Bill's PC keeps a Pokémon holding MAIL out of the boxes. Saves keep each
holder's MAIL and the MAILBOX.

The letter shows its message and author natively; the stationery's
graphics are not drawn yet.

Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 01:10:08 +02:00
1 parent e2dc5ba520
commit 5c1ac9d726
17 files changed
+871 -54

No files matched your search

+37 -1
View File
@@ -20,7 +20,8 @@ async function start(
| "mom"
| "fly"
| "headbutt"
| "fishing" = false,
| "fishing"
| "mail" = false,
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
@@ -539,6 +540,41 @@ test("Select moves an item within the pack", async ({ page }) => {
]);
});
test("MAIL is written when given, and read from the party menu", async ({
page,
}) => {
await start(page, "mail");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart"),
letter = page.locator("#mail-view");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^PACK/ }).click();
await pack.getByRole("button", { name: /^FLOWER MAIL/ }).click();
await choices.getByRole("option", { name: "GIVE", exact: true }).click();
await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click();
await expect(text).toHaveText(/Made CYNDAQUIL\s+hold FLOWER MAIL\./);
await page.keyboard.press("KeyX");
await choices.getByLabel("MAIL line 1").fill("HELLO, CHRIS!");
await choices.getByLabel("MAIL line 2").fill("SEE YOU!");
await choices.getByRole("button", { name: "OK" }).click();
await expect(pack).toBeVisible();
await page.keyboard.press("KeyZ");
await choices.getByRole("option", { name: /^POKéMON/ }).click();
await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click();
await choices.getByRole("option", { name: "MAIL", exact: true }).click();
await choices.getByRole("option", { name: "READ", exact: true }).click();
await expect(letter).toBeVisible();
await expect(page.locator("#mail-type")).toHaveText("FLOWER MAIL");
await expect(page.locator("#mail-message")).toHaveText(
"HELLO, CHRIS! \nSEE YOU!",
);
await expect(page.locator("#mail-author")).toHaveText("CHRIS");
await page.locator("#mail-close").click();
await expect(letter).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
});
test("the Old Rod hooks a Magikarp from New Bark Town's shore", async ({
page,
}) => {
+8 -4
View File
@@ -13,7 +13,8 @@ import { generatePartyMon } from "../src/game/party/pokemon";
* "headbutt" faces Route 29's first reachable Headbutt tree from below
* with a Pokémon that knows Headbutt; Random always returning 0 drops a
* sleeping Hoothoot. "fishing" faces the sea on New Bark Town's shore
* with the Old Rod, which then hooks a Magikarp. */
* with the Old Rod, which then hooks a Magikarp. "mail" is "center" with a
* FLOWER MAIL in the pack. */
export function foundationFixture(
assets: CrystalAssets,
room:
@@ -25,7 +26,8 @@ export function foundationFixture(
| "mom"
| "fly"
| "headbutt"
| "fishing",
| "fishing"
| "mail",
) {
const world = new GameWorld(
assets,
@@ -34,7 +36,7 @@ export function foundationFixture(
);
world.state.scenes["24:4"] = 1;
world.state.bag.keyItems.push(OW.BICYCLE);
if (room === "center" || room === "candy") {
if (room === "center" || room === "candy" || room === "mail") {
world.state.party = [155, 16].map((species, i) =>
generatePartyMon(assets.pokemon, {
species,
@@ -49,6 +51,8 @@ export function foundationFixture(
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
if (room === "candy")
world.state.bag.items.push({ item: 0x20, quantity: 1 });
if (room === "mail")
world.state.bag.items.push({ item: 0x9e, quantity: 1 });
}
if (room === "pokegear" || room === "mom") {
world.state.party = [
@@ -132,7 +136,7 @@ export function foundationFixture(
? headbuttSpot(assets)
: room === "fishing"
? ["24:4", 17, 7]
: room === "center" || room === "candy"
: room === "center" || room === "candy" || room === "mail"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
+368
View File
@@ -0,0 +1,368 @@
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,
EGG = 0xfd;
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("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]),
species: 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");
});
});
-16
View File
@@ -31,7 +31,6 @@ const BICYCLE = 7,
EVERSTONE = 0x70,
ENERGYPOWDER = 0x79,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const SOFTBOILED = 0x87;
const CUT = 0x0f,
@@ -517,21 +516,6 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
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;