Files
Crystal/src/game/scripts/pc.ts
T
kawaandClaude Opus 5.5 f05fd8ebee
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m31s
Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate,
Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's
room and the Hall of Fame are open.

- halloffame (game/scripts/league.ts): the music fades as SAVING RECORD
  shows, the win and the team (without Eggs) are recorded, each Pokémon's
  back picture passes and its front picture slides in for its "New Hall of
  Famer!" page and cry, then the player's card with the play time and
  Professor Oak's rating; the play time stops meanwhile. The world then
  continues at New Bark as Continue does after the cartridge's reset.
- credits and the credits after the Hall of Fame (CreditsMachine): the
  ROM's script, strings, banner Pokémon and THE END, frame by frame as
  VBlank_Credits presents them, with the frames each command group runs
  over measured on the cartridge. B shortens credits seen before.
- wait, appear/disappear of the player, and rival names in battle texts
  (PlaceEnemysName prints wRivalName alone: "???" before he is named).
- The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows
  their own hSCX.
- Reference: the credits match the cartridge on every frame (BG map,
  banner tiles, CRAM palettes, LY overrides, parser), with and without B;
  the ceremony's pages match for every species and the player's card for
  Chris and Kris.
- Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from
  Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four
  and Lance); the driver follows warps within a map and fights carefully
  from here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:52:14 +02:00

866 lines
30 KiB
TypeScript

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<T = void> = Generator<ScriptEffect, T, Input>;
/** 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<number, string>;
numbers: Record<number, number>;
/** _PlayerDecorationMenu: true if a decoration changed. */
decorationMenu(): Gen<boolean>;
/** 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<number> {
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<boolean> {
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<boolean> {
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<number> {
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<number>();
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<number> {
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<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;
}
/** 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<boolean> {
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);
}
}