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", pocketIsFullText: "PocketIsFullText",
findItemInBallScript: "FindItemInBallScript", findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText", caughtAskNicknameText: "CaughtAskNicknameText",
stringEgg: "String_Egg",
tmhmMoves: "TMHMMoves",
nicknameStrings: "NamingScreenJumptable.NicknameStrings", nicknameStrings: "NamingScreenJumptable.NicknameStrings",
rivalNameString: "NamingScreenJumptable.RivalNameString", rivalNameString: "NamingScreenJumptable.RivalNameString",
rivalDefaultName: "NameRival.DefaultName", rivalDefaultName: "NameRival.DefaultName",
+4 -4
View File
@@ -1391,7 +1391,7 @@ export class Battle {
private *useItemSelectMon(): Gen<number> { private *useItemSelectMon(): Gen<number> {
const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true); const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true);
if (slot < 0) return -1; if (slot < 0) return -1;
if (this.options.party[slot].species === C.EGG) { if (this.options.party[slot].egg) {
yield* this.say(BATTLE.ItemCantUseOnEggText); yield* this.say(BATTLE.ItemCantUseOnEggText);
return -1; return -1;
} }
@@ -3465,7 +3465,7 @@ export class Battle {
} }
const mon = party[this.beatUpMon]; const mon = party[this.beatUpMon];
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party."); 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."); 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. // The player's members go by nickname, a trainer's by species name.
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species); this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
@@ -5467,7 +5467,7 @@ export class Battle {
const mon = this.options.party[slot]; const mon = this.options.party[slot];
if (mon.hp) return true; if (mon.hp) return true;
if (!this.battleHasJustStarted) 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; return false;
} }
/** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */ /** 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. */ * only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
private changeHappiness(index: number, action: number) { private changeHappiness(index: number, action: number) {
const mon = this.options.party[index]; 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; const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness( mon.happiness = changedHappiness(
mon.happiness, 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 { export interface ItemSlot {
item: number; item: number;
@@ -9,12 +9,20 @@ export interface Bag {
items: ItemSlot[]; items: ItemSlot[];
balls: ItemSlot[]; balls: ItemSlot[];
keyItems: number[]; 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 => ({ export const copyBag = (bag: Bag): Bag => ({
items: bag.items.map((slot) => ({ ...slot })), items: bag.items.map((slot) => ({ ...slot })),
balls: bag.balls.map((slot) => ({ ...slot })), balls: bag.balls.map((slot) => ({ ...slot })),
keyItems: [...bag.keyItems], keyItems: [...bag.keyItems],
tmsHms: [...bag.tmsHms],
}); });
const MAX_ITEMS = 20, const MAX_ITEMS = 20,
@@ -72,7 +80,12 @@ export function receiveItem(
bag.keyItems.push(item); bag.keyItems.push(item);
return true; 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. */ /** CheckItem across the pockets. */
@@ -80,7 +93,8 @@ export function hasItem(bag: Bag, item: number): boolean {
return ( return (
bag.items.some((slot) => slot.item === item) || bag.items.some((slot) => slot.item === item) ||
bag.balls.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") && validItemSlots(data, bag.balls, MAX_BALLS, "ball") &&
Array.isArray(bag.keyItems) && Array.isArray(bag.keyItems) &&
bag.keyItems.length <= MAX_KEY_ITEMS && 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) { 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, /** TossItem as `takeitem` uses it: only the first matching stack is used,
* and taking more than it holds fails. */ * and taking more than it holds fails. */
export function tossItem(bag: Bag, item: number, quantity: number): boolean { 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); const key = bag.keyItems.indexOf(item);
if (key >= 0) { if (key >= 0) {
bag.keyItems.splice(key, 1); bag.keyItems.splice(key, 1);
+22 -1
View File
@@ -27,9 +27,29 @@ export interface PartyMon {
stats: [number, number, number, number, number, number]; stats: [number, number, number, number, number, number];
/** sPartyMail's entry while the Pokémon holds MAIL. */ /** sPartyMail's entry while the Pokémon holds MAIL. */
mail?: 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; 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 /** Characters a nickname can hold: the naming screen's letters and
* symbols, plus the digits and apostrophes of default species names. */ * symbols, plus the digits and apostrophes of default species names. */
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u; 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.happiness, 0, 255) &&
integer(mon.pokerus, 0, 255) && integer(mon.pokerus, 0, 255) &&
integer(mon.caughtData, 0, 0xffff) && integer(mon.caughtData, 0, 0xffff) &&
integer(mon.level, 1, 100) integer(mon.level, 1, 100) &&
(mon.egg === undefined || mon.egg === true)
); );
} catch { } catch {
return false; 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 { export function bufferMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box); const mon = structuredClone(box);
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp); 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. */ /** ReceiveItem into wNumPCItems. */
+50 -1
View File
@@ -11,6 +11,32 @@ import {
SYMBOLS, SYMBOLS,
} from "./profile"; } from "./profile";
import { defaultTextContext, readString } from "../scripts/text"; 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"; import type { Palette } from "../world/types";
/** data/pokemon/base_stats: one 32-byte record per species. */ /** data/pokemon/base_stats: one 32-byte record per species. */
@@ -131,10 +157,33 @@ export class PokemonData {
} }
itemName(item: number): string { itemName(item: number): string {
this.checkItem(item); 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}`, () => return this.cachedName(`item:${item}`, () =>
this.listName(SYMBOLS.itemNames, 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) { private cachedName(key: string, read: () => string) {
let name = this.names.get(key); let name = this.names.get(key);
if (name === undefined) { if (name === undefined) {
@@ -144,7 +193,7 @@ export class PokemonData {
return name; return name;
} }
private checkItem(item: number) { 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."); throw new Error("Unknown item.");
} }
/** ItemAttributes: price, held effect, parameter, property, pocket, menus. */ /** ItemAttributes: price, held effect, parameter, property, pocket, menus. */
+20
View File
@@ -57,6 +57,8 @@ export const SYMBOLS = {
pocketIsFullText: 0x97060, pocketIsFullText: 0x97060,
findItemInBallScript: 0x122ce, findItemInBallScript: 0x122ce,
caughtAskNicknameText: 0x4db44, caughtAskNicknameText: 0x4db44,
stringEgg: 0xe035,
tmhmMoves: 0x1167a,
nicknameStrings: 0x11780, nicknameStrings: 0x11780,
rivalNameString: 0x117c3, rivalNameString: 0x117c3,
rivalDefaultName: 0xc2b2, rivalDefaultName: 0xc2b2,
@@ -194,6 +196,11 @@ export const ENGINE_FLAGS = {
/** Overworld scripts, routines and tables, keyed by their PRET labels so /** Overworld scripts, routines and tables, keyed by their PRET labels so
* that `npm run check:reference` verifies each address by name. */ * that `npm run check:reference` verifies each address by name. */
export const OVERWORLD = { export const OVERWORLD = {
NPCTrades: 0xfce58,
TradeTexts: 0xfcf53,
NPCTradeCableText: 0xfcf7b,
TradedForText: 0xfcf80,
"TradedForText.done": 0xfcf92,
BugCatchingContestBattleScript: 0x135eb, BugCatchingContestBattleScript: 0x135eb,
ContestMons: 0x97d87, ContestMons: 0x97d87,
"OverworldTownMap": 0xc2c0, "OverworldTownMap": 0xc2c0,
@@ -1028,5 +1035,18 @@ export const NATIVE_MAPS: Record<string, string> = {
"26:8": "Evolution speech house", "26:8": "Evolution speech house",
"26:9": "Berry house", "26:9": "Berry house",
"26:10": "Mr. Pokémon’s 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; 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 { import {
EMPTY_ROUTINES, EMPTY_ROUTINES,
ENGINE_FLAGS, ENGINE_FLAGS,
MENU,
NATIVE_ROUTINES, NATIVE_ROUTINES,
NUM_FRUIT_TREES, NUM_FRUIT_TREES,
EVENTS, EVENTS,
@@ -44,6 +45,7 @@ import {
caughtData, caughtData,
generatePartyMon, generatePartyMon,
maxPP, maxPP,
partyMonDetails,
type PartyMon, type PartyMon,
} from "../party/pokemon"; } from "../party/pokemon";
import { copyMail, type Mail } from "../party/mail"; import { copyMail, type Mail } from "../party/mail";
@@ -137,6 +139,8 @@ export interface ScriptState {
repel: number; repel: number;
/** wCoins: the Coin Case's coins. */ /** wCoins: the Coin Case's coins. */
coins: number; coins: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
tradeFlags: number;
/** wBlueCardBalance: the Blue Card's points. */ /** wBlueCardBalance: the Blue Card's points. */
blueCardBalance: number; blueCardBalance: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */ /** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
@@ -177,6 +181,7 @@ export const emptyScriptState = (): ScriptState => ({
mailbox: [], mailbox: [],
repel: 0, repel: 0,
coins: 0, coins: 0,
tradeFlags: 0,
blueCardBalance: 0, blueCardBalance: 0,
options: defaultOptions(), options: defaultOptions(),
}); });
@@ -214,6 +219,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
mailbox: state.mailbox.map(copyMail), mailbox: state.mailbox.map(copyMail),
repel: state.repel, repel: state.repel,
coins: state.coins, coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance, blueCardBalance: state.blueCardBalance,
options: { ...state.options }, options: { ...state.options },
...(state.bugContest && { ...(state.bugContest && {
@@ -269,6 +275,33 @@ const UP = 1;
const ROUTE_36 = "10:3"; const ROUTE_36 = "10:3";
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */ /** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10; 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. */ /** 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. */ /** The mart's lists and quantity box, which the PC's item storage reuses. */
@@ -667,7 +700,10 @@ export class ScriptRun {
asm: (offset) => asm: (offset) =>
offset === OVERWORLD.UseFlashTextScript + 4 offset === OVERWORLD.UseFlashTextScript + 4
? OVERWORLD["UseFlashTextScript.BlankText"] ? 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; lossText = this.tempTrainer?.lostScript || undefined;
let canLose = false; let canLose = false;
let battleType: number | undefined; let battleType: number | undefined;
let menuHeader: number | undefined;
const temp = (offset: number, opcode: number) => { const temp = (offset: number, opcode: number) => {
if (!this.tempTrainer) if (!this.tempTrainer)
throw new UnsupportedScript(offset, opcode, " (no map trainer)"); throw new UnsupportedScript(offset, opcode, " (no map trainer)");
@@ -1471,9 +1508,10 @@ export class ScriptRun {
break; break;
} }
case 0x2c: { case 0x2c: {
// checkpoke reads wPartySpecies, where an Egg is EGG.
const species = byte(); const species = byte();
value = Number( value = Number(
this.state.party.some((mon) => mon.species === species), this.state.party.some((mon) => !mon.egg && mon.species === species),
); );
break; break;
} }
@@ -1572,13 +1610,120 @@ export class ScriptRun {
picture = false; picture = false;
yield synchronous({ kind: "picture", species: 0 }); yield synchronous({ kind: "picture", species: 0 });
break; 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 contact = byte();
const phone = this.state.phone; value = !answer
value = Number( ? PHONE_CONTACT_REFUSED
!phone.includes(contact) && phone.length < CONTACT_LIST_SIZE, : this.addPhoneNumber(contact)
); ? PHONE_CONTACT_GOT
if (value) phone.push(contact); : 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; break;
} }
case 0x29: { case 0x29: {
@@ -2177,7 +2322,7 @@ export class ScriptRun {
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */ /** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
private partyMove(move: number): number { private partyMove(move: number): number {
return this.state.party.findIndex( 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. */ /** 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 /** RandomRange: a byte below `range`, rejecting the draws past its
* largest multiple, as Script_random also does. */ * largest multiple, as Script_random also does. */
private randomRange(range: number) { 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). */ /** DelayFrames after a Bill's PC message, in overworld ticks (two frames). */
const MESSAGE_TICKS = 25, const MESSAGE_TICKS = 25,
RELEASED_TICKS = 40; RELEASED_TICKS = 40;
const EGG = 0xfd;
/** A pack entry and where it came from, for TossItem's wCurItemQuantity. */ /** A pack entry and where it came from, for TossItem's wCurItemQuantity. */
interface PackSlot extends ItemSlot { interface PackSlot extends ItemSlot {
@@ -287,7 +286,7 @@ export class PcSession {
); );
if (slot < 0) return; if (slot < 0) return;
const mon = party[slot]; const mon = party[slot];
if (mon.species === EGG) { if (mon.egg) {
yield* this.print(PC["MailboxPC.MailEggText"]); yield* this.print(PC["MailboxPC.MailEggText"]);
continue; continue;
} }
@@ -698,7 +697,7 @@ export class PcSession {
} }
if (action !== SUBMENU_RELEASE) break; if (action !== SUBMENU_RELEASE) break;
if (!(yield* this.partyMonMayLeave(index))) break; if (!(yield* this.partyMonMayLeave(index))) break;
if (mon.species === EGG) { if (mon.egg) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS)); yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
break; break;
} }
@@ -751,7 +750,7 @@ export class PcSession {
continue; continue;
} }
if (action !== SUBMENU_RELEASE) break; if (action !== SUBMENU_RELEASE) break;
if (mon.species === EGG) { if (mon.egg) {
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS)); yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
continue; continue;
} }
+11 -22
View File
@@ -8,7 +8,7 @@ import {
restoredPP, restoredPP,
} from "../battle/battle"; } from "../battle/battle";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag"; 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 type { GameTime } from "../world/game-time";
import { PartyEvolution, evoStoneCompatible } from "./evolution"; import { PartyEvolution, evoStoneCompatible } from "./evolution";
import { import {
@@ -60,7 +60,6 @@ const ITEM_MENUS: Record<string, ItemAction[]> = {
"MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"], "MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"],
}; };
const { const {
EGG,
ITEMMENU_CURRENT, ITEMMENU_CURRENT,
ITEMMENU_PARTY, ITEMMENU_PARTY,
ITEMMENU_CLOSE, ITEMMENU_CLOSE,
@@ -435,14 +434,14 @@ export class StartMenuRun {
party.map((mon) => party.map((mon) =>
// PlacePartyMonEvoStoneCompatibility replaces the HP bar. // PlacePartyMonEvoStoneCompatibility replaces the HP bar.
action === C.PARTYMENUACTION_EVO_STONE action === C.PARTYMENUACTION_EVO_STONE
? mon.species === EGG ? mon.egg
? "" ? ""
: this.string( : this.string(
evoStoneCompatible(r, mon.species, item) evoStoneCompatible(r, mon.species, item)
? MENU["PlacePartyMonEvoStoneCompatibility.string_able"] ? MENU["PlacePartyMonEvoStoneCompatibility.string_able"]
: MENU["PlacePartyMonEvoStoneCompatibility.string_not_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. // InitPartyMenuWithCancel: the last row chosen, if still listed.
this.cursors.party >= 1 && this.cursors.party <= party.length 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, /** GetMonSubmenuItems: field moves the Pokémon knows, then STATS,
* SWITCH, MOVE, ITEM or MAIL, and CANCEL if there is room. */ * SWITCH, MOVE, ITEM or MAIL, and CANCEL if there is room. */
private monSubmenuItems(mon: PartyMon) { private monSubmenuItems(mon: PartyMon) {
if (mon.species === EGG) if (mon.egg)
return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL]; return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL];
const r = this.reader, const r = this.reader,
items: number[] = []; items: number[] = [];
@@ -621,7 +620,7 @@ export class StartMenuRun {
/** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */ /** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */
private *giveTakePartyMonItem(slot: number): Gen { private *giveTakePartyMonItem(slot: number): Gen {
const mon = this.state.party[slot]; const mon = this.state.party[slot];
if (mon.species === EGG) return; if (mon.egg) return;
const choice = yield* this.menu( const choice = yield* this.menu(
[mon.nickname], [mon.nickname],
this.stringList(MENU["GiveTakeItemMenuData.Items"] + 2, 2), 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 * or next party member's moves, skipping Eggs, unless a move is being
* moved. */ * moved. */
private *managePokemonMoves(slot: number): Gen { 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); const where = this.string(MENU.String_MoveWhere);
let cursor = 0; let cursor = 0;
for (;;) { for (;;) {
@@ -807,7 +806,7 @@ export class StartMenuRun {
private cycleMoveScreen(slot: number, direction: "left" | "right") { private cycleMoveScreen(slot: number, direction: "left" | "right") {
const party = this.state.party; const party = this.state.party;
let current = slot; let current = slot;
const egg = (index: number) => party[index].species === EGG; const egg = (index: number) => party[index].egg;
let step: "left" | "right" | "done" = direction; let step: "left" | "right" | "done" = direction;
for (let guard = 0; step !== "done" && guard < 64; guard++) { for (let guard = 0; step !== "done" && guard < 64; guard++) {
if (step === "right") { if (step === "right") {
@@ -1021,7 +1020,7 @@ export class StartMenuRun {
for (;;) { for (;;) {
const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM); const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM);
if (slot < 0) return; if (slot < 0) return;
if (this.state.party[slot].species === EGG) { if (this.state.party[slot].egg) {
yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]); yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]);
continue; continue;
} }
@@ -1152,7 +1151,7 @@ export class StartMenuRun {
case MENU.SacredAshEffect: case MENU.SacredAshEffect:
// _SacredAsh: SacredAshScript heals the party once the menu closes, // _SacredAsh: SacredAshScript heals the party once the menu closes,
// if a Pokémon that is not an Egg has fainted (CheckAnyFaintedMon). // 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; return false;
this.useDisposableItem(use); this.useDisposableItem(use);
this.deferred = MENU.SacredAshScript; this.deferred = MENU.SacredAshScript;
@@ -1275,7 +1274,7 @@ export class StartMenuRun {
): Gen<number> { ): Gen<number> {
const slot = yield* this.partyMenu(action, undefined, item); const slot = yield* this.partyMenu(action, undefined, item);
if (slot < 0) return -1; if (slot < 0) return -1;
if (this.state.party[slot].species === EGG) { if (this.state.party[slot].egg) {
yield* this.print(BATTLE.ItemCantUseOnEggText); yield* this.print(BATTLE.ItemCantUseOnEggText);
return -1; return -1;
} }
@@ -1305,7 +1304,7 @@ export class StartMenuRun {
} }
private changeHappiness(slot: number, action: number) { private changeHappiness(slot: number, action: number) {
const mon = this.state.party[slot]; 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; const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness( mon.happiness = changedHappiness(
mon.happiness, mon.happiness,
@@ -1563,13 +1562,3 @@ interface FieldItemUse {
index: number; 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"; } from "../party/mail";
import type { MovementPose, MovementStep } from "../scripts/movement"; import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag"; import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import { NUM_TMS_HMS } from "../rom/pokemon";
import { import {
copyStorage, copyStorage,
emptyStorage, emptyStorage,
@@ -165,6 +166,8 @@ export interface NativeSave {
repel: number; repel: number;
coins: number; coins: number;
blueCardBalance: 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. */ /** The option bytes, saved with the game as sOptions is. */
options: GameOptions; options: GameOptions;
} }
@@ -3852,7 +3855,7 @@ export class GameWorld {
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */ /** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
private partyMove(move: number): number { private partyMove(move: number): number {
return this.state.party.findIndex( 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), mailbox: state.mailbox.map(copyMail),
repel: state.repel, repel: state.repel,
coins: state.coins, coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance, blueCardBalance: state.blueCardBalance,
options: { ...state.options }, options: { ...state.options },
}; };
@@ -4400,6 +4404,7 @@ export class GameWorld {
!integer(save.registeredItem.item, 0, 255) || !integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) || !integer(save.repel, 0, 255) ||
!integer(save.coins, 0, C.MAX_COINS) || !integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.tradeFlags, 0, 0x7f) ||
!integer(save.blueCardBalance, 0, 255) || !integer(save.blueCardBalance, 0, 255) ||
!validOptions(save.options) !validOptions(save.options)
) { ) {
@@ -4471,6 +4476,7 @@ export class GameWorld {
mailbox: save.mailbox.map(copyMail), mailbox: save.mailbox.map(copyMail),
repel: save.repel, repel: save.repel,
coins: save.coins, coins: save.coins,
tradeFlags: save.tradeFlags,
blueCardBalance: save.blueCardBalance, blueCardBalance: save.blueCardBalance,
options: { ...save.options }, options: { ...save.options },
specialPhoneCall: save.specialPhoneCall, specialPhoneCall: save.specialPhoneCall,
@@ -4589,6 +4595,18 @@ export function migrateSave(
coins: 0, coins: 0,
blueCardBalance: 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, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }), ...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }), ...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4641,6 +4659,7 @@ export function migrateSave(
repel: 0, repel: 0,
coins: 0, coins: 0,
blueCardBalance: 0, blueCardBalance: 0,
tradeFlags: 0,
intro: false, intro: false,
gameTime: emptyGameTime(), gameTime: emptyGameTime(),
options: defaultOptions(), options: defaultOptions(),
+5 -4
View File
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
DARK_CAVE_VIOLET_ENTRANCE = "3:78"; DARK_CAVE_VIOLET_ENTRANCE = "3:78";
const SPAWN_NEW_BARK = 14, const SPAWN_NEW_BARK = 14,
SPAWN_CHERRYGROVE = 15, SPAWN_CHERRYGROVE = 15,
SPAWN_VIOLET = 16; SPAWN_AZALEA = 18;
const ENGINE_STORMBADGE = 32, const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64; EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13, 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", () => { it("refuses to fly to a flypoint that is not ported", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10); const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE); 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"); useMove(world, "FLY");
world.moveCursor("down"); // wraps to Violet City for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
expect(flyMap(world)?.name).toBe("VIOLET CITY"); world.moveCursor("down");
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
world.choose("a"); world.choose("a");
until(world); until(world);
expect(world.notice).toBe("This destination is not ported yet."); 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"; "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const POKEMON_CENTER = "26:5", const POKEMON_CENTER = "26:5",
CYNDAQUIL = 155, CYNDAQUIL = 155,
PIDGEY = 16, PIDGEY = 16;
EGG = 0xfd;
const POTION = 0x12, const POTION = 0x12,
BERRY = 0xad, BERRY = 0xad,
FLOWER_MAIL = 0x9e, FLOWER_MAIL = 0x9e,
@@ -282,7 +281,8 @@ describe.skipIf(!existsSync(romPath))("MAIL", () => {
]; ];
world.state.party.push({ world.state.party.push({
...structuredClone(world.state.party[1]), ...structuredClone(world.state.party[1]),
species: EGG, egg: true,
nickname: "EGG",
}); });
mailbox(world); mailbox(world);
expect(menuItems(world)).toEqual([ expect(menuItems(world)).toEqual([
+8 -6
View File
@@ -517,14 +517,16 @@ describe.skipIf(!existsSync(romPath))(
load(), load(),
() => new Date("2026-09-22T12:00:00"), () => new Date("2026-09-22T12:00:00"),
); );
const route = world.assets.map("26:1"); // Violet City's south edge leads to Route 32, which stays closed.
const edge = Array.from({ length: route.width }, (_, x) => x).find( const city = world.assets.map("10:5"),
bottom = city.height - 1;
const edge = Array.from({ length: city.width }, (_, x) => x).find(
(x) => (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 }); world.restore({ ...world.save(), map: "10:5", x: edge, y: bottom });
expect(world.move("up")).toBe(false); expect(world.move("down")).toBe(false);
expect(world.notice).toBe("Route 31 is not ported yet."); expect(world.notice).toBe("Route 32 is not ported yet.");
const { const {
scenes, scenes,
phone, phone,
+143 -1
View File
@@ -14,7 +14,23 @@ const NEW_BARK = "24:4",
ROUTE_29 = "24:3", ROUTE_29 = "24:3",
CHERRYGROVE = "26:3", CHERRYGROVE = "26:3",
ROUTE_30 = "26:1", 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 /** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs, * introduction to the catching tutorial through the world's own inputs,
@@ -196,6 +212,89 @@ describe.skipIf(!existsSync(romPath))(
expect(world.save()).toEqual(saved); 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(() => { beforeEach(() => {
log.length = 0; log.length = 0;
}); });
@@ -317,5 +416,48 @@ describe.skipIf(!existsSync(romPath))(
expect(save.rivalName).toBe("SILVER"); expect(save.rivalName).toBe("SILVER");
expect(save.intro).toBe(false); 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", () => { it("shows the other party members' moves with left and right, skipping Eggs", () => {
const world = fixture(); const world = fixture();
const party = world.state.party; const party = world.state.party;
party.push({ ...structuredClone(party[1]), species: 0xfd }); // EGG party.push({ ...structuredClone(party[1]), egg: true, nickname: "EGG" });
open(world); open(world);
pick(world, "POKéMON"); pick(world, "POKéMON");
pick(world, "CYNDAQUIL"); pick(world, "CYNDAQUIL");