diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index b36565e..67c4d31 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -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", diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index 7638005..205fb46 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -1391,7 +1391,7 @@ export class Battle { private *useItemSelectMon(): Gen { 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, diff --git a/src/game/party/bag.ts b/src/game/party/bag.ts index 7d33442..55ef7e5 100644 --- a/src/game/party/bag.ts +++ b/src/game/party/bag.ts @@ -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(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); diff --git a/src/game/party/pokemon.ts b/src/game/party/pokemon.ts index 7fad8e1..5522925 100644 --- a/src/game/party/pokemon.ts +++ b/src/game/party/pokemon.ts @@ -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; diff --git a/src/game/party/storage.ts b/src/game/party/storage.ts index 19cb7c5..4f71407 100644 --- a/src/game/party/storage.ts +++ b/src/game/party/storage.ts @@ -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. */ diff --git a/src/game/rom/pokemon.ts b/src/game/rom/pokemon.ts index ae3ce13..420f474 100644 --- a/src/game/rom/pokemon.ts +++ b/src/game/rom/pokemon.ts @@ -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. */ diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index f88f475..b68d41d 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -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 = { "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; diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 2a2e913..8299a64 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -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 { + 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 { + 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) { diff --git a/src/game/scripts/pc.ts b/src/game/scripts/pc.ts index fa2c145..1fc0ef4 100644 --- a/src/game/scripts/pc.ts +++ b/src/game/scripts/pc.ts @@ -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; } diff --git a/src/game/scripts/start-menu.ts b/src/game/scripts/start-menu.ts index 8f1b120..66ace85 100644 --- a/src/game/scripts/start-menu.ts +++ b/src/game/scripts/start-menu.ts @@ -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 = { "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 { 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] ?? ""; -} diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 95e72c9..2552d6b 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -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(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(), diff --git a/tests/fly-dig-teleport.test.ts b/tests/fly-dig-teleport.test.ts index af2bf77..70d2d17 100644 --- a/tests/fly-dig-teleport.test.ts +++ b/tests/fly-dig-teleport.test.ts @@ -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."); diff --git a/tests/mail.test.ts b/tests/mail.test.ts index 9da0448..39c6ca2 100644 --- a/tests/mail.test.ts +++ b/tests/mail.test.ts @@ -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([ diff --git a/tests/native.test.ts b/tests/native.test.ts index 86f8fff..111832b 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -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, diff --git a/tests/playthrough.test.ts b/tests/playthrough.test.ts index 95157ee..edbc44d 100644 --- a/tests/playthrough.test.ts +++ b/tests/playthrough.test.ts @@ -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(); + }); }, ); diff --git a/tests/start-menu.test.ts b/tests/start-menu.test.ts index 3fa7f34..a72b8d4 100644 --- a/tests/start-menu.test.ts +++ b/tests/start-menu.test.ts @@ -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");