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