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

+80 -3
View File
@@ -1,6 +1,7 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
import { copyMail } from "../party/mail";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import {
BOX_NAME_PATTERN,
@@ -215,8 +216,7 @@ export class PcSession {
yield* this.tossItems();
break;
case PLAYERSPCITEM_MAIL_BOX:
// _PlayerMailBoxMenu: no MAIL can be stored yet.
yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
yield* this.mailbox();
break;
case PLAYERSPCITEM_DECORATION:
if (yield* this.host.decorationMenu()) return true;
@@ -228,6 +228,80 @@ export class PcSession {
}
}
// ----------------------------------------------------------- mailbox
/** _PlayerMailBoxMenu and MailboxPC: the MAIL listed by author, each with
* READ MAIL, PUT IN PACK, ATTACH MAIL or CANCEL, until B or CANCEL. */
private *mailbox(): Gen {
if (!this.state.mailbox.length)
return yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
const actions = this.strings(PC["MailboxPC.SubMenuData"] + 2, 4);
let cursor = 0;
for (;;) {
const mailbox = this.state.mailbox;
const index = yield* this.menu(
[],
[
...mailbox.map((mail) => mail.author),
this.string(PC["ScrollingMenu_UpdateDisplay.CancelString"]),
],
undefined,
Math.min(cursor, mailbox.length),
);
if (index < 0 || index >= mailbox.length) return;
cursor = index;
const action = yield* this.menu([], actions);
if (action === 0) yield { kind: "mail", mail: copyMail(mailbox[index]) };
else if (action === 1) yield* this.putMailInPack(index);
else if (action === 2) yield* this.attachMail(index);
}
}
/** MailboxPC.PutInPack: the MAIL item goes to the pack without its
* message (DeleteMailFromPC). */
private *putMailInPack(index: number): Gen {
const ok = yield {
kind: "yesno",
pages: this.pages(PC["MailboxPC.MailMessageLostText"]),
};
if (!ok) return;
const mailbox = this.state.mailbox;
if (!receiveItem(this.host.data, this.state.bag, mailbox[index].type, 1))
return yield* this.print(PC["MailboxPC.MailPackFullText"]);
mailbox.splice(index, 1);
yield* this.print(PC["MailboxPC.MailClearedPutAwayText"]);
}
/** MailboxPC.AttachMail: the party menu until a Pokémon that is not an
* Egg and holds nothing is chosen (MoveMailFromPCToParty), or B. */
private *attachMail(index: number): Gen {
const r = this.host.reader,
party = this.state.party;
// PlacePartyMenuText with wPartyMenuActionText 0: "Choose a POKéMON."
const prompt = this.string(
r.near(MENU.PartyMenuStrings, r.bank(MENU.PartyMenuStrings)),
);
for (;;) {
const slot = yield* this.menu(
[prompt],
party.map((mon) => mon.nickname),
this.monDetails(party),
);
if (slot < 0) return;
const mon = party[slot];
if (mon.species === EGG) {
yield* this.print(PC["MailboxPC.MailEggText"]);
continue;
}
if (mon.item) {
yield* this.print(PC["MailboxPC.MailAlreadyHoldingItemText"]);
continue;
}
const [mail] = this.state.mailbox.splice(index, 1);
mon.item = mail.type;
mon.mail = mail;
return yield* this.print(PC["MailboxPC.MailMovedFromBoxText"]);
}
}
// ------------------------------------------------------ item storage
private itemEntries(slots: ItemSlot[]): MartEntry[] {
@@ -554,13 +628,16 @@ export class PcSession {
}
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
* or the last that has not fainted, stays. No MAIL exists yet. */
* or the last that has not fainted, stays, and one holding MAIL
* (wBillsPC_MonHasMail) must have it removed first. */
private *partyMonMayLeave(index: number): Gen<boolean> {
const party = this.state.party;
let text: number | undefined;
if (party.length < 2) text = PC.PCString_ItsYourLastPKMN;
else if (party.every((mon, i) => i === index || !mon.hp))
text = PC.PCString_NoMoreUsablePKMN;
else if (this.host.data.itemIsMail(party[index].item))
text = PC.PCString_RemoveMail;
if (text === undefined) return true;
yield* this.placeString(this.string(text));
return false;