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 {