Port the PC and boxes
Checks / web (push) Canceled after 0s

Read TileCollisionStdScripts from the ROM so the Pokémon Center PC runs
PokemonCenterPC: Bill's PC (deposit, withdraw, release, change and name
boxes), the player's item storage (withdraw, deposit, toss, mail box) and
Oak's Pokédex rating. The house PC now opens the full player's PC menu.

A catch with a full party goes to the current box through SendMonIntoBox;
a full box keeps the ball, and filling it queues Bill's call as a memory
script after the battle. Saves gain boxes, box names and PC items,
migrated by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 17:12:26 +02:00
1 parent 451464f7c6
commit 3bdca0099e
21 files changed
+1999 -131

No files matched your search

+79 -20
View File
@@ -11,6 +11,7 @@ import {
NUM_FRUIT_TREES,
EVENTS,
OVERWORLD,
PC,
RAM,
SPECIAL_ROUTINES,
SYMBOLS,
@@ -24,18 +25,28 @@ import {
receiveItem,
tossItem,
type Bag,
type ItemSlot,
} from "../party/bag";
import {
MONS_PER_BOX,
copyStorage,
currentBox,
emptyStorage,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
caughtData,
generatePartyMon,
maxPP,
type PartyMon,
} from "../party/pokemon";
import { TrainerData, type TrainerHeader } from "../rom/trainers";
import { DIRECTION_ORDER, type Direction } from "../world/types";
import { OW } from "../world/constants";
import { readMovement, type MovementStep } from "./movement";
import { PcSession } from "./pc";
import {
defaultTextContext,
readString,
@@ -59,6 +70,10 @@ export interface ScriptState {
clock: ClockSettings;
party: PartyMon[];
bag: Bag;
/** Bill's PC: the boxes, their names and wCurBox. */
storage: PokemonStorage;
/** wPCItems: the Player's PC item storage. */
pcItems: ItemSlot[];
pokedex: { seen: Set<number>; caught: Set<number> };
/** wMoney: a 24-bit amount. */
money: number;
@@ -94,6 +109,8 @@ export const emptyScriptState = (): ScriptState => ({
clock: { weekdayOffset: 0, dst: false },
party: [],
bag: emptyBag(),
storage: emptyStorage(),
pcItems: [],
pokedex: { seen: new Set(), caught: new Set() },
money: C.START_MONEY,
fruitTrees: new Set(),
@@ -116,6 +133,8 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
clock: { ...state.clock },
party: structuredClone(state.party),
bag: copyBag(state.bag),
storage: copyStorage(state.storage),
pcItems: state.pcItems.map((slot) => ({ ...slot })),
pokedex: {
seen: new Set(state.pokedex.seen),
caught: new Set(state.pokedex.caught),
@@ -138,9 +157,10 @@ export const PLAYER = 0;
const LAST_TALKED = 0xfe;
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
const MONS_PER_BOX = 20;
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
/** The mart's lists and quantity box, which the PC's item storage reuses. */
export type MartMode = "buy" | "sell" | "withdraw" | "deposit" | "toss";
export interface MartEntry {
item: number;
name: string;
@@ -174,7 +194,7 @@ export type ScriptEffect =
* answered with the chosen entry's index, or a negative number to cancel. */
| {
kind: "martlist";
mode: "buy" | "sell";
mode: MartMode;
entries: MartEntry[];
selected: number;
}
@@ -182,7 +202,7 @@ export type ScriptEffect =
* number below 1 to cancel back to the item list. */
| {
kind: "martquantity";
mode: "buy" | "sell";
mode: MartMode;
item: number;
name: string;
price: number;
@@ -209,9 +229,22 @@ export type ScriptEffect =
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction }
/** A vertical menu (the elevator's floors); answered with the chosen
* item's index, or -1 when cancelled with B. */
| { kind: "menu"; pages: string[]; items: string[]; selected: number }
| {
kind: "menu";
pages: string[];
items: string[];
/** Secondary text per item, such as a level or a box's count. */
details?: string[];
selected: number;
}
/** A native message for presentation the port does not have yet; the
* script continues. */
| { kind: "notice"; text: string }
/** warpcheck: the player's tile events run again after the script. */
| { kind: "warpcheck" }
/** LoadMemScript: a script (with a phone caller's script) that runs at
* the next PlayerEvents, unless one is already queued. */
| { kind: "memscript"; script: number; callerScript?: number }
/** changeblock: the block covering map tile (x, y) becomes `block`. */
| { kind: "changeblock"; x: number; y: number; block: number }
/** changemapblocks: replace the map's block data from the ROM. */
@@ -265,6 +298,10 @@ type Input = number | string | undefined;
const ITEM_FROM_MEM = 0xff;
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
const PHONE_CONTACT_SCRIPT2_BANK = 9;
/** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */
const PHONE_CONTACT_SIZE = 12;
const PHONE_BILL = 3;
const BATTLERESULT_BOX_FULL = 0x80;
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
* a one-tick sleep, then normal steps to the player's tile, dropping the
@@ -871,6 +908,20 @@ export class ScriptRun {
case 0x60:
// Script_reloadmapafterbattle: a loss continues with the whiteout.
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
// A wild catch that filled the box queues Bill's call
// (Script_SpecialBillCall: LoadCallerScript for PHONE_BILL, then
// Script_ReceivePhoneCall). BATTLESCRIPT_WILD_F is set by trainer
// battles, which instead run the unported MomTriesToBuySomething.
else if (!trainer && battleResult & BATTLERESULT_BOX_FULL)
yield synchronous({
kind: "memscript",
script: PC.Script_ReceivePhoneCall,
callerScript: r.far(
PC.PhoneContacts +
PHONE_BILL * PHONE_CONTACT_SIZE +
PHONE_CONTACT_SCRIPT2_BANK,
),
});
canLose = false;
break;
case 0x7b:
@@ -1597,6 +1648,19 @@ export class ScriptRun {
}
}
/** The PCs, sharing this script's state and text buffers. */
private pc() {
return new PcSession({
state: this.state,
reader: this.reader,
data: this.host.data,
text: () => this.text(),
strings: this.strings,
numbers: this.numbers,
decorationMenu: () => this.decorationMenu(),
});
}
/** _PlayerDecorationMenu: owned categories, replacement and ornament sides. */
private *decorationMenu(): Generator<
ScriptEffect,
@@ -1782,10 +1846,8 @@ export class ScriptRun {
case 0x0d:
return number;
case 0x10:
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
// deposited yet (catching with a full party is refused and the PC
// is not ported), so the current box is empty.
return MONS_PER_BOX;
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount.
return MONS_PER_BOX - currentBox(this.state.storage).length;
case 0x12:
return this.host.position().x;
case 0x13:
@@ -1862,16 +1924,9 @@ export class ScriptRun {
for (const mon of this.state.party) {
mon.status = 0;
mon.hp = mon.stats[0];
mon.pp = mon.moves.map((move, slot) => {
if (!move) return 0;
const ups = mon.pp[slot] >> 6,
base = this.host.data.move(move).pp;
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
// maxes at 61 instead of overflowing the packed byte at 64.
return (
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
);
}) as PartyMon["pp"];
mon.pp = mon.moves.map((move, slot) =>
move ? maxPP(this.host.data, move, mon.pp[slot] >> 6) : 0,
) as PartyMon["pp"];
}
return value;
case SPECIAL_ROUTINES.checkPokerus:
@@ -1937,9 +1992,13 @@ export class ScriptRun {
case OVERWORLD.OverworldTownMap:
yield { kind: "townmap" };
return value;
case PC.PokemonCenterPC:
if (pages.length) yield { kind: "text", pages: take() };
yield* this.pc().pokemonCenterPC();
return value;
case OVERWORLD.PlayersHousePC: {
take();
const changed = yield* this.decorationMenu();
if (pages.length) yield { kind: "text", pages: take() };
const changed = yield* this.pc().playersHousePC();
if (changed)
yield {
kind: "warp",
+647
View File
@@ -0,0 +1,647 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, PC, RAM } from "../rom/profile";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { BOX_NAME_PATTERN, PARTY_LENGTH } from "../party/pokemon";
import {
NUM_BOXES,
currentBox,
depositMon,
receivePCItem,
withdrawnMon,
} 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>;
}
/** PokemonCenterPC's menu items (PCPCITEM_*). */
const PCPCITEM_PLAYERS_PC = 0,
PCPCITEM_BILLS_PC = 1,
PCPCITEM_OAKS_PC = 2,
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;
const EGG = 0xfd;
/** 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;
}
yield* this.print(PC.PokecenterPCTurnOnText);
const whose = this.pages(PC.PokecenterPCWhoseText);
let cursor = 0;
for (;;) {
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX or PCPC_BEFORE_HOF.
// No Hall of Fame entry exists before the League is ported.
const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? 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,
);
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_TURN_OFF:
yield* this.print(PC.PokecenterPCOaksClosedText);
return;
default:
throw new Error("The Hall of Fame PC is not ported.");
}
}
}
/** _PlayersHousePC: returns true when a decoration changed, which makes
* the script reload the room. */
*playersHousePC(): Gen<boolean> {
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:
// _PlayerMailBoxMenu: no MAIL can be stored yet.
yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
break;
case PLAYERSPCITEM_DECORATION:
if (yield* this.host.decorationMenu()) return true;
break;
default:
// LOG OFF and TURN OFF.
return false;
}
}
}
// ------------------------------------------------------ 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 {
kind: "notice",
text: "Moving Pokémon between boxes is not ported yet.",
};
else return;
}
}
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
* or the last that has not fainted, stays. No MAIL exists yet. */
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;
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: "notice", text: "The stats screen is not ported yet." };
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (!(yield* this.partyMonMayLeave(index))) break;
if (mon.species === 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) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (mon.species === 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 {
kind: "notice",
text: "Printing needs a Game Boy Printer, which is not supported.",
};
}
}
}
// ----------------------------------------------------------- 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);
// FindOakRating: the first entry whose count reaches the owned total.
let entry = PC.OakRatings;
while (r.byte(entry) < caught.size) entry += 5;
yield* this.print(r.near(entry + 3, r.bank(entry)));
}
yield* this.print(PC.OakPCText4);
}
}