import type { RomReader } from "../rom/reader"; import type { PokemonData } from "../rom/pokemon"; import type { PokedexData } from "../rom/pokedex"; import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile"; import { C } from "../battle/constants"; import { changedHappiness, restoreHealth, restoredPP, } from "../battle/battle"; import { receiveItem, removeFromSlot, tossItem, type ItemSlot } from "../party/bag"; import { HM01, NUM_TMS, tmhmItem, tmhmNumber } from "../rom/pokemon"; import { calcStats, maxPP, partyMonDetails, type PartyMon } from "../party/pokemon"; import type { GameTime } from "../world/game-time"; import { PartyEvolution, evoStoneCompatible } from "./evolution"; import { copyScriptState, type FieldMoveContext, type MartEntry, type ScriptEffect, type ScriptHost, type ScriptState, } from "./interpreter"; import { Pokegear } from "./pokegear"; import { TrainerCard } from "./trainer-card"; import { Pokedex } from "./pokedex"; import { OptionsMenu, menuAccount } from "./options"; import { flyMap } from "./fly-map"; import { OW } from "../world/constants"; import { DecorationData } from "../world/decorations"; import { MAIL_LINE_LENGTH, MAILBOX_CAPACITY, validMailMessage, type Mail, } from "../party/mail"; import { readString, readText, type TextContext } from "./text"; type Input = number | string | undefined; type Gen = Generator; /** StartMenu.Items indexes (STARTMENUITEM_*). */ const STARTMENUITEM_POKEDEX = 0, STARTMENUITEM_POKEMON = 1, STARTMENUITEM_PACK = 2, STARTMENUITEM_STATUS = 3, STARTMENUITEM_SAVE = 4, STARTMENUITEM_OPTION = 5, STARTMENUITEM_EXIT = 6, STARTMENUITEM_POKEGEAR = 7; /** The pack's pockets in Pack_InterpretJoypad order (*_POCKET). */ const POCKETS = ["item", "ball", "key", "tm"] as const; type PocketIndex = 0 | 1 | 2 | 3; /** The pack's item submenus: the strings of each MenuHeader_*.MenuData * and the action of each line. */ type ItemAction = "use" | "give" | "toss" | "sel" | "quit"; const ITEM_MENUS: Record = { "MenuHeader_UsableKeyItem.MenuData": ["use", "give", "toss", "sel", "quit"], "MenuHeader_UsableItem.MenuData": ["use", "give", "toss", "quit"], "MenuHeader_UnusableItem.MenuData": ["use", "quit"], "MenuHeader_UnusableKeyItem.MenuData": ["use", "sel", "quit"], "MenuHeader_HoldableKeyItem.MenuData": ["give", "toss", "sel", "quit"], "MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"], }; const { ITEMMENU_CURRENT, ITEMMENU_PARTY, ITEMMENU_CLOSE, MONMENU_FIELD_MOVE, MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_ITEM, MONMENUITEM_CANCEL, MONMENUITEM_MOVE, MONMENUITEM_MAIL, NUM_MONMENU_ITEMS, PARTYMENUACTION_CHOOSE_POKEMON, PARTYMENUACTION_HEALING_ITEM, PARTYMENUACTION_MOVE, PARTYMENUACTION_GIVE_ITEM, PARTYMENUACTION_TEACH_TMHM, KEY_ITEM_POCKET, TM_HM_POCKET, PP_UP, SKETCH, PP_UP_MASK, PP_UP_ONE, } = C; const NOT_PORTED = "is not ported yet."; /** MAX_ITEM_STACK */ const MAX_ITEM_STACK = 99; /** MAP_RADIO_TOWER_3F and MAP_GOLDENROD_UNDERGROUND * (constants/map_constants.asm). */ const RADIO_TOWER_3F = "3:19", GOLDENROD_UNDERGROUND = "3:53"; /** wPlayerDirection's UP, as the host's facing() numbers it. */ const FACING_UP = 1; const FACING_VECTORS = [ [0, 1], [0, -1], [-1, 0], [1, 0], ] as const; /** Menu cursors in work RAM, which CleanUpBattleRAM clears after every * battle: wBattleMenuCursorPosition (the start menu's 1-based position, * shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket. * wPrevDexEntry, the Pokédex's last entry, survives battles. */ export interface MenuCursors { start: number; party: number; pocket: number; dex: number; } /** The world's verdict on a field move chosen from the party menu: a * script to run once the menu closes (with the context the world checked), * the messages of a failed attempt, a notice for what is not ported, or * Fly's map to choose a destination from. */ export type MenuFieldMove = | { script: number; fieldMove: FieldMoveContext; wild?: { species: number; level: number }; /** hLastTalked, when the move acts on a facing object. */ lastTalked?: number; /** BATTLETYPE_FISH: the wild Pokémon is on a fishing rod's hook. */ fish?: boolean; } | { texts: number[] } | { notice: string } | { flyMap: true }; /** What the start menu needs from the world. */ export interface StartMenuHost { reader: RomReader; data: PokemonData; dex: PokedexData; text(): TextContext; /** BikeFunction: the script that getting on or off the Bicycle queues * here, or undefined when .CheckEnvironment refuses it. */ bicycleScript(state: ScriptState, select: boolean): number | undefined; /** wTimeOfDay: morning 0, day 1, night 2. */ timeOfDay(): number; /** GetWorldMapLocation for the current map (LevelUpHappinessMod). */ landmark(): number; /** A field move's checks for party slot `mon`, on the menu's draft. */ fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove; /** FlyFunction after the Fly map: flying to SPAWN_* point `spawn`. */ flyTo(state: ScriptState, mon: number, spawn: number): MenuFieldMove; /** EscapeRopeFunction's checks, on the menu's draft. */ escapeRope(state: ScriptState): MenuFieldMove; /** HasVisitedSpawn: the wVisitedSpawns bit of a SPAWN_* point. */ visitedSpawn(state: ScriptState, spawn: number): boolean; /** The script host a Pokégear phone call runs with. */ scriptHost(): ScriptHost; /** GetWorldMapLocation for any map. */ landmarkOf(mapId: string): number; /** wGameTime, for the trainer card. */ gameTime(): GameTime; /** CheckForHiddenItems: an unfound hidden item on the screen. */ hiddenItemOnScreen(state: ScriptState): boolean; /** FishFunction with rod 0-2: a script, or a silent failure. */ fish(state: ScriptState, rod: number): MenuFieldMove; } /** The overworld start menu (engine/menus/start_menu.asm) with its * POKéMON party menu and submenu (engine/pokemon/mon_menu.asm, * mon_submenu.asm, party_menu.asm) and PACK (engine/items/pack.asm and * the field side of engine/items/item_effects.asm). The original draws its * own screens; here menus, lists and messages are presented natively. It * runs on a draft like any script, so a refused step changes nothing. */ export class StartMenuRun { readonly state: ScriptState; /** A script queued by an item (QueueScript), which runs after the menu * closes, with the item named as UseItem names it. */ deferred?: number; deferredItem?: number; /** The field move context and wild Pokémon a queued script needs. */ deferredMove?: Extract; /** The menu cursors kept in work RAM between openings. */ readonly cursors: MenuCursors; private strings: Record = {}; private numbers: Record = {}; private iterator: Gen; private done = false; /** wUsingItemWithSelect: the registered item is being used. */ private usingSelect = false; /** `select` runs SelectMenu (the Select button) instead of StartMenu. */ constructor( state: ScriptState, private host: StartMenuHost, cursors: MenuCursors = { start: 0, party: 0, pocket: 0, dex: 0 }, select = false, ) { this.state = copyScriptState(state); this.cursors = { ...cursors }; this.iterator = select ? this.selectMenu() : this.run(); } /** Advance until the next effect, or undefined when the menu closed. */ resume(input?: Input): ScriptEffect | undefined { if (this.done) return undefined; const next = this.iterator.next(input); if (next.done) { this.done = true; return undefined; } return next.value; } // ------------------------------------------------------------ helpers private get data() { return this.host.data; } private get reader() { return this.host.reader; } private text(): TextContext { const base = this.host.text(); return { ...base, rival: this.state.rivalName, strings: { ...base.strings, ...this.strings }, numbers: { ...base.numbers, ...this.numbers }, }; } private string(address: number) { return readString(this.reader, address, this.text()); } /** Consecutive "@"-terminated strings, as static menus store them. */ private stringList(address: number, count: number) { const r = this.reader, result: string[] = []; let p = address; for (let i = 0; i < count; i++) { result.push(this.string(p)); while (r.byte(p++) !== 0x50); } return result; } private pages(address: number) { return readText(this.reader, address, this.text()) .split("\n\n") .filter(Boolean); } /** PrintText or a menu textbox that waits for a button. */ private *print(address: number): Gen { const pages = this.pages(address); if (pages.length) yield { kind: "text", pages }; } private *yesNo(address: number): Gen { return Boolean(yield { kind: "yesno", pages: this.pages(address) }); } /** A menu of `items`; the chosen index, or -1 for B. */ private *menu( pages: string[], items: string[], details?: string[], selected = 0, ): Gen { const choice = yield { kind: "menu", pages, items, details, selected }; return typeof choice === "number" && Number.isInteger(choice) && choice >= 0 && choice < items.length ? choice : -1; } private *notice(text: string): Gen { yield { kind: "notice", text }; } /** GetItemName and CopyName1: the item in wStringBuffer1 and 2. */ private nameItem(item: number) { this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = this.data.itemName(item); } /** GetCurNickname copied to wMonOrItemNameBuffer. */ private nameMon(slot: number) { this.strings[RAM.monOrItemNameBuffer] = this.state.party[slot].nickname; } private isMail(item: number) { return this.data.itemIsMail(item); } // --------------------------------------------------------- start menu /** StartMenu.SetUpMenuItems: no Link mode or Bug-Catching Contest yet. */ private menuItems() { const items: number[] = []; if (this.state.flags.has(ENGINE_FLAGS.pokedex)) items.push(STARTMENUITEM_POKEDEX); if (this.state.party.length) items.push(STARTMENUITEM_POKEMON); items.push(STARTMENUITEM_PACK); if (this.state.flags.has(ENGINE_FLAGS.pokegear)) items.push(STARTMENUITEM_POKEGEAR); items.push( STARTMENUITEM_STATUS, STARTMENUITEM_SAVE, STARTMENUITEM_OPTION, STARTMENUITEM_EXIT, ); return items; } /** StartMenu.Items: (routine, string, description) pointers. */ private itemString(item: number, field: 1 | 2) { const r = this.reader, entry = MENU["StartMenu.Items"] + item * 6 + field * 2; return this.string(r.near(entry, r.bank(entry))); } private *run(): Gen { for (;;) { const items = this.menuItems(); const choice = yield* this.menu( [], items.map((item) => this.itemString(item, 1)), // The menu account (wOptions2) describes the highlighted item. menuAccount(this.state.options) ? items.map((item) => this.itemString(item, 2).replace("\n", " ")) : undefined, Math.min(items.length - 1, Math.max(0, this.cursors.start - 1)), ); if (choice < 0) return; this.cursors.start = choice + 1; const item = items[choice]; switch (item) { case STARTMENUITEM_POKEDEX: { // StartMenu_Pokedex needs a party and returns to the start menu. if (!this.state.party.length) break; const dex = new Pokedex( { data: this.data, dex: this.host.dex, seen: this.state.pokedex.seen, caught: this.state.pokedex.caught, }, this.cursors.dex, ); yield* dex.run(); this.cursors.dex = dex.prevEntry; break; } case STARTMENUITEM_POKEMON: if (yield* this.pokemon()) return; break; case STARTMENUITEM_PACK: if (yield* this.pack()) return; break; case STARTMENUITEM_POKEGEAR: // StartMenu_Pokegear returns to the start menu. yield* new Pokegear(this.state, { reader: this.reader, data: this.data, text: () => this.text(), scriptHost: () => this.host.scriptHost(), landmarkOf: (mapId) => this.host.landmarkOf(mapId), }).run(); break; case STARTMENUITEM_STATUS: // StartMenu_Status returns to the start menu. yield* new TrainerCard(this.state, { reader: this.reader, text: () => this.text(), player: () => this.host.scriptHost().player(), gameTime: () => this.host.gameTime(), }).run(); break; case STARTMENUITEM_OPTION: // StartMenu_Option returns to the start menu. yield* new OptionsMenu(this.state.options, { reader: this.reader, text: () => this.text(), }).run(); break; case STARTMENUITEM_EXIT: return; case STARTMENUITEM_SAVE: yield* this.notice( "The game saves itself as you play. Use the Savegames tab for backups.", ); break; default: yield* this.notice(`${this.itemString(item, 1)} ${NOT_PORTED}`); } } } // -------------------------------------------------------- select menu /** SelectMenu: use the registered item, or suggest registering one. */ private *selectMenu(): Gen { const registered = this.checkRegisteredItem(); if (!registered) return yield* this.print(MENU.MayRegisterItemText); const { item, index } = registered; // UseRegisteredItem by the item's field menu. const menu = this.data.itemFieldMenu(item); if (menu < ITEMMENU_CURRENT) return yield* this.print(MENU.ItemsOakWarningText); this.usingSelect = menu === ITEMMENU_CLOSE; const used = yield* this.doItemEffect(index, item); this.usingSelect = false; if (menu === ITEMMENU_CLOSE && used === false) yield* this.print(MENU.ItemsOakWarningText); } /** CheckRegisteredItem: the registered item if it is still in its slot * (or, for key items, anywhere in the pocket); otherwise the * registration is cleared. A registered ball is never found, because * .CheckBall's slot test is inverted. */ private checkRegisteredItem(): { item: number; index: number } | undefined { const registered = this.state.registeredItem, bag = this.state.bag; const pocket = registered.which >> 6, index = (registered.which & 0x3f) - 1; let found: { item: number; index: number } | undefined; if (registered.which) { if (pocket === 0) { if (index < bag.items.length && bag.items[index].item === registered.item) found = { item: registered.item, index }; } else if (pocket === KEY_ITEM_POCKET) { if (bag.keyItems.includes(registered.item)) found = { item: registered.item, index: -1 }; } } if (!found) this.state.registeredItem = { which: 0, item: 0 }; return found; } /** RegisterItem: CANT_SELECT items cannot be registered. */ private *registerItem(pocket: PocketIndex, index: number, item: number): Gen { if (this.data.itemUnselectable(item)) return yield* this.print(MENU.CantRegisterText); this.state.registeredItem = { which: ((pocket << 6) & 0xc0) | ((index + 1) & 0x3f), item, }; this.nameItem(item); yield* this.print(MENU.RegisteredItemText); } // --------------------------------------------------------- party menu /** The party menu with PlacePartyMenuText's prompt. Returns the party * index, or -1 when cancelled. `from` marks SwitchPartyMons' source. */ private *partyMenu( action: number, from?: number, item = 0, ): Gen { const r = this.reader, entry = MENU.PartyMenuStrings + action * 2; const prompt = this.string(r.near(entry, r.bank(entry))); const party = this.state.party; const choice = yield* this.menu( [prompt], party.map((mon, slot) => (slot === from ? `▷${mon.nickname}` : mon.nickname)), party.map((mon) => // PlacePartyMonEvoStoneCompatibility replaces the HP bar. action === C.PARTYMENUACTION_EVO_STONE ? mon.egg ? "" : this.string( evoStoneCompatible(r, mon.species, item) ? MENU["PlacePartyMonEvoStoneCompatibility.string_able"] : MENU["PlacePartyMonEvoStoneCompatibility.string_not_able"], ) : partyMonDetails(mon), ), // InitPartyMenuWithCancel: the last row chosen, if still listed. this.cursors.party >= 1 && this.cursors.party <= party.length ? this.cursors.party - 1 : 0, ); if (choice >= 0) this.cursors.party = choice + 1; return choice; } /** StartMenu_Pokemon: the party menu until B. Returns true when an action * closes every menu (a field move). */ private *pokemon(): Gen { if (!this.state.party.length) return false; for (;;) { const slot = yield* this.partyMenu(PARTYMENUACTION_CHOOSE_POKEMON); if (slot < 0) return false; if (yield* this.pokemonActionSubmenu(slot)) return true; } } /** 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.egg) return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL]; const r = this.reader, items: number[] = []; for (const move of mon.moves) { if (!move) continue; // IsFieldMove: MonMenuOptions' field moves, before the options. for (let p = MENU.MonMenuOptions; r.byte(p) === MONMENU_FIELD_MOVE; p += 3) if (r.byte(p + 2) === move) { items.push(r.byte(p + 1)); break; } } items.push( MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_MOVE, this.isMail(mon.item) ? MONMENUITEM_MAIL : MONMENUITEM_ITEM, ); if (items.length !== NUM_MONMENU_ITEMS) items.push(MONMENUITEM_CANCEL); return items; } /** GetMonMenuString: a field move's name, or MonMenuOptionStrings. */ private monMenuString(item: number) { const r = this.reader; let p = MENU.MonMenuOptions; while (r.byte(p + 1) !== item) p += 3; const value = r.byte(p + 2); if (r.byte(p) === MONMENU_FIELD_MOVE) return this.data.moveName(value); return this.stringList(MENU.MonMenuOptionStrings, value)[value - 1]; } /** PokemonActionSubmenu (MonSubmenu); B is CANCEL. Returns true when a * field move closes every menu to run its script. */ private *pokemonActionSubmenu(slot: number): Gen { const mon = this.state.party[slot]; const items = this.monSubmenuItems(mon); const choice = yield* this.menu( [mon.nickname], items.map((item) => this.monMenuString(item)), ); const item = choice < 0 ? MONMENUITEM_CANCEL : items[choice]; switch (item) { case MONMENUITEM_STATS: // OpenPartyStats yield { kind: "stats", mon }; break; case MONMENUITEM_SWITCH: yield* this.switchPartyMons(slot); break; case MONMENUITEM_ITEM: yield* this.giveTakePartyMonItem(slot); break; case MONMENUITEM_MOVE: yield* this.managePokemonMoves(slot); break; case MONMENUITEM_CANCEL: break; case MONMENUITEM_MAIL: yield* this.monMailAction(slot); break; default: return yield* this.fieldMove(slot, item); } return false; } /** MonMenu_Cut and the other field moves: Softboiled and Milk Drink run * here; the others ask the world, which checks the map. */ private *fieldMove(slot: number, item: number): Gen { const r = this.reader; let p = MENU.MonMenuOptions; while (r.byte(p + 1) !== item) p += 3; const move = r.byte(p + 2); if (move === C.SOFTBOILED || move === C.MILK_DRINK) { yield* this.softboiledMilkDrink(slot); return false; } let result = this.host.fieldMove(this.state, slot, move); if ("flyMap" in result) { // FlyFunction.TryFly: B on the map (.illegal) returns to the party // menu like a failure. const spawn = yield* flyMap( { reader: this.reader, text: () => this.text(), landmark: () => { const landmark = this.host.landmark(); const backup = this.state.backupWarp?.map; return landmark === OW.LANDMARK_SPECIAL && backup ? this.host.landmarkOf(backup) : landmark; }, visited: (spawn) => this.host.visitedSpawn(this.state, spawn), }, this.state.party[slot].species, ); if (spawn < 0) return false; result = this.host.flyTo(this.state, slot, spawn); } if ("script" in result) { this.deferred = result.script; this.deferredMove = result; return true; } if ("notice" in result) yield* this.notice(result.notice); else if ("texts" in result) for (const text of result.texts) yield* this.print(text); return false; } /** MonMenu_Softboiled_MilkDrink: the user needs more than a fifth of * its maximum HP, and gives that much to another Pokémon that has not * fainted and is not at full HP. */ private *softboiledMilkDrink(user: number): Gen { const party = this.state.party, source = party[user]; const fifth = Math.floor(source.stats[0] / 5); if (source.hp <= fifth) { yield* this.print(MENU["MonMenu_Softboiled_MilkDrink.PokemonNotEnoughHPText"]); return; } for (;;) { const target = yield* this.partyMenu(PARTYMENUACTION_HEALING_ITEM); if (target < 0) break; const mon = party[target]; if (target === user || !mon.hp || mon.hp >= mon.stats[0]) { yield* this.print( OVERWORLD["Softboiled_MilkDrinkFunction.ItemCantUseOnMonText"], ); continue; } source.hp = Math.max(0, source.hp - fifth); this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], fifth)); yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP); break; } // .skip: the cursor returns to the user. this.cursors.party = user + 1; } /** SwitchPartyMons and _SwitchPartyMons: "Move to where?" without a * CANCEL row (B still cancels); the two Pokémon trade places. */ private *switchPartyMons(from: number): Gen { const party = this.state.party; if (party.length < 2) return; const to = yield* this.partyMenu(PARTYMENUACTION_MOVE, from); if (to < 0 || to === from) return; [party[from], party[to]] = [party[to], party[from]]; } /** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */ private *giveTakePartyMonItem(slot: number): Gen { const mon = this.state.party[slot]; if (mon.egg) return; const choice = yield* this.menu( [mon.nickname], this.stringList(MENU["GiveTakeItemMenuData.Items"] + 2, 2), ); if (choice < 0) return; this.nameMon(slot); if (choice === 1) return yield* this.takePartyItem(slot); // GiveTakePartyMonItem.GiveItem: DepositSellPack until an item is // chosen that a Pokémon can hold, or B. for (;;) { const chosen = yield* this.choosePackItem(); if (!chosen) return; const { pocket, index, item } = chosen; if (pocket === KEY_ITEM_POCKET || this.data.itemUnsellable(item)) { yield* this.print(MENU.ItemCantHeldText); continue; } return yield* this.tryGiveItemToPartymon(slot, pocket, index, item); } } /** TryGiveItemToPartymon: hold the item, or offer to swap it for the one * held. GiveItemToPokemon tosses one from the pack slot it came from. */ private *tryGiveItemToPartymon( slot: number, pocket: PocketIndex, index: number, item: number, ): Gen { const mon = this.state.party[slot]; this.nameItem(item); const held = mon.item; if (!held) { this.giveItemToPokemon(pocket, index, item); yield* this.print(MENU.PokemonHoldItemText); return yield* this.givePartyItem(slot, item); } if (this.isMail(held)) return yield* this.print(MENU.PokemonRemoveMailText); this.strings[RAM.stringBuffer1] = this.data.itemName(held); if (!(yield* this.yesNo(MENU.PokemonAskSwapItemText))) return; this.giveItemToPokemon(pocket, index, item); if (!receiveItem(this.data, this.state.bag, held, 1)) { receiveItem(this.data, this.state.bag, item, 1); return yield* this.print(MENU.ItemStorageFullText); } yield* this.print(MENU.PokemonSwapItemText); yield* this.givePartyItem(slot, item); } /** GivePartyItem: the Pokémon holds the item, and MAIL gets its message * (ComposeMailMessage). */ private *givePartyItem(slot: number, item: number): Gen { const mon = this.state.party[slot]; mon.item = item; if (!this.isMail(item)) return; // _ComposeMailMessage's entry, then the author (wPlayerName), wPlayerID, // wCurPartySpecies and the MAIL (wCurItem). // The mail entry has no title above its two lines. const message = yield { kind: "name", pages: [""], current: "", max: MAIL_LINE_LENGTH, mail: true, }; if (!validMailMessage(message)) throw new Error("Invalid MAIL message."); mon.mail = { message, author: this.text().player, authorId: this.host.scriptHost().player().id, species: mon.species, type: item, }; } /** MonMailAction: READ, TAKE or QUIT for a Pokémon holding MAIL. TAKE * sends the MAIL to the PC (SendMailToPC), or puts the item in the pack * once its message may be lost. */ private *monMailAction(slot: number): Gen { const mon = this.state.party[slot]; const choice = yield* this.menu( [], this.stringList(MENU["MonMailAction.MenuData"] + 2, 3), ); if (choice === 0) return yield* this.readMail(mon.mail); if (choice !== 1) return; if (yield* this.yesNo(MENU["MonMailAction.MailAskSendToPCText"])) { if (!mon.mail || this.state.mailbox.length >= MAILBOX_CAPACITY) return yield* this.print(MENU["MonMailAction.MailboxFullText"]); this.state.mailbox.push(mon.mail); delete mon.mail; mon.item = 0; return yield* this.print(MENU["MonMailAction.MailSentToPCText"]); } // .RemoveMailToBag if (!(yield* this.yesNo(MENU["MonMailAction.MailLoseMessageText"]))) return; if (!receiveItem(this.data, this.state.bag, mon.item, 1)) return yield* this.print(MENU["MonMailAction.MailNoSpaceText"]); mon.item = 0; delete mon.mail; this.strings[RAM.stringBuffer1] = mon.nickname; yield* this.print(MENU["MonMailAction.MailDetachedText"]); } /** ReadPartyMonMail: the letter, until a button. */ private *readMail(mail: Mail | undefined): Gen { if (!mail) throw new Error("The Pokémon holds MAIL without a message."); yield { kind: "mail", mail: { ...mail } }; } private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) { if (pocket === TM_HM_POCKET) { if (!tossItem(this.state.bag, item, 1)) throw new Error("The item is not in the pack."); return; } const list = this.pocketSlots(pocket); if (!list || !removeFromSlot(list, index, item, 1)) throw new Error("The item is not in the pack."); } /** TakePartyItem: the held item goes back to the pack. */ private *takePartyItem(slot: number): Gen { const mon = this.state.party[slot]; if (!mon.item) return yield* this.print(MENU.PokemonNotHoldingText); if (!receiveItem(this.data, this.state.bag, mon.item, 1)) return yield* this.print(MENU.ItemStorageFullText); this.strings[RAM.stringBuffer1] = this.data.itemName(mon.item); mon.item = 0; yield* this.print(MENU.PokemonTookItemText); } /** ManagePokemonMoves (MoveScreenLoop): choose a move, then "Where?"; * the moves and their PP trade places. Left and right show the previous * or next party member's moves, skipping Eggs, unless a move is being * moved. */ private *managePokemonMoves(slot: number): Gen { if (this.state.party[slot].egg) return; const where = this.string(MENU.String_MoveWhere); let cursor = 0; for (;;) { // MoveScreenLoop puts the party menu cursor on wCurPartyMon. this.cursors.party = slot + 1; const mon = this.state.party[slot]; const count = mon.moves.filter(Boolean).length; const names = mon.moves.slice(0, count).map((move) => this.data.moveName(move)); const details = mon.moves .slice(0, count) .map( (move, index) => `PP ${mon.pp[index] & C.PP_MASK}/${maxPP(this.data, move, mon.pp[index] >> 6) & C.PP_MASK}`, ); const heading = `${mon.nickname} :L${mon.level}`; const answer = yield { kind: "menu", pages: [heading], items: names, details, selected: cursor, sides: true, }; if (answer === "left" || answer === "right") { const next = this.cycleMoveScreen(slot, answer); // Load2DMenuData puts the cursor back on the first move. if (next !== slot) [slot, cursor] = [next, 0]; continue; } const first = typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < names.length ? answer : -1; if (first < 0) return; const second = yield* this.menu([where], names, details, first); if (second < 0) { cursor = first; continue; } for (const list of [mon.moves, mon.pp]) [list[first], list[second]] = [list[second], list[first]]; cursor = second; } } /** MoveScreenLoop's .cycle_right and .cycle_left: the next party member * that is not an Egg; past the last one, back to the last that is not. * The original's routines call each other, so a step can come back to * where it started. */ private cycleMoveScreen(slot: number, direction: "left" | "right") { const party = this.state.party; let current = slot; 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") { current++; if (current >= party.length) step = "left"; else if (!egg(current)) step = "done"; } else { // .cycle_left if (current === 0) break; current--; if (!egg(current)) step = "done"; else if (current === 0) step = "right"; } } return current; } // --------------------------------------------------------------- pack private pocketSlots(pocket: PocketIndex): ItemSlot[] | undefined { const bag = this.state.bag; return pocket === 0 ? bag.items : pocket === 1 ? bag.balls : undefined; } /** A pocket's list: quantities for items and balls, none for key items; * TMs (with a count) and HMs by number, with the move they teach * (TMHM_DisplayPocketItems). */ private pocketEntries(pocket: PocketIndex): MartEntry[] { const bag = this.state.bag; if (pocket === TM_HM_POCKET) return bag.tmsHms.flatMap((count, index) => { if (!count) return []; const number = index + 1, item = tmhmItem(number); return [ { item, name: `${this.data.itemName(item)} ${this.data.moveName(this.data.tmhmMove(number))}`, price: 0, ...(number <= NUM_TMS && { owned: count }), }, ]; }); const slots: ItemSlot[] = pocket === KEY_ITEM_POCKET ? bag.keyItems.map((item) => ({ item, quantity: 1 })) : (this.pocketSlots(pocket) ?? []); return slots.map(({ item, quantity }) => ({ item, name: this.data.itemName(item), price: 0, ...(pocket !== KEY_ITEM_POCKET && { owned: quantity }), })); } /** The pack's pockets from wLastPocket; the chosen pocket, index and * item, or undefined for B. With `reorder` (the start menu's PACK, * Pack_InterpretJoypad), Select moves items within a pocket. */ private *choosePackItem( reorder = false, ): Gen<{ pocket: PocketIndex; index: number; item: number } | undefined> { let selected = 0; /** wSwitchItem: the entry being moved, if any. */ let switching: number | undefined; for (;;) { const answer = yield { kind: "pack", pockets: POCKETS.map((pocket, index) => ({ name: this.data.pocketName(pocket), entries: this.pocketEntries(index as PocketIndex), })), pocket: this.cursors.pocket, selected, ...(switching !== undefined && { switching, prompt: readText(this.reader, MENU.AskItemMoveText, this.text()), }), }; const select = typeof answer === "string" && /^select:\d+$/.test(answer); // DepositSellPack's joypad ignores Select. if (select && !reorder) continue; const value = select ? Number((answer as string).slice(7)) : answer; if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { // .switching_item: B ends the move and stays in the pack. if (switching === undefined) return undefined; switching = undefined; continue; } const pocket = (value >> 8) as PocketIndex, index = value & 0xff; if (pocket > TM_HM_POCKET) continue; selected = index; if (switching !== undefined) { // .place_insert: A or Select on the destination. if (pocket === this.cursors.pocket) this.switchItems(pocket, switching, index); switching = undefined; continue; } this.cursors.pocket = pocket; if (select) { // SwitchItemsInBag's .init; the TM pocket has its own joypad. if (pocket !== TM_HM_POCKET) switching = index; continue; } const item = this.pocketEntries(pocket)[index]?.item; if (item !== undefined) return { pocket, index, item }; } } /** SwitchItemsInBag placing the entry at `from` on `to`: the same item * merges into the destination's stack unless either holds 99 (the rest * stays behind when they add up to more); otherwise the entry moves to * the destination's row and the ones between shift. */ private switchItems(pocket: PocketIndex, from: number, to: number) { if (from === to) return; const list: (ItemSlot | number)[] | undefined = pocket === KEY_ITEM_POCKET ? this.state.bag.keyItems : this.pocketSlots(pocket); if (!list || from >= list.length || to >= list.length) return; const source = list[from], target = list[to]; if ( typeof source === "object" && typeof target === "object" && source.item === target.item && source.quantity !== MAX_ITEM_STACK && target.quantity !== MAX_ITEM_STACK ) { const total = source.quantity + target.quantity; target.quantity = Math.min(total, MAX_ITEM_STACK); if (total > MAX_ITEM_STACK) source.quantity = total - MAX_ITEM_STACK; else list.splice(from, 1); return; } list.splice(to, 0, ...list.splice(from, 1)); } /** StartMenu_Pack (Pack): returns true when an item closes the menus to * run a script (PACKSTATE_QUITRUNSCRIPT). */ private *pack(): Gen { for (;;) { const chosen = yield* this.choosePackItem(true); if (!chosen) return false; if (chosen.pocket === TM_HM_POCKET) yield* this.tmhmSubmenu(chosen.item); else if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item)) return true; } } /** Pack's .TMHMPocketMenu: USE, GIVE and QUIT for a TM that can be * tossed; USE and QUIT for an HM. */ private *tmhmSubmenu(item: number): Gen { const tossable = !this.data.itemUnsellable(item); const actions = tossable ? ["use", "give", "quit"] : ["use", "quit"]; const choice = yield* this.menu( [this.data.itemName(item)], this.stringList(tossable ? MENU["Pack.MenuData_2"] + 2 : MENU["Pack.MenuData_1"] + 2, actions.length), ); const action = choice < 0 ? "quit" : actions[choice]; if (action === "give") return yield* this.giveItem(TM_HM_POCKET, 0, item); if (action === "use") yield* this.teachTMHM(item); } /** AskTeachTMHM, ChooseMonToLearnTMHM and TeachTMHM: the move goes to a * compatible Pokémon that does not know it, through LearnMove. A TM is * used up and makes the Pokémon happier; an HM stays. */ private *teachTMHM(item: number): Gen { const data = this.data, number = tmhmNumber(item), move = data.tmhmMove(number), name = data.moveName(move); // GetMoveName and CopyName1. this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = name; yield* this.print(item >= HM01 ? MENU.BootedHMText : MENU.BootedTMText); if (!(yield* this.yesNo(MENU.ContainedMoveText))) return; const party = this.state.party; let slot: number; for (;;) { // PlacePartyMonTMHMCompatibility: ABLE or NOT ABLE; nothing for Eggs. const strings = MENU.PartyMenuStrings + PARTYMENUACTION_TEACH_TMHM * 2; const prompt = this.string(this.reader.near(strings, this.reader.bank(strings))); slot = yield* this.menu( [prompt], party.map((mon) => mon.nickname), party.map((mon) => mon.egg ? "" : this.string( data.canLearnTMHM(mon.species, move) ? MENU["PlacePartyMonTMHMCompatibility.string_able"] : MENU["PlacePartyMonTMHMCompatibility.string_not_able"], ), ), ); if (slot < 0) return; // An Egg plays SFX_WRONG and the menu stays. if (!party[slot].egg) break; } const mon = party[slot]; this.strings[RAM.stringBuffer1] = mon.nickname; this.strings[RAM.stringBuffer2] = name; if (!data.canLearnTMHM(mon.species, move)) return yield* this.print(MENU.TMHMNotCompatibleText); if (mon.moves.includes(move)) return yield* this.print(MENU["KnowsMove.KnowsMoveText"]); const evolution = this.evolution(); evolution.strings[RAM.stringBuffer1] = evolution.strings[RAM.stringBuffer2] = name; if (!(yield* evolution.learnMove(slot, move))) return; if (item >= HM01) return; this.changeHappiness(slot, C.HAPPINESS_LEARNMOVE); // ConsumeTM tossItem(this.state.bag, item, 1); } /** Pack.ItemBallsKey_LoadSubmenu: the submenu the item's permissions and * field menu select. */ private *itemSubmenu( pocket: PocketIndex, index: number, item: number, ): Gen { const data = this.data; const usable = data.itemFieldMenu(item) !== 0; const label = data.itemUnsellable(item) ? data.itemUnselectable(item) ? "MenuHeader_UnusableItem.MenuData" : "MenuHeader_UnusableKeyItem.MenuData" : data.itemUnselectable(item) ? usable ? "MenuHeader_UsableItem.MenuData" : "MenuHeader_HoldableItem.MenuData" : usable ? "MenuHeader_UsableKeyItem.MenuData" : "MenuHeader_HoldableKeyItem.MenuData"; const actions = ITEM_MENUS[label]; const choice = yield* this.menu( [data.itemName(item)], this.stringList(MENU[label as keyof typeof MENU] + 2, actions.length), ); switch (choice < 0 ? "quit" : actions[choice]) { case "use": return yield* this.useItem(index, item); case "give": yield* this.giveItem(pocket, index, item); return false; case "toss": yield* this.tossMenu(pocket, index, item); return false; case "sel": yield* this.registerItem(pocket, index, item); return false; default: return false; } } /** TossMenu: how many, confirm, then TossItem from the chosen slot. */ private *tossMenu(pocket: PocketIndex, index: number, item: number): Gen { const list = this.pocketSlots(pocket); const slot = list?.[index]; if (!list || slot?.item !== item) return; this.nameItem(item); const quantity = yield { kind: "martquantity", mode: "toss", item, name: this.data.itemName(item), price: 0, max: slot.quantity, prompt: readText(this.reader, MENU.AskThrowAwayText, this.text()), selected: 1, }; if ( typeof quantity !== "number" || !Number.isInteger(quantity) || quantity < 1 || quantity > slot.quantity ) return; this.nameItem(item); this.numbers[RAM.itemQuantityChange] = quantity; if (!(yield* this.yesNo(MENU.AskQuantityThrowAwayText))) return; removeFromSlot(list, index, item, quantity); this.nameItem(item); yield* this.print(MENU.ThrewAwayText); } /** The pack's GiveItem: "To which PKMN?" until a Pokémon that is not an * Egg is chosen, or B. */ private *giveItem(pocket: PocketIndex, index: number, item: number): Gen { if (!this.state.party.length) return yield* this.print(MENU.YouDontHaveAMonText); for (;;) { const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM); if (slot < 0) return; if (this.state.party[slot].egg) { yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]); continue; } this.nameMon(slot); return yield* this.tryGiveItemToPartymon(slot, pocket, index, item); } } /** UseItem: the field menu decides. Returns true when the pack quits to * run the script an ITEMMENU_CLOSE item queued. */ private *useItem(index: number, item: number): Gen { const menu = this.data.itemFieldMenu(item); if (menu < ITEMMENU_CURRENT) { yield* this.print(BATTLE.OakThisIsntTheTimeText); return false; } if (menu === ITEMMENU_PARTY && !this.state.party.length) { yield* this.print(MENU.YouDontHaveAMonText); return false; } const used = yield* this.doItemEffect(index, item); if (menu !== ITEMMENU_CLOSE) return false; if (used === false) yield* this.print(BATTLE.OakThisIsntTheTimeText); return used === true; } /** _DoItemEffect: ItemEffects by ROM address. Returns * wItemEffectSucceeded, or "unported" after a notice. */ private *doItemEffect( index: number, item: number, ): Gen { const r = this.reader; this.nameItem(item); const effect = r.near( BATTLE.ItemEffects + (item - 1) * 2, r.bank(BATTLE.ItemEffects), ); const use = { item, index }; switch (effect) { case BATTLE.RestoreHPEffect: return yield* this.statusHealerResult(yield* this.itemRestoreHP(use)); case BATTLE.EnergypowderEffect: case BATTLE.EnergyRootEffect: { const code = yield* this.itemRestoreHP(use); if (!code) yield* this.looksBitter( effect === BATTLE.EnergyRootEffect ? C.HAPPINESS_ENERGYROOT : C.HAPPINESS_BITTERPOWDER, ); return yield* this.statusHealerResult(code); } case BATTLE.StatusHealingEffect: { const target = yield* this.useItemSelectMon(); if (target < 0) return false; return yield* this.statusHealerResult( yield* this.useStatusHealer(use, target), ); } case BATTLE.HealPowderEffect: { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const code = yield* this.useStatusHealer(use, target); if (!code) yield* this.looksBitter(C.HAPPINESS_BITTERPOWDER); return yield* this.statusHealerResult(code); } case BATTLE.FullRestoreEffect: return yield* this.fullRestoreEffect(use); case BATTLE.ReviveEffect: case BATTLE.RevivalHerbEffect: { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const code = yield* this.revivePokemon(use, target); if (!code && effect === BATTLE.RevivalHerbEffect) yield* this.looksBitter(C.HAPPINESS_REVIVALHERB); return yield* this.statusHealerResult(code); } case BATTLE.RestorePPEffect: return yield* this.restorePPEffect(use); case MENU.RareCandyEffect: return yield* this.rareCandyEffect(use); case MENU.VitaminEffect: return yield* this.vitaminEffect(use); case MENU.EvoStoneEffect: return yield* this.evoStoneEffect(use); case MENU.EscapeRopeEffect: { // EscapeRopeEffect: UseDisposableItem once EscapeRopeOrDig queued // its script; a failure prints nothing before Oak's message. const result = this.host.escapeRope(this.state); if ("notice" in result) { yield* this.notice(result.notice); return "unported"; } if (!("script" in result)) return false; this.useDisposableItem(use); this.deferred = result.script; this.deferredItem = item; this.deferredMove = result; return true; } case MENU.BicycleEffect: { const script = this.host.bicycleScript(this.state, this.usingSelect); if (script === undefined) return false; this.deferred = script; this.deferredItem = item; return true; } case MENU.RepelEffect: case MENU.SuperRepelEffect: case MENU.MaxRepelEffect: return yield* this.useRepel( use, effect === MENU.RepelEffect ? 100 : effect === MENU.SuperRepelEffect ? 200 : 250, ); case MENU.CoinCaseEffect: this.numbers[RAM.coins] = this.state.coins; yield* this.print(MENU["CoinCaseEffect.CoinCaseCountText"]); return true; case MENU.BlueCardEffect: this.numbers[RAM.blueCardBalance] = this.state.blueCardBalance; yield* this.print(MENU["BlueCardEffect.BlueCardBalanceText"]); return true; 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.egg && !mon.hp)) return false; this.useDisposableItem(use); this.deferred = MENU.SacredAshScript; this.deferredItem = item; return true; case MENU.OldRodEffect: case MENU.GoodRodEffect: case MENU.SuperRodEffect: { // UseRod: FishFunction queues a script unless it fails. const result = this.host.fish( this.state, effect === MENU.OldRodEffect ? 0 : effect === MENU.GoodRodEffect ? 1 : 2, ); if (!("script" in result)) return false; this.deferred = result.script; this.deferredItem = item; this.deferredMove = result; return true; } case MENU.ItemfinderEffect: // ItemFinder: one script or the other, and always a success. this.deferred = this.host.hiddenItemOnScreen(this.state) ? OVERWORLD["ItemFinder.Script_FoundSomething"] : OVERWORLD["ItemFinder.Script_FoundNothing"]; this.deferredItem = item; return true; case MENU.CardKeyEffect: // _CardKey: facing up at the slot on Radio Tower 3F. return this.useKey( item, RADIO_TOWER_3F, { x: 18, y: 6 }, OVERWORLD["_CardKey.CardKeyScript"], FACING_UP, ); case MENU.BasementKeyEffect: // _BasementKey: facing the door in the Goldenrod Underground. return this.useKey( item, GOLDENROD_UNDERGROUND, { x: 22, y: 10 }, OVERWORLD["_BasementKey.BasementKeyScript"], ); case MENU.SquirtbottleEffect: // _Squirtbottle: its script checks for Sudowoodo. this.deferred = OVERWORLD["_Squirtbottle.SquirtbottleScript"]; this.deferredItem = item; return true; case MENU.NormalBoxEffect: case MENU.GorgeousBoxEffect: return yield* this.openBox( use, effect === MENU.NormalBoxEffect ? C.DECOFLAG_SILVER_TROPHY_DOLL : C.DECOFLAG_GOLD_TROPHY_DOLL, ); } yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`); return "unported"; } /** _CardKey and _BasementKey: the key's script runs once the menu closes * if the player faces `tile` (in wPlayerMapX/Y coordinates, which count * the map's border) on `mapId`, and `facing` if given. */ private useKey( item: number, mapId: string, tile: { x: number; y: number }, script: number, facing?: number, ): boolean { const host = this.host.scriptHost(), direction = host.facing(), [dx, dy] = FACING_VECTORS[direction], { x, y } = host.position(); if ( host.mapId !== mapId || (facing !== undefined && direction !== facing) || x + dx + 4 !== tile.x || y + dy + 4 !== tile.y ) return false; this.deferred = script; this.deferredItem = item; return true; } /** UseRepel: a Repel for `steps` steps, unless one is in effect. */ private *useRepel(use: FieldItemUse, steps: number): Gen { if (this.state.repel) { yield* this.print(MENU.RepelUsedEarlierIsStillInEffectText); return false; } this.state.repel = steps; yield* this.useItemText(use); return true; } /** UseItemText: " used the …", then UseDisposableItem. */ private *useItemText(use: FieldItemUse): Gen { yield* this.print(BATTLE.ItemUsedText); this.useDisposableItem(use); } /** OpenBox: SetSpecificDecorationFlag gives the trophy doll, which the * player sends home. */ private *openBox(use: FieldItemUse, flag: number): Gen { const r = this.reader, decoration = r.byte(OVERWORLD.DecorationIDs + flag); this.state.events.add( new DecorationData(r, this.data).attribute(decoration).event, ); yield* this.print(MENU["OpenBox.SentTrophyHomeText"]); this.useDisposableItem(use); return true; } /** UseItem_SelectMon: the party menu for an item. The party index, or * -1 when cancelled or an Egg was chosen. */ private *useItemSelectMon( action: number = PARTYMENUACTION_HEALING_ITEM, item = 0, ): Gen { const slot = yield* this.partyMenu(action, undefined, item); if (slot < 0) return -1; if (this.state.party[slot].egg) { yield* this.print(BATTLE.ItemCantUseOnEggText); return -1; } return slot; } /** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */ private *statusHealerResult(code: number): Gen { if (code === 1) yield* this.print(BATTLE.ItemWontHaveEffectText); return code === 0; } /** ItemActionTextWaitButton: PrintPartyMenuActionText for the Pokémon. */ private *itemActionText(target: number, text: number): Gen { this.strings[RAM.stringBuffer1] = this.state.party[target].nickname; const r = this.reader, table = BATTLE["PrintPartyMenuActionText.MenuActionTexts"]; yield* this.print(r.near(table + (text & 0x0f) * 2, r.bank(table))); } /** UseDisposableItem: one from the Items pocket slot it was used from. */ private useDisposableItem({ item, index }: FieldItemUse) { if (!removeFromSlot(this.state.bag.items, index, item, 1)) throw new Error("The item is not in the pack."); } /** ChangeHappiness outside battle, then LooksBitterMessage. */ private *looksBitter(action: number) { this.changeHappiness(this.cursors.party - 1, action); yield* this.print(BATTLE.ItemLooksBitterText); } private changeHappiness(slot: number, action: number) { const mon = this.state.party[slot]; if (mon.egg) return; const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2; mon.happiness = changedHappiness( mon.happiness, this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier), ); } /** The new HP, with HealHP_SFX_GFX's delta for the action text. */ private setHP(mon: PartyMon, hp: number) { this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp; mon.hp = hp; } /** ItemRestoreHP: HealingHPAmounts up to the maximum HP. */ private *itemRestoreHP(use: FieldItemUse): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return 2; const mon = this.state.party[target]; if (!mon.hp || mon.hp >= mon.stats[0]) return 1; const r = this.reader; let amount: number | undefined; for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3) if (r.byte(p) === use.item) { amount = r.word(p + 1); break; } if (amount === undefined) throw new Error("Missing healing amount."); this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], amount)); yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP); this.useDisposableItem(use); return 0; } /** GetItemHealingAction: StatusHealingActions' text and status mask. */ private healingAction(item: number) { const r = this.reader; let p = BATTLE.StatusHealingActions; while (r.byte(p) !== item) { if (r.byte(p) === 0xff) throw new Error("Missing status healing action."); p += 3; } return { text: r.byte(p + 1), mask: r.byte(p + 2) }; } /** UseStatusHealer outside battle: 0 if used, 1 if it had no effect. */ private *useStatusHealer(use: FieldItemUse, target: number): Gen { const mon = this.state.party[target]; if (!mon.hp) return 1; const { text, mask } = this.healingAction(use.item); if (!(mon.status & mask)) return 1; mon.status = 0; yield* this.itemActionText(target, text); this.useDisposableItem(use); return 0; } /** FullRestoreEffect: full HP and no status; at full HP only the status. */ private *fullRestoreEffect(use: FieldItemUse): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.state.party[target]; if (!mon.hp) return yield* this.statusHealerResult(1); if (mon.hp >= mon.stats[0]) return yield* this.statusHealerResult( yield* this.useStatusHealer(use, target), ); this.setHP(mon, mon.stats[0]); mon.status = 0; yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP); this.useDisposableItem(use); return true; } /** RevivePokemon: a Revive restores half the maximum HP, the others all * of it. 0 if used, 1 if the Pokémon had not fainted. */ private *revivePokemon(use: FieldItemUse, target: number): Gen { const mon = this.state.party[target]; if (mon.hp) return 1; this.setHP(mon, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0]); yield* this.itemActionText(target, C.PARTYMENUTEXT_REVIVE); this.useDisposableItem(use); return 0; } /** Evolution and move learning on this menu's draft. */ private evolution() { return new PartyEvolution(this.state, this.host); } /** RareCandyEffect: one level at the level's minimum experience, the * stats (and HP, even of a fainted Pokémon) raised, the level-up * happiness, new moves and a possible evolution. */ private *rareCandyEffect(use: FieldItemUse): Gen { const data = this.data; const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.state.party[target]; this.strings[RAM.stringBuffer1] = mon.nickname; if (mon.level >= C.MAX_LEVEL) { yield* this.print(BATTLE.ItemWontHaveEffectText); return false; } const level = ++mon.level; mon.exp = data.expAtLevel(data.baseData(mon.species).growthRate, level); const oldMax = mon.stats[0]; mon.stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp); mon.hp = (mon.hp + mon.stats[0] - oldMax) & 0xffff; // LevelUpHappinessMod: more on the map where it was caught. this.changeHappiness( target, (mon.caughtData & 0x7f) === this.host.landmark() ? C.HAPPINESS_GAINLEVELATHOME : C.HAPPINESS_GAINLEVEL, ); this.numbers[RAM.curPartyLevel] = level; yield* this.itemActionText(target, C.PARTYMENUTEXT_LEVEL_UP); // PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def and Speed. const labels = readString( this.reader, BATTLE["PrintTempMonStats.StatNames"], this.text(), 64, ) .split("\n") .filter(Boolean); const values = [1, 2, 4, 5, 3].map((stat) => mon.stats[stat]); yield { kind: "text", pages: [labels.map((label, i) => `${label} ${values[i]}`).join("\n")], }; const evolution = this.evolution(); yield* evolution.learnLevelMoves(target, level); // EvolvePokemon, not forced: a level or happiness evolution. yield* evolution.evolveAfterBattle(1 << target, false, use.item); this.useDisposableItem(use); return true; } /** VitaminEffect: ten more in the high byte of a stat's experience, up * to 100, then CalcMonStats (the current HP stays). */ private *vitaminEffect(use: FieldItemUse): Gen { const r = this.reader; const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.state.party[target]; this.strings[RAM.stringBuffer1] = mon.nickname; // GetStatExpRelativePointer: the stat experience word for the item. let p = MENU.StatExpItemPointerOffsets; while (r.byte(p) !== use.item) p += 2; const offset = r.byte(p + 1), stat = offset >> 1; if (mon.statExp[stat] >> 8 >= 100) { yield* this.print(BATTLE.ItemWontHaveEffectText); return false; } mon.statExp[stat] += 10 << 8; mon.stats = calcStats(this.data, mon.species, mon.dvs, mon.level, mon.statExp); const entry = MENU.StatStrings + offset; this.strings[RAM.stringBuffer2] = this.string(r.near(entry, r.bank(entry))); yield* this.print(MENU.ItemStatRoseText); this.changeHappiness(target, C.HAPPINESS_USEDITEM); this.useDisposableItem(use); return true; } /** EvoStoneEffect: a forced EvolvePokemon with the stone as wCurItem. * An Everstone or no matching evolution has no effect. */ private *evoStoneEffect(use: FieldItemUse): Gen { const target = yield* this.useItemSelectMon( C.PARTYMENUACTION_EVO_STONE, use.item, ); if (target < 0) return false; if (this.state.party[target].item === C.EVERSTONE) { yield* this.print(BATTLE.ItemWontHaveEffectText); return false; } if ( !(yield* this.evolution().evolveAfterBattle(1 << target, true, use.item)) ) { yield* this.print(BATTLE.ItemWontHaveEffectText); return false; } this.useDisposableItem(use); return true; } /** RestorePPEffect for Ethers, Elixers, the MysteryBerry and PP Up. B on * the move list goes back to the party menu. */ private *restorePPEffect(use: FieldItemUse): Gen { const data = this.data, item = use.item; for (;;) { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.state.party[target]; if (item === C.ELIXER || item === C.MAX_ELIXER) { // Elixer_RestorePPofAllMoves let restored = false; for (let slot = 0; slot < 4; slot++) if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true; if (!restored) return yield* this.statusHealerResult(1); yield* this.print(BATTLE.PPRestoredText); this.useDisposableItem(use); return true; } const ask = item === PP_UP ? MENU.RaiseThePPOfWhichMoveText : BATTLE.RestoreThePPOfWhichMoveText; for (;;) { // MoveSelectionScreen with wMoveSelectionMenuType 2. const count = mon.moves.filter(Boolean).length; const move = yield* this.menu( this.pages(ask), mon.moves.slice(0, count).map((id) => data.moveName(id)), mon.moves .slice(0, count) .map((id, slot) => `PP ${mon.pp[slot] & C.PP_MASK}/${maxPP(data, id, mon.pp[slot] >> 6) & C.PP_MASK}`), ); if (move < 0) break; this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.moveName(mon.moves[move]); if (item !== PP_UP) { if (!this.restorePP(mon, move, item)) return yield* this.statusHealerResult(1); yield* this.print(BATTLE.PPRestoredText); this.useDisposableItem(use); return true; } if (mon.moves[move] === SKETCH || mon.pp[move] >= PP_UP_MASK) { yield* this.print(MENU.PPIsMaxedOutText); continue; } // ApplyPPUp: wUsePPUp shares wTempPP's byte, so ComputeMaxPP adds // a single step (a fifth of the base PP, at most 7) to the PP. const base = data.move(mon.moves[move]).pp; mon.pp[move] = (mon.pp[move] + PP_UP_ONE + Math.min(Math.floor(base / 5), 7)) & 0xff; yield* this.print(MENU.PPsIncreasedText); this.useDisposableItem(use); return true; } } } /** RestorePP up to GetMaxPPOfMove; false if the PP was already full. */ private restorePP(mon: PartyMon, slot: number, item: number) { const max = maxPP(this.data, mon.moves[slot], mon.pp[slot] >> 6) & C.PP_MASK; const pp = restoredPP(mon.pp[slot], max, item); if (pp === undefined) return false; mon.pp[slot] = pp; return true; } } /** An item used from a pack slot (wCurItem and wCurItemQuantity). */ interface FieldItemUse { item: number; index: number; }