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

+84 -25
View File
@@ -2,10 +2,18 @@ import { RomReader } from "../rom/reader";
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
import type { PokemonData } from "../rom/pokemon";
import type { Bag } from "../party/bag";
import {
MONS_PER_BOX,
currentBox,
restorePP,
type BoxMon,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
calcStats,
levelForExp,
type PartyMon,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
@@ -140,6 +148,8 @@ export interface BattleOptions {
text: () => TextContext;
/** The player's pack, needed to throw Poké Balls. */
bag?: Bag;
/** Bill's PC: a catch with a full party goes to the current box. */
storage?: PokemonStorage;
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
hasPokedex?: boolean;
/** SetCaughtData's word for a Pokémon caught at this level. */
@@ -148,6 +158,8 @@ export interface BattleOptions {
tutorial?: boolean;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
const BATTLERESULT_BOX_FULL = 0x80;
const SUB1 = 1,
SUB2 = 2,
SUB3 = 3,
@@ -844,12 +856,22 @@ export class Battle {
this.tossBall(item, slot);
return true;
}
// With a full party the Pokémon would go to the current box; if that is
// full too, the ball is not used (Ball_BoxIsFullMessage).
const storage = this.options.storage;
if (
this.options.party.length >= PARTY_LENGTH &&
(!storage || currentBox(storage).length >= MONS_PER_BOX)
) {
// The Dude's tutorial reaches this check too, and the original then
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
yield* this.say(BATTLE.BallBoxFullText);
return false;
}
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
// A full party sends the Pokémon into the current box (SendMonIntoBox).
// The tutorial never keeps its Pokémon, so it does not reach the box.
if (!tutorial && this.options.party.length >= PARTY_LENGTH)
throw new UnsupportedBattle("PC boxes are not ported yet.");
this.wildMon = 0;
yield* this.say(BATTLE.ItemUsedText);
@@ -920,10 +942,15 @@ export class Battle {
// NewPokedexEntry's Pokédex page is not presented yet.
yield* this.say(BATTLE.NewDexDataText);
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
this.tossBall(item, slot);
return true;
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
const mon: PartyMon = {
species,
item: enemy.item,
@@ -944,24 +971,59 @@ export class Battle {
stats: calcStats(data, species, enemy.dvs, level),
};
this.options.party.push(mon);
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (yield { kind: "yesno", text: "" }) {
// NamingScreen (NAME_MON): "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
if (!/^ *$/.test(answer)) mon.nickname = answer;
}
mon.nickname = yield* this.askNickname(name);
this.tossBall(item, slot);
return true;
}
/** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the
* chosen name, or the species name for a declined or blank one. */
private *askNickname(name: string): Gen<string> {
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (!(yield { kind: "yesno", text: "" })) return name;
// "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
return /^ *$/.test(answer) ? name : answer;
}
/** PokeBallEffect's .SendToPC: SendMonIntoBox puts the caught Pokémon
* first in the current box (from wEnemyMon, with restored PP), then
* SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for
* Bill's call; the nickname and BallSentToPCText follow. */
private *sendToPC(species: number, level: number, name: string): Gen {
const data = this.options.data,
storage = this.options.storage!,
box = currentBox(storage);
const mon: BoxMon = {
species,
item: this.enemy.mon.item,
moves: [...this.wildMoves] as BoxMon["moves"],
otId: this.options.playerId,
ot: this.options.playerName,
nickname: name,
exp: data.expAtLevel(data.baseData(species).growthRate, level),
statExp: [0, 0, 0, 0, 0],
dvs: this.enemy.mon.dvs,
pp: [...this.wildPP] as BoxMon["pp"],
happiness: C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData!(level),
level,
};
restorePP(data, mon);
box.unshift(mon);
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
mon.nickname = yield* this.askNickname(name);
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
yield* this.say(BATTLE.BallSentToPCText);
}
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
* selected slot if it matches, otherwise from the first matching slot. */
private tossBall(item: number, slot: number) {
@@ -2798,10 +2860,7 @@ export class Battle {
}
/** CalcLevel: the highest level whose exp does not exceed the total. */
private levelForExp(species: number, exp: number) {
const growth = this.options.data.baseData(species).growthRate;
let level = 1;
while (level < C.MAX_LEVEL && this.options.data.expAtLevel(growth, level + 1) <= exp) level++;
return level;
return levelForExp(this.options.data, species, exp);
}
/** LearnLevelMoves: moves the species learns at exactly this level. */
private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen {
+50 -11
View File
@@ -1,9 +1,13 @@
import type { PokemonData, Pocket } from "../rom/pokemon";
export interface ItemSlot {
item: number;
quantity: number;
}
/** The player's pack, with the original pocket capacities. */
export interface Bag {
items: { item: number; quantity: number }[];
balls: { item: number; quantity: number }[];
items: ItemSlot[];
balls: ItemSlot[];
keyItems: number[];
}
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
@@ -20,8 +24,8 @@ const MAX_ITEMS = 20,
/** PutItemInPocket: room exists if matching stacks can absorb the quantity
* or a new slot is free; stacks then fill to 99 before a new one starts. */
function putInPocket(
pocket: { item: number; quantity: number }[],
export function putInPocket(
pocket: ItemSlot[],
capacity: number,
item: number,
quantity: number,
@@ -80,9 +84,15 @@ export function hasItem(bag: Bag, item: number): boolean {
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
const slots = (list: unknown, capacity: number, pocket: Pocket) =>
/** A list of item stacks. Without `pocket`, any item with a pack pocket
* (the PC's list holds items from every pocket). */
export function validItemSlots(
data: PokemonData,
list: unknown,
capacity: number,
pocket?: Pocket,
): list is ItemSlot[] {
return (
Array.isArray(list) &&
list.length <= capacity &&
list.every(
@@ -92,13 +102,22 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
Number.isInteger(slot.quantity) &&
slot.quantity >= 1 &&
slot.quantity <= MAX_ITEM_STACK &&
itemIn(data, slot.item, pocket),
);
(pocket
? itemIn(data, slot.item, pocket)
: ["item", "ball", "key"].some((each) =>
itemIn(data, slot.item, each as Pocket),
)),
)
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
return (
Boolean(bag) &&
typeof bag === "object" &&
slots(bag.items, MAX_ITEMS, "item") &&
slots(bag.balls, MAX_BALLS, "ball") &&
validItemSlots(data, bag.items, MAX_ITEMS, "item") &&
validItemSlots(data, bag.balls, MAX_BALLS, "ball") &&
Array.isArray(bag.keyItems) &&
bag.keyItems.length <= MAX_KEY_ITEMS &&
bag.keyItems.every((item) => itemIn(data, item, "key"))
@@ -131,3 +150,23 @@ export function tossItem(bag: Bag, item: number, quantity: number): boolean {
}
return false;
}
/** RemoveItemFromPocket: take from the stack at `index` (wCurItemQuantity)
* if it holds `item`, otherwise from the first stack that does. Fails if
* that stack holds fewer. */
export function removeFromSlot(
list: ItemSlot[],
index: number,
item: number,
quantity: number,
): boolean {
const at =
list[index]?.item === item
? index
: list.findIndex((slot) => slot.item === item);
const slot = list[at];
if (!slot || quantity > slot.quantity) return false;
slot.quantity -= quantity;
if (!slot.quantity) list.splice(at, 1);
return true;
}
+64 -23
View File
@@ -30,9 +30,12 @@ export const PARTY_LENGTH = 6;
/** Characters a nickname can hold: the naming screen's letters and
* symbols, plus the digits and apostrophes of default species names. */
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
/** The same characters, for box names (up to eight). */
export const BOX_NAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,8}$/u;
/** BASE_HAPPINESS */
const BASE_HAPPINESS = 70;
const MAX_STAT_VALUE = 999;
const MAX_LEVEL = 100;
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
function squareRoot(value: number) {
@@ -159,11 +162,30 @@ export function generatePartyMon(
};
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
/** CalcLevel: the highest level whose experience the total reaches. */
export function levelForExp(
data: PokemonData,
value: unknown,
): value is PartyMon {
species: number,
exp: number,
): number {
const growth = data.baseData(species).growthRate;
let level = 1;
while (level < MAX_LEVEL && data.expAtLevel(growth, level + 1) <= exp)
level++;
return level;
}
/** ComputeMaxPP for a move with `ups` PP Ups: each adds a fifth of the base
* PP, clamped to 7, so a 40 PP move maxes at 61 instead of overflowing the
* packed byte at 64. The PP Ups stay in the top two bits. */
export function maxPP(data: PokemonData, move: number, ups: number): number {
const base = data.move(move).pp;
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
}
/** The box_struct fields of a stored Pokémon: everything but status, HP
* and stats. */
export function validBoxFields(value: unknown): boolean {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
@@ -171,28 +193,47 @@ export function validPartyMon(
Array.isArray(value) &&
value.length === length &&
value.every((item) => integer(item, 0, max));
try {
return (
Boolean(mon) &&
typeof mon === "object" &&
integer(mon.species, 1, 251) &&
integer(mon.item, 0, 255) &&
tuple(mon.moves, 4, 251) &&
integer(mon.otId, 0, 0xffff) &&
typeof mon.ot === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname) &&
integer(mon.exp, 0, 0xffffff) &&
tuple(mon.statExp, 5, 0xffff) &&
integer(mon.dvs, 0, 0xffff) &&
tuple(mon.pp, 4, 255) &&
integer(mon.happiness, 0, 255) &&
integer(mon.pokerus, 0, 255) &&
integer(mon.caughtData, 0, 0xffff) &&
integer(mon.level, 1, 100)
);
} catch {
return false;
}
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
data: PokemonData,
value: unknown,
): value is PartyMon {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
try {
if (
!mon ||
typeof mon !== "object" ||
!integer(mon.species, 1, 251) ||
!integer(mon.item, 0, 255) ||
!tuple(mon.moves, 4, 251) ||
!integer(mon.otId, 0, 0xffff) ||
typeof mon.ot !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) ||
typeof mon.nickname !== "string" ||
!NICKNAME_PATTERN.test(mon.nickname) ||
!integer(mon.exp, 0, 0xffffff) ||
!tuple(mon.statExp, 5, 0xffff) ||
!integer(mon.dvs, 0, 0xffff) ||
!tuple(mon.pp, 4, 255) ||
!integer(mon.happiness, 0, 255) ||
!integer(mon.pokerus, 0, 255) ||
!integer(mon.caughtData, 0, 0xffff) ||
!integer(mon.level, 1, 100) ||
!validBoxFields(mon) ||
!integer(mon.status, 0, 255) ||
!tuple(mon.stats, 6, MAX_STAT_VALUE) ||
!Array.isArray(mon.stats) ||
mon.stats.length !== 6 ||
!mon.stats.every((stat) => integer(stat, 0, MAX_STAT_VALUE)) ||
!integer(mon.hp, 0, mon.stats[0])
)
return false;
+133
View File
@@ -0,0 +1,133 @@
import type { PokemonData } from "../rom/pokemon";
import {
BOX_NAME_PATTERN,
calcStats,
levelForExp,
maxPP,
validBoxFields,
type PartyMon,
} from "./pokemon";
import { putInPocket, validItemSlots, type ItemSlot } from "./bag";
/** NUM_BOXES and MONS_PER_BOX (constants/pokemon_data_constants.asm). */
export const NUM_BOXES = 14;
export const MONS_PER_BOX = 20;
/** BOX_NAME_LENGTH minus the terminator. */
export const BOX_NAME_MAX = 8;
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
export const MAX_PC_ITEMS = 50;
/** The fields a stored BoxMon has, so no party fields slip into a box. */
const BOX_MON_FIELDS = 14;
/** box_struct: a party_struct without status, HP and stats. */
export type BoxMon = Omit<PartyMon, "status" | "hp" | "stats">;
/** Bill's PC: every box, wCurBox and wBoxNames. A null name is the one
* SetDefaultBoxNames writes ("BOX" and the box number, from the ROM). */
export interface PokemonStorage {
current: number;
names: (string | null)[];
boxes: BoxMon[][];
}
export const emptyStorage = (): PokemonStorage => ({
current: 0,
names: Array.from({ length: NUM_BOXES }, () => null),
boxes: Array.from({ length: NUM_BOXES }, () => []),
});
export const copyStorage = (storage: PokemonStorage): PokemonStorage => ({
current: storage.current,
names: [...storage.names],
boxes: structuredClone(storage.boxes),
});
export const currentBox = (storage: PokemonStorage) =>
storage.boxes[storage.current];
/** The first BOXMON_STRUCT_LENGTH bytes of a party struct. */
export function toBoxMon(mon: PartyMon): BoxMon {
const {
status: _status,
hp: _hp,
stats: _stats,
...box
} = structuredClone(mon);
return box;
}
/** RestorePPOfDepositedPokemon: each move's PP becomes its maximum,
* keeping the PP Ups (GetMaxPPOfMove through ComputeMaxPP). */
export function restorePP(data: PokemonData, mon: BoxMon) {
mon.pp = mon.moves.map((move, slot) =>
move ? maxPP(data, move, mon.pp[slot] >> 6) : mon.pp[slot],
) as BoxMon["pp"];
}
/** SendGetMonIntoFromBox with PC_DEPOSIT: the Pokémon joins the end of
* the current box with its PP restored. Returns false if the box is full. */
export function depositMon(
data: PokemonData,
storage: PokemonStorage,
mon: PartyMon,
): boolean {
const box = currentBox(storage);
if (box.length >= MONS_PER_BOX) return false;
const stored = toBoxMon(mon);
restorePP(data, stored);
box.push(stored);
return true;
}
/** SendGetMonIntoFromBox with PC_WITHDRAW: CalcLevel from the experience,
* then CalcMonStats with stat experience, no status and full HP. */
export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box);
const level = levelForExp(data, mon.species, mon.exp);
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
return { ...mon, level, status: 0, hp: stats[0], stats };
}
/** ReceiveItem into wNumPCItems. */
export function receivePCItem(
items: ItemSlot[],
item: number,
quantity: number,
): boolean {
return putInPocket(items, MAX_PC_ITEMS, item, quantity);
}
export function validStorage(value: unknown): value is PokemonStorage {
const storage = value as PokemonStorage;
return (
Boolean(storage) &&
typeof storage === "object" &&
Number.isInteger(storage.current) &&
storage.current >= 0 &&
storage.current < NUM_BOXES &&
Array.isArray(storage.names) &&
storage.names.length === NUM_BOXES &&
storage.names.every(
(name) =>
name === null ||
(typeof name === "string" &&
BOX_NAME_PATTERN.test(name) &&
!/^ *$/.test(name)),
) &&
Array.isArray(storage.boxes) &&
storage.boxes.length === NUM_BOXES &&
storage.boxes.every(
(box) =>
Array.isArray(box) &&
box.length <= MONS_PER_BOX &&
box.every(
(mon) =>
validBoxFields(mon) &&
Object.keys(mon as object).length === BOX_MON_FIELDS,
),
)
);
}
export function validPCItems(
data: PokemonData,
value: unknown,
): value is ItemSlot[] {
return validItemSlots(data, value, MAX_PC_ITEMS);
}
+69
View File
@@ -281,6 +281,73 @@ export const OVERWORLD = {
MansionPalette2: 0x496fe,
} as const;
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
* data, strings and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const PC = {
TileCollisionStdScripts: 0x13681,
PokemonCenterPC: 0x1559a,
"PokemonCenterPC.WhichPC": 0x1562c,
"PokemonCenterPC.Jumptable": 0x155e6,
"PC_CheckPartyForPokemon.PokecenterPCCantUseText": 0x15663,
PokecenterPCTurnOnText: 0x15a27,
PokecenterPCWhoseText: 0x15a2c,
PokecenterBillsPCText: 0x15a31,
PokecenterPlayersPCText: 0x15a36,
PokecenterOaksPCText: 0x15a3b,
PokecenterPCOaksClosedText: 0x15a40,
PlayersPCTurnOnText: 0x156ff,
PlayersPCAskWhatDoText: 0x157cc,
"PlayersPCMenuData.WhichPC": 0x157ac,
"PlayersPCMenuData.PlayersPCMenuPointers": 0x15746,
"PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText": 0x15850,
"PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText": 0x15855,
"PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText": 0x1585a,
"PlayerDepositItemMenu.PlayersPCNoItemsText": 0x158c7,
"PlayerDepositItemMenu.PlayersPCHowManyDepositText": 0x1596e,
"PlayerDepositItemMenu.PlayersPCDepositItemsText": 0x15973,
"PlayerDepositItemMenu.PlayersPCNoRoomDepositText": 0x15978,
"TossItemFromPC.ItemsTossOutHowManyText": 0x12a45,
"TossItemFromPC.ItemsThrowAwayText": 0x12a4a,
"TossItemFromPC.ItemsDiscardedText": 0x12a4f,
"TossItemFromPC.ItemsTooImportantText": 0x12a5b,
"_PlayerMailBoxMenu.EmptyMailboxText": 0x447b4,
"_BillsPC.PCGottaHavePokemonText": 0xe417,
"_BillsPC.PCWhatText": 0xe43a,
"_BillsPC.strings": 0xe47f,
"BillsPCDepositMenuHeader.MenuData": 0xe2545,
"BillsPC_Withdraw.MenuData": 0xe2739,
"BillsPC_ChangeBoxSubmenu.MenuData": 0xe3783,
PCString_ChooseaPKMN: 0xe34dd,
PCString_WhatsUp: 0xe34ea,
PCString_ReleasePKMN: 0xe34f4,
PCString_ItsYourLastPKMN: 0xe350f,
PCString_NoMoreUsablePKMN: 0xe3531,
PCString_ReleasedPKMN: 0xe3551,
PCString_Bye: 0xe355e,
PCString_Stored: 0xe3563,
PCString_Got: 0xe356b,
PCString_BoxFull: 0xe3575,
PCString_PartyFull: 0xe3586,
PCString_NoReleasingEGGS: 0xe3597,
"BillsPC_PlaceChooseABoxString.ChooseABox": 0xe37a1,
"BillsPC_PlaceEmptyBoxString_SFX.NoMonString": 0xe37d3,
"BillsPC_PrintBoxName.Current": 0xe36f1,
"BillsPC_PrintBoxCountAndCapacity.OutOf20": 0xe3668,
"BillsPC_RefreshTextboxes.CancelString": 0xe2c67,
"BillsPC_BoxName.PartyPKMN": 0xe2abd,
"NamingScreenJumptable.BoxNameString": 0x11822,
"SetDefaultBoxNames.Box": 0x5ccf,
ChangeBoxSaveText: 0x152a1,
OakPCText1: 0x266de,
OakPCText2: 0x266e3,
OakPCText3: 0x266e8,
OakPCText4: 0x2674c,
OakRatings: 0x2667f,
PhoneContacts: 0x9045f,
Script_ReceivePhoneCall: 0x90241,
} as const;
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
@@ -294,6 +361,8 @@ export const BATTLE = {
NewDexDataText: 0xedf0,
AskGiveNicknameText: 0xedf5,
BallBlockedText: 0xf824,
BallBoxFullText: 0xf838,
BallSentToPCText: 0xedeb,
BallDontBeAThiefText: 0xf829,
WobbleProbabilities: 0xf9ba,
"CatchTutorial.Dude": 0x4e5da,
+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);
}
}
+67 -15
View File
@@ -10,6 +10,7 @@ import {
INITIAL_POSITION,
NUM_FRUIT_TREES,
OVERWORLD,
PC,
SYMBOLS,
} from "../rom/profile";
import { OW } from "./constants";
@@ -19,12 +20,20 @@ import {
copyScriptState,
emptyScriptState,
type MartEntry,
type MartMode,
type ScriptEffect,
type ScriptHost,
type ScriptState,
} from "../scripts/interpreter";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag } from "../party/bag";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
copyStorage,
emptyStorage,
validPCItems,
validStorage,
type PokemonStorage,
} from "../party/storage";
import {
NICKNAME_PATTERN,
caughtData,
@@ -75,6 +84,8 @@ export interface NativeSave {
playerId: number;
party: PartyMon[];
bag: Bag;
storage: PokemonStorage;
pcItems: ItemSlot[];
pokedex: { seen: number[]; caught: number[] };
money: number;
fruitTrees: number[];
@@ -186,20 +197,20 @@ export type Choice =
/** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined }
/** A vertical menu; `selected` answers with that item's index. */
| { kind: "menu"; items: string[]; selected: number }
| { kind: "menu"; items: string[]; details?: string[]; selected: number }
/** The mart's BUY/SELL/QUIT box. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list; `selected` answers with that entry's index. */
| {
kind: "martlist";
mode: "buy" | "sell";
mode: MartMode;
entries: MartEntry[];
selected: number;
}
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
mode: "buy" | "sell";
mode: MartMode;
item: number;
name: string;
price: number;
@@ -263,10 +274,18 @@ interface ActiveScript {
warp?: { map: string; x: number; y: number; facing?: Direction };
/** warpcheck: the player's tile events run on the tick after the script. */
warpCheck?: boolean;
/** LoadMemScript: queued for the next PlayerEvents once this commits. */
memScript?: MemScript;
/** The trainer whose sight started this script, if any. */
sighted?: number;
}
/** wMapReentryScriptQueueFlag's script: work RAM, so it is not saved. */
interface MemScript {
script: number;
callerScript?: number;
}
const OPPOSITE: Record<Direction, Direction> = {
down: "up",
up: "down",
@@ -455,6 +474,7 @@ export class GameWorld {
notice = "";
diagnostic = "";
private actors = new Map<number, Actor>();
private memScript?: MemScript;
private currentMap?: GameMap;
/** wCmdQueue's CMDQUEUE_STONETABLE entries for the current map. */
private stoneTables: number[] = [];
@@ -1731,6 +1751,7 @@ export class GameWorld {
canLose: effect.canLose,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
storage: draft.storage,
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
caughtData: (level) =>
caughtData(
@@ -1757,6 +1778,16 @@ export class GameWorld {
case "warpcheck":
script.warpCheck = true;
return false;
case "notice":
this.notice = effect.text;
return false;
case "memscript":
// LoadMemScript keeps a script that is already queued.
script.memScript ??= {
script: effect.script,
callerScript: effect.callerScript,
};
return false;
case "townmap":
this.townMap = true;
this.dialogue = { pages: ["Town Map"], index: 0 };
@@ -1768,6 +1799,7 @@ export class GameWorld {
choice: {
kind: "menu",
items: effect.items,
...(effect.details && { details: effect.details }),
selected: effect.selected,
},
};
@@ -2153,6 +2185,7 @@ export class GameWorld {
this.arrival = { x: this.x, y: this.y };
}
this.script = undefined;
this.memScript ??= script.memScript;
this.townMap = false;
this.dialogue = undefined;
this.emote = undefined;
@@ -2686,6 +2719,15 @@ export class GameWorld {
}
if (this.busy || this.mapId !== map) return;
} else if (this.checkTrainers()) return;
// RunMemScript precedes RunSceneScript.
const memScript = this.memScript;
if (memScript) {
this.memScript = undefined;
this.startScript(memScript.script, PLAYER, {
callerScript: memScript.callerScript,
});
if (this.busy) return;
}
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
@@ -2791,17 +2833,16 @@ export class GameWorld {
return;
}
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.player.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
// CheckFacingTileForStdScript: TileCollisionStdScripts pairs a
// collision type with a std script index, in any facing direction.
const r = this.assets.reader;
for (let entry = PC.TileCollisionStdScripts; ; entry += 3) {
const type = r.byte(entry);
if (type === 0xff) break;
if (type !== collision) continue;
script = r.far(SYMBOLS.standardScripts + r.word(entry + 1) * 3);
break;
}
}
if (script === undefined) {
const [fx, fy] = VECTORS[this.player.direction];
@@ -3136,6 +3177,8 @@ export class GameWorld {
playerId: this.playerId,
party: structuredClone(state.party),
bag: structuredClone(state.bag),
storage: copyStorage(state.storage),
pcItems: structuredClone(state.pcItems),
pokedex: {
seen: [...state.pokedex.seen].sort((a, b) => a - b),
caught: [...state.pokedex.caught].sort((a, b) => a - b),
@@ -3215,6 +3258,8 @@ export class GameWorld {
save.party.length > 6 ||
!save.party.every((mon) => validPartyMon(this.assets.pokemon, mon)) ||
!validBag(this.assets.pokemon, save.bag) ||
!validStorage(save.storage) ||
!validPCItems(this.assets.pokemon, save.pcItems) ||
!save.pokedex ||
!species(save.pokedex.seen) ||
!species(save.pokedex.caught) ||
@@ -3288,6 +3333,8 @@ export class GameWorld {
clock: { ...save.clock },
party: structuredClone(save.party),
bag: structuredClone(save.bag),
storage: copyStorage(save.storage),
pcItems: structuredClone(save.pcItems),
pokedex: {
seen: new Set(save.pokedex.seen),
caught: new Set(save.pokedex.caught),
@@ -3351,6 +3398,7 @@ export class GameWorld {
this.picture = undefined;
this.battle = undefined;
this.battleRun = undefined;
this.memScript = undefined;
this.clearNotice();
}
}
@@ -3376,6 +3424,8 @@ export function migrateSave(
decorations: initialDecorations(),
}),
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
@@ -3407,6 +3457,8 @@ export function migrateSave(
playerId: newPlayerId(),
party: [],
bag: emptyBag(),
storage: emptyStorage(),
pcItems: [],
pokedex: { seen: [], caught: [] },
lastSpawnMap: null,
specialPhoneCall: 0,