Open Route 31, Violet City and Sprout Tower (#31)

Enable Route 31 and its gate, Dark Cave's Violet entrance, Violet City with its buildings and Sprout Tower. Port the script commands they use: gettrainername, askforphonenumber (with AddPhoneNumber's room for Mom and Elm; addcellnum now reports failure as the original does), checkpokemail, loadmenu/_2dmenu/closewindow, giveegg and trade (NPCTrade, with wTradeFlags saved).

Eggs keep their species with an egg flag, as wPartySpecies does. The pack's TM/HM counts receive, check and toss TMs and HMs, which are named TM01-HM07.

The playthrough continues through Route 31 (with Mom's call), Violet City and Sprout Tower to Elder Li's Flash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 16:24:29 +02:00
1 parent ec3d1cd5c4
commit 7909302db1
16 files changed
+585 -63

No files matched your search

+2
View File
@@ -86,6 +86,8 @@ const names = {
pocketIsFullText: "PocketIsFullText",
findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText",
stringEgg: "String_Egg",
tmhmMoves: "TMHMMoves",
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
rivalNameString: "NamingScreenJumptable.RivalNameString",
rivalDefaultName: "NameRival.DefaultName",
+4 -4
View File
@@ -1391,7 +1391,7 @@ export class Battle {
private *useItemSelectMon(): Gen<number> {
const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true);
if (slot < 0) return -1;
if (this.options.party[slot].species === C.EGG) {
if (this.options.party[slot].egg) {
yield* this.say(BATTLE.ItemCantUseOnEggText);
return -1;
}
@@ -3465,7 +3465,7 @@ export class Battle {
}
const mon = party[this.beatUpMon];
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party.");
if (party.some((member) => member.species === C.EGG))
if (party.some((member) => member.egg))
throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet.");
// The player's members go by nickname, a trainer's by species name.
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
@@ -5467,7 +5467,7 @@ export class Battle {
const mon = this.options.party[slot];
if (mon.hp) return true;
if (!this.battleHasJustStarted)
yield* this.say(mon.species === C.EGG ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
yield* this.say(mon.egg ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
return false;
}
/** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */
@@ -5953,7 +5953,7 @@ export class Battle {
* only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
private changeHappiness(index: number, action: number) {
const mon = this.options.party[index];
if (mon.species === C.EGG) return;
if (mon.egg) return;
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness(
mon.happiness,
+29 -5
View File
@@ -1,4 +1,4 @@
import type { PokemonData, Pocket } from "../rom/pokemon";
import { NUM_TMS_HMS, isTMHM, tmhmNumber, type PokemonData, type Pocket } from "../rom/pokemon";
export interface ItemSlot {
item: number;
@@ -9,12 +9,20 @@ export interface Bag {
items: ItemSlot[];
balls: ItemSlot[];
keyItems: number[];
/** wTMsHMs: how many of each TM and HM, by TM/HM number less one. */
tmsHms: number[];
}
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
export const emptyBag = (): Bag => ({
items: [],
balls: [],
keyItems: [],
tmsHms: Array<number>(NUM_TMS_HMS).fill(0),
});
export const copyBag = (bag: Bag): Bag => ({
items: bag.items.map((slot) => ({ ...slot })),
balls: bag.balls.map((slot) => ({ ...slot })),
keyItems: [...bag.keyItems],
tmsHms: [...bag.tmsHms],
});
const MAX_ITEMS = 20,
@@ -72,7 +80,12 @@ export function receiveItem(
bag.keyItems.push(item);
return true;
}
throw new Error("TM and HM items are not ported yet.");
// ReceiveTMHM: the count, which fails past 99.
const index = tmhmNumber(item) - 1,
total = bag.tmsHms[index] + quantity;
if (total > MAX_ITEM_STACK) return false;
bag.tmsHms[index] = total;
return true;
}
/** CheckItem across the pockets. */
@@ -80,7 +93,8 @@ export function hasItem(bag: Bag, item: number): boolean {
return (
bag.items.some((slot) => slot.item === item) ||
bag.balls.some((slot) => slot.item === item) ||
bag.keyItems.includes(item)
bag.keyItems.includes(item) ||
(isTMHM(item) && bag.tmsHms[tmhmNumber(item) - 1] > 0)
);
}
@@ -120,7 +134,10 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
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"))
bag.keyItems.every((item) => itemIn(data, item, "key")) &&
Array.isArray(bag.tmsHms) &&
bag.tmsHms.length === NUM_TMS_HMS &&
bag.tmsHms.every((count) => Number.isInteger(count) && count >= 0 && count <= MAX_ITEM_STACK)
);
}
function itemIn(data: PokemonData, item: unknown, pocket: Pocket) {
@@ -134,6 +151,13 @@ function itemIn(data: PokemonData, item: unknown, pocket: Pocket) {
/** TossItem as `takeitem` uses it: only the first matching stack is used,
* and taking more than it holds fails. */
export function tossItem(bag: Bag, item: number, quantity: number): boolean {
// TossTMHM: the count, which fails if it is too small.
if (isTMHM(item)) {
const index = tmhmNumber(item) - 1;
if (bag.tmsHms[index] < quantity) return false;
bag.tmsHms[index] -= quantity;
return true;
}
const key = bag.keyItems.indexOf(item);
if (key >= 0) {
bag.keyItems.splice(key, 1);
+22 -1
View File
@@ -27,9 +27,29 @@ export interface PartyMon {
stats: [number, number, number, number, number, number];
/** sPartyMail's entry while the Pokémon holds MAIL. */
mail?: Mail;
/** An Egg: wPartySpecies holds EGG for this slot. `species` is what
* hatches, `happiness` counts the remaining egg cycles and the nickname
* is "EGG". */
egg?: true;
}
export const PARTY_LENGTH = 6;
const STATUS_NAMES: [number, string][] = [
[0x08, " PSN"],
[0x10, " BRN"],
[0x20, " FRZ"],
[0x40, " PAR"],
];
function statusName(status: number) {
if (status & 7) return " SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
/** A party menu row's level, HP and status (or FNT); an Egg shows none. */
export function partyMonDetails(mon: PartyMon): string {
if (mon.egg) return "";
return `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`;
}
/** 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;
@@ -215,7 +235,8 @@ export function validBoxFields(value: unknown): boolean {
integer(mon.happiness, 0, 255) &&
integer(mon.pokerus, 0, 255) &&
integer(mon.caughtData, 0, 0xffff) &&
integer(mon.level, 1, 100)
integer(mon.level, 1, 100) &&
(mon.egg === undefined || mon.egg === true)
);
} catch {
return false;
+1 -1
View File
@@ -91,7 +91,7 @@ export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
export function bufferMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box);
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
return { ...mon, status: 0, hp: mon.species === 0xfd ? 0 : stats[0], stats };
return { ...mon, status: 0, hp: mon.egg ? 0 : stats[0], stats };
}
/** ReceiveItem into wNumPCItems. */
+50 -1
View File
@@ -11,6 +11,32 @@ import {
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
/** The TM and HM items (constants/item_constants.asm): TM01-TM50, then
* HM01-HM07, skipping the dummy ITEM_C3 and ITEM_DC. */
export const TM01 = 0xbf,
HM01 = 0xf3,
NUM_TMS = 50,
NUM_HMS = 7,
NUM_TMS_HMS = NUM_TMS + NUM_HMS;
const ITEM_C3 = 0xc3,
ITEM_DC = 0xdc;
/** GetTMHMNumber: TM01 is 1, HM01 is 51. */
export function tmhmNumber(item: number): number {
let value = item;
if (item >= ITEM_C3) value--;
if (item >= ITEM_DC) value--;
return value - TM01 + 1;
}
/** GetNumberedTMHM: the item for a TM/HM number. */
export function tmhmItem(number: number): number {
let item = number + TM01 - 1;
if (item >= ITEM_C3) item++;
if (item >= ITEM_DC) item++;
return item;
}
export const isTMHM = (item: number) =>
item >= TM01 && item < HM01 + NUM_HMS && item !== ITEM_C3 && item !== ITEM_DC;
import type { Palette } from "../world/types";
/** data/pokemon/base_stats: one 32-byte record per species. */
@@ -131,10 +157,33 @@ export class PokemonData {
}
itemName(item: number): string {
this.checkItem(item);
if (isTMHM(item)) {
// GetTMHMName: "TM" or "HM" and two digits; HMs count from 1.
const number = tmhmNumber(item),
hm = item >= HM01;
return `${hm ? "HM" : "TM"}${String(hm ? number - NUM_TMS : number).padStart(2, "0")}`;
}
return this.cachedName(`item:${item}`, () =>
this.listName(SYMBOLS.itemNames, item),
);
}
/** GetTMHMMove: the move a TM/HM number teaches. */
tmhmMove(number: number): number {
if (!Number.isInteger(number) || number < 1 || number > NUM_TMS_HMS)
throw new Error("Unknown TM or HM.");
return this.reader.byte(SYMBOLS.tmhmMoves + number - 1);
}
/** CanLearnTMHMMove: the move's place in TMHMMoves (with the tutor
* moves after the HMs) as a bit of the species' base TM/HM flags. */
canLearnTMHM(species: number, move: number): boolean {
this.checkSpecies(species);
const r = this.reader;
let index = 0;
for (let p = SYMBOLS.tmhmMoves; r.byte(p); p++, index++)
if (r.byte(p) === move)
return Boolean(r.byte(SYMBOLS.baseData + (species - 1) * 32 + 24 + (index >> 3)) & (1 << (index & 7)));
return false;
}
private cachedName(key: string, read: () => string) {
let name = this.names.get(key);
if (name === undefined) {
@@ -144,7 +193,7 @@ export class PokemonData {
return name;
}
private checkItem(item: number) {
if (!Number.isInteger(item) || item < 1 || item > NUM_ITEMS)
if (!Number.isInteger(item) || ((item < 1 || item > NUM_ITEMS) && !isTMHM(item)))
throw new Error("Unknown item.");
}
/** ItemAttributes: price, held effect, parameter, property, pocket, menus. */
+20
View File
@@ -57,6 +57,8 @@ export const SYMBOLS = {
pocketIsFullText: 0x97060,
findItemInBallScript: 0x122ce,
caughtAskNicknameText: 0x4db44,
stringEgg: 0xe035,
tmhmMoves: 0x1167a,
nicknameStrings: 0x11780,
rivalNameString: 0x117c3,
rivalDefaultName: 0xc2b2,
@@ -194,6 +196,11 @@ export const ENGINE_FLAGS = {
/** Overworld scripts, routines and tables, keyed by their PRET labels so
* that `npm run check:reference` verifies each address by name. */
export const OVERWORLD = {
NPCTrades: 0xfce58,
TradeTexts: 0xfcf53,
NPCTradeCableText: 0xfcf7b,
TradedForText: 0xfcf80,
"TradedForText.done": 0xfcf92,
BugCatchingContestBattleScript: 0x135eb,
ContestMons: 0x97d87,
"OverworldTownMap": 0xc2c0,
@@ -1028,5 +1035,18 @@ export const NATIVE_MAPS: Record<string, string> = {
"26:8": "Evolution speech house",
"26:9": "Berry house",
"26:10": "Mr. Pokémon’s house",
"26:2": "Route 31",
"26:11": "Route 31 Violet gate",
"3:78": "Dark Cave",
"10:5": "Violet City",
"10:6": "Violet Mart",
"10:7": "Violet Gym",
"10:8": "Earl’s Pokémon Academy",
"10:9": "Nickname speech house",
"10:10": "Pokémon Center",
"10:11": "Kyle’s house",
"3:1": "Sprout Tower 1F",
"3:2": "Sprout Tower 2F",
"3:3": "Sprout Tower 3F",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+263 -9
View File
@@ -7,6 +7,7 @@ import { RomReader } from "../rom/reader";
import {
EMPTY_ROUTINES,
ENGINE_FLAGS,
MENU,
NATIVE_ROUTINES,
NUM_FRUIT_TREES,
EVENTS,
@@ -44,6 +45,7 @@ import {
caughtData,
generatePartyMon,
maxPP,
partyMonDetails,
type PartyMon,
} from "../party/pokemon";
import { copyMail, type Mail } from "../party/mail";
@@ -137,6 +139,8 @@ export interface ScriptState {
repel: number;
/** wCoins: the Coin Case's coins. */
coins: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
tradeFlags: number;
/** wBlueCardBalance: the Blue Card's points. */
blueCardBalance: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
@@ -177,6 +181,7 @@ export const emptyScriptState = (): ScriptState => ({
mailbox: [],
repel: 0,
coins: 0,
tradeFlags: 0,
blueCardBalance: 0,
options: defaultOptions(),
});
@@ -214,6 +219,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
options: { ...state.options },
...(state.bugContest && {
@@ -269,6 +275,33 @@ const UP = 1;
const ROUTE_36 = "10:3";
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
* PHONE_MOM's and PHONE_ELM's values. */
const PERMANENT_NUMBERS = [1, 4];
/** askforphonenumber's results (constants/script_constants.asm). */
const PHONE_CONTACT_GOT = 0,
PHONE_CONTACTS_FULL = 1,
PHONE_CONTACT_REFUSED = 2;
/** NPCTrades (data/events/npc_trades.asm) and its dialogs. */
const NUM_NPC_TRADES = 7,
NPCTRADE_STRUCT_LENGTH = 32,
TRADE_DIALOG_INTRO = 0,
TRADE_DIALOG_CANCEL = 1,
TRADE_DIALOG_WRONG = 2,
TRADE_DIALOG_COMPLETE = 3,
TRADE_DIALOG_AFTER = 4,
TRADE_DIALOGSET_GIRL = 3,
TRADE_GENDER_MALE = 1,
TRADE_GENDER_FEMALE = 2,
LANDMARK_GIFT = 0x7e,
PARTYMENUACTION_CHOOSE_POKEMON = 0,
PARTYMENUACTION_GIVE_MON = 6;
/** checkpokemail's results. */
const POKEMAIL_WRONG_MAIL = 0,
POKEMAIL_CORRECT = 1,
POKEMAIL_REFUSED = 2,
POKEMAIL_NO_MAIL = 3,
POKEMAIL_LAST_MON = 4;
/** 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. */
@@ -667,7 +700,10 @@ export class ScriptRun {
asm: (offset) =>
offset === OVERWORLD.UseFlashTextScript + 4
? OVERWORLD["UseFlashTextScript.BlankText"]
: undefined,
: // TradedForText: the fanfare after Text_NPCTraded.
offset === OVERWORLD.TradedForText + 4
? OVERWORLD["TradedForText.done"]
: undefined,
};
}
@@ -692,6 +728,7 @@ export class ScriptRun {
lossText = this.tempTrainer?.lostScript || undefined;
let canLose = false;
let battleType: number | undefined;
let menuHeader: number | undefined;
const temp = (offset: number, opcode: number) => {
if (!this.tempTrainer)
throw new UnsupportedScript(offset, opcode, " (no map trainer)");
@@ -1471,9 +1508,10 @@ export class ScriptRun {
break;
}
case 0x2c: {
// checkpoke reads wPartySpecies, where an Egg is EGG.
const species = byte();
value = Number(
this.state.party.some((mon) => mon.species === species),
this.state.party.some((mon) => !mon.egg && mon.species === species),
);
break;
}
@@ -1572,13 +1610,120 @@ export class ScriptRun {
picture = false;
yield synchronous({ kind: "picture", species: 0 });
break;
case 0x28: {
case 0x28:
// addcellnum: TRUE when AddPhoneNumber fails.
value = Number(!this.addPhoneNumber(byte()));
break;
case 0x97: {
// askforphonenumber: Yes/No for the text already shown, then
// AddPhoneNumber.
const answer = yield { kind: "yesno", pages: take() };
const contact = byte();
const phone = this.state.phone;
value = Number(
!phone.includes(contact) && phone.length < CONTACT_LIST_SIZE,
);
if (value) phone.push(contact);
value = !answer
? PHONE_CONTACT_REFUSED
: this.addPhoneNumber(contact)
? PHONE_CONTACT_GOT
: PHONE_CONTACTS_FULL;
break;
}
case 0x43: {
// gettrainername: GetTrainerName (the trainer's own name) into a
// string buffer.
const classId = byte(),
id = byte();
this.strings[buffer(byte())] = new TrainerData(r).trainer(classId, id).name;
break;
}
case 0x4f:
// loadmenu: the header for the next menu command.
menuHeader = near();
break;
case 0x50:
break; // closewindow: the native menu has already closed.
case 0x58: {
// _2dmenu over the loaded header's MenuData: its strings, the
// default option, and wMenuCursorPosition (0 for B).
if (menuHeader === undefined)
throw new UnsupportedScript(offset, opcode, " (no menu header)");
const bank = r.bank(menuHeader),
menuData = r.address(bank, r.word(menuHeader + 5)),
initial = r.byte(menuHeader + 7);
const shape = r.byte(menuData + 1),
count = (shape >> 4) * (shape & 0x0f);
if (r.word(menuData + 7))
throw new UnsupportedScript(offset, opcode, " (menu display routine)");
let entry = r.address(r.byte(menuData + 3), r.word(menuData + 4));
const items: string[] = [];
for (let i = 0; i < count; i++) {
items.push(readString(r, entry, this.text()));
while (r.byte(entry++) !== 0x50);
}
const answer = yield {
kind: "menu",
pages: take(),
items,
selected: Math.max(0, initial - 1),
};
if (typeof answer !== "number" || !Number.isInteger(answer) || answer >= items.length)
throw new Error("Invalid menu choice.");
value = answer < 0 ? 0 : answer + 1;
break;
}
case 0x96: {
// trade: NPCTrade.
const index = byte();
yield* this.npcTrade(index, write, take);
break;
}
case 0x30: {
// checkpokemail: SelectMonFromParty, its MAIL against the text
// (up to the text's terminator, at most two lines), then the
// Pokémon leaves the party unless it is the last that can fight.
const expected = readString(r, near(), this.text());
const slot = yield* this.selectMonFromParty(take());
const mon = this.state.party[slot];
if (!mon) value = POKEMAIL_REFUSED;
else if (!mon.item || !data.itemIsMail(mon.item) || !mon.mail) value = POKEMAIL_NO_MAIL;
else if (!mon.mail.message.startsWith(expected.slice(0, 2 * 16 + 1))) value = POKEMAIL_WRONG_MAIL;
else if (!this.state.party.some((other, index) => index !== slot && other.hp > 0))
value = POKEMAIL_LAST_MON;
else {
this.state.party.splice(slot, 1);
value = POKEMAIL_CORRECT;
}
break;
}
case 0x2e: {
// giveegg: GiveEgg. TryAddMonToParty generates the base form
// (GetPreEvolution twice) at the level, outside battle; the slot
// then holds the given species as an EGG named "EGG", with the
// egg cycles as happiness and no HP. The Pokédex is unchanged.
const species = byte(),
level = byte();
if (this.state.party.length >= PARTY_LENGTH)
throw new UnsupportedScript(offset, opcode, " (full party)");
const base = this.preEvolution(this.preEvolution(species));
const player = this.host.player();
// GeneratePartyMonStats draws the Attack/Defense byte first.
const dvs = (this.host.random() << 8) | this.host.random();
const mon = generatePartyMon(data, {
species: base,
level,
item: 0,
otId: player.id,
ot: this.host.text().player,
dvs,
caughtData: 0,
});
Object.assign(mon, {
species,
egg: true,
nickname: readString(r, SYMBOLS.stringEgg, this.text()),
happiness: data.baseData(base).eggCycles,
hp: 0,
});
this.state.party.push(mon);
value = 0;
break;
}
case 0x29: {
@@ -2177,7 +2322,7 @@ export class ScriptRun {
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
private partyMove(move: number): number {
return this.state.party.findIndex(
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
(mon) => !mon.egg && mon.moves.includes(move as never),
);
}
/** GetPartyNickname: wCurPartyMon's nickname into wStringBuffer1-3. */
@@ -2267,6 +2412,115 @@ export class ScriptRun {
};
}
/** NPCTrade: the trader's request, the Pokémon to give from the party
* menu, then TryAddMonToParty's Pokémon (two DV draws, both replaced)
* with the trade's nickname, OT, ID, DVs, item and gift caught data. The
* trade animation is not presented. */
private *npcTrade(
index: number,
write: (address: number) => void,
take: () => string[],
): Generator<ScriptEffect, void, Input> {
const r = this.reader,
data = this.host.data,
party = this.state.party;
if (index >= NUM_NPC_TRADES) throw new Error("Invalid trade.");
const entry = OVERWORLD.NPCTrades + index * NPCTRADE_STRUCT_LENGTH;
const dialogSet = r.byte(entry),
give = r.byte(entry + 1),
get = r.byte(entry + 2),
gender = r.byte(entry + 30);
// GetTradeMonNames: GetBasePokemonName drops Nidoran's symbols; the
// requested Pokémon's name takes the gender the trader wants.
const baseName = (species: number) => data.speciesName(species).replace(/[♂♀]/gu, "");
this.strings[RAM.stringBuffer2] = baseName(get);
this.strings[RAM.monOrItemNameBuffer] = baseName(give);
this.strings[RAM.stringBuffer1] =
baseName(give) + (gender === TRADE_GENDER_MALE ? "♂" : gender === TRADE_GENDER_FEMALE ? "♀" : "");
const tradeText = (dialog: number) => {
const pointer = OVERWORLD.TradeTexts + (dialog * 4 + dialogSet) * 2;
write(r.near(pointer, r.bank(pointer)));
};
const flag = 1 << index;
if (this.state.tradeFlags & flag) return tradeText(TRADE_DIALOG_AFTER);
tradeText(TRADE_DIALOG_INTRO);
if (!(yield { kind: "yesno", pages: take() })) return tradeText(TRADE_DIALOG_CANCEL);
const slot = yield* this.selectMonFromParty(take(), PARTYMENUACTION_GIVE_MON);
if (slot < 0) return tradeText(TRADE_DIALOG_CANCEL);
const mon = party[slot];
const monGender = data.gender(mon.species, mon.dvs);
if (
mon.egg ||
mon.species !== give ||
(gender === TRADE_GENDER_MALE && monGender !== "male") ||
(gender === TRADE_GENDER_FEMALE && monGender !== "female")
)
return tradeText(TRADE_DIALOG_WRONG);
this.state.tradeFlags |= flag;
write(OVERWORLD.NPCTradeCableText);
// DoNPCTrade: RemoveMonFromPartyOrBox (with any MAIL), then the new one.
party.splice(slot, 1);
this.host.random();
this.host.random();
const traded = generatePartyMon(data, {
species: get,
level: mon.level,
item: r.byte(entry + 16),
otId: r.byte(entry + 17) | (r.byte(entry + 18) << 8),
ot: readString(r, entry + 19, this.text()),
dvs: (r.byte(entry + 14) << 8) | r.byte(entry + 15),
// SetGiftPartyMonCaughtData: a girl's dialog set marks CAUGHT_BY_GIRL.
caughtData: LANDMARK_GIFT | (dialogSet >= TRADE_DIALOGSET_GIRL ? 0x80 : 0),
});
traded.nickname = readString(r, entry + 3, this.text());
party.push(traded);
this.state.pokedex.seen.add(get);
this.state.pokedex.caught.add(get);
write(OVERWORLD.TradedForText);
tradeText(TRADE_DIALOG_COMPLETE);
}
/** SelectMonFromParty (or SelectTradeOrDayCareMon with another party
* menu prompt). Answers a party index, or -1 for B. */
private *selectMonFromParty(
pages: string[],
action = PARTYMENUACTION_CHOOSE_POKEMON,
): Generator<ScriptEffect, number, Input> {
const r = this.reader,
party = this.state.party,
strings = MENU.PartyMenuStrings + action * 2;
const prompt = readString(r, r.near(strings, r.bank(strings)), this.text());
const answer = yield {
kind: "menu",
pages: [...pages, prompt],
items: party.map((mon) => mon.nickname),
details: party.map((mon) => partyMonDetails(mon)),
selected: 0,
};
if (typeof answer !== "number" || !Number.isInteger(answer) || answer >= party.length)
throw new Error("Invalid party choice.");
return answer < 0 ? -1 : answer;
}
/** AddPhoneNumber: false when the contact is already listed or no slot
* is free. GetRemainingSpaceInPhoneList keeps room for Mom and Elm until
* they are registered. */
private addPhoneNumber(contact: number): boolean {
const phone = this.state.phone;
if (phone.includes(contact)) return false;
const reserved = PERMANENT_NUMBERS.filter(
(permanent) => permanent !== contact && !phone.includes(permanent),
).length;
if (phone.length >= CONTACT_LIST_SIZE - reserved) return false;
phone.push(contact);
return true;
}
/** GetPreEvolution: the first species whose evolutions lead to this
* one, or the species itself. */
private preEvolution(species: number): number {
const data = this.host.data;
for (let from = 1; from <= 251; from++)
if (data.evolutions(from).some((evolution) => evolution.species === species)) return from;
return species;
}
/** RandomRange: a byte below `range`, rejecting the draws past its
* largest multiple, as Script_random also does. */
private randomRange(range: number) {
+3 -4
View File
@@ -66,7 +66,6 @@ const BOX_SWITCH = 0,
/** 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 {
@@ -287,7 +286,7 @@ export class PcSession {
);
if (slot < 0) return;
const mon = party[slot];
if (mon.species === EGG) {
if (mon.egg) {
yield* this.print(PC["MailboxPC.MailEggText"]);
continue;
}
@@ -698,7 +697,7 @@ export class PcSession {
}
if (action !== SUBMENU_RELEASE) break;
if (!(yield* this.partyMonMayLeave(index))) break;
if (mon.species === EGG) {
if (mon.egg) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
break;
}
@@ -751,7 +750,7 @@ export class PcSession {
continue;
}
if (action !== SUBMENU_RELEASE) break;
if (mon.species === EGG) {
if (mon.egg) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
continue;
}
+11 -22
View File
@@ -8,7 +8,7 @@ import {
restoredPP,
} from "../battle/battle";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { calcStats, maxPP, type PartyMon } from "../party/pokemon";
import { calcStats, maxPP, partyMonDetails, type PartyMon } from "../party/pokemon";
import type { GameTime } from "../world/game-time";
import { PartyEvolution, evoStoneCompatible } from "./evolution";
import {
@@ -60,7 +60,6 @@ const ITEM_MENUS: Record<string, ItemAction[]> = {
"MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"],
};
const {
EGG,
ITEMMENU_CURRENT,
ITEMMENU_PARTY,
ITEMMENU_CLOSE,
@@ -435,14 +434,14 @@ export class StartMenuRun {
party.map((mon) =>
// PlacePartyMonEvoStoneCompatibility replaces the HP bar.
action === C.PARTYMENUACTION_EVO_STONE
? mon.species === EGG
? mon.egg
? ""
: this.string(
evoStoneCompatible(r, mon.species, item)
? MENU["PlacePartyMonEvoStoneCompatibility.string_able"]
: MENU["PlacePartyMonEvoStoneCompatibility.string_not_able"],
)
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`,
: partyMonDetails(mon),
),
// InitPartyMenuWithCancel: the last row chosen, if still listed.
this.cursors.party >= 1 && this.cursors.party <= party.length
@@ -467,7 +466,7 @@ export class StartMenuRun {
/** GetMonSubmenuItems: field moves the Pokémon knows, then STATS,
* SWITCH, MOVE, ITEM or MAIL, and CANCEL if there is room. */
private monSubmenuItems(mon: PartyMon) {
if (mon.species === EGG)
if (mon.egg)
return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL];
const r = this.reader,
items: number[] = [];
@@ -621,7 +620,7 @@ export class StartMenuRun {
/** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */
private *giveTakePartyMonItem(slot: number): Gen {
const mon = this.state.party[slot];
if (mon.species === EGG) return;
if (mon.egg) return;
const choice = yield* this.menu(
[mon.nickname],
this.stringList(MENU["GiveTakeItemMenuData.Items"] + 2, 2),
@@ -751,7 +750,7 @@ export class StartMenuRun {
* or next party member's moves, skipping Eggs, unless a move is being
* moved. */
private *managePokemonMoves(slot: number): Gen {
if (this.state.party[slot].species === EGG) return;
if (this.state.party[slot].egg) return;
const where = this.string(MENU.String_MoveWhere);
let cursor = 0;
for (;;) {
@@ -807,7 +806,7 @@ export class StartMenuRun {
private cycleMoveScreen(slot: number, direction: "left" | "right") {
const party = this.state.party;
let current = slot;
const egg = (index: number) => party[index].species === EGG;
const egg = (index: number) => party[index].egg;
let step: "left" | "right" | "done" = direction;
for (let guard = 0; step !== "done" && guard < 64; guard++) {
if (step === "right") {
@@ -1021,7 +1020,7 @@ export class StartMenuRun {
for (;;) {
const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM);
if (slot < 0) return;
if (this.state.party[slot].species === EGG) {
if (this.state.party[slot].egg) {
yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]);
continue;
}
@@ -1152,7 +1151,7 @@ export class StartMenuRun {
case MENU.SacredAshEffect:
// _SacredAsh: SacredAshScript heals the party once the menu closes,
// if a Pokémon that is not an Egg has fainted (CheckAnyFaintedMon).
if (!this.state.party.some((mon) => mon.species !== EGG && !mon.hp))
if (!this.state.party.some((mon) => !mon.egg && !mon.hp))
return false;
this.useDisposableItem(use);
this.deferred = MENU.SacredAshScript;
@@ -1275,7 +1274,7 @@ export class StartMenuRun {
): Gen<number> {
const slot = yield* this.partyMenu(action, undefined, item);
if (slot < 0) return -1;
if (this.state.party[slot].species === EGG) {
if (this.state.party[slot].egg) {
yield* this.print(BATTLE.ItemCantUseOnEggText);
return -1;
}
@@ -1305,7 +1304,7 @@ export class StartMenuRun {
}
private changeHappiness(slot: number, action: number) {
const mon = this.state.party[slot];
if (mon.species === EGG) return;
if (mon.egg) return;
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness(
mon.happiness,
@@ -1563,13 +1562,3 @@ interface FieldItemUse {
index: number;
}
const STATUS_NAMES: [number, string][] = [
[0x08, " PSN"],
[0x10, " BRN"],
[0x20, " FRZ"],
[0x40, " PAR"],
];
function statusName(status: number) {
if (status & 7) return " SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
+20 -1
View File
@@ -72,6 +72,7 @@ import {
} from "../party/mail";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import { NUM_TMS_HMS } from "../rom/pokemon";
import {
copyStorage,
emptyStorage,
@@ -165,6 +166,8 @@ export interface NativeSave {
repel: number;
coins: number;
blueCardBalance: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
tradeFlags: number;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
}
@@ -3852,7 +3855,7 @@ export class GameWorld {
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
private partyMove(move: number): number {
return this.state.party.findIndex(
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
(mon) => !mon.egg && mon.moves.includes(move as never),
);
}
@@ -4280,6 +4283,7 @@ export class GameWorld {
mailbox: state.mailbox.map(copyMail),
repel: state.repel,
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
options: { ...state.options },
};
@@ -4400,6 +4404,7 @@ export class GameWorld {
!integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) ||
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.tradeFlags, 0, 0x7f) ||
!integer(save.blueCardBalance, 0, 255) ||
!validOptions(save.options)
) {
@@ -4471,6 +4476,7 @@ export class GameWorld {
mailbox: save.mailbox.map(copyMail),
repel: save.repel,
coins: save.coins,
tradeFlags: save.tradeFlags,
blueCardBalance: save.blueCardBalance,
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
@@ -4589,6 +4595,18 @@ export function migrateSave(
coins: 0,
blueCardBalance: 0,
}),
// Packs saved before the TM/HM pocket had no TMs or HMs.
...(typeof save.bag === "object" &&
save.bag !== null &&
!Object.hasOwn(save.bag, "tmsHms")
? {
bag: {
...(save.bag as object),
tmsHms: Array<number>(NUM_TMS_HMS).fill(0),
},
}
: {}),
...(!Object.hasOwn(save, "tradeFlags") && { tradeFlags: 0 }),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4641,6 +4659,7 @@ export function migrateSave(
repel: 0,
coins: 0,
blueCardBalance: 0,
tradeFlags: 0,
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),
+5 -4
View File
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
const SPAWN_NEW_BARK = 14,
SPAWN_CHERRYGROVE = 15,
SPAWN_VIOLET = 16;
SPAWN_AZALEA = 18;
const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13,
@@ -203,10 +203,11 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
it("refuses to fly to a flypoint that is not ported", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_VIOLET);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_AZALEA);
useMove(world, "FLY");
world.moveCursor("down"); // wraps to Violet City
expect(flyMap(world)?.name).toBe("VIOLET CITY");
for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
world.choose("a");
until(world);
expect(world.notice).toBe("This destination is not ported yet.");
+3 -3
View File
@@ -15,8 +15,7 @@ const romPath =
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const POKEMON_CENTER = "26:5",
CYNDAQUIL = 155,
PIDGEY = 16,
EGG = 0xfd;
PIDGEY = 16;
const POTION = 0x12,
BERRY = 0xad,
FLOWER_MAIL = 0x9e,
@@ -282,7 +281,8 @@ describe.skipIf(!existsSync(romPath))("MAIL", () => {
];
world.state.party.push({
...structuredClone(world.state.party[1]),
species: EGG,
egg: true,
nickname: "EGG",
});
mailbox(world);
expect(menuItems(world)).toEqual([
+8 -6
View File
@@ -517,14 +517,16 @@ describe.skipIf(!existsSync(romPath))(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const route = world.assets.map("26:1");
const edge = Array.from({ length: route.width }, (_, x) => x).find(
// Violet City's south edge leads to Route 32, which stays closed.
const city = world.assets.map("10:5"),
bottom = city.height - 1;
const edge = Array.from({ length: city.width }, (_, x) => x).find(
(x) =>
world.assets.permission(world.assets.collision(route, x, 0)) === 0,
world.assets.permission(world.assets.collision(city, x, bottom)) === 0,
)!;
world.restore({ ...world.save(), map: "26:1", x: edge, y: 0 });
expect(world.move("up")).toBe(false);
expect(world.notice).toBe("Route 31 is not ported yet.");
world.restore({ ...world.save(), map: "10:5", x: edge, y: bottom });
expect(world.move("down")).toBe(false);
expect(world.notice).toBe("Route 32 is not ported yet.");
const {
scenes,
phone,
+143 -1
View File
@@ -14,7 +14,23 @@ const NEW_BARK = "24:4",
ROUTE_29 = "24:3",
CHERRYGROVE = "26:3",
ROUTE_30 = "26:1",
MR_POKEMONS_HOUSE = "26:10";
MR_POKEMONS_HOUSE = "26:10",
CHERRYGROVE_CENTER = "26:5",
ROUTE_31 = "26:2",
ROUTE_31_GATE = "26:11",
VIOLET = "10:5",
VIOLET_CENTER = "10:10",
VIOLET_GYM = "10:7",
SPROUT_1F = "3:1",
SPROUT_2F = "3:2",
SPROUT_3F = "3:3";
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
EVENT_GOT_HM05_FLASH = 0x14,
EVENT_BEAT_FALKNER = 0x2bd,
EVENT_GOT_TM31_MUD_SLAP = 0x8,
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 0x48c,
ENGINE_ZEPHYRBADGE = 0x1b;
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
@@ -196,6 +212,89 @@ describe.skipIf(!existsSync(romPath))(
expect(world.save()).toEqual(saved);
};
const lead = () => world.state.party[0];
/** PokecenterNurseScript from in front of the counter, then back out. */
const heal = (center: string, city: string) => {
enter(center, "up");
walk(3, 3);
world.move("up"); // the counter: this only turns the player
world.interact();
play();
expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true);
exit(city);
};
/** Pace between two neighbouring grass tiles (the nearest pair) until
* the lead reaches `level`. `rest` heals and comes back here. */
const train = (level: number, rest: () => void) => {
const map = world.mapId,
grass = world.assets.grassCollisions();
const isGrass = (x: number, y: number) =>
x >= 0 && y >= 0 && x < world.map.width && y < world.map.height &&
grass.includes(world.assets.collision(world.map, x, y));
for (let round = 0; round < 2000 && lead().level < level; round++) {
if (lead().hp * 2 < lead().stats[0]) {
rest();
expect(world.mapId).toBe(map);
}
const pairs: { x: number; y: number; length: number }[] = [];
for (let y = 0; y < world.map.height; y++)
for (let x = 0; x < world.map.width; x++)
if (isGrass(x, y) && isGrass(x + 1, y))
try {
pairs.push({ x, y, length: findPath(world, x, y).length });
} catch {
/* unreachable */
}
pairs.sort((a, b) => a.length - b.length);
const { x, y } = pairs[0];
walk(x, y);
walk(x + 1, y);
}
expect(lead().level).toBeGreaterThanOrEqual(level);
};
/** Take the stairs (or another warp) to `target`, trying each. */
const stairs = (target: string) => {
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
walk(warp.x, warp.y);
if (world.mapId === target) return;
}
throw new Error(`No way from ${world.mapId} to ${target}`);
};
/** Find a way through warps among `maps` to `target`, as a player
* trying staircases would: depth first, backing out of dead ends. */
const route = (target: string, maps: string[], depth = 6): boolean => {
if (world.mapId === target) return true;
if (!depth) return false;
const here = world.save();
for (const warp of world.map.warps) {
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
const from = world.mapId;
walk(warp.x, warp.y);
if (world.mapId !== from && route(target, maps, depth - 1)) return true;
world.restore(here);
}
return false;
};
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
const restInViolet = () => {
exit(ROUTE_31_GATE, "left");
exit(VIOLET, "left");
heal(VIOLET_CENTER, VIOLET);
exit(ROUTE_31_GATE, "right");
exit(ROUTE_31, "right");
};
beforeEach(() => {
log.length = 0;
});
@@ -317,5 +416,48 @@ describe.skipIf(!existsSync(romPath))(
expect(save.rivalName).toBe("SILVER");
expect(save.intro).toBe(false);
});
it("trains and walks Route 30 and Route 31 to Violet City", () => {
traceFailures();
const restInCherrygrove = () => {
cross("left", CHERRYGROVE);
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
cross("right", ROUTE_29);
};
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
cross("right", ROUTE_29);
train(10, restInCherrygrove);
cross("left", CHERRYGROVE);
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
cross("up", ROUTE_30);
cross("up", ROUTE_31);
exit(ROUTE_31_GATE, "left");
// Mom worries when the player has not come home since the Egg; the
// call rings on a step after Route 31's callback queued it.
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true);
exit(VIOLET, "left");
expect(world.state.flags.size).toBeGreaterThan(0);
reload();
});
it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => {
traceFailures();
exit(ROUTE_31_GATE, "right");
exit(ROUTE_31, "right");
train(14, restInViolet);
restInViolet();
exit(ROUTE_31_GATE, "left");
exit(VIOLET, "left");
enter(SPROUT_1F, "up");
expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true);
walk(10, 3);
world.move("up"); // Elder Li: this only turns the player
world.interact();
play();
const pages = log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("FLASH");
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
reload();
});
},
);
+1 -1
View File
@@ -316,7 +316,7 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
it("shows the other party members' moves with left and right, skipping Eggs", () => {
const world = fixture();
const party = world.state.party;
party.push({ ...structuredClone(party[1]), species: 0xfd }); // EGG
party.push({ ...structuredClone(party[1]), egg: true, nickname: "EGG" });
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");