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:
1 parent
e2dc5ba520
commit
5c1ac9d726
17 files changed
+871
-54
No files matched your search
+26
-6
@@ -31,8 +31,8 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
|
||||
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. |
|
||||
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
||||
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
|
||||
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL gives a notice. |
|
||||
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the MAIL BOX (`MailboxPC`) and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
|
||||
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`), MAIL (`MonMailAction`, `ComposeMailMessage`) and `SwitchItemsInBag`. `party/mail.ts` holds the `mailmsg` struct. |
|
||||
| `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. |
|
||||
| `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. |
|
||||
| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. |
|
||||
@@ -307,8 +307,8 @@ and right (`.cycle_left`, `.cycle_right`) arrive as "left" and "right"
|
||||
answers of a `menu` effect with `sides`. `GiveTakePartyMonItem`,
|
||||
`TryGiveItemToPartymon` (including the swap and the full-pack refund) and
|
||||
`TakePartyItem` use `GiveItemToPokemon`/`ReceiveItemFromPokemon` on the
|
||||
chosen pack slot. Giving MAIL needs `ComposeMailMessage` and is refused
|
||||
before anything changes.
|
||||
chosen pack slot. Giving MAIL ends with `GivePartyItem`'s
|
||||
`ComposeMailMessage`.
|
||||
|
||||
The pack's submenu follows `.ItemBallsKey_LoadSubmenu`: `CANT_TOSS`,
|
||||
`CANT_SELECT` and the field menu (`CheckItemMenu`, the help byte's high
|
||||
@@ -349,6 +349,21 @@ while the menu is open. A Chromium/WebKit test opens the menu with Start,
|
||||
switches Pokémon, changes pockets and tosses a Potion. These are not
|
||||
emulator comparisons.
|
||||
|
||||
MAIL lives on the `PartyMon` that holds it (`sPartyMail` follows its
|
||||
Pokémon, so `_SwitchPartyMons`' mail swap and `RemoveMonFromParty`'s shift
|
||||
need nothing more) and in the state's `mailbox` (`sMailboxes`, up to
|
||||
`MAILBOX_CAPACITY`). Saves accept a message exactly while MAIL is held, and
|
||||
box Pokémon never carry one. The author's "nationality" bytes are not kept.
|
||||
`MonMailAction`'s READ, TAKE (`SendMailToPC`, or `ReceiveItemFromPokemon`
|
||||
after `MailLoseMessageText`) and QUIT, `MailboxPC`'s READ MAIL, PUT IN PACK
|
||||
(`DeleteMailFromPC`) and ATTACH MAIL (`MoveMailFromPCToParty`), and
|
||||
`BillsPC_CheckMail_PreventBlackout`'s `PCString_RemoveMail` use the ROM's
|
||||
strings. The naming screen's `mail` mode is `_ComposeMailMessage`: the
|
||||
first line gets its `<NEXT>` once full, so a second typed line pads it.
|
||||
`ReadAnyMail` shows the message and author natively; `MailGFXPointers`'
|
||||
stationery and the Portrait MAIL's picture are not drawn. A scripted
|
||||
`givepoke` with MAIL (`GivePokeMail`) is refused.
|
||||
|
||||
`Pack_InterpretJoypad`'s Select runs `SwitchItemsInBag` on the bag
|
||||
(`.try_combining_stacks`, `.combine_stacks`, `.above` and `.below`) through
|
||||
a `select:` answer of the `pack` effect, which shows the moving entry
|
||||
@@ -395,10 +410,15 @@ Squirtbottle (the interpreter facing a test Sudowoodo on Route 36),
|
||||
Town with the poses and the bubble, the surf and water refusals, and the
|
||||
Magikarp DV filters. `tests/start-menu.test.ts` moves and merges items
|
||||
with Select (keys included, and none in GIVE's pack) and steps through the
|
||||
party on the MOVE screen past an Egg. `tests/reference-parity.spec.ts` compares
|
||||
party on the MOVE screen past an Egg. `tests/mail.test.ts` writes MAIL
|
||||
given from the pack and swapped for a Berry, reads it, takes it to a PC
|
||||
(and a full MAILBOX) or into the pack (and a full pack), keeps it out of
|
||||
the boxes, reads, packs and attaches it from the MAIL BOX, and covers the
|
||||
save rules. `tests/reference-parity.spec.ts` compares
|
||||
`magikarpLength` with `CalcMagikarpLength` in the reference emulator over
|
||||
1,509 DV and ID pairs. Chromium/WebKit tests hook a Magikarp with the
|
||||
Old Rod and move a Potion below a Rare Candy with the keyboard's Select; the four fishing directions were checked in close-up
|
||||
Old Rod, move a Potion below a Rare Candy with the keyboard's Select, and
|
||||
write and read MAIL through the two-line entry and the letter panel; the four fishing directions were checked in close-up
|
||||
screenshots, not against the original.
|
||||
|
||||
## Evolution
|
||||
|
||||
+4
-3
@@ -125,7 +125,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- `TileCollisionStdScripts` is read from the ROM, so facing a `COLL_PC` tile (in any direction, as in the original) runs `PCScript`. Bookshelves, TVs and similar tiles no longer need the player to face up.
|
||||
- `PokemonCenterPC` checks for a Pokémon, then lists the PCs from `PokemonCenterPC.WhichPC`: Bill's PC and the player's PC, plus Oak's PC once the Pokédex is owned. The Hall of Fame PC is refused, since no Hall of Fame entry can exist yet.
|
||||
- Bill's PC: deposit (`BillsPC_CheckMail_PreventBlackout` keeps the last Pokémon and the last one that has not fainted; `RestorePPOfDepositedPokemon`), withdraw (`CalcLevel`, then `CalcMonStats` with stat experience, full HP and no status), release with confirmation ("Bye," with the species name), and change box: switch with the ROM's confirmation, name (a blank name keeps the old one) and print (only the empty-box message). Menus and messages come from the ROM; lists are presented natively with each Pokémon's level or each box's count.
|
||||
- The player's PC withdraws, deposits (from every pocket; untossable items move one at a time) and tosses items (up to 50 stacks, `RemoveItemFromPocket` slot rules), and shows the empty mail box. The house PC adds DECORATION, which closes the PC after a change.
|
||||
- The player's PC withdraws, deposits (from every pocket; untossable items move one at a time) and tosses items (up to 50 stacks, `RemoveItemFromPocket` slot rules), and holds MAIL since the remaining field items section. The house PC adds DECORATION, which closes the PC after a change.
|
||||
- Oak's PC rates the Pokédex from `OakRatings`.
|
||||
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
|
||||
- `VAR_BOXSPACE` reads the current box.
|
||||
@@ -169,7 +169,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte.
|
||||
- PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out".
|
||||
- The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`.
|
||||
- Not ported: MAIL (it gives a notice and changes nothing). The Escape Rope runs since the Fly section below; Repels and the other field items, the rods, item reordering with Select and left and right on the MOVE screen since the last section. Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it.
|
||||
- The Escape Rope runs since the Fly section below; Repels and the other field items, the rods, item reordering with Select, left and right on the MOVE screen and MAIL since the last section. Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it.
|
||||
|
||||
## Implemented: evolution
|
||||
|
||||
@@ -251,6 +251,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- The Old, Good and Super Rods fish facing water, never while surfing (Oak objects otherwise; the facing tile may hold an object, as in the original). The map header's fish group decides: none gives "Not even a nibble!" at once; otherwise `Fish` bites below 50% + 1 and picks the rod's `FishGroups` entry, with `TimeFishGroups` for the morning and day or the night. The player casts with the fishing frames (`LoadFishingGFX`'s body and rod tiles), jiggles four times on a bite, shows "!", and after "Oh! A bite!" puts the rod away before a `BATTLETYPE_FISH` battle: "The hooked MAGIKARP attacked!". Qwilfish and Remoraid swarms come from trainer calls, which are not ported. New Bark Town, Route 30 and Cherrygrove City have fish.
|
||||
- In the start menu's pack, Select marks an item and asks "Where should this be moved to?"; A or Select on another row of the same pocket moves it there (the rows between shift), merging it into a stack of the same item unless either holds 99 (what exceeds 99 stays behind), and B ends the move without leaving the pack. Left and right keep the pocket meanwhile. The packs that GIVE and the PC open ignore Select, as `DepositSellPack` does.
|
||||
- On the MOVE screen, left and right show the previous or next party member's moves, skipping Eggs (`MoveScreenLoop`'s `.cycle_left` and `.cycle_right`, which come back to the same Pokémon past either end), with the cursor back on the first move; not while a move is being moved. The party menu's cursor follows.
|
||||
- MAIL: giving MAIL (from the pack, or swapped for a held item) opens the mail entry, two lines of up to 16 characters with the second after a full first one (`_ComposeMailMessage`), and keeps the message with the player's name and ID, the holder's species and the MAIL (`ComposeMailMessage`). A Pokémon holding MAIL shows MAIL in its submenu: READ shows the letter (the message and the author; the stationery's graphics are not drawn yet), TAKE asks "Send the removed MAIL to your PC?" (`SendMailToPC`: up to ten in the MAILBOX, else "Your PC's MAILBOX is full."), or on NO "The MAIL will lose its message. OK?" and puts the item in the pack. SWITCH carries the MAIL with its Pokémon; another item still needs "Please remove the MAIL first."; Bill's PC refuses to deposit or release a Pokémon holding MAIL ("Remove MAIL."). The player's PC MAIL BOX lists the MAIL by author with READ MAIL, PUT IN PACK (the message is lost) and ATTACH MAIL (not to an Egg or a Pokémon holding an item). The mail entry offers the original's characters that have one of their own; `<PK>`, `<MN>`, `<PO>`, `<KE>` and the 'd-'v ligatures are left out. Save format 3 gained each Pokémon's `mail` and the `mailbox`, migrated by absence to none.
|
||||
- Wild Magikarp now battle: `LoadEnemyMon` draws their DVs again as the original does, through `CalcMagikarpLength` (compared with the original routine) and its filters, whose millimetre limits only work as a floor of four feet that about 39% of draws skip (39 percent + 1), nowhere in map group 9 or on maps numbered 6 (the Lake of Rage check). Unown are still refused.
|
||||
- Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0.
|
||||
|
||||
@@ -336,7 +337,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
|
||||
|
||||
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
|
||||
|
||||
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters. Next: the remaining field items, MAIL and pack reordering.
|
||||
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Next: the remaining battle commands, move effects and Smart AI handlers (board #28).
|
||||
|
||||
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
|
||||
/** A mailmsg struct (macros/ram.asm): the message, the author's name and
|
||||
* trainer ID, the species that held the MAIL when it was written, and the
|
||||
* MAIL item. The author's two "nationality" bytes are whatever followed
|
||||
* the player's name and are not kept. */
|
||||
export interface Mail {
|
||||
/** Up to MAIL_LINE_LENGTH characters, then a second line after "\n"
|
||||
* only once the first is full (the naming screen's <NEXT>). */
|
||||
message: string;
|
||||
author: string;
|
||||
authorId: number;
|
||||
species: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
/** MAIL_LINE_LENGTH and MAILBOX_CAPACITY (constants/item_data_constants.asm). */
|
||||
export const MAIL_LINE_LENGTH = 16;
|
||||
export const MAILBOX_CAPACITY = 10;
|
||||
/** The mail entry's characters (data/text/mail_input_chars.asm) that have
|
||||
* a single character of their own; <PK>, <MN>, <PO>, <KE> and the 'd to 'v
|
||||
* ligatures are left out. */
|
||||
const MAIL_CHARACTERS = /^[A-Za-z0-9 ,?!.\-/'&()“”[\]:;é♂♀¥…×]*$/u;
|
||||
|
||||
/** A message the mail entry could have written. */
|
||||
export function validMailMessage(message: unknown): message is string {
|
||||
if (typeof message !== "string") return false;
|
||||
const lines = message.split("\n");
|
||||
return (
|
||||
lines.length <= 2 &&
|
||||
lines.every(
|
||||
(line) =>
|
||||
[...line].length <= MAIL_LINE_LENGTH && MAIL_CHARACTERS.test(line),
|
||||
) &&
|
||||
(lines.length === 1 || [...lines[0]].length === MAIL_LINE_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
/** Two typed lines as the mail entry stores them: the first is padded to
|
||||
* a full line when a second follows. */
|
||||
export function mailMessage(first: string, second: string): string {
|
||||
return second
|
||||
? `${first.padEnd(MAIL_LINE_LENGTH, " ")}\n${second}`
|
||||
: first;
|
||||
}
|
||||
|
||||
export function validMail(data: PokemonData, value: unknown): value is Mail {
|
||||
const mail = value as Mail;
|
||||
const integer = (value: unknown, min: number, max: number) =>
|
||||
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
|
||||
return (
|
||||
Boolean(mail) &&
|
||||
typeof mail === "object" &&
|
||||
validMailMessage(mail.message) &&
|
||||
typeof mail.author === "string" &&
|
||||
/^[A-Za-z0-9 -]{1,7}$/.test(mail.author) &&
|
||||
integer(mail.authorId, 0, 0xffff) &&
|
||||
integer(mail.species, 1, 251) &&
|
||||
integer(mail.type, 1, 255) &&
|
||||
data.itemIsMail(mail.type)
|
||||
);
|
||||
}
|
||||
|
||||
export const copyMail = (mail: Mail): Mail => ({ ...mail });
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { Mail } from "./mail";
|
||||
|
||||
/** A party Pokémon with the fields of the original party_struct. */
|
||||
export interface PartyMon {
|
||||
@@ -24,6 +25,8 @@ export interface PartyMon {
|
||||
hp: number;
|
||||
/** Max HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */
|
||||
stats: [number, number, number, number, number, number];
|
||||
/** sPartyMail's entry while the Pokémon holds MAIL. */
|
||||
mail?: Mail;
|
||||
}
|
||||
|
||||
export const PARTY_LENGTH = 6;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { decompress } from "./lz";
|
||||
import {
|
||||
BATTLE,
|
||||
INTRO,
|
||||
MENU,
|
||||
NUM_ITEMS,
|
||||
NUM_POKEMON,
|
||||
NUM_TRAINER_CLASSES,
|
||||
@@ -161,6 +162,13 @@ export class PokemonData {
|
||||
this.checkItem(item);
|
||||
return this.reader.word(SYMBOLS.itemAttributes + (item - 1) * 7);
|
||||
}
|
||||
/** ItemIsMail: whether MailItems lists the item. */
|
||||
itemIsMail(item: number): boolean {
|
||||
const r = this.reader;
|
||||
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++)
|
||||
if (r.byte(p) === item) return true;
|
||||
return false;
|
||||
}
|
||||
/** ItemAttributes permissions, bit 7 (CANT_TOSS_F): mostly key items and
|
||||
* TMs/HMs, checked by _CheckTossableItem before a mart buys one back. */
|
||||
itemUnsellable(item: number): boolean {
|
||||
|
||||
@@ -443,6 +443,15 @@ export const PC = {
|
||||
"TossItemFromPC.ItemsDiscardedText": 0x12a4f,
|
||||
"TossItemFromPC.ItemsTooImportantText": 0x12a5b,
|
||||
"_PlayerMailBoxMenu.EmptyMailboxText": 0x447b4,
|
||||
"MailboxPC.SubMenuData": 0x4496c,
|
||||
"MailboxPC.MailClearedPutAwayText": 0x448ac,
|
||||
"MailboxPC.MailPackFullText": 0x448b1,
|
||||
"MailboxPC.MailMessageLostText": 0x448b6,
|
||||
"MailboxPC.MailAlreadyHoldingItemText": 0x4493c,
|
||||
"MailboxPC.MailEggText": 0x44941,
|
||||
"MailboxPC.MailMovedFromBoxText": 0x44946,
|
||||
PCString_RemoveMail: 0xe3544,
|
||||
"ScrollingMenu_UpdateDisplay.CancelString": 0x2485f,
|
||||
"_BillsPC.PCGottaHavePokemonText": 0xe417,
|
||||
"_BillsPC.PCWhatText": 0xe43a,
|
||||
"_BillsPC.strings": 0xe47f,
|
||||
@@ -509,6 +518,13 @@ export const MENU = {
|
||||
PokemonTookItemText: 0x12cc8,
|
||||
PokemonAskSwapItemText: 0x12ccd,
|
||||
ItemCantHeldText: 0x12cd2,
|
||||
"MonMailAction.MenuData": 0x12dd1,
|
||||
"MonMailAction.MailLoseMessageText": 0x12de2,
|
||||
"MonMailAction.MailDetachedText": 0x12de7,
|
||||
"MonMailAction.MailNoSpaceText": 0x12dec,
|
||||
"MonMailAction.MailAskSendToPCText": 0x12df1,
|
||||
"MonMailAction.MailboxFullText": 0x12df6,
|
||||
"MonMailAction.MailSentToPCText": 0x12dfb,
|
||||
"GiveItem.AnEggCantHoldAnItemText": 0x1048d,
|
||||
"MenuHeader_UsableKeyItem.MenuData": 0x10251,
|
||||
"MenuHeader_UsableItem.MenuData": 0x1027c,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
maxPP,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import { copyMail, type Mail } from "../party/mail";
|
||||
import { TrainerData, type TrainerHeader } from "../rom/trainers";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
import { OW } from "../world/constants";
|
||||
@@ -129,6 +130,8 @@ export interface ScriptState {
|
||||
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
|
||||
* and wRegisteredItem: the item Select uses; zeros when none. */
|
||||
registeredItem: { which: number; item: number };
|
||||
/** sMailboxes: MAIL sent to the player's PC, oldest first. */
|
||||
mailbox: Mail[];
|
||||
/** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */
|
||||
repel: number;
|
||||
/** wCoins: the Coin Case's coins. */
|
||||
@@ -166,6 +169,7 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
mailbox: [],
|
||||
repel: 0,
|
||||
coins: 0,
|
||||
blueCardBalance: 0,
|
||||
@@ -202,6 +206,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
mailbox: state.mailbox.map(copyMail),
|
||||
repel: state.repel,
|
||||
coins: state.coins,
|
||||
blueCardBalance: state.blueCardBalance,
|
||||
@@ -275,8 +280,18 @@ export type ScriptEffect =
|
||||
| { kind: "portrait"; portrait?: Portrait }
|
||||
/** InitClock's hour or minute stepper; answered with the chosen index. */
|
||||
| { kind: "clock"; pages: string[]; labels: string[]; selected: number }
|
||||
/** The naming screen; answered with the name, or "" to keep `current`. */
|
||||
| { kind: "name"; pages: string[]; current: string; max: number }
|
||||
/** The naming screen; answered with the name, or "" to keep `current`.
|
||||
* With `mail`, _ComposeMailMessage's entry: two lines of `max`
|
||||
* characters as a mail message (validMailMessage), "" for none. */
|
||||
| {
|
||||
kind: "name";
|
||||
pages: string[];
|
||||
current: string;
|
||||
max: number;
|
||||
mail?: boolean;
|
||||
}
|
||||
/** ReadAnyMail: a letter, closed with a button. */
|
||||
| { kind: "mail"; mail: Mail }
|
||||
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
|
||||
| { kind: "martmenu"; prompt: string; selected: number }
|
||||
/** The mart's item list (buy: full stock; sell: the bag's pockets);
|
||||
@@ -1438,6 +1453,9 @@ export class ScriptRun {
|
||||
gift = byte();
|
||||
if (gift)
|
||||
throw new UnsupportedScript(offset, opcode, " (gift with OT)");
|
||||
// A MAIL item needs its message (GivePokeMail).
|
||||
if (item && data.itemIsMail(item))
|
||||
throw new UnsupportedScript(offset, opcode, " (MAIL)");
|
||||
if (this.state.party.length >= PARTY_LENGTH)
|
||||
throw new UnsupportedScript(
|
||||
offset,
|
||||
|
||||
+80
-3
@@ -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;
|
||||
|
||||
@@ -25,6 +25,12 @@ import { OptionsMenu, menuAccount } from "./options";
|
||||
import { flyMap } from "./fly-map";
|
||||
import { OW } from "../world/constants";
|
||||
import { DecorationData } from "../world/decorations";
|
||||
import {
|
||||
MAIL_LINE_LENGTH,
|
||||
MAILBOX_CAPACITY,
|
||||
validMailMessage,
|
||||
type Mail,
|
||||
} from "../party/mail";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
@@ -271,10 +277,7 @@ export class StartMenuRun {
|
||||
this.strings[RAM.monOrItemNameBuffer] = this.state.party[slot].nickname;
|
||||
}
|
||||
private isMail(item: number) {
|
||||
const r = this.reader;
|
||||
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++)
|
||||
if (r.byte(p) === item) return true;
|
||||
return false;
|
||||
return this.data.itemIsMail(item);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- start menu
|
||||
@@ -523,7 +526,7 @@ export class StartMenuRun {
|
||||
case MONMENUITEM_CANCEL:
|
||||
break;
|
||||
case MONMENUITEM_MAIL:
|
||||
yield* this.notice(`MAIL ${NOT_PORTED}`);
|
||||
yield* this.monMailAction(slot);
|
||||
break;
|
||||
default:
|
||||
return yield* this.fieldMove(slot, item);
|
||||
@@ -649,16 +652,12 @@ export class StartMenuRun {
|
||||
item: number,
|
||||
): Gen {
|
||||
const mon = this.state.party[slot];
|
||||
// ComposeMailMessage follows a given MAIL; it is refused before
|
||||
// anything changes.
|
||||
if (this.isMail(item)) return yield* this.notice(`Writing MAIL ${NOT_PORTED}`);
|
||||
this.nameItem(item);
|
||||
const held = mon.item;
|
||||
if (!held) {
|
||||
this.giveItemToPokemon(pocket, index, item);
|
||||
yield* this.print(MENU.PokemonHoldItemText);
|
||||
mon.item = item;
|
||||
return;
|
||||
return yield* this.givePartyItem(slot, item);
|
||||
}
|
||||
if (this.isMail(held)) return yield* this.print(MENU.PokemonRemoveMailText);
|
||||
this.strings[RAM.stringBuffer1] = this.data.itemName(held);
|
||||
@@ -669,7 +668,66 @@ export class StartMenuRun {
|
||||
return yield* this.print(MENU.ItemStorageFullText);
|
||||
}
|
||||
yield* this.print(MENU.PokemonSwapItemText);
|
||||
yield* this.givePartyItem(slot, item);
|
||||
}
|
||||
/** GivePartyItem: the Pokémon holds the item, and MAIL gets its message
|
||||
* (ComposeMailMessage). */
|
||||
private *givePartyItem(slot: number, item: number): Gen {
|
||||
const mon = this.state.party[slot];
|
||||
mon.item = item;
|
||||
if (!this.isMail(item)) return;
|
||||
// _ComposeMailMessage's entry, then the author (wPlayerName), wPlayerID,
|
||||
// wCurPartySpecies and the MAIL (wCurItem).
|
||||
// The mail entry has no title above its two lines.
|
||||
const message = yield {
|
||||
kind: "name",
|
||||
pages: [""],
|
||||
current: "",
|
||||
max: MAIL_LINE_LENGTH,
|
||||
mail: true,
|
||||
};
|
||||
if (!validMailMessage(message)) throw new Error("Invalid MAIL message.");
|
||||
mon.mail = {
|
||||
message,
|
||||
author: this.text().player,
|
||||
authorId: this.host.scriptHost().player().id,
|
||||
species: mon.species,
|
||||
type: item,
|
||||
};
|
||||
}
|
||||
|
||||
/** MonMailAction: READ, TAKE or QUIT for a Pokémon holding MAIL. TAKE
|
||||
* sends the MAIL to the PC (SendMailToPC), or puts the item in the pack
|
||||
* once its message may be lost. */
|
||||
private *monMailAction(slot: number): Gen {
|
||||
const mon = this.state.party[slot];
|
||||
const choice = yield* this.menu(
|
||||
[],
|
||||
this.stringList(MENU["MonMailAction.MenuData"] + 2, 3),
|
||||
);
|
||||
if (choice === 0) return yield* this.readMail(mon.mail);
|
||||
if (choice !== 1) return;
|
||||
if (yield* this.yesNo(MENU["MonMailAction.MailAskSendToPCText"])) {
|
||||
if (!mon.mail || this.state.mailbox.length >= MAILBOX_CAPACITY)
|
||||
return yield* this.print(MENU["MonMailAction.MailboxFullText"]);
|
||||
this.state.mailbox.push(mon.mail);
|
||||
delete mon.mail;
|
||||
mon.item = 0;
|
||||
return yield* this.print(MENU["MonMailAction.MailSentToPCText"]);
|
||||
}
|
||||
// .RemoveMailToBag
|
||||
if (!(yield* this.yesNo(MENU["MonMailAction.MailLoseMessageText"]))) return;
|
||||
if (!receiveItem(this.data, this.state.bag, mon.item, 1))
|
||||
return yield* this.print(MENU["MonMailAction.MailNoSpaceText"]);
|
||||
mon.item = 0;
|
||||
delete mon.mail;
|
||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
yield* this.print(MENU["MonMailAction.MailDetachedText"]);
|
||||
}
|
||||
/** ReadPartyMonMail: the letter, until a button. */
|
||||
private *readMail(mail: Mail | undefined): Gen {
|
||||
if (!mail) throw new Error("The Pokémon holds MAIL without a message.");
|
||||
yield { kind: "mail", mail: { ...mail } };
|
||||
}
|
||||
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
|
||||
const list = this.pocketSlots(pocket);
|
||||
|
||||
+56
-7
@@ -62,6 +62,13 @@ import {
|
||||
} from "../scripts/start-menu";
|
||||
import { PartyEvolution } from "../scripts/evolution";
|
||||
import { fish } from "../scripts/fish";
|
||||
import {
|
||||
copyMail,
|
||||
MAILBOX_CAPACITY,
|
||||
validMail,
|
||||
validMailMessage,
|
||||
type Mail,
|
||||
} from "../party/mail";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
import {
|
||||
@@ -151,6 +158,8 @@ export interface NativeSave {
|
||||
noWildEncounters: boolean;
|
||||
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
|
||||
registeredItem: { which: number; item: number };
|
||||
/** sMailboxes: the PC's MAIL. */
|
||||
mailbox: Mail[];
|
||||
/** wRepelEffect, wCoins and wBlueCardBalance. */
|
||||
repel: number;
|
||||
coins: number;
|
||||
@@ -255,8 +264,15 @@ export type Choice =
|
||||
| { kind: "weekday"; names: string[]; selected: number }
|
||||
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
||||
| { kind: "clock"; labels: string[]; selected: number }
|
||||
/** The naming screen: an empty answer keeps `current`. */
|
||||
| { kind: "name"; current: string; max: number; selected?: undefined }
|
||||
/** The naming screen: an empty answer keeps `current`. With `mail`, the
|
||||
* mail entry's two lines. */
|
||||
| {
|
||||
kind: "name";
|
||||
current: string;
|
||||
max: number;
|
||||
mail?: boolean;
|
||||
selected?: undefined;
|
||||
}
|
||||
/** A vertical menu; `selected` answers with that item's index. */
|
||||
| {
|
||||
kind: "menu";
|
||||
@@ -2520,6 +2536,10 @@ export class GameWorld {
|
||||
this.statsScreen = effect.mon;
|
||||
this.dialogue = { pages: ["Stats"], index: 0 };
|
||||
return true;
|
||||
case "mail":
|
||||
this.mailView = { ...effect.mail };
|
||||
this.dialogue = { pages: ["MAIL"], index: 0 };
|
||||
return true;
|
||||
case "pokegear":
|
||||
this.pokegear = effect.view;
|
||||
if (!effect.view) return false;
|
||||
@@ -2585,7 +2605,12 @@ export class GameWorld {
|
||||
this.dialogue = {
|
||||
pages: effect.pages,
|
||||
index: 0,
|
||||
choice: { kind: "name", current: effect.current, max: effect.max },
|
||||
choice: {
|
||||
kind: "name",
|
||||
current: effect.current,
|
||||
max: effect.max,
|
||||
...(effect.mail && { mail: true }),
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "martmenu":
|
||||
@@ -2989,6 +3014,7 @@ export class GameWorld {
|
||||
this.memScript ??= script.memScript;
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
this.pokegear = undefined;
|
||||
this.trainerCard = undefined;
|
||||
this.dialogue = undefined;
|
||||
@@ -3041,6 +3067,7 @@ export class GameWorld {
|
||||
this.script = undefined;
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
this.pokegear = undefined;
|
||||
this.trainerCard = undefined;
|
||||
this.dialogue = undefined;
|
||||
@@ -3484,6 +3511,8 @@ export class GameWorld {
|
||||
townMap = false;
|
||||
/** The Pokémon a script's stats screen shows (Bill's PC). */
|
||||
statsScreen: PartyMon | undefined;
|
||||
/** A letter ReadAnyMail shows, until a button closes it. */
|
||||
mailView: Mail | undefined;
|
||||
/** The Pokégear card on screen while the Pokégear is open, including
|
||||
* during the phone calls it makes. */
|
||||
pokegear: PokegearView | undefined;
|
||||
@@ -3547,6 +3576,7 @@ export class GameWorld {
|
||||
if (--dialogue.holdTicks <= 0) {
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.pump();
|
||||
}
|
||||
@@ -4002,6 +4032,7 @@ export class GameWorld {
|
||||
}
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.pump();
|
||||
}
|
||||
@@ -4095,9 +4126,11 @@ export class GameWorld {
|
||||
// The naming screen only offers its own characters; keep it open.
|
||||
if (
|
||||
dialogue.choice.kind === "name" &&
|
||||
(typeof answer !== "string" ||
|
||||
answer.length > dialogue.choice.max ||
|
||||
(answer !== "" && !NICKNAME_PATTERN.test(answer)))
|
||||
(dialogue.choice.mail
|
||||
? !validMailMessage(answer)
|
||||
: typeof answer !== "string" ||
|
||||
answer.length > dialogue.choice.max ||
|
||||
(answer !== "" && !NICKNAME_PATTERN.test(answer)))
|
||||
)
|
||||
return;
|
||||
if (
|
||||
@@ -4115,6 +4148,7 @@ export class GameWorld {
|
||||
return;
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
this.dialogue = undefined;
|
||||
this.pump(answer);
|
||||
}
|
||||
@@ -4184,6 +4218,7 @@ export class GameWorld {
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
mailbox: state.mailbox.map(copyMail),
|
||||
repel: state.repel,
|
||||
coins: state.coins,
|
||||
blueCardBalance: state.blueCardBalance,
|
||||
@@ -4255,7 +4290,17 @@ export class GameWorld {
|
||||
!integer(save.playerId, 0, 0xffff) ||
|
||||
!Array.isArray(save.party) ||
|
||||
save.party.length > 6 ||
|
||||
!save.party.every((mon) => validPartyMon(this.assets.pokemon, mon)) ||
|
||||
!save.party.every(
|
||||
(mon) =>
|
||||
validPartyMon(this.assets.pokemon, mon) &&
|
||||
// sPartyMail: a message exactly while MAIL is held.
|
||||
(this.assets.pokemon.itemIsMail(mon.item)
|
||||
? validMail(this.assets.pokemon, mon.mail)
|
||||
: !Object.hasOwn(mon, "mail")),
|
||||
) ||
|
||||
!Array.isArray(save.mailbox) ||
|
||||
save.mailbox.length > MAILBOX_CAPACITY ||
|
||||
!save.mailbox.every((mail) => validMail(this.assets.pokemon, mail)) ||
|
||||
!validBag(this.assets.pokemon, save.bag) ||
|
||||
!validStorage(save.storage) ||
|
||||
!validPCItems(this.assets.pokemon, save.pcItems) ||
|
||||
@@ -4364,6 +4409,7 @@ export class GameWorld {
|
||||
flash: save.flash,
|
||||
noWildEncounters: save.noWildEncounters,
|
||||
registeredItem: { ...save.registeredItem },
|
||||
mailbox: save.mailbox.map(copyMail),
|
||||
repel: save.repel,
|
||||
coins: save.coins,
|
||||
blueCardBalance: save.blueCardBalance,
|
||||
@@ -4416,6 +4462,7 @@ export class GameWorld {
|
||||
this.playerId = save.playerId;
|
||||
this.townMap = false;
|
||||
this.statsScreen = undefined;
|
||||
this.mailView = undefined;
|
||||
this.pokegear = undefined;
|
||||
this.trainerCard = undefined;
|
||||
this.dialogue = undefined;
|
||||
@@ -4477,6 +4524,7 @@ export function migrateSave(
|
||||
...(!Object.hasOwn(save, "registeredItem") && {
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "mailbox") && { mailbox: [] }),
|
||||
...(!Object.hasOwn(save, "repel") && {
|
||||
repel: 0,
|
||||
coins: 0,
|
||||
@@ -4530,6 +4578,7 @@ export function migrateSave(
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
mailbox: [],
|
||||
repel: 0,
|
||||
coins: 0,
|
||||
blueCardBalance: 0,
|
||||
|
||||
+70
-2
@@ -15,6 +15,11 @@ import { PokemonData } from "./game/rom/pokemon";
|
||||
import type { Direction, Palette } from "./game/world/types";
|
||||
import type { Button } from "./input/types";
|
||||
import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon";
|
||||
import {
|
||||
MAIL_LINE_LENGTH,
|
||||
mailMessage,
|
||||
validMailMessage,
|
||||
} from "./game/party/mail";
|
||||
import { GameSession } from "./app/session";
|
||||
import { InputController } from "./input/controller";
|
||||
import { bindControls } from "./input/bindings";
|
||||
@@ -52,6 +57,7 @@ function showWorld(world: GameWorld) {
|
||||
get("game-notice").textContent = world.notice;
|
||||
showBattle(world);
|
||||
showStatsScreen(world);
|
||||
showMail(world);
|
||||
showPokegear(world);
|
||||
showTrainerCard(world);
|
||||
showOptions(world);
|
||||
@@ -76,7 +82,8 @@ function showWorld(world: GameWorld) {
|
||||
dialogue.choice?.kind === "flymap" ||
|
||||
Boolean(mart) ||
|
||||
world.townMap ||
|
||||
Boolean(world.statsScreen);
|
||||
Boolean(world.statsScreen) ||
|
||||
Boolean(world.mailView);
|
||||
get("pokepic-next").hidden = !pictureWait;
|
||||
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
|
||||
get("dialogue-page").textContent =
|
||||
@@ -227,7 +234,11 @@ function showChoice(choice: Choice | undefined) {
|
||||
);
|
||||
} else if (choice.kind === "name")
|
||||
container.replaceChildren(
|
||||
...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
|
||||
...(choice.mail
|
||||
? mailField((message) => runtime.choose(message))
|
||||
: nameField(choice.current, choice.max, (name) =>
|
||||
runtime.choose(name),
|
||||
)),
|
||||
);
|
||||
else if (choice.kind === "menu") {
|
||||
// A vertical menu such as the elevator's floors; B cancels.
|
||||
@@ -295,6 +306,58 @@ function nameField(
|
||||
queueMicrotask(() => input.focus({ preventScroll: true }));
|
||||
return [input, ok];
|
||||
}
|
||||
/** _ComposeMailMessage's entry as two lines of 16 characters and OK. A
|
||||
* second line starts once the first is full, so the first is padded. */
|
||||
function mailField(answer: (message: string) => void): HTMLElement[] {
|
||||
const lines = [1, 2].map((line) => {
|
||||
const input = document.createElement("input");
|
||||
input.maxLength = MAIL_LINE_LENGTH;
|
||||
input.setAttribute("aria-label", `MAIL line ${line}`);
|
||||
input.autocomplete = "off";
|
||||
input.spellcheck = false;
|
||||
input.addEventListener("input", () => input.setCustomValidity(""));
|
||||
return input;
|
||||
});
|
||||
const submit = () => {
|
||||
const message = mailMessage(lines[0].value, lines[1].value.trimEnd());
|
||||
if (!validMailMessage(message)) {
|
||||
lines[0].setCustomValidity(
|
||||
"Use letters, digits, spaces and the MAIL's punctuation.",
|
||||
);
|
||||
lines[0].reportValidity();
|
||||
return;
|
||||
}
|
||||
answer(message);
|
||||
canvas.focus({ preventScroll: true });
|
||||
};
|
||||
lines[0].addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") lines[1].focus({ preventScroll: true });
|
||||
});
|
||||
lines[1].addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") submit();
|
||||
});
|
||||
const ok = document.createElement("button");
|
||||
ok.type = "button";
|
||||
ok.textContent = "OK";
|
||||
ok.addEventListener("click", () => {
|
||||
if (session.state === "playing") submit();
|
||||
});
|
||||
queueMicrotask(() => lines[0].focus({ preventScroll: true }));
|
||||
const box = document.createElement("div");
|
||||
box.className = "mail-lines";
|
||||
box.append(...lines);
|
||||
return [box, ok];
|
||||
}
|
||||
/** ReadAnyMail: the message at its two lines and the author below. The
|
||||
* stationery's graphics are not drawn. */
|
||||
function showMail(world: GameWorld) {
|
||||
const mail = world.mailView;
|
||||
get("mail-view").hidden = !mail;
|
||||
if (!mail) return;
|
||||
get("mail-type").textContent = world.assets.pokemon.itemName(mail.type);
|
||||
get("mail-message").textContent = mail.message;
|
||||
get("mail-author").textContent = mail.author;
|
||||
}
|
||||
type MartChoice = Extract<
|
||||
Choice,
|
||||
{ kind: "martmenu" | "martlist" | "martquantity" | "pack" }
|
||||
@@ -1525,6 +1588,11 @@ setInterval(() => {
|
||||
}, 10_000);
|
||||
update();
|
||||
|
||||
get("mail-close").onclick = () => {
|
||||
if (session.state !== "playing" || !runtime.world?.mailView) return;
|
||||
runtime.advanceDialogue();
|
||||
canvas.focus({ preventScroll: true });
|
||||
};
|
||||
get("town-map-close").onclick = () => {
|
||||
if (session.state !== "playing") return;
|
||||
runtime.advanceDialogue();
|
||||
|
||||
@@ -32,6 +32,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
||||
</div>
|
||||
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
|
||||
<div id="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></div>
|
||||
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
|
||||
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
|
||||
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
|
||||
|
||||
@@ -326,6 +326,16 @@ dialog h2 {
|
||||
max-height: 9.5em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dialogue-choice .mail-lines {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.dialogue-choice .mail-lines input {
|
||||
width: calc(16ch + 22px);
|
||||
max-width: 100%;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.dialogue-choice input {
|
||||
flex: 1 1 12ch;
|
||||
min-width: 0;
|
||||
@@ -459,6 +469,38 @@ dialog h2 {
|
||||
background: #f8f8f0;
|
||||
border-top: 8px solid var(--stats-page, #e8a0b8);
|
||||
}
|
||||
.mail-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
color: var(--ink);
|
||||
background: #f8f8f0;
|
||||
border: 8px double #a8b8d0;
|
||||
}
|
||||
.mail-type {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mail-message {
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mail-author {
|
||||
margin: 0;
|
||||
justify-self: end;
|
||||
}
|
||||
.mail-view button {
|
||||
justify-self: end;
|
||||
}
|
||||
.stats-screen[data-page="1"] {
|
||||
--stats-page: #88c888;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user