import type { GameSound } from "../audio/sound"; import { SOUND } from "../audio/constants"; import type { RomReader } from "../rom/reader"; import type { PokemonData } from "../rom/pokemon"; import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile"; import { hallOfFamePC } from "./hall-of-fame"; import { oakRating } from "./league"; import { readMail } from "./mail"; import { printerDisconnected } from "./printer"; import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag"; import { BOX_NAME_PATTERN, PARTY_LENGTH, type PartyMon, } from "../party/pokemon"; import { MONS_PER_BOX, NUM_BOXES, bufferMon, currentBox, depositMon, receivePCItem, restorePP, toBoxMon, withdrawnMon, type BoxMon, } from "../party/storage"; import { readString, readText, type TextContext } from "./text"; import type { MartEntry, ScriptEffect, ScriptState } from "./interpreter"; type Input = number | string | undefined; type Gen = Generator; /** What the PC routines need from the running script. */ export interface PcHost { state: ScriptState; reader: RomReader; data: PokemonData; text(): TextContext; /** text_ram and text_decimal values, keyed by RAM address. */ strings: Record; numbers: Record; /** _PlayerDecorationMenu: true if a decoration changed. */ decorationMenu(): Gen; /** The sound engine, for the PC's boot, choice and shutdown sounds. */ sound?: GameSound; } /** PokemonCenterPC's menu items (PCPCITEM_*). */ const PCPCITEM_PLAYERS_PC = 0, PCPCITEM_BILLS_PC = 1, PCPCITEM_OAKS_PC = 2, PCPCITEM_HALL_OF_FAME = 3, PCPCITEM_TURN_OFF = 4; /** _PlayersPC's menu items (PLAYERSPCITEM_*). */ const PLAYERSPCITEM_WITHDRAW_ITEM = 0, PLAYERSPCITEM_DEPOSIT_ITEM = 1, PLAYERSPCITEM_TOSS_ITEM = 2, PLAYERSPCITEM_MAIL_BOX = 3, PLAYERSPCITEM_DECORATION = 4; /** _BillsPC's menu items. */ const BILLSPC_WITHDRAW = 0, BILLSPC_DEPOSIT = 1, BILLSPC_CHANGE_BOX = 2, BILLSPC_MOVE = 3; /** The box submenus: DEPOSIT or WITHDRAW, STATS, RELEASE, CANCEL; and * SWITCH, NAME, PRINT, QUIT for a box. */ const SUBMENU_TAKE = 0, SUBMENU_STATS = 1, SUBMENU_RELEASE = 2; const BOX_SWITCH = 0, BOX_NAME = 1, BOX_PRINT = 2; /** DelayFrames after a Bill's PC message, in overworld ticks (two frames). */ const MESSAGE_TICKS = 25, RELEASED_TICKS = 40; /** A pack entry and where it came from, for TossItem's wCurItemQuantity. */ interface PackSlot extends ItemSlot { pocket: "items" | "balls" | "keyItems"; index: number; } /** The Pokémon Center PC (engine/events/pokecenter_pc.asm), Bill's PC * (engine/pokemon/bills_pc_top.asm and bills_pc.asm) and Oak's PC * (engine/events/prof_oaks_pc.asm). The original draws its own screens; * here menus, lists and messages are presented natively. */ export class PcSession { constructor(private host: PcHost) {} private get state() { return this.host.state; } private string(address: number) { return readString(this.host.reader, address, this.host.text()); } private pages(address: number) { return readText(this.host.reader, address, this.host.text()).split("\n\n"); } private *print(address: number): Gen { yield { kind: "text", pages: this.pages(address) }; } /** BillsPC_PlaceString followed by DelayFrames: a message that stays * for a fixed time without waiting for a button. */ private *placeString(text: string, ticks = MESSAGE_TICKS): Gen { yield { kind: "text", pages: [text], holdTicks: ticks }; } /** A menu of `items`; the chosen index, or -1 for B. */ private *menu( pages: string[], items: string[], details?: string[], selected = 0, ): Gen { const choice = yield { kind: "menu", pages, items, details, selected }; return typeof choice === "number" && choice >= 0 && choice < items.length ? choice : -1; } /** Consecutive "@"-terminated strings, as static menus store them. */ private strings(address: number, count: number) { const r = this.host.reader, result: string[] = []; let p = address; for (let i = 0; i < count; i++) { result.push(this.string(p)); while (r.byte(p++) !== 0x50); } return result; } /** A WhichPC list: a count, the item ids, then -1. */ private indexSet(address: number, set: number) { const r = this.host.reader; let p = address; for (let i = 0; i < set; i++) p += r.byte(p) + 2; const count = r.byte(p); return Array.from({ length: count }, (_, i) => r.byte(p + 1 + i)); } /** A jumptable of (routine, string) pointers: the item's string. */ private itemString(table: number, item: number) { const r = this.host.reader, entry = table + item * 4 + 2; return this.string(r.near(entry, r.bank(entry))); } // ------------------------------------------------------------ PC menus /** PokemonCenterPC: a Pokémon is needed, then the PCs listed for the * player's progress until TURN OFF or B. */ *pokemonCenterPC(): Gen { if (!this.state.party.length) { yield* this.print(PC["PC_CheckPartyForPokemon.PokecenterPCCantUseText"]); return; } // PC_PlayBootSound this.host.sound?.playSFX(SOUND.SFX_BOOT_PC); yield* this.print(PC.PokecenterPCTurnOnText); const whose = this.pages(PC.PokecenterPCWhoseText); let cursor = 0; for (;;) { // .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX, PCPC_BEFORE_HOF, or // PCPC_POSTGAME once wHallOfFameCount (not the League flag) is set. const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? this.state.hallOfFameCount ? 2 : 1 : 0; const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set); const choice = yield* this.menu( whose, items.map((item) => this.itemString(PC["PokemonCenterPC.Jumptable"], item), ), undefined, cursor, ); // PC_PlayShutdownSound when leaving; PC_PlayChoosePCSound otherwise. const sound = this.host.sound; if (choice < 0 || items[choice] === PCPCITEM_TURN_OFF) sound?.playSFX(SOUND.SFX_SHUT_DOWN_PC); else sound?.playSFX(SOUND.SFX_CHOOSE_PC_OPTION); if (choice < 0) return; cursor = choice; switch (items[choice]) { case PCPCITEM_BILLS_PC: yield* this.print(PC.PokecenterBillsPCText); yield* this.billsPC(); break; case PCPCITEM_PLAYERS_PC: yield* this.print(PC.PokecenterPlayersPCText); yield* this.playersPC(false); break; case PCPCITEM_OAKS_PC: yield* this.print(PC.PokecenterOaksPCText); yield* this.oaksPC(); break; case PCPCITEM_HALL_OF_FAME: yield* hallOfFamePC( this.host.data, this.state.hallOfFameTeams, this.state.options.textboxFrame, this.host.sound, ); break; case PCPCITEM_TURN_OFF: yield* this.print(PC.PokecenterPCOaksClosedText); return; default: throw new Error("Unknown Pokémon Center PC entry."); } } } /** _PlayersHousePC: returns true when a decoration changed, which makes * the script reload the room. */ *playersHousePC(): Gen { this.host.sound?.playSFX(SOUND.SFX_BOOT_PC); yield* this.print(PC.PlayersPCTurnOnText); return yield* this.playersPC(true); } /** _PlayersPC: PLAYERSPC_NORMAL or PLAYERSPC_HOUSE. A changed * decoration closes the PC. */ private *playersPC(house: boolean): Gen { const items = this.indexSet(PC["PlayersPCMenuData.WhichPC"], house ? 1 : 0); const names = items.map((item) => this.itemString(PC["PlayersPCMenuData.PlayersPCMenuPointers"], item), ); const ask = this.pages(PC.PlayersPCAskWhatDoText); let cursor = 0; for (;;) { const choice = yield* this.menu(ask, names, undefined, cursor); if (choice < 0) return false; cursor = choice; switch (items[choice]) { case PLAYERSPCITEM_WITHDRAW_ITEM: yield* this.withdrawItems(); break; case PLAYERSPCITEM_DEPOSIT_ITEM: yield* this.depositItems(); break; case PLAYERSPCITEM_TOSS_ITEM: yield* this.tossItems(); break; case PLAYERSPCITEM_MAIL_BOX: yield* this.mailbox(); break; case PLAYERSPCITEM_DECORATION: if (yield* this.host.decorationMenu()) return true; break; default: // LOG OFF and TURN OFF. return false; } } } // ----------------------------------------------------------- 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* readMail(this.host.data, mailbox[index], this.state.options.textboxFrame); 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.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[] { const data = this.host.data; return slots.map((slot) => ({ item: slot.item, name: data.itemName(slot.item), price: 0, owned: slot.quantity, })); } /** The item named by PartyMonItemName (wStringBuffer1 and 2) and the * quantity text_decimal shows (wItemQuantityChange). */ private nameItem(item: number, quantity?: number) { const name = this.host.data.itemName(item); this.host.strings[RAM.stringBuffer1] = name; this.host.strings[RAM.stringBuffer2] = name; if (quantity !== undefined) this.host.numbers[RAM.itemQuantityChange] = quantity; } /** SelectQuantityToToss for a stack, unless _CheckTossableItem makes it * always one. Returns 0 if cancelled. */ private *quantity( mode: "withdraw" | "deposit" | "toss", slot: ItemSlot, prompt: number, ): Gen { if (this.host.data.itemUnsellable(slot.item)) return 1; this.nameItem(slot.item); const chosen = yield { kind: "martquantity", mode, item: slot.item, name: this.host.data.itemName(slot.item), price: 0, max: slot.quantity, prompt: readText(this.host.reader, prompt, this.host.text()), selected: 1, }; return typeof chosen === "number" && Number.isInteger(chosen) && chosen >= 1 && chosen <= slot.quantity ? chosen : 0; } /** PlayerWithdrawItemMenu: into the matching pack pocket. */ private *withdrawItems(): Gen { const items = this.state.pcItems; for (;;) { const index = yield { kind: "martlist", mode: "withdraw", entries: this.itemEntries(items), selected: 0, }; const slot = typeof index === "number" ? items[index] : undefined; if (!slot) return; const quantity = yield* this.quantity( "withdraw", slot, PC["PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText"], ); if (!quantity) continue; const item = slot.item; if (!receiveItem(this.host.data, this.state.bag, item, quantity)) { yield* this.print( PC["PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText"], ); continue; } removeFromSlot(items, index as number, item, quantity); this.nameItem(item, quantity); yield* this.print( PC["PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText"], ); } } /** The pack as DepositSellPack lists it: items, balls and key items, * with the pocket and index each entry comes from. */ private packSlots(): PackSlot[] { const bag = this.state.bag; return [ ...bag.items.map((slot, index) => ({ ...slot, pocket: "items" as const, index, })), ...bag.balls.map((slot, index) => ({ ...slot, pocket: "balls" as const, index, })), ...bag.keyItems.map((item, index) => ({ item, quantity: 1, pocket: "keyItems" as const, index, })), ]; } /** PlayerDepositItemMenu: from any pocket into the PC. */ private *depositItems(): Gen { if (!this.packSlots().length) { yield* this.print(PC["PlayerDepositItemMenu.PlayersPCNoItemsText"]); return; } for (;;) { const slots = this.packSlots(); const choice = yield { kind: "martlist", mode: "deposit", entries: this.itemEntries(slots), selected: 0, }; const slot = typeof choice === "number" ? slots[choice] : undefined; if (!slot) return; const quantity = yield* this.quantity( "deposit", slot, PC["PlayerDepositItemMenu.PlayersPCHowManyDepositText"], ); if (!quantity) continue; if (!receivePCItem(this.state.pcItems, slot.item, quantity)) { yield* this.print( PC["PlayerDepositItemMenu.PlayersPCNoRoomDepositText"], ); continue; } const bag = this.state.bag; if (slot.pocket === "keyItems") bag.keyItems.splice(slot.index, 1); else removeFromSlot(bag[slot.pocket], slot.index, slot.item, quantity); this.nameItem(slot.item, quantity); yield* this.print(PC["PlayerDepositItemMenu.PlayersPCDepositItemsText"]); } } /** PlayerTossItemMenu and TossItemFromPC. */ private *tossItems(): Gen { const items = this.state.pcItems; for (;;) { const index = yield { kind: "martlist", mode: "toss", entries: this.itemEntries(items), selected: 0, }; const slot = typeof index === "number" ? items[index] : undefined; if (!slot) return; if (this.host.data.itemUnsellable(slot.item)) { yield* this.print(PC["TossItemFromPC.ItemsTooImportantText"]); continue; } const quantity = yield* this.quantity( "toss", slot, PC["TossItemFromPC.ItemsTossOutHowManyText"], ); if (!quantity) continue; const item = slot.item; this.nameItem(item, quantity); const confirmed = yield { kind: "yesno", pages: this.pages(PC["TossItemFromPC.ItemsThrowAwayText"]), }; if (!confirmed) continue; removeFromSlot(items, index as number, item, quantity); this.nameItem(item, quantity); yield* this.print(PC["TossItemFromPC.ItemsDiscardedText"]); } } // --------------------------------------------------------- Bill's PC /** GetBoxName: the saved name, or SetDefaultBoxNames' "BOX" and number. */ boxName(box: number) { return ( this.state.storage.names[box] ?? `${this.string(PC["SetDefaultBoxNames.Box"])}${box + 1}` ); } private monDetails(mons: { level: number }[]) { return [...mons.map((mon) => `:L${mon.level}`), ""]; } /** _BillsPC: the top menu until SEE YA! or B. */ private *billsPC(): Gen { if (!this.state.party.length) { yield* this.print(PC["_BillsPC.PCGottaHavePokemonText"]); return; } const names = this.strings(PC["_BillsPC.strings"], 5); const what = this.pages(PC["_BillsPC.PCWhatText"]); let cursor = 0; for (;;) { const choice = yield* this.menu(what, names, undefined, cursor); if (choice < 0) return; cursor = choice; if (choice === BILLSPC_WITHDRAW) yield* this.withdrawMons(); else if (choice === BILLSPC_DEPOSIT) yield* this.depositMons(); else if (choice === BILLSPC_CHANGE_BOX) yield* this.changeBox(); else if (choice === BILLSPC_MOVE) yield* this.movePKMNMenu(); else return; } } // ------------------------------------------------ MOVE PKMN W/O MAIL /** BillsPC_MovePKMNMenu: refused while a party Pokémon holds MAIL * (IsAnyMonHoldingMail), then StartMoveMonWOMail_SaveGame's question. The * native session saves by itself, so no save messages follow. */ private *movePKMNMenu(): Gen { const r = this.host.reader; const mail = new Set(); for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++) mail.add(r.byte(p)); if (this.state.party.some((mon) => mail.has(mon.item))) return yield* this.print(PC["BillsPC_MovePKMNMenu.PCMonHoldingMailText"]); const ok = yield { kind: "yesno", pages: this.pages(PC.MoveMonWOMailSaveText) }; if (ok) yield* this.moveWithoutMail(); } /** wBillsPC_LoadedBox: 0 is the party, 1-14 the boxes. */ private loadedBox(box: number): (PartyMon | BoxMon)[] { return box ? this.state.storage.boxes[box - 1] : this.state.party; } private loadedBoxName(box: number) { return box ? this.boxName(box - 1) : this.string(PC["BillsPC_BoxName.PartyPKMN"]); } /** Choose the party or a box (left and right in the original). */ private *chooseLoadedBox(prompt: string, selected: number): Gen { const boxes = Array.from({ length: NUM_BOXES + 1 }, (_, box) => box); return yield* this.menu( [prompt], boxes.map((box) => this.loadedBoxName(box)), boxes.map((box) => `${this.loadedBox(box).length}/${box ? MONS_PER_BOX : PARTY_LENGTH}`), selected, ); } /** _MovePKMNWithoutMail: choose a Pokémon in the party or a box, MOVE it * (STATS shows it) and choose where it goes; the list is presented per * box instead of with left and right. */ private *moveWithoutMail(): Gen { const data = this.host.data; const choose = this.string(PC.PCString_ChooseaPKMN), where = this.string(PC.PCString_MoveToWhere), cancel = this.string(PC["BillsPC_RefreshTextboxes.CancelString"]), options = this.strings(PC["_MovePKMNWithoutMail.MenuData"] + 2, 3); let loaded = this.state.storage.current + 1; for (;;) { const from = yield* this.chooseLoadedBox(choose, loaded); if (from < 0) return; loaded = from; const list = this.loadedBox(from); const index = yield* this.menu( [`${this.loadedBoxName(from)}\n${choose}`], [...list.map((mon) => mon.nickname), cancel], this.monDetails(list), ); if (index < 0 || index >= list.length) continue; const action = yield* this.menu([this.string(PC.PCString_WhatsUp)], options); if (action === 1) { const mon = list[index]; yield { kind: "stats", mon: from ? bufferMon(data, mon as BoxMon) : structuredClone(mon as PartyMon), }; continue; } if (action !== 0) continue; // BillsPC_CheckMail_PreventBlackout, even within the party. if (!from && !(yield* this.partyMonMayLeave(index))) continue; // Where: a box, then the position to insert before. for (;;) { const to = yield* this.chooseLoadedBox(where, from); if (to < 0) break; const destination = this.loadedBox(to); const position = yield* this.menu( [`${this.loadedBoxName(to)}\n${where}`], [...destination.map((mon) => mon.nickname), "▶"], this.monDetails(destination), destination.length, ); if (position < 0) continue; // BillsPC_CheckSpaceInDestination if ( to !== from && destination.length >= (to ? MONS_PER_BOX : PARTY_LENGTH) ) { yield* this.placeString(this.string(PC.PCString_TheresNoRoom)); continue; } yield* this.placeString( this.string(PC["MovePKMNWithoutMail_InsertMon.Saving_LeaveOn"]), 10, ); this.insertMon(from, index, to, position); loaded = to; break; } } } /** MovePKMNWithoutMail_InsertMon: remove, then insert before `position` * (one earlier when moving down the same list, .CheckTrivialMove). A * boxed Pokémon joining the party gets CalcBufferMonStats' full HP; * InsertPokemonIntoBox restores the PP. */ private insertMon(from: number, index: number, to: number, position: number) { const data = this.host.data; const source = this.loadedBox(from); const [moved] = source.splice(index, 1); if (from === to && index < position) position--; if (to) { const stored = from ? (moved as BoxMon) : toBoxMon(moved as PartyMon); restorePP(data, stored); this.state.storage.boxes[to - 1].splice(position, 0, stored); } else this.state.party.splice( position, 0, from ? bufferMon(data, moved as BoxMon) : (moved as PartyMon), ); } /** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one, * or the last that has not fainted, stays, and one holding MAIL * (wBillsPC_MonHasMail) must have it removed first. */ private *partyMonMayLeave(index: number): Gen { 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; } /** ReleasePKMN_ByePKMN: the species name, not the nickname. */ private *released(species: number): Gen { yield* this.placeString( this.string(PC.PCString_ReleasedPKMN), RELEASED_TICKS, ); yield* this.placeString( `${this.string(PC.PCString_Bye)} ${this.host.data.speciesName(species)}!`, ); } private *confirmRelease(): Gen { return Boolean( yield { kind: "yesno", pages: [this.string(PC.PCString_ReleasePKMN)] }, ); } /** _DepositPKMN: choose a party Pokémon, then DEPOSIT, STATS or RELEASE. */ private *depositMons(): Gen { const party = this.state.party, options = this.strings(PC["BillsPCDepositMenuHeader.MenuData"] + 2, 4); for (;;) { const index = yield* this.menu( [ `${this.string(PC["BillsPC_BoxName.PartyPKMN"])}\n${this.string(PC.PCString_ChooseaPKMN)}`, ], [ ...party.map((mon) => mon.nickname), this.string(PC["BillsPC_RefreshTextboxes.CancelString"]), ], this.monDetails(party), ); if (index < 0 || index >= party.length) return; for (;;) { const action = yield* this.menu( [this.string(PC.PCString_WhatsUp)], options, ); const mon = party[index]; if (action === SUBMENU_TAKE) { if (!(yield* this.partyMonMayLeave(index))) break; if (!depositMon(this.host.data, this.state.storage, mon)) { yield* this.placeString(this.string(PC.PCString_BoxFull)); continue; } party.splice(index, 1); yield* this.placeString( `${this.string(PC.PCString_Stored)}${mon.nickname}!`, ); break; } if (action === SUBMENU_STATS) { yield { kind: "stats", mon: structuredClone(mon) }; continue; } if (action !== SUBMENU_RELEASE) break; if (!(yield* this.partyMonMayLeave(index))) break; if (mon.egg) { yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS)); break; } if (!(yield* this.confirmRelease())) continue; party.splice(index, 1); yield* this.released(mon.species); break; } } } /** _WithdrawPKMN: choose a Pokémon in the current box, then WITHDRAW, * STATS or RELEASE. */ private *withdrawMons(): Gen { const options = this.strings(PC["BillsPC_Withdraw.MenuData"] + 2, 4); for (;;) { const box = currentBox(this.state.storage); const index = yield* this.menu( [ `${this.boxName(this.state.storage.current)}\n${this.string(PC.PCString_ChooseaPKMN)}`, ], [ ...box.map((mon) => mon.nickname), this.string(PC["BillsPC_RefreshTextboxes.CancelString"]), ], this.monDetails(box), ); if (index < 0 || index >= box.length) return; for (;;) { const action = yield* this.menu( [this.string(PC.PCString_WhatsUp)], options, ); const mon = box[index]; if (action === SUBMENU_TAKE) { if (this.state.party.length >= PARTY_LENGTH) { yield* this.placeString(this.string(PC.PCString_PartyFull)); continue; } box.splice(index, 1); this.state.party.push(withdrawnMon(this.host.data, mon)); yield* this.placeString( `${this.string(PC.PCString_Got)}${mon.nickname}!`, ); break; } if (action === SUBMENU_STATS) { // CalcTempmonStats: stats from stat experience, full HP, no status. yield { kind: "stats", mon: withdrawnMon(this.host.data, mon) }; continue; } if (action !== SUBMENU_RELEASE) break; if (mon.egg) { yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS)); continue; } if (!(yield* this.confirmRelease())) continue; box.splice(index, 1); yield* this.released(mon.species); break; } } } /** _ChangeBox: choose a box, then SWITCH, NAME, PRINT or QUIT. */ private *changeBox(): Gen { const storage = this.state.storage, options = this.strings(PC["BillsPC_ChangeBoxSubmenu.MenuData"] + 2, 4), current = this.string(PC["BillsPC_PrintBoxName.Current"]), outOf = this.string(PC["BillsPC_PrintBoxCountAndCapacity.OutOf20"]); let cursor = 0; for (;;) { const names = Array.from({ length: NUM_BOXES }, (_, box) => this.boxName(box), ); const box = yield* this.menu( [ `${current}\n${this.boxName(storage.current)}\n${this.string(PC["BillsPC_PlaceChooseABoxString.ChooseABox"])}`, ], names, storage.boxes.map((mons) => `${mons.length}${outOf}`), cursor, ); if (box < 0) return; cursor = box; const action = yield* this.menu( [this.string(PC.PCString_WhatsUp)], options, ); if (action === BOX_SWITCH) { if (box === storage.current) continue; // ChangeBoxSaveGame: the native session saves continuously, so the // switch itself is what the confirmation commits. const confirmed = yield { kind: "yesno", pages: this.pages(PC.ChangeBoxSaveText), }; if (confirmed) storage.current = box; } else if (action === BOX_NAME) { const name = yield { kind: "name", pages: [this.string(PC["NamingScreenJumptable.BoxNameString"])], current: names[box], max: 8, }; if (typeof name !== "string" || (name && !BOX_NAME_PATTERN.test(name))) throw new Error("Invalid box name."); // InitString: a blank name keeps the box's name. if (!/^ *$/.test(name)) storage.names[box] = name; } else if (action === BOX_PRINT) { if (!storage.boxes[box].length) yield* this.placeString( this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]), ); else yield* printerDisconnected(this.host.reader, this.state.options.textboxFrame); } } } // ----------------------------------------------------------- Oak's PC /** ProfOaksPC: an optional Pokédex rating from OakRatings. */ private *oaksPC(): Gen { const r = this.host.reader; const rate = yield { kind: "yesno", pages: this.pages(PC.OakPCText1), }; if (rate) { yield* this.print(PC.OakPCText2); const { seen, caught } = this.state.pokedex; this.host.strings[RAM.stringBuffer3] = String(seen.size); this.host.strings[RAM.stringBuffer4] = String(caught.size); yield* this.print(PC.OakPCText3); yield* this.print(oakRating(r, caught.size).text); } yield* this.print(PC.OakPCText4); } }