import { RomReader } from "../rom/reader"; import { BATTLE, RAM, SYMBOLS } from "../rom/profile"; import type { PokemonData } from "../rom/pokemon"; import type { Bag } from "../party/bag"; import { MONS_PER_BOX, currentBox, restorePP, type BoxMon, type PokemonStorage, } from "../party/storage"; import { NICKNAME_PATTERN, PARTY_LENGTH, calcStats, levelForExp, maxPP, type PartyMon, } from "../party/pokemon"; import { readString, readTextEnd, type TextContext } from "../scripts/text"; import { BattleData } from "./data"; import { C, COMMAND } from "./constants"; import { chooseTrainerMove, validateTrainerAI } from "./ai"; /** Raised for battle rules that have not been translated. The battle is * discarded, as if it never started. */ export class UnsupportedBattle extends Error {} /** A battle_struct: the Pokémon on the field, with stage-modified stats. */ export interface BattleMon { species: number; item: number; moves: number[]; dvs: number; pp: number[]; happiness: number; level: number; status: number; hp: number; maxHp: number; /** Attack, Defense, Speed, Sp. Atk, Sp. Def after stages and status. */ stats: number[]; types: [number, number]; nickname: string; } interface MoveStruct { anim: number; effect: number; power: number; type: number; accuracy: number; pp: number; chance: number; } /** One side's battle variables (wPlayer* or wEnemy*). */ class Side { mon!: BattleMon; /** Unmodified stats (wPlayerStats), used for stages and criticals. */ stats = [0, 0, 0, 0, 0]; levels = [7, 7, 7, 7, 7, 7, 7]; /** SubStatus1-5; index 0 is unused so indices match the names. */ sub = [0, 0, 0, 0, 0, 0]; move: MoveStruct = { anim: 0, effect: 0, power: 0, type: 0, accuracy: 0, pp: 0, chance: 0 }; curMove = 0; moveNum = 0; confuseCount = 0; toxicCount = 0; disableCount = 0; disabledMove = 0; encoreCount = 0; rolloutCount = 0; furyCutterCount = 0; protectCount = 0; rageCounter = 0; wrapCount = 0; damageTaken = 0; screens = 0; lightScreenCount = 0; reflectCount = 0; turnsTaken = 0; charging = 0; lastMove = 0; lastCounterMove = 0; minimized = 0; justGotFrozen = 0; has(byte: number, bit: number) { return Boolean(this.sub[byte] & (1 << bit)); } set(byte: number, bit: number) { this.sub[byte] |= 1 << bit; } reset(byte: number, bit: number) { this.sub[byte] &= ~(1 << bit); } } export type BattleEffect = /** A battle message; prompt texts wait for a button, others continue. */ | { kind: "text"; text: string; prompt: boolean } /** HP, status or level changed; renderers re-read the battle state. */ | { kind: "hud" } /** The battle menu: answered with an action. */ | { kind: "action" } | { kind: "yesno"; text: string } /** Choose a move to forget (0-3); 4 cancels, like B on the list. */ | { kind: "forget"; moves: number[]; learning: number } /** The level-up stat box (PrintTempMonStats), acknowledged with a button. */ | { kind: "stats"; labels: string[]; values: number[] } /** The naming screen for a caught Pokémon: answered with the name, or "" * to keep `current`. */ | { kind: "name"; title: string; current: string; max: number } /** The battle party menu: answered with a party index, or -1 to go back * when `cancel` allows it. With `menu`, the choice goes through the * SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */ | { kind: "party"; text: string; menu: boolean; cancel: boolean } /** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */ | { kind: "move"; text: string; moves: number[]; pp: number[] }; /** An item from a pack slot (wCurItem and wCurItemQuantity). */ type ItemUse = { item: number; slot: number }; export type BattleAction = | { action: "fight"; move: number } | { action: "run" } /** PKMN: open the party menu to switch. */ | { action: "pkmn" } /** PACK: use the item in this slot of its pocket (only Balls so far). */ | { action: "item"; item: number; slot: number }; export type BattleInput = number | string | BattleAction | undefined; /** wBattleResult, the prize money and wEvolvableFlags (a bit per party * index that levelled up). */ export type BattleOutcome = { result: number; prizeMoney?: number; /** The part of the prize sent to Mom's savings. */ momMoney?: number; evolvable?: number; }; export interface BattleTrainer { name: string; className: string; /** wOTPartyMons: HP and status change only when a Pokémon faints or is * withdrawn, as in the original. */ party: PartyMon[]; aiFlags: number; /** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */ switchFlags: number; reward: number; winText?: number; lossText?: number; } type Gen = Generator; export interface BattleOptions { data: PokemonData; party: PartyMon[]; pokedex: { seen: Set; caught: Set }; playerId: number; playerName: string; /** wTimeOfDay: morning 0, day 1, night 2. */ timeOfDay: number; random: () => number; wild?: { species: number; level: number }; trainer?: BattleTrainer; canLose?: boolean; text: () => TextContext; /** The player's pack, needed to throw Poké Balls. */ bag?: Bag; /** Bill's PC: a catch with a full party goes to the current box. */ storage?: PokemonStorage; /** ENGINE_POKEDEX: a first catch shows the new-data message. */ hasPokedex?: boolean; /** SetCaughtData's word for a Pokémon caught at this level. */ caughtData?: (level: number) => number; /** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */ tutorial?: boolean; /** BATTLETYPE_TREE: a wild Pokémon that fell from a Headbutt tree. */ tree?: boolean; /** GetWorldMapLocation for the current map (LevelUpHappinessMod). */ landmark?: number; /** wPartyMenuCursor as the last party menu outside battle left it. */ partyMenuCursor?: number; /** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much * of a trainer's prize goes to Mom. */ mom?: { saving: number; money: number }; /** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */ shift?: boolean; } /** BATTLERESULT_BOX_FULL: a catch filled the current box. */ const BATTLERESULT_BOX_FULL = 0x80; const SUB1 = 1, SUB2 = 2, SUB3 = 3, SUB4 = 4, SUB5 = 5; /** "n percent" in the original: n * $ff / 100, rounded down. */ const percent = (n: number) => Math.floor((n * 0xff) / 100); /** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */ const SWITCH_OFTEN = 1 << 0, SWITCH_RARELY = 1 << 1, SWITCH_SOMETIMES = 1 << 2; /** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam * tier ($10, $20, $30), a Random threshold and whether a roll below it * switches (true) or keeps the Pokémon in (false). */ const SWITCH_CHANCES: readonly (readonly [number, boolean])[][] = [ [[percent(50) + 1, true], [percent(79) - 1, true], [percent(4), false]], [[percent(8), true], [percent(12), true], [percent(79) - 1, false]], [[percent(20) - 1, true], [percent(50) + 1, true], [percent(20) - 1, false]], ]; export interface TypeMatchup { attacker: number; defender: number; multiplier: number; } /** BattleCommand_DamageCalc's arithmetic. `boost` is a type-boosting held * item's percentage; `current` is the damage already in wCurDamage. * Returns undefined when the original leaves wCurDamage unchanged. */ export function calculateDamage(input: { power: number; level: number; attack: number; defense: number; effect: number; critical: boolean; boost?: number; current: number; }): number | undefined { let { defense } = input; const { power, effect } = input; // Selfdestruct and Explosion halve defense. if (effect === C.EFFECT_SELFDESTRUCT) defense = defense >> 1 || 1; if (effect !== C.EFFECT_MULTI_HIT && effect !== C.EFFECT_CONVERSION && !power) return undefined; if (!defense) defense = 1; let value = Math.floor(((input.level * 2) & 0x1ff) / 5) + 2; value = Math.floor((value * power * input.attack) / defense); value = Math.floor(value / 50); if (input.boost !== undefined) value = Math.floor((value * (input.boost + 100)) / 100); if (input.critical) { // Only the low 16 bits are doubled, capped at $ffff. const high = value - (value & 0xffff); const doubled = (value & 0xffff) * 2; value = high + (doubled > 0xffff ? 0xffff : doubled); } // Add to wCurDamage, capped at 997, then add 2. The original first adds // the high byte of wCurDamage to the result's low byte. const current = input.current; let low = (value & 0xff) + (current >> 8); let high = (value >> 8) & 0xff; if (low > 0xff) { low &= 0xff; high = (high + 1) & 0xff; if (!high) return 999; } if ((value >> 16) & 0xff) return 999; // only hQuotient + 1 is checked const result = (high << 8) | low; if (result >= 998) return 999; const total = result + current; if (total > 0xffff || total >= 998) return 999; return total + 2; } /** BattleCommand_Stab and BattleCheckTypeMatchup. */ export function applyTypeMatchups( matchups: readonly TypeMatchup[], input: { damage: number; modifier: number; moveType: number; userTypes: readonly number[]; targetTypes: readonly number[]; identified: boolean; }, ): { damage: number; modifier: number; matchup: number; missed: boolean } { let { damage, modifier } = input; let missed = false; const type = input.moveType; if (type === input.userTypes[0] || type === input.userTypes[1]) { damage = (damage + (damage >> 1)) & 0xffff; modifier |= C.STAB_DAMAGE; } const hits = (row: TypeMatchup) => row.attacker === type && (row.defender === input.targetTypes[0] || row.defender === input.targetTypes[1]); for (const row of matchups) { // Rows after the -2 marker (Ghost immunities) stop under Foresight. if (row.attacker === 0xfe) { if (input.identified) break; continue; } if (!hits(row)) continue; if (!row.multiplier) missed = true; modifier = (modifier & C.STAB_DAMAGE) + row.multiplier; const product = damage * row.multiplier; damage = product ? Math.floor(product / 10) & 0xffff || 1 : 0; } let matchup: number = C.EFFECTIVE; for (const row of matchups) { if (row.attacker === 0xfe) { if (input.identified) break; continue; } if (hits(row)) matchup = Math.floor((row.multiplier * matchup) / 10) & 0xff; } return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed }; } /** A native translation of Crystal's battle engine. * The move effect scripts are the ROM's own; each battle command is a * native translation. Unsupported AI, switching, contest and item paths are refused. */ /** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in * the original 8- and 16-bit registers. Returns undefined when the * formula would divide by zero (e.g. maximum HP of 342 or 683). */ export function catchRate(input: { rate: number; ball: number; hp: number; maxHp: number; status: number; /** The player's battle Pokémon's held item effect. */ held: { effect: number; parameter: number }; }): number | undefined { let rate = input.rate; // BallMultiplierFunctionTable if (input.ball === C.ULTRA_BALL) rate = Math.min(0xff, rate << 1); else if (input.ball === C.GREAT_BALL) rate = Math.min(0xff, rate + (rate >> 1)); // A maximum HP above 341 overflows the HP term (a kept bug). let hp = (input.hp << 1) & 0xffff, max = (input.maxHp * 3) & 0xffff; if (max >> 8) { max >>= 2; hp >>= 2; if (!(hp & 0xff)) hp |= 1; } const divisor = max & 0xff, remaining = hp & 0xff; if (!divisor) return undefined; let chance = Math.floor((rate * ((divisor - remaining) & 0xff)) / divisor) & 0xff; if (!chance) chance = 1; // Only sleep and freeze add to the chance; BRN/PSN/PAR do not (a kept bug). if (input.status & ((1 << C.FRZ) | C.SLP_MASK)) chance = Math.min(0xff, chance + 10); // HELD_CATCH_CHANCE adds its parameter, but no item has that effect. if (input.held.effect === C.HELD_CATCH_CHANCE) chance = Math.min(0xff, chance + input.held.parameter); return chance; } /** HP × 25 / (max HP / 4) as a byte, as SendOutMonText and WithdrawMonText * compute it. Undefined when max HP / 4 is zero: the original divides by * zero and freezes. */ function cheerPercent(hp: number, maxHp: number): number | undefined { const divisor = (maxHp >> 2) & 0xff; return divisor ? Math.floor((hp * 25) / divisor) & 0xff : undefined; } /** SendOutMonText's cheer from the enemy's remaining HP ("Go!" when it * has fainted). */ export function sendOutText(hp: number, maxHp: number) { if (!hp) return "GoMonText" as const; const percent = cheerPercent(hp, maxHp); if (percent === undefined) return undefined; return percent >= 70 ? ("GoMonText" as const) : percent >= 40 ? ("DoItMonText" as const) : percent >= 10 ? ("GoForItMonText" as const) : ("YourFoesWeakGetmMonText" as const); } /** WithdrawMonText's choice from the HP lost since the send-out, in 16 * bits. The multiplicand's high byte is left over from earlier arithmetic * in the original; it is taken as zero here. */ export function withdrawText(hpAtSwitch: number, hp: number, maxHp: number) { const percent = cheerPercent((hpAtSwitch - hp) & 0xffff, maxHp); if (percent === undefined) return undefined; return !percent ? ("ThatsEnoughComeBackText" as const) : percent < 30 ? ("ComeBackText" as const) : percent < 70 ? ("OKComeBackText" as const) : ("GoodComeBackText" as const); } /** RestoreHealth: a 16-bit sum that overflows or passes the maximum is * the maximum. */ export function restoreHealth(hp: number, maxHp: number, amount: number) { const sum = hp + amount; return sum > 0xffff || sum > maxHp ? maxHp : sum; } /** RestorePP's new PP byte (PP Ups kept), or undefined when the move's PP * is already at `max` (GetMaxPPOfMove). MAX items fill the move; others add * 10, or 5 for the MysteryBerry. */ export function restoredPP(pp: number, max: number, item: number) { const current = pp & C.PP_MASK; if (current >= max) return undefined; const full = item === C.MAX_ELIXER || item === C.MAX_ETHER; return (pp & ~C.PP_MASK) | (full ? max : Math.min(max, current + (item === C.MYSTERYBERRY ? 5 : 10))); } /** ChangeHappiness' arithmetic with a HappinessChanges byte: bytes from * $64 up (`cp $64`) are decreases that stop at zero. */ export function changedHappiness(happiness: number, delta: number) { const sum = happiness + delta; return delta >= 0x64 ? (sum > 0xff ? sum & 0xff : 0) : Math.min(0xff, sum); } export class Battle { readonly data: BattleData; readonly player = new Side(); readonly enemy = new Side(); /** hBattleTurn: 0 for the player's turn, 1 for the enemy's. */ turn = 0; curBattleMon = 0; battleMode: number = C.WILD_BATTLE; battleType: number = C.BATTLETYPE_NORMAL; battleResult: number = C.WIN; battleEnded = false; battlePlayerAction = 0; enemyGoesFirst = 0; numFleeAttempts = 0; /** wBattleParticipantsNotFainted, which shares the experience. */ participants = 0; /** wEvolvableFlags: party members that levelled up, which * EvolveAfterBattle checks after a win. */ private evolvable = 0; /** wBattleParticipantsIncludingFainted, which revives consult. */ private participantsAll = 0; /** wEnemyMonBaseStats: five base stats, catch rate and base exp. */ enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 }; weather: number = C.WEATHER_NONE; /** wCurOTMon: the trainer's party index in battle. */ curOTMon = 0; /** wWildMon after a ball: the caught species, or 0. */ wildMon = 0; /** wFinalCatchRate from the last ball that ran the catch formula. */ private finalCatchRate = 0; /** wWildMonMoves/wWildMonPP: the wild Pokémon's moves and PP when it * appeared, which a caught Pokémon keeps. */ private wildMoves: number[] = []; private wildPP: number[] = []; /** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */ private enemySwitchMonIndex = 0; private battleHasJustStarted = 0; /** wCurPartyMon: the party index a menu picked. */ private curPartyMon = 0; /** wPartyMenuCursor: the 1-based cursor of the last party menu, from * the start menu's until the battle's own party menu sets it. */ private partyMenuCursor: number; /** wLastPlayerMon: the Pokémon that was out before the last switch. */ lastPlayerMon = 0; /** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */ private enemyIsSwitching = 0; /** wEnemyHPAtTimeOfPlayerSwitch, which WithdrawMonText compares with. */ private enemyHPAtSwitch = 0; /** wPlayerUsedMoves: the player's distinct moves since sending out. */ private playerUsedMoves = [0, 0, 0, 0]; private curDamage = 0; private criticalHit = 0; private attackMissed = 0; private effectFailed = 0; private typeModifier: number = C.EFFECTIVE; private alreadyDisobeyed = 0; private alreadyFailed = 0; private turnEnded = 0; private loweredStat = 0; private failedMessage = 0; private script: number[] = []; private scriptIndex = 0; private strings: Record = {}; private numbers: Record = {}; private matchups; private moveGrammarCache = new Map(); constructor(private options: BattleOptions) { this.data = new BattleData(options.data.reader); this.matchups = this.data.typeMatchups(); this.partyMenuCursor = options.partyMenuCursor ?? 0; if (options.trainer) this.battleMode = C.TRAINER_BATTLE; if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL; else if (options.tree) this.battleType = C.BATTLETYPE_TREE; } private get reader(): RomReader { return this.options.data.reader; } private random() { return this.options.random() & 0xff; } private get user() { return this.turn === 0 ? this.player : this.enemy; } private get target() { return this.turn === 0 ? this.enemy : this.player; } get partyMon(): PartyMon { return this.options.party[this.curBattleMon]; } // ------------------------------------------------------------- messages private context(): TextContext { const base = this.options.text(); return { ...base, // CatchTutorial copies "DUDE" over wPlayerName for the battle. player: this.battleType === C.BATTLETYPE_TUTORIAL ? readString(this.reader, BATTLE["CatchTutorial.Dude"], base) : base.player, trainer: this.options.trainer ? `${this.options.trainer.className} ${this.options.trainer.name}`.trim() : base.trainer, strings: { ...base.strings, [RAM.battleMonNickname]: this.player.mon?.nickname ?? "", [RAM.enemyMonNickname]: this.enemy.mon?.nickname ?? "", ...this.strings, }, numbers: this.numbers, battle: { turn: this.turn, player: this.player.mon?.nickname ?? "", enemy: this.enemy.mon?.nickname ?? "", enemyPrefix: readString(this.reader, BATTLE.EnemyText, base), }, asm: (offset) => this.textAsm(offset), }; } /** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for * a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */ private *say(address: number, button = false): Gen { const { text, prompt } = readTextEnd(this.reader, address, this.context()); // The tutorial's auto input presses A at every prompt. const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL; for (const page of text.split("\n\n").filter(Boolean)) yield { kind: "text", text: page, prompt: waits }; } /** text_asm routines that battle texts run between fragments. */ private textAsm(offset: number): number { const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment; switch (offset) { case at("UsedMoveText"): { // Updates the last move, then picks the grammar for "used". if (this.turn === 0) this.updateUsedMoves(this.player.move.anim); const user = this.user; if (!user.charging) { user.lastCounterMove = user.move.anim; user.lastMove = user.move.anim; } const grammar = this.grammar(user.move.anim); return this.alreadyDisobeyed || grammar < 3 ? BATTLE.UsedMove2Text : BATTLE.UsedMove1Text; } case at("UsedMove1Text"): case at("UsedMove2Text"): return this.alreadyDisobeyed ? BATTLE["UsedMoveText_CheckObedience.UsedInsteadText"] : BATTLE.MoveNameText; case at("UsedMoveText_CheckObedience.UsedInsteadText"): return BATTLE.MoveNameText; case at("MoveNameText"): { const ends = BATTLE["MoveNameText.endusedmovetexts"]; const grammar = this.grammar(this.user.move.anim); return this.reader.near(ends + grammar * 2, this.reader.bank(ends)); } case at("BattleCommand_StatUpMessage.stat"): return this.loweredStat & 0xf0 ? BATTLE["BattleCommand_StatUpMessage.BattleStatWentWayUpText"] : BATTLE["BattleCommand_StatUpMessage.BattleStatWentUpText"]; case at("BattleCommand_StatDownMessage.stat"): return this.loweredStat & 0xf0 ? BATTLE["BattleCommand_StatDownMessage.BattleStatSharplyFellText"] : BATTLE["BattleCommand_StatDownMessage.BattleStatFellText"]; case at("Text_MonGainedExpPoint"): return this.numbers[RAM.stringBuffer2 + 2] ? BATTLE.BoostedExpPointsText : BATTLE.ExpPointsText; case at("GoMonText"): case at("DoItMonText"): case at("GoForItMonText"): case at("YourFoesWeakGetmMonText"): return BATTLE.BattleMonNicknameText; case at("WithdrawMonText.WithdrawMonText"): { const text = withdrawText(this.enemyHPAtSwitch, this.enemy.mon.hp, this.enemy.mon.maxHp); if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here."); return BATTLE[text]; } case at("Text_1_2_and_Poof"): return BATTLE["Text_1_2_and_Poof.MoveForgotText"]; case at("Text_GotchaMonWasCaught"): // Capture music (not ported), then WaitButtonText. return BATTLE.WaitButtonText; default: throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`); } } private grammar(move: number) { let value = this.moveGrammarCache.get(move); if (value === undefined) { value = this.data.moveGrammar(move); this.moveGrammarCache.set(move, value); } return value; } private hud(): BattleEffect { return { kind: "hud" }; } // --------------------------------------------------------------- setup /** LoadEnemyMon: the trainer's wCurOTMon, or a wild Pokémon from a grass * or water table. */ private loadEnemyMon() { const trainer = this.options.trainer; if (trainer) { const mon = trainer.party[this.curOTMon], base = this.options.data.baseData(mon.species); this.enemy.mon = { species: mon.species, item: mon.item, moves: [...mon.moves], dvs: mon.dvs, pp: [...mon.pp], happiness: mon.happiness, level: mon.level, status: mon.status, hp: mon.hp, maxHp: mon.stats[0], stats: mon.stats.slice(1), types: [...base.types], nickname: mon.nickname, }; this.enemyBase = { stats: base.stats.slice(0, 5), catchRate: base.catchRate, exp: base.baseExp, }; this.enemy.stats = [...this.enemy.mon.stats]; this.applyStatusEffects(this.enemy); this.options.pokedex.seen.add(mon.species); return; } if (!this.options.wild) throw new UnsupportedBattle("Missing encounter."); const { species, level } = this.options.wild; if (species === C.UNOWN || species === C.MAGIKARP) throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet."); const data = this.options.data, base = data.baseData(species); // 25% chance of an item: 23% the first, 2% the second. let item = 0; if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0]; const dvs = (this.random() << 8) | this.random(); const stats = calcStats(data, species, dvs, level); const moves: number[] = []; for (const learned of data.learnset(species)) { if (learned.level > level) break; if (moves.includes(learned.move)) continue; if (moves.length === 4) moves.shift(); moves.push(learned.move); } while (moves.length < 4) moves.push(0); this.enemy.mon = { species, item, moves, dvs, pp: moves.map((move) => (move ? data.move(move).pp : 0)), happiness: C.BASE_HAPPINESS, level, // .TreeMon: some Pokémon fall from trees asleep. status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0, hp: stats[0], maxHp: stats[0], stats: stats.slice(1), types: [base.types[0], base.types[1]], nickname: data.speciesName(species), }; this.enemyBase = { stats: base.stats.slice(0, 5), catchRate: base.catchRate, exp: base.baseExp, }; this.options.pokedex.seen.add(species); this.enemy.stats = [...this.enemy.mon.stats]; this.wildMoves = [...moves]; this.wildPP = [...this.enemy.mon.pp]; } /** CheckSleepingTreeMon: in a tree battle, the time of day's * AsleepTreeMons list names the species that sleep. */ private sleepingTreeMon(species: number) { if (this.battleType !== C.BATTLETYPE_TREE) return false; const time = this.options.timeOfDay; let p = time < C.DAY_F ? BATTLE.AsleepTreeMonsMorn : time === C.DAY_F ? BATTLE.AsleepTreeMonsDay : BATTLE.AsleepTreeMonsNite; for (const r = this.reader; r.byte(p) !== 0xff; p++) if (r.byte(p) === species) return true; return false; } /** InitBattleMon from the party, then status penalties. */ private initBattleMon() { const mon = this.partyMon, base = this.options.data.baseData(mon.species); this.player.mon = { species: mon.species, item: mon.item, moves: [...mon.moves], dvs: mon.dvs, pp: [...mon.pp], happiness: mon.happiness, level: mon.level, status: mon.status, hp: mon.hp, maxHp: mon.stats[0], stats: mon.stats.slice(1), types: [base.types[0], base.types[1]], nickname: mon.nickname, }; this.player.stats = mon.stats.slice(1); this.applyStatusEffects(this.player); // No badges exist yet, so BadgeStatBoosts changes nothing. } /** ApplyPrzEffectOnSpeed and ApplyBrnEffectOnAttack for one side. */ private applyStatusEffects(side: Side) { const mon = side.mon; if (mon.status & (1 << C.PAR)) mon.stats[C.SPEED] = Math.max(1, mon.stats[C.SPEED] >> 2); if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1); } /** NewBattleMonStatus (with ResetPlayerStatLevels) or NewEnemyMonStatus * at the start of a battle. */ private newStatus(side: Side) { if (side === this.player) { this.playerUsedMoves = [0, 0, 0, 0]; this.enemy.lastCounterMove = 0; this.enemy.wrapCount = 0; this.enemy.reset(SUB5, C.SUBSTATUS_CANT_RUN); } side.sub = [0, 0, 0, 0, 0, 0]; side.levels = [7, 7, 7, 7, 7, 7, 7]; side.lastMove = 0; side.lastCounterMove = 0; side.disableCount = 0; side.disabledMove = 0; side.furyCutterCount = 0; side.protectCount = 0; side.rageCounter = 0; side.minimized = 0; side.wrapCount = 0; side.turnsTaken = 0; } /** UpdateBattleMonInParty: level, status and HP go back to the party. */ private updateBattleMonInParty() { const mon = this.partyMon; mon.level = this.player.mon.level; mon.status = this.player.mon.status; mon.hp = this.player.mon.hp; } // ---------------------------------------------------------------- flow /** StartBattle through ExitBattle. */ *run(): Gen { const trainer = this.options.trainer; if (trainer) { validateTrainerAI(trainer.aiFlags, trainer.party.flatMap((mon) => mon.moves.filter(Boolean).map((move) => this.options.data.move(move).effect), )); if (trainer.party.length < 1 || trainer.party.length > 6) throw new UnsupportedBattle("Invalid trainer party."); if ( !Number.isInteger(trainer.reward) || trainer.reward < 0 || trainer.reward > C.MAX_MONEY ) throw new UnsupportedBattle("Invalid trainer reward."); } if (this.options.tutorial && (trainer || !this.options.wild)) throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon."); this.loadEnemyMon(); this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0); if (this.curBattleMon < 0) throw new UnsupportedBattle("No Pokémon can fight."); if (this.battleType === C.BATTLETYPE_TUTORIAL) { // DoBattle goes straight to BattleMenu without sending anyone out. // The auto input picks PACK, then TutorialPack's Balls pocket, and // BattleMenu_Pack uses the Dude's own POKé BALL. yield* this.say(BATTLE.WildPokemonAppearedText); yield this.hud(); yield* this.pokeBallEffect(C.POKE_BALL, -1); if (!this.wildMon) throw new UnsupportedBattle("The tutorial ball did not catch."); this.battleResult &= 0xc0; // BattleMenu_Pack.UseItem's .run: WIN return { result: this.battleResult }; } if (trainer) { yield* this.say(BATTLE.WantsToBattleText); yield* this.say(BATTLE.BattleText_EnemySentOut); } else yield* this.say( this.battleType === C.BATTLETYPE_TREE ? BATTLE.PokemonFellFromTreeText : BATTLE.WildPokemonAppearedText, ); this.resetBattleParticipants(); this.initBattleMon(); const amuletCoin = this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN; this.newStatus(this.player); this.newStatus(this.enemy); yield* this.sendOutMonText(); yield this.hud(); this.turn = 0; // SetPlayerTurn before SpikesDamage yield* this.battleTurn(); if (trainer && (this.battleResult & 0x3f) === C.WIN) { yield* this.say(BATTLE.BattleText_EnemyWasDefeated); if (this.options.canLose) { for (const mon of this.options.party) { mon.hp = mon.stats[0]; mon.status = 0; mon.pp = mon.moves.map((move, slot) => { if (!move) return 0; const ups = mon.pp[slot] >> 6, base = this.options.data.move(move).pp; return ( (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups) ); }) as PartyMon["pp"]; } this.initBattleMon(); yield this.hud(); } if (trainer.winText) yield* this.say(trainer.winText); return { result: this.battleResult, ...(yield* this.giveMoney(trainer.reward, amuletCoin)), evolvable: this.evolvable, }; } if (trainer && this.options.canLose && trainer.lossText) yield* this.say(trainer.lossText); return { result: this.battleResult, evolvable: this.evolvable }; } /** WinTrainerBattle.give_money: the reward (TrainerClassAttributes' * base times the last Pokémon's level, which `reward` holds four times) * is added four times, to Mom for as many quarters as her saving mode * takes unless her account is full, and to the wallet for the rest. Each * account stops at MAX_MONEY. The text shows four times the reward. */ private *giveMoney( reward: number, amuletCoin: boolean, ): Gen<{ prizeMoney: number; momMoney?: number }> { const quarter = (reward / 4) * (amuletCoin ? 2 : 1); const mom = this.options.mom ?? { saving: 0, money: 0 }, maxedOut = mom.money >= C.MAX_MONEY; if (![0, 1, 2, 3].includes(mom.saving)) throw new UnsupportedBattle("Unsupported Mom saving mode."); // .CheckMaxedOutMomMoney, then SOME|HALF counts as all four quarters. const toMom = maxedOut ? 0 : mom.saving === 3 ? 4 : mom.saving; this.numbers[RAM.battleReward] = quarter * 4; yield* this.say( maxedOut || !mom.saving ? BATTLE.GotMoneyForWinningText : [ BATTLE.SentSomeToMomText, BATTLE.SentHalfToMomText, BATTLE.SentAllToMomText, ][mom.saving - 1], ); return { prizeMoney: quarter * (4 - toMom), ...(toMom ? { momMoney: quarter * toMom } : {}), }; } private *battleTurn(): Gen { for (let turns = 0; turns < 1000; turns++) { this.curDamage = 0; this.enemyIsSwitching = 0; this.player.justGotFrozen = 0; this.enemy.justGotFrozen = 0; this.updateBattleMonInParty(); // AIChooseMove runs once before BattleMenu, even with exhausted PP. if (this.options.trainer && !this.checkEnemyLockedIn()) { const choice = chooseTrainerMove({ weights: this.options.trainer.aiFlags, player: this.player, enemy: this.enemy, data: this.options.data, battleData: this.data, weather: this.weather, random: () => this.random(), damage: (move) => this.enemyAIDamage(move), }); this.enemy.moveNum = choice.index; this.enemy.curMove = choice.move; if (choice.enemyTurn) this.turn = 1; } if (!this.checkPlayerLockedIn()) { for (;;) { const escaped = yield* this.battleMenu(); if (escaped || this.battleEnded) return; if (yield* this.parsePlayerAction()) break; } } else if (!(yield* this.parsePlayerAction())) continue; if (this.determineMoveOrder()) yield* this.battlePlayerFirst(); else yield* this.battleEnemyFirst(); if (this.battleEnded) return; yield* this.handleBetweenTurnEffects(); if (this.battleEnded) return; } throw new UnsupportedBattle("The battle did not end."); } /** AIDamageCalc: pre-variation damage used only for scoring. It omits * accuracy and does not roll a critical hit, but, as in the ROM, a * wCriticalHit left over from an earlier move still selects unboosted * stats and doubles the estimate. */ private enemyAIDamage(moveId: number): number { const move = this.options.data.move(moveId); switch (move.effect) { case C.EFFECT_SUPER_FANG: return Math.max(1, this.player.mon.hp >> 1); case C.EFFECT_STATIC_DAMAGE: return move.power; case C.EFFECT_LEVEL_DAMAGE: return this.enemy.mon.level; case C.EFFECT_PSYWAVE: { const limit = this.enemy.mon.level + (this.enemy.mon.level >> 1); for (let attempt = 0; attempt < 4096; attempt++) { const damage = this.random(); if (damage && damage < limit) return damage; } throw new UnsupportedBattle("Trainer Psywave AI random source did not produce damage."); } } if (!move.power) return 0; if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); const special = move.type >= C.SPECIAL, attackStat = special ? C.SP_ATTACK : C.ATTACK, defenseStat = special ? C.SP_DEFENSE : C.DEFENSE, screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT; const unboosted = this.criticalHit && this.player.levels[defenseStat] >= this.enemy.levels[attackStat]; let attack: number, defense: number; if (unboosted) { attack = this.enemy.stats[attackStat]; defense = this.player.stats[defenseStat]; } else { attack = this.enemy.mon.stats[attackStat]; defense = this.player.mon.stats[defenseStat]; if (this.player.screens & (1 << screen)) defense = (defense * 2) & 0xffff; } const boosted = special ? this.enemy.mon.species === C.PIKACHU && this.enemy.mon.item === C.LIGHT_BALL : (this.enemy.mon.species === C.CUBONE || this.enemy.mon.species === C.MAROWAK) && this.enemy.mon.item === C.THICK_CLUB; if (boosted) attack = (attack * 2) & 0xffff; [attack, defense] = this.truncate(attack, defense); if (this.player.mon.species === C.DITTO && this.player.mon.item === C.METAL_POWDER) throw new UnsupportedBattle("Metal Powder is not ported yet."); const held = this.data.heldEffect(this.enemy.mon.item), typeBoost = this.data.typeBoost(held.effect), boost = held.effect && typeBoost === move.type ? held.parameter : undefined, baseDamage = calculateDamage({ power: move.power, level: this.enemy.mon.level, attack, defense, effect: move.effect, critical: Boolean(this.criticalHit), boost, current: 0, }); if (baseDamage === undefined) return 0; return applyTypeMatchups(this.matchups, { damage: baseDamage, modifier: C.EFFECTIVE, moveType: move.type, userTypes: this.enemy.mon.types, targetTypes: this.player.mon.types, identified: this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED), }).damage; } /** BattleMenu: FIGHT chooses a move; RUN tries to flee. Returns true if * the battle ended from the menu. */ private *battleMenu(): Gen { for (;;) { const input = yield { kind: "action" }; if (!input || typeof input !== "object") continue; if (input.action === "item") { const used = yield* this.battleMenuPack(input.item, input.slot); if (this.wildMon) { // BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the // battle as a WIN, keeping the result's flag bits. this.wildMon = 0; this.battleResult &= 0xc0; this.battleEnded = true; return true; } if (!used) continue; this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM; return false; } if (input.action === "pkmn") { if (yield* this.battleMenuPKMN()) return false; continue; } if (input.action === "run") { const escaped = yield* this.tryToRunAway(); if (escaped) { this.battleEnded = true; return true; } // A failed escape uses the turn (BATTLEPLAYERACTION_USEITEM). if (this.battlePlayerAction) return false; continue; } this.battlePlayerAction = 0; if (!(yield* this.moveSelection(input.move))) continue; return false; } } /** BattleMenu_Pack with BattlePack's USE on a Balls- or Items-pocket * slot. Returns true if the item was used, which takes the player's turn * (wItemEffectSucceeded shares wBattlePlayerAction's byte). */ private *battleMenuPack(item: number, slot: number): Gen { const data = this.options.data, kind = data.itemPocket(item); const pocket = kind === "ball" ? this.options.bag?.balls : kind === "item" ? this.options.bag?.items : undefined; if (!pocket || !Number.isInteger(slot) || pocket[slot]?.item !== item) return false; if (kind === "ball") return yield* this.pokeBallEffect(item, slot); // _DoItemEffect names the item in wStringBuffer1 and wStringBuffer2. this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item); // CheckItemContext: the low nibble of the item's help byte. const context = this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 6) & 0x0f; if (context < C.ITEMMENU_CURRENT) { yield* this.say(BATTLE.OakThisIsntTheTimeText); return false; } // ItemEffects: the routine this item runs, chosen by its ROM address. const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects)); const use = { item, slot }; if (context === C.ITEMMENU_PARTY) { // BattlePack's .BattleField: anything but success stays in the pack. switch (effect) { case BATTLE.RestoreHPEffect: return yield* this.statusHealerResult(yield* this.itemRestoreHP(use)); case BATTLE.StatusHealingEffect: return yield* this.statusHealingEffect(use); case BATTLE.FullRestoreEffect: return yield* this.fullRestoreEffect(use); case BATTLE.ReviveEffect: return yield* this.reviveEffect(use, 0); case BATTLE.RevivalHerbEffect: return yield* this.reviveEffect(use, C.HAPPINESS_REVIVALHERB); case BATTLE.EnergypowderEffect: case BATTLE.EnergyRootEffect: return yield* this.bitterRestoreHP(use, effect === BATTLE.EnergyRootEffect ? C.HAPPINESS_ENERGYROOT : C.HAPPINESS_BITTERPOWDER); case BATTLE.HealPowderEffect: return yield* this.healPowderEffect(use); case BATTLE.BitterBerryEffect: return yield* this.bitterBerryEffect(use); case BATTLE.RestorePPEffect: return yield* this.restorePPEffect(use); } } else if (context !== C.ITEMMENU_CURRENT) { // BattlePack's .BattleOnly: failure gives Oak's warning, and the // "won't have any effect" value 2 returns to the pack. let result: number | undefined; switch (effect) { case BATTLE.XItemEffect: result = yield* this.xItemEffect(use); break; case BATTLE.XAccuracyEffect: result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_X_ACCURACY); break; case BATTLE.GuardSpecEffect: result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_MIST); break; case BATTLE.DireHitEffect: result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_FOCUS_ENERGY); break; case BATTLE.PokeDollEffect: result = yield* this.pokeDollEffect(use); break; } if (result !== undefined) { if (!result) yield* this.say(BATTLE.OakThisIsntTheTimeText); return result === 1; } } throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`); } /** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */ private *statusHealerResult(code: number): Gen { if (code === 1) yield* this.say(BATTLE.ItemWontHaveEffectText); return code === 0; } /** UseItem_SelectMon: the party menu for an item. Returns the party * index, or -1 when cancelled or an Egg was chosen. */ 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) { yield* this.say(BATTLE.ItemCantUseOnEggText); return -1; } return slot; } /** UseItemText: " used !", a button, then UseDisposableItem. */ private *useItemText({ item, slot }: ItemUse): Gen { yield* this.say(BATTLE.ItemUsedText, true); this.tossItem("items", item, slot); } /** ItemRestoreHP: HealingHPAmounts, up to the maximum. The Pokémon out * takes the party HP, which UpdateBattleMonInParty copied at the start of * the turn. Returns the StatusHealer_Jumptable value. */ private *itemRestoreHP({ item, slot }: ItemUse): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return 2; const mon = this.options.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) === item) amount = r.word(p + 1); if (amount === undefined) throw new UnsupportedBattle("Missing healing amount."); yield* this.restoreHP(target, restoreHealth(mon.hp, mon.stats[0], amount), C.PARTYMENUTEXT_HEAL_HP); this.tossItem("items", item, slot); return 0; } /** The HP change, BattlemonRestoreHealth and the party menu action text * with wCurHPAnimDeltaHP. */ private *restoreHP(target: number, hp: number, text: number): Gen { const mon = this.options.party[target]; this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp; mon.hp = hp; if (target === this.curBattleMon) this.player.mon.hp = hp; yield this.hud(); yield* this.partyMenuActionText(target, text); } /** EnergypowderEffect and EnergyRootEffect: ItemRestoreHP, then the * bitter taste costs happiness. */ private *bitterRestoreHP(use: ItemUse, happiness: number): Gen { const code = yield* this.itemRestoreHP(use); if (!code) { this.changeHappiness(this.curPartyMon, happiness); yield* this.say(BATTLE.ItemLooksBitterText); } return yield* this.statusHealerResult(code); } /** StatusHealingEffect. */ private *statusHealingEffect(use: ItemUse): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return false; return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target)); } /** HealPowderEffect: a status heal that tastes bitter. */ private *healPowderEffect(use: ItemUse): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const code = yield* this.useStatusHealer(use, target); if (!code) { this.changeHappiness(target, C.HAPPINESS_BITTERPOWDER); yield* this.say(BATTLE.ItemLooksBitterText); } return yield* this.statusHealerResult(code); } /** 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 UnsupportedBattle("Missing status healing action."); p += 3; } return { text: r.byte(p + 1), mask: r.byte(p + 2) }; } /** UseStatusHealer: 0 if used, 1 if it would have no effect. */ private *useStatusHealer({ item, slot }: ItemUse, target: number): Gen { const mon = this.options.party[target]; if (!mon.hp) return 1; let { text, mask } = this.healingAction(item); if (!(mon.status & mask)) { // IsItemUsedOnConfusedMon: a full heal still cures the confusion of // the Pokémon out. if (!(target === this.curBattleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED))) return 1; text = C.PARTYMENUTEXT_HEAL_CONFUSION; } mon.status = 0; this.healStatus(target, mask); yield this.hud(); yield* this.partyMenuActionText(target, text); this.tossItem("items", item, slot); return 0; } /** HealStatus: the Pokémon out loses its status, Toxic and Nightmare * (and confusion with a full heal), then CalcPlayerStats. */ private healStatus(target: number, mask: number) { if (target !== this.curBattleMon) return; this.player.mon.status = 0; this.player.reset(SUB5, C.SUBSTATUS_TOXIC); this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE); if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED); this.calcPlayerStats(); } /** FullRestoreEffect: full HP and no status; at full HP only the status. */ private *fullRestoreEffect(use: ItemUse): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.options.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)); mon.status = 0; this.healStatus(target, this.healingAction(use.item).mask); yield* this.restoreHP(target, mon.stats[0], C.PARTYMENUTEXT_HEAL_HP); this.tossItem("items", use.item, use.slot); return true; } /** ReviveEffect and RevivalHerbEffect (RevivePokemon). A revived * Pokémon that took part in the battle shares the experience again. */ private *reviveEffect(use: ItemUse, happiness: number): Gen { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.options.party[target]; if (mon.hp) return yield* this.statusHealerResult(1); if (this.participantsAll & (1 << target)) this.participants |= 1 << target; // ReviveHalfHP halves the maximum HP; the others restore all of it. yield* this.restoreHP(target, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0], C.PARTYMENUTEXT_REVIVE); this.tossItem("items", use.item, use.slot); if (happiness) { this.changeHappiness(target, happiness); yield* this.say(BATTLE.ItemLooksBitterText); } return true; } /** BitterBerryEffect: cures the confusion of the Pokémon out. */ private *bitterBerryEffect(use: ItemUse): Gen { if (!this.player.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.statusHealerResult(1); this.player.reset(SUB3, C.SUBSTATUS_CONFUSED); this.turn = 0; yield* this.useItemText(use); yield* this.say(BATTLE.ConfusedNoMoreText); return true; } /** RestorePPEffect for Ethers, Elixers and the MysteryBerry. Choosing no * move goes back to the party menu. */ private *restorePPEffect(use: ItemUse): Gen { const data = this.options.data, item = use.item; for (;;) { const target = yield* this.useItemSelectMon(); if (target < 0) return false; const mon = this.options.party[target]; let restored = false; if (item === C.ELIXER || item === C.MAX_ELIXER) { for (let slot = 0; slot < 4; slot++) if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true; } else { const choice = yield { kind: "move", text: readTextEnd(this.reader, BATTLE.RestoreThePPOfWhichMoveText, this.context()).text, moves: [...mon.moves], pp: [...mon.pp], }; if (typeof choice !== "number" || choice < 0 || choice > 3 || !mon.moves[choice]) continue; // GetMoveName and CopyName1: the move's name in wStringBuffer2. this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.moveName(mon.moves[choice]); restored = this.restorePP(mon, choice, item); } if (!restored) return yield* this.statusHealerResult(1); // BattleRestorePP: the Pokémon out takes the PP of matching moves, // unless it is transformed. if (target === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED)) this.player.mon.moves.forEach((move, slot) => { if (move && move === mon.moves[slot]) this.player.mon.pp[slot] = mon.pp[slot]; }); yield* this.say(BATTLE.PPRestoredText); this.tossItem("items", item, use.slot); return true; } } /** RestorePP: MAX items fill the move; others add 10 (the MysteryBerry * 5) up to GetMaxPPOfMove. Returns false if the PP was already full. */ private restorePP(mon: PartyMon, slot: number, item: number) { const max = maxPP(this.options.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; } /** XItemEffect: the item is used, then RaiseStat on the player's side, * even if the stat cannot rise, and a small happiness gain. */ private *xItemEffect(use: ItemUse): Gen { yield* this.useItemText(use); const r = this.reader; let p = BATTLE.XItemStats; while (r.byte(p) !== use.item) p += 2; this.turn = 0; this.attackMissed = 0; this.effectFailed = 0; this.raiseStat(r.byte(p + 1)); yield this.hud(); yield* this.statUpMessage(); yield* this.statUpFailText(); this.changeHappiness(this.curBattleMon, C.HAPPINESS_USEDXITEM); return 1; } /** XAccuracyEffect, GuardSpecEffect and DireHitEffect: a SubStatus4 flag * that can be set once (WontHaveAnyEffect_NotUsedMessage returns 2). */ private *setPlayerSubstatusItem(use: ItemUse, bit: number): Gen { if (this.player.has(SUB4, bit)) { yield* this.say(BATTLE.ItemWontHaveEffectText); return 2; } this.player.sub[SUB4] |= 1 << bit; yield* this.useItemText(use); return 1; } /** PokeDollEffect: ends a wild battle as a draw (wForcedSwitch); in a * trainer battle it fails. */ private *pokeDollEffect(use: ItemUse): Gen { if (this.battleMode !== C.WILD_BATTLE) return 0; this.battleResult = (this.battleResult & 0xc0) | C.DRAW; yield* this.useItemText(use); this.battleEnded = true; return 1; } /** CalcPlayerStats: stat stages, then the paralysis and burn penalties * for the side that hBattleTurn names after BattleCommand_SwitchTurn, * which is the enemy if hBattleTurn held the enemy's turn. At the battle * menu that happens after a full turn (HandleHealingItems ends on the * enemy) and after a trainer AI layer set it. */ private calcPlayerStats() { const player = this.player; for (let stat = 0; stat < 5; stat++) { const [numerator, denominator] = this.data.statMultiplier(player.levels[stat]); const value = Math.floor((player.stats[stat] * numerator) / denominator); player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value); } this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy); } /** PrintPartyMenuActionText with the Pokémon's nickname. */ private *partyMenuActionText(target: number, text: number): Gen { this.strings[RAM.stringBuffer1] = this.options.party[target].nickname; const table = BATTLE["PrintPartyMenuActionText.MenuActionTexts"]; yield* this.say(this.reader.near(table + (text & 0x0f) * 2, this.reader.bank(table))); } /** UseDisposableItem: toss one from the pocket slot it was used from. */ private tossItem(pocketName: "items" | "balls", item: number, slot: number) { const pocket = this.options.bag?.[pocketName]; if (!pocket) throw new UnsupportedBattle("Missing pack."); const index = pocket[slot]?.item === item ? slot : pocket.findIndex((entry) => entry.item === item); if (index < 0) throw new UnsupportedBattle("The item is not in the pack."); if (!--pocket[index].quantity) pocket.splice(index, 1); } /** PokeBallEffect (engine/items/item_effects.asm). `slot` is the pocket * index the ball came from (wCurItemQuantity); the tutorial uses the * Dude's own ball and passes -1. Returns false if no ball was used. */ private *pokeBallEffect(item: number, slot: number): Gen { const data = this.options.data, tutorial = this.battleType === C.BATTLETYPE_TUTORIAL; // _DoItemEffect: GetItemName, then CopyName1 into wStringBuffer2. this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item); if (this.battleMode !== C.WILD_BATTLE) { // UseBallInTrainerBattle: the blocked-ball animation, then // UseDisposableItem. The turn is used. yield* this.say(BATTLE.BallBlockedText); yield* this.say(BATTLE.BallDontBeAThiefText); this.tossItem("balls", item, slot); return true; } // With a full party the Pokémon would go to the current box; if that is // full too, the ball is not used (Ball_BoxIsFullMessage). const storage = this.options.storage; if ( this.options.party.length >= PARTY_LENGTH && (!storage || currentBox(storage).length >= MONS_PER_BOX) ) { // The Dude's tutorial reaches this check too, and the original then // repeats the unusable ball forever (docs/bugs_and_glitches.md). if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box."); if (!storage) throw new UnsupportedBattle("Missing PC boxes."); yield* this.say(BATTLE.BallBoxFullText); return false; } if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never)) throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`); this.wildMon = 0; yield* this.say(BATTLE.ItemUsedText); const { caught, wobbles } = this.rollCapture(item, tutorial); if (!caught) { yield* this.say( [ BATTLE.BallBrokeFreeText, BATTLE.BallAppearedCaughtText, BATTLE.BallAlmostHadItText, BATTLE.BallSoCloseText, ][wobbles - 1], ); if (!tutorial) this.tossItem("balls", item, slot); return true; } // Caught: LoadEnemyMon runs again with SUBSTATUS_TRANSFORMED set, which // keeps the DVs, redraws the wild item (restored afterwards) and // recalculates stats. Status, HP and item are kept; moves and PP return // to wWildMonMoves and wWildMonPP. if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) throw new UnsupportedBattle("Catching a transformed Pokémon is not ported yet."); if (this.random() >= percent(75) + 1) this.random(); const enemy = this.enemy.mon, species = enemy.species, level = enemy.level; this.wildMon = species; if (tutorial) { // .FinishTutorial: the Dude's Pokémon is not kept, and his ball is // not tossed. yield* this.say(BATTLE.Text_GotchaMonWasCaught); return true; } yield* this.say(BATTLE.Text_GotchaMonWasCaught); const pokedex = this.options.pokedex, caughtBefore = pokedex.caught.has(species); pokedex.seen.add(species); pokedex.caught.add(species); if (!caughtBefore && this.options.hasPokedex) { // NewPokedexEntry's Pokédex page is not presented yet. yield* this.say(BATTLE.NewDexDataText); } if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data."); const name = data.speciesName(species); if (this.options.party.length >= PARTY_LENGTH) { yield* this.sendToPC(species, level, name); this.tossItem("balls", item, slot); return true; } // TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies // the wild Pokémon, then SetCaughtData. const mon: PartyMon = { species, item: enemy.item, moves: [...this.wildMoves] as PartyMon["moves"], otId: this.options.playerId, ot: this.options.playerName, nickname: name, exp: data.expAtLevel(data.baseData(species).growthRate, level), statExp: [0, 0, 0, 0, 0], dvs: enemy.dvs, pp: [...this.wildPP] as PartyMon["pp"], happiness: C.BASE_HAPPINESS, pokerus: 0, caughtData: this.options.caughtData(level), level, status: enemy.status, hp: enemy.hp, stats: calcStats(data, species, enemy.dvs, level), }; this.options.party.push(mon); mon.nickname = yield* this.askNickname(name); this.tossItem("balls", item, slot); return true; } /** PokeBallEffect's decision, before animation and caught-mon loading. */ private rollCapture(item: number, tutorial: boolean) { let caught = tutorial || item === C.MASTER_BALL; if (!caught) { const held = this.data.heldEffect(this.player.mon?.item ?? 0); const chance = catchRate({ rate: this.enemyBase.catchRate, ball: item, hp: this.enemy.mon.hp, maxHp: this.enemy.mon.maxHp, status: this.enemy.mon.status, held, }); if (chance === undefined) throw new UnsupportedBattle("The catch rate divides by zero."); this.finalCatchRate = chance; caught = this.random() <= chance; } return { caught, wobbles: this.ballWobbles(caught) }; } private ballWobbles(caught: boolean): number { // BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until // the ball clicks or the Pokémon breaks free. let wobbles = 0; for (;;) { wobbles++; if (wobbles === 4) break; if (caught) continue; let entry = BATTLE.WobbleProbabilities; while (this.reader.byte(entry) < this.finalCatchRate) entry += 2; if (this.random() >= this.reader.byte(entry + 1)) break; } return wobbles; } /** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the * chosen name, or the species name for a declined or blank one. */ private *askNickname(name: string): Gen { this.strings[RAM.stringBuffer1] = name; yield* this.say(BATTLE.AskGiveNicknameText); if (!(yield { kind: "yesno", text: "" })) return name; // "'S" / "NICKNAME?". let second = SYMBOLS.nicknameStrings; while (this.reader.byte(second++) !== 0x50); const context = this.context(), suffix = readString(this.reader, SYMBOLS.nicknameStrings, context), title = readString(this.reader, second, context); const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 }; if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer))) throw new UnsupportedBattle("Invalid nickname."); // InitName: a name of zero or more spaces takes the species name. return /^ *$/.test(answer) ? name : answer; } /** PokeBallEffect's .SendToPC: SendMonIntoBox puts the caught Pokémon * first in the current box (from wEnemyMon, with restored PP), then * SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for * Bill's call; the nickname and BallSentToPCText follow. */ private *sendToPC(species: number, level: number, name: string): Gen { const data = this.options.data, storage = this.options.storage!, box = currentBox(storage); const mon: BoxMon = { species, item: this.enemy.mon.item, moves: [...this.wildMoves] as BoxMon["moves"], otId: this.options.playerId, ot: this.options.playerName, nickname: name, exp: data.expAtLevel(data.baseData(species).growthRate, level), statExp: [0, 0, 0, 0, 0], dvs: this.enemy.mon.dvs, pp: [...this.wildPP] as BoxMon["pp"], happiness: C.BASE_HAPPINESS, pokerus: 0, caughtData: this.options.caughtData!(level), level, }; restorePP(data, mon); box.unshift(mon); if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL; mon.nickname = yield* this.askNickname(name); this.strings[RAM.monOrItemNameBuffer] = mon.nickname; yield* this.say(BATTLE.BallSentToPCText); } /** TossItem after a throw: RemoveItemFromPocket takes the ball from the * selected slot if it matches, otherwise from the first matching slot. */ /** MoveSelectionScreen's checks for a chosen move. */ private *moveSelection(index: number): Gen { const mon = this.player.mon; if (!this.checkPlayerHasUsableMoves()) { yield* this.say(BATTLE.BattleText_MonHasNoMovesLeft); this.player.curMove = C.STRUGGLE; return true; } if (!Number.isInteger(index) || index < 0 || index > 3 || !mon.moves[index]) return false; if (this.player.disableCount && index === ((this.player.disableCount >> 4) & 0xf) - 1) { this.strings[RAM.stringBuffer1] = this.options.data.moveName(mon.moves[index]); yield* this.say(BATTLE.BattleText_TheMoveIsDisabled); return false; } if (!(mon.pp[index] & C.PP_MASK)) { yield* this.say(BATTLE.BattleText_TheresNoPPLeftForThisMove); return false; } this.player.moveNum = index; this.player.curMove = mon.moves[index]; return true; } private checkPlayerHasUsableMoves() { const side = this.player; if (side.disableCount) { const disabled = ((side.disableCount >> 4) & 0xf) - 1; return side.mon.pp.some((pp, index) => index !== disabled && pp & C.PP_MASK); } return Boolean(side.mon.pp.reduce((a, b) => a | b, 0) & C.PP_MASK); } private checkPlayerLockedIn(): boolean { const player = this.player; if (player.has(SUB4, C.SUBSTATUS_RECHARGE)) return true; this.enemy.reset(SUB3, C.SUBSTATUS_FLINCHED); player.reset(SUB3, C.SUBSTATUS_FLINCHED); if (player.sub[SUB3] & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE))) return true; return player.has(SUB1, C.SUBSTATUS_ROLLOUT); } /** ParsePlayerAction then ParseEnemyAction. Returns false to re-open the menu. */ private *parsePlayerAction(): Gen { const player = this.player; if (this.checkPlayerLockedIn() || player.has(SUB5, C.SUBSTATUS_ENCORED) || player.has(SUB3, C.SUBSTATUS_BIDE)) throw new UnsupportedBattle("Locked-in, encored and Bide moves are not ported yet."); if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) { // PlayerSwitch already ran ParseEnemyAction. player.furyCutterCount = 0; player.protectCount = 0; player.rageCounter = 0; player.reset(SUB4, C.SUBSTATUS_RAGE); return true; } if (this.battlePlayerAction === 0) { this.turn = 0; this.updateMoveData(); player.charging = 0; if (player.move.effect !== C.EFFECT_FURY_CUTTER) player.furyCutterCount = 0; if (player.move.effect !== C.EFFECT_RAGE) { player.reset(SUB4, C.SUBSTATUS_RAGE); player.rageCounter = 0; } if (player.move.effect !== C.EFFECT_PROTECT && player.move.effect !== C.EFFECT_ENDURE) player.protectCount = 0; } else { player.reset(SUB3, C.SUBSTATUS_BIDE); player.furyCutterCount = 0; player.protectCount = 0; player.rageCounter = 0; player.reset(SUB4, C.SUBSTATUS_RAGE); } this.parseEnemyAction(); return true; } /** ParseEnemyAction: use the trainer AI selection or a random wild move. */ private parseEnemyAction() { if (this.enemyIsSwitching) return; const enemy = this.enemy, mon = enemy.mon; if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn()) throw new UnsupportedBattle( "Locked-in and encored enemy moves are not ported yet.", ); let move: number = C.STRUGGLE; const usable = mon.moves.some( (candidate, slot) => candidate && candidate !== enemy.disabledMove && mon.pp[slot] & C.PP_MASK, ); if (this.options.trainer && usable) { move = enemy.curMove; } else for (let slot = 0; slot < 4; slot++) { const candidate = mon.moves[slot]; if (!candidate) break; if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK)) continue; for (let guard = 0; guard < 4096; guard++) { const choice = this.random() & 3; if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue; if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue; enemy.moveNum = choice; move = mon.moves[choice]; break; } break; } enemy.curMove = move; this.turn = 1; this.updateMoveData(); if (!this.checkEnemyLockedIn()) enemy.charging = 0; if (enemy.move.effect !== C.EFFECT_FURY_CUTTER) enemy.furyCutterCount = 0; if (enemy.move.effect !== C.EFFECT_RAGE) { enemy.reset(SUB4, C.SUBSTATUS_RAGE); enemy.rageCounter = 0; } if (enemy.move.effect !== C.EFFECT_PROTECT && enemy.move.effect !== C.EFFECT_ENDURE) enemy.protectCount = 0; } private checkEnemyLockedIn() { const enemy = this.enemy; return ( enemy.has(SUB4, C.SUBSTATUS_RECHARGE) || Boolean( enemy.sub[SUB3] & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE)), ) || enemy.has(SUB1, C.SUBSTATUS_ROLLOUT) ); } /** UpdateMoveData: the move struct for the current turn's move. */ private updateMoveData() { const side = this.user; const move = this.options.data.move(side.curMove); side.move = { anim: move.id, effect: move.effect, power: move.power, type: move.type, accuracy: move.accuracy, pp: move.pp, chance: move.chance, }; // GetMoveName fills wStringBuffer1; CopyName1 copies it to wStringBuffer2. const name = this.options.data.moveName(side.curMove); this.strings[RAM.stringBuffer1] = name; this.strings[RAM.stringBuffer2] = name; } /** DetermineMoveOrder: true if the player moves first. */ private determineMoveOrder(): boolean { if (this.battlePlayerAction) return true; const player = this.data.priority( this.options.data.move(this.player.curMove).effect, C.BASE_PRIORITY, ); const enemy = this.data.priority( this.options.data.move(this.enemy.curMove).effect, C.BASE_PRIORITY, ); const priority = (move: number, value: number) => move === C.VITAL_THROW ? 0 : value; const playerPriority = priority(this.player.curMove, player), enemyPriority = priority(this.enemy.curMove, enemy); if (playerPriority !== enemyPriority) return playerPriority > enemyPriority; const playerItem = this.data.heldEffect(this.player.mon.item), enemyItem = this.data.heldEffect(this.enemy.mon.item); if (playerItem.effect === C.HELD_QUICK_CLAW && enemyItem.effect === C.HELD_QUICK_CLAW) { if (this.random() < enemyItem.parameter) return false; if (this.random() < playerItem.parameter) return true; } else if (playerItem.effect === C.HELD_QUICK_CLAW) { if (this.random() < playerItem.parameter) return true; } else if (enemyItem.effect === C.HELD_QUICK_CLAW) { if (this.random() < enemyItem.parameter) return false; } const playerSpeed = this.player.mon.stats[C.SPEED], enemySpeed = this.enemy.mon.stats[C.SPEED]; if (playerSpeed !== enemySpeed) return playerSpeed > enemySpeed; return this.random() < percent(50) + 1; } private *battlePlayerFirst(): Gen { this.enemyGoesFirst = 0; this.turn = 1; const switched = yield* this.aiSwitchOrTryItem(); yield* this.doTurnFor(0); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); this.turn = 0; if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint(); yield this.hud(); if (!switched) { if (this.tryEnemyFlee()) return yield* this.wildFled(); yield* this.doTurnFor(1); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); } this.turn = 1; if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint(); yield this.hud(); this.battlePlayerAction = 0; } private *battleEnemyFirst(): Gen { if (this.tryEnemyFlee()) return yield* this.wildFled(); this.turn = 1; this.enemyGoesFirst = 1; if (!(yield* this.aiSwitchOrTryItem())) { yield* this.doTurnFor(1); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); } this.turn = 1; if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint(); yield this.hud(); yield* this.doTurnFor(0); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); this.turn = 0; if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint(); yield this.hud(); this.battlePlayerAction = 0; } /** PlayerTurn/EnemyTurn_EndOpponentProtectEndureDestinyBond. */ private *doTurnFor(turn: number): Gen { this.turn = turn; this.user.reset(SUB5, C.SUBSTATUS_DESTINY_BOND); if (turn === 0 && this.battlePlayerAction) { // DoPlayerTurn does nothing after a failed escape or an item. } else yield* this.doTurn(); const opponent = this.target; opponent.reset(SUB1, C.SUBSTATUS_PROTECT); opponent.reset(SUB1, C.SUBSTATUS_ENDURE); opponent.reset(SUB5, C.SUBSTATUS_DESTINY_BOND); } private hasFainted(side: Side) { return side.mon.hp === 0; } /** TryEnemyFlee: only some species flee, and never while asleep. */ private tryEnemyFlee(): boolean { if (this.battleMode !== C.WILD_BATTLE) return false; if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false; if (this.enemy.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return false; const species = this.enemy.mon.species; if (this.data.byteList(BATTLE.AlwaysFleeMons).includes(species)) return true; const roll = this.random(); if (roll >= percent(50) + 1) return false; if (this.data.byteList(BATTLE.OftenFleeMons).includes(species)) return true; if (roll >= percent(10) + 1) return false; return this.data.byteList(BATTLE.SometimesFleeMons).includes(species); } private *wildFled(): Gen { this.battleResult = (this.battleResult & 0xc0) + C.DRAW; yield* this.say(BATTLE.BattleText_WildFled); this.battleEnded = true; } /** TryToRunAwayFromBattle for a wild battle. Returns true on escape. * The Speed compared is the battle Pokémon's, except after a faint. */ private *tryToRunAway(playerSpeed = this.player.mon.stats[C.SPEED]): Gen { if (this.options.trainer) { yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle); return false; } if (this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.player.wrapCount) { yield* this.say(BATTLE.BattleText_CantEscape); return false; } if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_ESCAPE) throw new UnsupportedBattle("Escape items are not ported yet."); this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff; const enemySpeed = this.enemy.mon.stats[C.SPEED]; let escaped = playerSpeed >= enemySpeed; if (!escaped) { const divisor = (enemySpeed >> 2) & 0xff; if (!divisor) escaped = true; else { const quotient = Math.floor(((playerSpeed * 32) & 0xffff) / divisor); if (quotient > 0xff) escaped = true; else { let odds = quotient; for (let attempt = this.numFleeAttempts - 1; attempt > 0; attempt--) { odds += 30; if (odds > 0xff) { escaped = true; break; } } if (!escaped) escaped = odds >= this.random(); } } } if (!escaped) { this.battlePlayerAction = 1; // BATTLEPLAYERACTION_USEITEM yield* this.say(BATTLE.BattleText_CantEscape2); return false; } this.battleResult = (this.battleResult & 0xc0) + C.DRAW; yield* this.say(BATTLE.BattleText_GotAwaySafely); return true; } // ------------------------------------------------------ move execution /** DoTurn: CheckTurn, then the move's effect script. */ private *doTurn(): Gen { this.turnEnded = 0; yield* this.checkTurn(); if (this.turnEnded) return; this.updateMoveData(); this.script = this.data.effectScript(this.user.move.effect); this.scriptIndex = 0; for (let steps = 0; steps < 256; steps++) { const command = this.script[this.scriptIndex++]; if (command >= COMMAND.endturn) return; yield* this.command(command); } throw new UnsupportedBattle("Move effect script did not end."); } /** EndMoveEffect: stop reading the effect script. */ private endMoveEffect() { this.script.splice(this.scriptIndex, this.script.length, 0xff, 0xff, 0xff); } private *endTurn(): Gen { this.turnEnded = 1; this.curDamage = 0; } private *checkTurn(): Gen { const user = this.user, mon = user.mon; if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE this.attackMissed = 0; this.effectFailed = 0; this.alreadyDisobeyed = 0; this.alreadyFailed = 0; this.typeModifier = C.EFFECTIVE; if (user.has(SUB4, C.SUBSTATUS_RECHARGE)) { user.reset(SUB4, C.SUBSTATUS_RECHARGE); yield* this.say(BATTLE.MustRechargeText); this.cantMove(); return yield* this.endTurn(); } if (mon.status & C.SLP_MASK) { mon.status = (mon.status & ~C.SLP_MASK) | (((mon.status & C.SLP_MASK) - 1) & C.SLP_MASK); // The player checks the sleep counter; the enemy checks the whole status. const awake = this.turn === 0 ? !(mon.status & C.SLP_MASK) : mon.status === 0; if (awake) { yield* this.say(BATTLE.WokeUpText); this.cantMove(); if (this.turn === 0) this.updateBattleMonInParty(); yield this.hud(); user.reset(SUB1, C.SUBSTATUS_NIGHTMARE); } else { yield* this.say(BATTLE.FastAsleepText); if (user.curMove !== C.SNORE && user.curMove !== C.SLEEP_TALK) { this.cantMove(); return yield* this.endTurn(); } } } if (mon.status & (1 << C.FRZ) && user.curMove !== C.FLAME_WHEEL && user.curMove !== C.SACRED_FIRE) { yield* this.say(BATTLE.FrozenSolidText); this.cantMove(); return yield* this.endTurn(); } if (user.has(SUB3, C.SUBSTATUS_FLINCHED)) { user.reset(SUB3, C.SUBSTATUS_FLINCHED); yield* this.say(BATTLE.FlinchedText); this.cantMove(); return yield* this.endTurn(); } if (user.disableCount) { user.disableCount = (user.disableCount - 1) & 0xff; if (!(user.disableCount & 0xf)) { user.disableCount = 0; user.disabledMove = 0; yield* this.say(BATTLE.DisabledNoMoreText); } } if (user.has(SUB3, C.SUBSTATUS_CONFUSED)) { user.confuseCount = (user.confuseCount - 1) & 0xff; if (!user.confuseCount) { user.reset(SUB3, C.SUBSTATUS_CONFUSED); yield* this.say(BATTLE.ConfusedNoMoreText); } else { yield* this.say(BATTLE.IsConfusedText); if (this.random() < percent(50) + 1) { user.sub[SUB3] &= 1 << C.SUBSTATUS_CONFUSED; yield* this.hitConfusion(); this.cantMove(); return yield* this.endTurn(); } } } if (user.has(SUB1, C.SUBSTATUS_IN_LOVE)) throw new UnsupportedBattle("Attraction is not ported yet."); if (user.disabledMove && user.disabledMove === user.curMove) { user.reset(SUB3, C.SUBSTATUS_CHARGED); this.strings[RAM.stringBuffer1] = this.options.data.moveName(user.curMove); yield* this.say(BATTLE.DisabledMoveText); this.cantMove(); return yield* this.endTurn(); } if (mon.status & (1 << C.PAR) && this.random() < percent(25)) { yield* this.say(BATTLE.FullyParalyzedText); this.cantMove(); return yield* this.endTurn(); } } /** CantMove: moves that span turns are interrupted. */ private cantMove() { const user = this.user; user.reset(SUB1, C.SUBSTATUS_ROLLOUT); user.sub[SUB3] &= ~((1 << C.SUBSTATUS_BIDE) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_CHARGED)); user.furyCutterCount = 0; if (user.move.anim === C.FLY || user.move.anim === C.DIG) { user.reset(SUB3, C.SUBSTATUS_UNDERGROUND); user.reset(SUB3, C.SUBSTATUS_FLYING); } } /** HitConfusion / HitSelfInConfusion: a typeless 40-power hit on itself. */ private *hitConfusion(): Gen { yield* this.say(BATTLE.HurtItselfText); this.criticalHit = 0; const user = this.user, mon = user.mon; this.curDamage = 0; let defense = mon.stats[C.DEFENSE]; if (user.screens & (1 << C.SCREENS_REFLECT)) defense *= 2; const [attack, def] = this.truncate(mon.stats[C.ATTACK], defense); this.damageCalc(40, mon.level, attack, def); this.doDamage(this.user, true); yield this.hud(); } private *command(command: number): Gen { switch (command) { case COMMAND.checkobedience: return this.checkObedience(); case COMMAND.usedmovetext: return yield* this.say(BATTLE.UsedMoveText); case COMMAND.doturn: return yield* this.doTurnCommand(); case COMMAND.critical: return this.critical(); case COMMAND.damagestats: return this.damageStats(); case COMMAND.damagecalc: return this.damageCalcCommand(); case COMMAND.stab: return this.stab(); case COMMAND.damagevariation: return this.damageVariation(); case COMMAND.checkhit: return yield* this.checkHit(); case COMMAND.effectchance: return this.effectChance(); case COMMAND.lowersub: case COMMAND.raisesub: case COMMAND.moveanimnosub: case COMMAND.moveanim: case COMMAND.statupanim: case COMMAND.statdownanim: case COMMAND.movedelay: return this.requireNoSubstitute(); case COMMAND.failuretext: return yield* this.failureText(); case COMMAND.applydamage: return yield* this.applyDamage(); case COMMAND.criticaltext: return yield* this.criticalText(); case COMMAND.supereffectivetext: return yield* this.superEffectiveText(); case COMMAND.checkfaint: return yield* this.checkFaint(); case COMMAND.buildopponentrage: return yield* this.buildOpponentRage(); case COMMAND.kingsrock: return this.kingsRock(); case COMMAND.poisontarget: return yield* this.poisonTarget(); case COMMAND.burntarget: return yield* this.burnTarget(); case COMMAND.paralyzetarget: return yield* this.paralyzeTarget(); case COMMAND.flinchtarget: return this.flinchTarget(); case COMMAND.sleeptarget: return yield* this.sleepTarget(); case COMMAND.poison: return yield* this.poisonCommand(); case COMMAND.paralyze: return yield* this.paralyzeCommand(); case COMMAND.confuse: return yield* this.confuseCommand(); case COMMAND.draintarget: return yield* this.drainTarget(); case COMMAND.recoil: return yield* this.recoil(); case COMMAND.checksafeguard: return yield* this.checkSafeguard(); case COMMAND.splash: return yield* this.say(BATTLE.NothingHappenedText); case COMMAND.foresight: return yield* this.foresight(); case COMMAND.rage: return this.user.set(SUB4, C.SUBSTATUS_RAGE); case COMMAND.ragedamage: return this.rageDamage(); case COMMAND.screen: return yield* this.screen(); case COMMAND.curl: return this.user.set(SUB2, C.SUBSTATUS_CURLED); case COMMAND.doubleflyingdamage: if (this.target.has(SUB3, C.SUBSTATUS_FLYING)) this.curDamage = Math.min(0xffff, this.curDamage * 2); return; case COMMAND.clearmissdamage: if (this.attackMissed) this.curDamage = 0; return; case COMMAND.switchturn: this.turn ^= 1; return; case COMMAND.healmorn: case COMMAND.healday: case COMMAND.healnite: return yield* this.timeBasedHeal(command - COMMAND.healmorn); case COMMAND.statupmessage: return yield* this.statUpMessage(); case COMMAND.statdownmessage: return yield* this.statDownMessage(); case COMMAND.statupfailtext: return yield* this.statUpFailText(); case COMMAND.statdownfailtext: return yield* this.statDownFailText(); default: if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) { const stat = (command - COMMAND.attackup) % 7; return this.raiseStat(stat | (command >= COMMAND.attackup2 ? 0x10 : 0)); } if (command >= COMMAND.attackdown && command <= COMMAND.evasiondown2) { const stat = (command - COMMAND.attackdown) % 7; return this.statDown(stat | (command >= COMMAND.attackdown2 ? 0x10 : 0)); } throw new UnsupportedBattle(`Battle command 0x${command.toString(16)} is not ported.`); } } /** Substitute presentation is only safe once substitutes exist. */ private requireNoSubstitute() { if (this.user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) throw new UnsupportedBattle("Substitutes are not ported yet."); } /** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */ private checkObedience() { if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return; if (this.partyMon.otId !== this.options.playerId) throw new UnsupportedBattle("Traded Pokémon obedience is not ported yet."); } /** BattleCommand_DoTurn: count the turn and use one PP. */ private *doTurnCommand(): Gen { const user = this.user; if (user.charging) return; user.turnsTaken = (user.turnsTaken + 1) & 0xff; if (user.curMove === C.STRUGGLE) return; if (user.sub[SUB3] & ((1 << C.SUBSTATUS_IN_LOOP) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE))) return; const slot = user.moveNum; if (!(user.mon.pp[slot] & C.PP_MASK)) { const continuous = [ C.EFFECT_RAZOR_WIND, C.EFFECT_SKY_ATTACK, C.EFFECT_SKULL_BASH, C.EFFECT_SOLARBEAM, C.EFFECT_FLY, C.EFFECT_ROLLOUT, C.EFFECT_BIDE, C.EFFECT_RAMPAGE, ] as number[]; yield* this.say(continuous.includes(user.move.effect) ? BATTLE.HasNoPPLeftText : BATTLE.NoPPLeftText); return this.endMoveEffect(); } user.mon.pp[slot]--; if (user.has(SUB5, C.SUBSTATUS_TRANSFORMED)) return; if (this.turn === 0) { // The party copy loses PP too, unless the move came from Mimic. if (user.curMove === C.MIMIC || this.partyMon.moves[slot] !== C.MIMIC) this.partyMon.pp[slot]--; } } private critical() { this.criticalHit = 0; const user = this.user, mon = user.mon; if (!user.move.power) return; let stage = 0; if (mon.species === C.CHANSEY && mon.item === C.LUCKY_PUNCH) stage = 2; else if (mon.species === C.FARFETCH_D && mon.item === C.STICK) stage = 2; else { if (user.has(SUB4, C.SUBSTATUS_FOCUS_ENERGY)) stage++; if (this.data.byteList(BATTLE.CriticalHitMoves).includes(user.move.anim)) stage += 2; if (this.data.heldEffect(mon.item).effect === C.HELD_CRITICAL_UP) stage++; } if (this.random() < this.data.criticalChance(stage)) this.criticalHit = 1; } /** BattleCommand_Screen: a repeated screen fails without renewing it. */ private *screen(): Gen { const side = this.user; const light = side.move.effect === C.EFFECT_LIGHT_SCREEN; const bit = 1 << (light ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT); if (side.screens & bit) return yield* this.say(BATTLE.ButItFailedText); side.screens |= bit; if (light) side.lightScreenCount = 5; else side.reflectCount = 5; yield* this.say(light ? BATTLE.LightScreenEffectText : BATTLE.ReflectEffectText); } /** HandleScreens, in non-Link order. Screens belong to the side and * survive a send-out; their setup turn is the first of five ticks. */ private *handleScreens(): Gen { for (const [turn, side] of [this.player, this.enemy].entries()) { this.turn = turn; this.strings[RAM.stringBuffer1] = turn === 0 ? "Your" : "Enemy"; if (side.screens & (1 << C.SCREENS_LIGHT_SCREEN)) { side.lightScreenCount = (side.lightScreenCount - 1) & 0xff; if (!side.lightScreenCount) { side.screens &= ~(1 << C.SCREENS_LIGHT_SCREEN); yield* this.say(BATTLE.BattleText_MonsLightScreenFell); } } if (side.screens & (1 << C.SCREENS_REFLECT)) { side.reflectCount = (side.reflectCount - 1) & 0xff; if (!side.reflectCount) { side.screens &= ~(1 << C.SCREENS_REFLECT); yield* this.say(BATTLE.BattleText_MonsReflectFaded); } } } } private damageArgs = { power: 0, level: 0, attack: 0, defense: 0 }; /** PlayerAttackDamage / EnemyAttackDamage. */ private damageStats() { this.curDamage = 0; const user = this.user, target = this.target, power = user.move.power; this.damageArgs.power = power; if (!power) return; const special = user.move.type >= C.SPECIAL; const stat = special ? C.SP_ATTACK : C.ATTACK, defenseStat = special ? C.SP_DEFENSE : C.DEFENSE; const screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT; // Criticals ignore stages when the target's are at least as high. const unboosted = this.criticalHit && target.levels[defenseStat] >= user.levels[stat]; let attack: number, defense: number; if (unboosted) { attack = user.stats[stat]; defense = target.stats[defenseStat]; } else { attack = user.mon.stats[stat]; defense = target.mon.stats[defenseStat]; if (target.screens & (1 << screen)) defense = (defense * 2) & 0xffff; } attack = this.speciesItemBoost(attack, special); [attack, defense] = this.truncate(attack, defense); // DittoMetalPowder boosts the defender's Defense. if (target.mon.species === C.DITTO && target.mon.item === C.METAL_POWDER) throw new UnsupportedBattle("Metal Powder is not ported yet."); this.damageArgs = { power, level: user.mon.level, attack, defense }; } /** ThickClubBoost / LightBallBoost. */ private speciesItemBoost(attack: number, special: boolean) { const mon = this.user.mon; const boosted = special ? mon.species === C.PIKACHU && mon.item === C.LIGHT_BALL : (mon.species === C.CUBONE || mon.species === C.MAROWAK) && mon.item === C.THICK_CLUB; return boosted ? (attack * 2) & 0xffff : attack; } /** TruncateHL_BC: scale both stats down by 4 until they fit a byte. */ private truncate(attack: number, defense: number): [number, number] { while (attack > 0xff || defense > 0xff) { defense = defense >> 2 || 1; attack = attack >> 2 || 1; } return [attack, defense]; } private damageCalcCommand() { const { power, level, attack, defense } = this.damageArgs; this.damageCalc(power, level, attack, defense); } /** BattleCommand_DamageCalc. */ private damageCalc(power: number, level: number, attack: number, defense: number) { const held = this.data.heldEffect(this.user.mon.item); const boost = held.effect && this.data.typeBoost(held.effect) === this.user.move.type ? held.parameter : undefined; const damage = calculateDamage({ power, level, attack, defense, effect: this.user.move.effect, critical: Boolean(this.criticalHit), boost, current: this.curDamage, }); if (damage !== undefined) this.curDamage = damage; } /** BattleCommand_Stab, including type effectiveness. */ private stab() { const user = this.user, target = this.target; if (user.move.anim === C.STRUGGLE) return; if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); const result = applyTypeMatchups(this.matchups, { damage: this.curDamage, modifier: this.typeModifier, moveType: user.move.type, userTypes: user.mon.types, targetTypes: target.mon.types, identified: target.has(SUB1, C.SUBSTATUS_IDENTIFIED), }); this.curDamage = result.damage; this.typeModifier = result.modifier; if (result.missed) this.attackMissed = 1; } /** BattleCommand_DamageVariation: 217-255 / 255. */ private damageVariation() { if (this.curDamage < 2) return; let multiplier: number; do { const r = this.random(); multiplier = ((r >> 1) | ((r & 1) << 7)) & 0xff; } while (multiplier < percent(85) + 1); this.curDamage = Math.floor((this.curDamage * multiplier) / 255) & 0xffff; } /** BattleCommand_CheckHit. */ private *checkHit(): Gen { const user = this.user, target = this.target, effect = user.move.effect; const miss = () => { if (effect !== C.EFFECT_JUMP_KICK) this.curDamage = 0; this.attackMissed = 1; }; if (effect === C.EFFECT_DREAM_EATER && !(target.mon.status & C.SLP_MASK)) return miss(); if (target.has(SUB1, C.SUBSTATUS_PROTECT)) { yield* this.say(BATTLE.ProtectingItselfText); return miss(); } if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && (effect === C.EFFECT_LEECH_HIT || effect === C.EFFECT_DREAM_EATER)) return miss(); if (target.has(SUB5, C.SUBSTATUS_LOCK_ON)) throw new UnsupportedBattle("Lock-On is not ported yet."); if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))) throw new UnsupportedBattle("Fly and Dig are not ported yet."); if (effect === C.EFFECT_THUNDER && this.weather === C.WEATHER_RAIN) return; if (user.has(SUB4, C.SUBSTATUS_X_ACCURACY)) return; if (effect === C.EFFECT_ALWAYS_HIT) return; this.statModifiers(); let accuracy = user.move.accuracy; const opponentItem = this.data.heldEffect(target.mon.item); if (opponentItem.effect === C.HELD_BRIGHTPOWDER) accuracy = Math.max(0, accuracy - opponentItem.parameter); if (accuracy === 0xff) return; if (this.random() >= accuracy) miss(); } /** CheckHit.StatModifiers: accuracy and evasion stages on the move. */ private statModifiers() { const user = this.user, target = this.target; const accuracyLevel = user.levels[C.ACCURACY], evasionLevel = target.levels[C.EVASION]; if (evasionLevel >= accuracyLevel && target.has(SUB1, C.SUBSTATUS_IDENTIFIED)) return; let value = user.move.accuracy; for (const level of [accuracyLevel, C.MAX_STAT_LEVEL + 1 - evasionLevel]) { const [numerator, denominator] = this.data.accuracyMultiplier(level); value = Math.floor((value * numerator) / denominator) || 1; } user.move.accuracy = value > 0xff ? 0xff : value; } private effectChance() { this.effectFailed = 0; if (this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || !(this.random() < this.user.move.chance)) this.effectFailed = 1; } /** BattleCommand_FailureText. */ private *failureText(): Gen { if (!this.attackMissed) return; yield* this.getFailureResultText(); const user = this.user; if (user.move.anim === C.FLY || user.move.anim === C.DIG) throw new UnsupportedBattle("Fly and Dig are not ported yet."); this.endMoveEffect(); } private *getFailureResultText(): Gen { let text: number = BATTLE.DoesntAffectText, protectedText: number = BATTLE.DoesntAffectText; if (this.typeModifier & C.EFFECTIVENESS_MASK) { if (this.user.move.effect === C.EFFECT_FUTURE_SIGHT) { text = BATTLE.ButItFailedText; protectedText = BATTLE.ItFailedText; } else { text = BATTLE.AttackMissedText; protectedText = BATTLE.AttackMissed2Text; if (this.criticalHit === 0xff) text = BATTLE.UnaffectedText; } } yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text); this.criticalHit = 0; if (this.user.move.effect === C.EFFECT_JUMP_KICK && this.typeModifier & C.EFFECTIVENESS_MASK) throw new UnsupportedBattle("Jump Kick crash damage is not ported yet."); } /** BattleCommand_ApplyDamage. */ private *applyDamage(): Gen { const target = this.target; let hungOn = 0; if (target.has(SUB1, C.SUBSTATUS_ENDURE)) throw new UnsupportedBattle("Endure is not ported yet."); const item = this.data.heldEffect(target.mon.item); if (item.effect === C.HELD_FOCUS_BAND && this.random() < item.parameter) hungOn = this.falseSwipe() ? 2 : 0; if (!target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) target.damageTaken = Math.min(0xffff, target.damageTaken + this.curDamage); this.doDamage(target, false); yield this.hud(); if (hungOn === 2) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); yield* this.say(BATTLE.HungOnText); } } /** BattleCommand_FalseSwipe's rule: leave 1 HP if the hit would faint. */ private falseSwipe(): boolean { const hp = this.target.mon.hp; if (this.curDamage < hp) return false; this.curDamage = hp - 1; if (this.curDamage <= 0) { this.curDamage = 0; return false; } return true; } /** DoEnemyDamage / DoPlayerDamage. */ private doDamage(side: Side, ignoreSubstitute: boolean) { if (!this.curDamage) return; if (!ignoreSubstitute && side.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) throw new UnsupportedBattle("Substitutes are not ported yet."); const hp = side.mon.hp; if (this.curDamage > hp) this.curDamage = hp; side.mon.hp = hp - this.curDamage; if (side === this.player) this.updateBattleMonInParty(); } private *criticalText(): Gen { if (!this.criticalHit) return; yield* this.say(this.criticalHit === 1 ? BATTLE.CriticalHitText : BATTLE.OneHitKOText); this.criticalHit = 0; } private *superEffectiveText(): Gen { const effectiveness = this.typeModifier & C.EFFECTIVENESS_MASK; if (effectiveness === C.EFFECTIVE) return; yield* this.say(effectiveness > C.EFFECTIVE ? BATTLE.SuperEffectiveText : BATTLE.NotVeryEffectiveText); } /** BattleCommand_CheckFaint: end the move if the target fainted. */ private *checkFaint(): Gen { if (this.target.mon.hp) return; if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND)) throw new UnsupportedBattle("Destiny Bond is not ported yet."); const effect = this.user.move.effect; if ([C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never)) throw new UnsupportedBattle("Multi-hit moves are not ported yet."); this.endMoveEffect(); } private *buildOpponentRage(): Gen { if (this.attackMissed) return; const target = this.target; if (!target.has(SUB4, C.SUBSTATUS_RAGE)) return; if (target.rageCounter === 0xff) return; target.rageCounter++; this.turn ^= 1; yield* this.say(BATTLE.RageBuildingText); this.turn ^= 1; } private rageDamage() { let damage = this.curDamage; for (let count = this.user.rageCounter; count > 0; count--) { damage += this.curDamage; if (damage > 0xffff) { damage = 0xffff; break; } } this.curDamage = damage; } /** BattleCommand_HeldFlinch (kingsrock). */ private kingsRock() { if (this.attackMissed) return; const item = this.data.heldEffect(this.user.mon.item); if (item.effect !== C.HELD_FLINCH || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; if (this.random() >= item.parameter) return; this.target.reset(SUB4, C.SUBSTATUS_RECHARGE); this.target.set(SUB3, C.SUBSTATUS_FLINCHED); } // ------------------------------------------------------------- statuses private safeguarded() { return Boolean(this.target.screens & (1 << C.SCREENS_SAFEGUARD)); } private isType(side: Side, type: number) { return side.mon.types[0] === type || side.mon.types[1] === type; } /** UseHeldStatusHealingItem for the opponent. Returns true if it cured. */ private *useHeldStatusHealingItem(): Gen { const side = this.target, held = this.data.heldEffect(side.mon.item); const mask = this.data.heldStatusHealing(held.effect); if (mask === undefined || !(side.mon.status & mask)) return false; side.mon.status = 0; this.updateOpponentInParty(); side.reset(SUB5, C.SUBSTATUS_TOXIC); side.reset(SUB1, C.SUBSTATUS_NIGHTMARE); // ALL_STATUS (a Miracle Berry) also cures confusion. if (mask === this.data.heldStatusHealing(C.HELD_HEAL_STATUS)) side.reset(SUB3, C.SUBSTATUS_CONFUSED); this.calcStats(side); yield this.hud(); yield* this.useOpponentItem(); return true; } /** UseOpponentItem: name the item, consume it and report the recovery. */ private *useOpponentItem(): Gen { const side = this.target; this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); this.consumeHeldItem(side); yield* this.say(BATTLE.RecoveredUsingText); } /** ConsumeHeldItem: consumable held items are used up for good. */ private consumeHeldItem(side: Side) { const effect = this.data.heldEffect(side.mon.item).effect; if (!this.data.byteList(BATTLE.ConsumableEffects).includes(effect)) return; side.mon.item = 0; if (side === this.player) this.partyMon.item = 0; } /** HandleHPHealingItem for the opponent: a Berry below half HP. */ private *handleHPHealingItem(): Gen { const side = this.target, mon = side.mon, held = this.data.heldEffect(mon.item); if (held.effect !== C.HELD_BERRY) return; if (((mon.hp * 2) & 0xffff) >= mon.maxHp) return; mon.hp = Math.min(mon.maxHp, mon.hp + held.parameter); if (side === this.player) this.updateBattleMonInParty(); yield this.hud(); yield* this.useOpponentItem(); } /** UseConfusionHealingItem for the opponent. */ private *useConfusionHealingItem(): Gen { const side = this.target; if (!side.has(SUB3, C.SUBSTATUS_CONFUSED)) return; const effect = this.data.heldEffect(side.mon.item).effect; if (effect !== C.HELD_HEAL_CONFUSION && effect !== C.HELD_HEAL_STATUS) return; side.reset(SUB3, C.SUBSTATUS_CONFUSED); this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); yield* this.say(BATTLE.BattleText_ItemHealedConfusion); side.mon.item = 0; if (side === this.player) this.partyMon.item = 0; } private *poisonTarget(): Gen { const target = this.target; if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return; if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON)) return; if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) return; if (this.effectFailed || this.safeguarded()) return; target.mon.status |= 1 << C.PSN; this.updateOpponentInParty(); yield this.hud(); yield* this.say(BATTLE.WasPoisonedText); yield* this.useHeldStatusHealingItem(); } private *burnTarget(): Gen { const target = this.target; if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; if (target.mon.status) { if (target.mon.status & (1 << C.FRZ)) throw new UnsupportedBattle("Defrosting is not ported yet."); return; } if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return; if (this.user.move.type !== C.NORMAL && this.isType(target, this.user.move.type)) return; if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_BURN) return; if (this.effectFailed || this.safeguarded()) return; target.mon.status |= 1 << C.BRN; this.updateOpponentInParty(); this.applyStatusEffects(target); yield this.hud(); yield* this.say(BATTLE.WasBurnedText); yield* this.useHeldStatusHealingItem(); } private *paralyzeTarget(): Gen { const target = this.target; if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return; if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return; if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) return; if (this.effectFailed || this.safeguarded()) return; target.mon.status |= 1 << C.PAR; this.updateOpponentInParty(); this.applyStatusEffects(target); yield this.hud(); yield* this.say(BATTLE.ParalyzedText); yield* this.useHeldStatusHealingItem(); } private flinchTarget() { const target = this.target; if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; if (target.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return; if (this.enemyGoesFirst ^ this.turn) return; if (this.effectFailed) return; target.set(SUB3, C.SUBSTATUS_FLINCHED); target.reset(SUB4, C.SUBSTATUS_RECHARGE); } /** The AI's 25% failure for status moves (not for the player). */ private aiRandomFail() { if (this.turn === 0 || this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false; return this.random() < percent(25) + 1; } private *didntAffect2(): Gen { yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.DidntAffect2Text : BATTLE.DidntAffect1Text); } private *sleepTarget(): Gen { const target = this.target; const item = this.data.heldEffect(target.mon.item); if (item.effect === C.HELD_PREVENT_SLEEP) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); return yield* this.say(BATTLE.ProtectedByText); } if (target.mon.status & C.SLP_MASK) return yield* this.say(BATTLE.AlreadyAsleepText); if (this.attackMissed) return yield* this.didntAffect2(); if (this.aiRandomFail() || target.mon.status || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.say(BATTLE.DidntAffect1Text); let turns: number; do turns = this.random() & C.SLP_MASK; while (!turns || turns === C.SLP_MASK); target.mon.status = turns + 1; this.updateOpponentInParty(); yield this.hud(); yield* this.say(BATTLE.FellAsleepText); // A sleeping opponent cannot finish a move that spans turns. if (!(yield* this.useHeldStatusHealingItem())) { this.turn ^= 1; this.cantMove(); this.turn ^= 1; } } private *poisonCommand(): Gen { const target = this.target; if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON)) return yield* this.say(BATTLE.DoesntAffectText); if (target.mon.status & (1 << C.PSN)) return yield* this.say(BATTLE.AlreadyPoisonedText); if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); return yield* this.say(BATTLE.ProtectedByText); } if (target.mon.status || this.aiRandomFail() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) return yield* this.say(BATTLE.DidntAffect1Text); if (this.user.move.effect === C.EFFECT_TOXIC) throw new UnsupportedBattle("Toxic is not ported yet."); target.mon.status |= 1 << C.PSN; this.updateOpponentInParty(); yield this.hud(); yield* this.say(BATTLE.WasPoisonedText); yield* this.useHeldStatusHealingItem(); } private *paralyzeCommand(): Gen { const target = this.target; if (target.mon.status & (1 << C.PAR)) return yield* this.say(BATTLE.AlreadyParalyzedText); if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return yield* this.say(BATTLE.DoesntAffectText); if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); return yield* this.say(BATTLE.ProtectedByText); } if (this.aiRandomFail() || target.mon.status || this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.didntAffect2(); target.mon.status |= 1 << C.PAR; this.updateOpponentInParty(); this.applyStatusEffects(target); yield this.hud(); yield* this.say(BATTLE.ParalyzedText); yield* this.useHeldStatusHealingItem(); } private *confuseCommand(): Gen { const target = this.target; if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); return yield* this.say(BATTLE.ProtectedByText); } if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.say(BATTLE.AlreadyConfusedText); if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) { const effect = this.user.move.effect; if (effect === C.EFFECT_CONFUSE_HIT || effect === C.EFFECT_SNORE || effect === C.EFFECT_SWAGGER) return; return yield* this.didntAffect2(); } target.set(SUB3, C.SUBSTATUS_CONFUSED); target.confuseCount = (this.random() & 3) + 2; yield* this.say(BATTLE.BecameConfusedText); yield* this.useConfusionHealingItem(); } /** BattleCommand_DrainTarget: SapHealth restores half the damage. */ private *drainTarget(): Gen { const mon = this.user.mon; const amount = this.curDamage >> 1 || 1; mon.hp = Math.min(mon.maxHp, mon.hp + amount); if (this.turn === 0) this.updateBattleMonInParty(); yield this.hud(); yield* this.say(BATTLE.SuckedHealthText); } /** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */ private *recoil(): Gen { const mon = this.user.mon; mon.hp = Math.max(0, mon.hp - (this.curDamage >> 2 || 1)); if (this.turn === 0) this.updateBattleMonInParty(); yield this.hud(); yield* this.say(BATTLE.RecoilText); } private *checkSafeguard(): Gen { if (!this.safeguarded()) return; this.attackMissed = 1; yield* this.say(BATTLE.SafeguardProtectText); this.endMoveEffect(); } private *foresight(): Gen { const target = this.target; if (this.attackMissed || target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)) || target.has(SUB1, C.SUBSTATUS_IDENTIFIED)) return yield* this.say(target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText); target.set(SUB1, C.SUBSTATUS_IDENTIFIED); yield* this.say(BATTLE.IdentifiedText); } /** BattleCommand_TimeBasedHealContinue (Morning Sun, Synthesis, Moonlight). */ private *timeBasedHeal(time: number): Gen { const mon = this.user.mon; if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText); let index = 2; if (this.options.timeOfDay !== time) index--; if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); const quarter = mon.maxHp >> 2 || 1; const amount = [quarter >> 1 || 1, quarter, mon.maxHp >> 1 || 1, mon.maxHp][index]; mon.hp = Math.min(mon.maxHp, mon.hp + amount); if (this.turn === 0) this.updateBattleMonInParty(); yield this.hud(); yield* this.say(BATTLE.RegainedHealthText); } private updateOpponentInParty() { if (this.turn === 1) this.updateBattleMonInParty(); } // ------------------------------------------------------ stat changes /** RaiseStat (BattleCommand_StatUp). */ private raiseStat(stat: number) { this.loweredStat = stat; const user = this.user, index = stat & 0xf; if (this.attackMissed || this.effectFailed) { this.failedMessage = 1; return; } let level = user.levels[index] + 1; if (level > C.MAX_STAT_LEVEL) { this.failedMessage = 2; this.attackMissed = 1; return; } if (stat & 0xf0) level = Math.min(C.MAX_STAT_LEVEL, level + 1); user.levels[index] = level; if (index < C.ACCURACY) { if (user.mon.stats[index] === 999) { user.levels[index]--; this.failedMessage = 2; this.attackMissed = 1; return; } this.calcStats(user); } this.failedMessage = 0; if (user.move.anim === C.MINIMIZE) throw new UnsupportedBattle("Minimize is not ported yet."); } /** BattleCommand_StatDown. */ private statDown(stat: number) { this.loweredStat = stat; const effect = this.user.move.effect; const mistEffect = (effect >= C.EFFECT_ATTACK_DOWN && effect <= C.EFFECT_EVASION_DOWN) || (effect >= C.EFFECT_ATTACK_DOWN_2 && effect <= C.EFFECT_EVASION_DOWN_2) || (effect >= C.EFFECT_ATTACK_DOWN_HIT && effect <= C.EFFECT_EVASION_DOWN_HIT); const target = this.target; if (mistEffect && target.has(SUB4, C.SUBSTATUS_MIST)) { this.failedMessage = 2; this.attackMissed = 1; return; } const index = stat & 0xf; let level = target.levels[index] - 1; const cantLower = () => { this.failedMessage = 3; this.attackMissed = 1; }; if (!level) return cantLower(); if (stat & 0xf0) { level--; if (!level) level = 1; } const failed = () => { this.failedMessage = 1; this.attackMissed = 1; }; if (this.turn === 1 && effect !== C.EFFECT_ACCURACY_DOWN_HIT && !this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) if (this.random() < percent(25) + 1) return failed(); if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed || this.effectFailed) return failed(); if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))) return failed(); target.levels[index] = level; if (index < C.ACCURACY) { // TryLowerStat: a stat already at 1 cannot drop further. The level is // raised back by one, even after a two-stage drop. if (target.mon.stats[index] === 1) { target.levels[index] = level + 1; return cantLower(); } this.calcStats(target); } this.failedMessage = 0; } /** CalcPlayerStats / CalcEnemyStats: stages, then status penalties. */ private calcStats(side: Side) { for (let stat = 0; stat < 5; stat++) { const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]); const value = Math.floor((side.stats[stat] * numerator) / denominator); side.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value); } this.applyStatusEffects(side); } private statName() { this.strings[RAM.stringBuffer2] = readString( this.reader, this.data.statNameAddress(this.loweredStat & 0xf), this.context(), ); } private *statUpMessage(): Gen { if (this.failedMessage) return; this.statName(); yield* this.say(BATTLE["BattleCommand_StatUpMessage.stat"]); } private *statDownMessage(): Gen { if (this.failedMessage) return; this.statName(); yield* this.say(BATTLE["BattleCommand_StatDownMessage.stat"]); } private *statUpFailText(): Gen { if (!this.failedMessage) return; if (this.failedMessage === 1) { if (!this.alreadyFailed) yield* this.say(BATTLE.ButItFailedText); return; } this.statName(); yield* this.say(BATTLE.WontRiseAnymoreText); } private *statDownFailText(): Gen { if (!this.failedMessage) return; if (this.failedMessage === 1) { if (!this.alreadyFailed) yield* this.say(BATTLE.ButItFailedText); return; } if (this.failedMessage === 2) return yield* this.say(BATTLE.ProtectedByMistText); this.statName(); yield* this.say(BATTLE.WontDropAnymoreText); } // ------------------------------------------------------- turn ending /** ResidualDamage for the side whose turn it is. Returns false if it fainted. */ private *residualDamage(): Gen { const user = this.user, mon = user.mon; if (!mon.hp) return false; if (mon.status & ((1 << C.PSN) | (1 << C.BRN))) { yield* this.say(mon.status & (1 << C.BRN) ? BATTLE.HurtByBurnText : BATTLE.HurtByPoisonText); if (user.has(SUB5, C.SUBSTATUS_TOXIC)) throw new UnsupportedBattle("Toxic is not ported yet."); const quarter = mon.maxHp >> 2 || 1; this.subtractHP(user, quarter >> 1 || 1); yield this.hud(); } if (!mon.hp) return false; if (user.sub[SUB4] & (1 << C.SUBSTATUS_LEECH_SEED) || user.sub[SUB1] & ((1 << C.SUBSTATUS_NIGHTMARE) | (1 << C.SUBSTATUS_CURSE))) throw new UnsupportedBattle("Leech Seed, Nightmare and Curse are not ported yet."); return mon.hp > 0; } private subtractHP(side: Side, amount: number) { side.mon.hp = Math.max(0, side.mon.hp - amount); if (side === this.player) this.updateBattleMonInParty(); } /** HandleBetweenTurnEffects. Effects no early move can cause are refused. */ private *handleBetweenTurnEffects(): Gen { if (yield* this.checkFaintPlayerThenEnemy()) return; if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); for (const side of [this.player, this.enemy]) { if (side.wrapCount || side.has(SUB1, C.SUBSTATUS_PERISH) || side.has(SUB5, C.SUBSTATUS_ENCORED)) throw new UnsupportedBattle("Wrap, Perish Song and Encore are not ported yet."); if (side.screens & ~((1 << C.SCREENS_LIGHT_SCREEN) | (1 << C.SCREENS_REFLECT))) throw new UnsupportedBattle("Other screen effects are not ported yet."); const effect = this.data.heldEffect(side.mon.item).effect; if (effect === C.HELD_RESTORE_PP || (effect >= C.HELD_ATTACK_UP && effect <= C.HELD_EVASION_UP)) throw new UnsupportedBattle("Mystery Berry and stat-raising held items are not ported yet."); } // HandleLeftovers: each side heals itself; the text names it as target. for (const side of [this.player, this.enemy]) { const mon = side.mon; if (this.data.heldEffect(mon.item).effect !== C.HELD_LEFTOVERS || mon.hp === mon.maxHp) continue; this.strings[RAM.stringBuffer1] = this.options.data.itemName(mon.item); const quarter = mon.maxHp >> 2 || 1; mon.hp = Math.min(mon.maxHp, mon.hp + (quarter >> 2 || 1)); if (side === this.player) this.updateBattleMonInParty(); this.turn = side === this.player ? 1 : 0; yield this.hud(); yield* this.say(BATTLE.BattleText_TargetRecoveredWithItem); } // HandleDefrost: a 10% thaw for a side that did not just freeze. for (const side of [this.player, this.enemy]) { if (!(side.mon.status & (1 << C.FRZ)) || side.justGotFrozen) continue; if (this.random() >= percent(10)) continue; side.mon.status = 0; if (side === this.player) this.updateBattleMonInParty(); this.turn = side === this.player ? 1 : 0; yield this.hud(); yield* this.say(BATTLE.DefrostedOpponentText); } yield* this.handleScreens(); // HandleHealingItems: turn 0 handles the enemy's items, then turn 1. for (const turn of [0, 1]) { this.turn = turn; yield* this.handleHPHealingItem(); yield* this.useHeldStatusHealingItem(); yield* this.useConfusionHealingItem(); } this.updateBattleMonInParty(); } private *checkFaintPlayerThenEnemy(): Gen { if (this.hasFainted(this.player)) { yield* this.handlePlayerMonFaint(); if (this.battleEnded) return true; } if (this.hasFainted(this.enemy)) { yield* this.handleEnemyMonFaint(); if (this.battleEnded) return true; } return false; } private *handleEnemyMonFaint(): Gen { yield* this.say(BATTLE.BattleText_EnemyMonFainted); if (!this.player.mon.hp) yield* this.faintYourPokemon(); yield* this.updateStateAndExperienceAfterEnemyFaint(false); if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle(); yield this.hud(); // A wild battle ends; so does a trainer's with no Pokémon left (WinTrainerBattle). if (!this.options.trainer || this.enemyTrainerDefeated()) { this.battleEnded = true; return; } if (!this.player.mon.hp) { // Trainer battles never ask, so both sides send out a new Pokémon. yield* this.askUseNextPokemon(); yield* this.forcePlayerMonChoice(); this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM; return yield* this.doubleSwitch(); } // HandleEnemySwitch: the player's Pokémon stays in. this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM; yield* this.enemyPartyMonEntrance(false); this.battlePlayerAction = 0; } /** CheckEnemyTrainerDefeated: every party HP is zero. */ private enemyTrainerDefeated() { return this.options.trainer!.party.every((mon) => mon.hp === 0); } /** EnemyPartyMonEntrance. `setMode` skips Shift mode's offer, as after a * double faint. */ private *enemyPartyMonEntrance(setMode: boolean): Gen { this.enemySwitchMonIndex = 0; this.newEnemyMonStatus(); this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels this.breakAttraction(); if (setMode) yield* this.enemySwitchSetMode(); else yield* this.enemySwitch(); this.resetBattleParticipants(); this.turn = 1; if (this.enemy.screens & (1 << C.SCREENS_SPIKES)) throw new UnsupportedBattle("Spikes are not ported yet."); this.enemy.move.anim = 0; this.battlePlayerAction = 0; } /** EnemySwitch: in Shift mode the player may switch too (OfferSwitch). */ private *enemySwitch(): Gen { if (!this.checkWhetherToAskSwitch()) return yield* this.enemySwitchSetMode(); this.loadEnemyMonToSwitchTo(); const switching = yield* this.offerSwitch(); yield* this.say(BATTLE.BattleText_EnemySentOut); yield this.hud(); if (!switching) return; this.participants = this.participantsAll = 0; this.battlePlayerAction = 0; this.enemyIsSwitching = 1; yield* this.playerSwitch(); } private *enemySwitchSetMode(): Gen { this.loadEnemyMonToSwitchTo(); this.enemyIsSwitching = 1; yield* this.say(BATTLE.BattleText_EnemySentOut); yield this.hud(); } /** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the * type-matchup choice) and LoadEnemyMonToSwitchTo. */ private loadEnemyMonToSwitchTo() { this.resetEnemyBattleVars(); const index = this.enemySwitchMonIndex ? this.enemySwitchMonIndex - 1 : this.battleHasJustStarted ? 0 : this.findMonInOTPartyToSwitchIntoBattle(); if (!this.options.trainer!.party[index]?.hp) throw new UnsupportedBattle("Invalid trainer switch."); // LoadEnemyMon records wCurOTMon. this.curOTMon = index; this.loadEnemyMon(); } /** CheckWhetherToAskSwitch: the SHIFT battle style asks when the player * has another Pokémon and its own is still up. */ private checkWhetherToAskSwitch() { return ( !this.battleHasJustStarted && this.options.shift !== false && this.options.party.length > 1 && this.partyMon.hp > 0 ); } /** OfferSwitch. Returns true if the player chose a Pokémon to send out. */ private *offerSwitch(): Gen { yield* this.say(BATTLE.BattleText_EnemyIsAboutToUseWillPlayerChangeMon); if (!(yield { kind: "yesno", text: "" })) return false; const slot = yield* this.pickSwitchMon(); if (slot < 0) return false; this.lastPlayerMon = this.curBattleMon; this.curBattleMon = slot; this.enemy.curMove = 0; this.player.curMove = 0; return true; } // ------------------------------------------------------------ switching /** The battle party menu. Returns a party index, or -1 if cancelled; * without `cancel`, B is refused (ForcePickPartyMonInBattle's SFX_WRONG). */ private *partyMenu(text: number, menu: boolean, cancel: boolean): Gen { const prompt = readString(this.reader, text, this.context()); for (;;) { const answer = yield { kind: "party", text: prompt, menu, cancel }; if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length) { this.partyMenuCursor = answer + 1; // PartyMenuSelect return (this.curPartyMon = answer); } if (cancel) return -1; } } /** CheckIfCurPartyMonIsFitToFight, with its texts. */ private *checkFitToFight(slot: number): Gen { 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); return false; } /** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */ private *pickSwitchMon(): Gen { for (;;) { const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, true); if (slot < 0) return -1; if (!(yield* this.checkFitToFight(slot))) continue; if (slot === this.curBattleMon) { yield* this.say(BATTLE.BattleText_MonIsAlreadyOut); continue; } return slot; } } /** BattleMenu_PKMN with BattleMonMenu's SWITCH, then TryPlayerSwitch. * Returns true if the player switched, which takes the turn. STATS is * a presentation screen, and CANCEL returns to the party menu. */ private *battleMenuPKMN(): Gen { for (;;) { const slot = yield* this.partyMenu(BATTLE.ChooseAMonString, true, true); if (slot < 0) return false; if (slot === this.curBattleMon) { yield* this.say(BATTLE.BattleText_MonIsAlreadyOut); continue; } if (this.player.wrapCount || this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN)) { yield* this.say(BATTLE.BattleText_MonCantBeRecalled); continue; } if (!(yield* this.checkFitToFight(slot))) continue; this.lastPlayerMon = this.curBattleMon; this.battlePlayerAction = C.BATTLEPLAYERACTION_SWITCH; this.curBattleMon = slot; yield* this.playerSwitch(); return true; } } /** PlayerSwitch: the enemy chooses its move, then BattleMonEntrance. */ private *playerSwitch(): Gen { this.parseEnemyAction(); yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]); this.player.reset(SUB4, C.SUBSTATUS_RAGE); this.turn = 1; // PursuitSwitch reads the effect of wCurEnemyMove as GetMoveEffect // does, even for no move (0 reads past the table). const r = this.reader; if (r.byte(SYMBOLS.moves + ((this.enemy.curMove - 1) & 0xff) * 7 + 1) === C.EFFECT_PURSUIT) throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet."); this.curPartyMon = this.curBattleMon; yield* this.sendOutPlayerMon(); } /** PlayerPartyMonEntrance: send out wCurPartyMon. */ private *playerPartyMonEntrance(): Gen { this.lastPlayerMon = this.curBattleMon; this.curBattleMon = this.curPartyMon; yield* this.sendOutPlayerMon(); } /** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and * PlayerPartyMonEntrance. */ private *sendOutPlayerMon(): Gen { this.addBattleParticipant(); this.initBattleMon(); yield* this.sendOutMonText(); this.newStatus(this.player); // with ResetPlayerStatLevels this.breakAttraction(); yield this.hud(); this.turn = 0; if (this.player.screens & (1 << C.SCREENS_SPIKES)) throw new UnsupportedBattle("Spikes are not ported yet."); } /** SendOutMonText: the cheer depends on the enemy's remaining HP. */ private *sendOutMonText(): Gen { const { hp, maxHp } = this.enemy.mon; if (hp) this.enemyHPAtSwitch = hp; const text = sendOutText(hp, maxHp); if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here."); yield* this.say(BATTLE[text]); } /** AskUseNextPokemon: only wild battles ask. NO tries to run, measured * with the first party Pokémon's Speed. Returns true if the player fled. */ private *askUseNextPokemon(): Gen { if (this.battleMode !== C.WILD_BATTLE) return false; yield* this.say(BATTLE.BattleText_UseNextMon); if (yield { kind: "yesno", text: "" }) return false; return yield* this.tryToRunAway(this.options.party[0].stats[3]); } /** ForcePlayerMonChoice. Returns false when the enemy fainted too: the * choice is then sent out by DoubleSwitch. */ private *forcePlayerMonChoice(): Gen { for (;;) { const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, false); if (yield* this.checkFitToFight(slot)) break; } this.battlePlayerAction = 0; if (!this.enemy.mon.hp) return false; yield* this.playerPartyMonEntrance(); return true; } /** DoubleSwitch: the player's choice, then the trainer's next Pokémon. */ private *doubleSwitch(): Gen { yield* this.playerPartyMonEntrance(); yield* this.enemyPartyMonEntrance(true); this.battlePlayerAction = 0; } /** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */ private resetEnemyBattleVars() { this.player.lastCounterMove = 0; this.enemy.lastCounterMove = 0; this.enemy.lastMove = 0; this.enemy.curMove = 0; this.player.wrapCount = 0; } /** NewEnemyMonStatus: also clears the player's counter move, both wrap * counts and the player's inability to run. */ private newEnemyMonStatus() { const enemy = this.enemy; this.player.lastCounterMove = 0; enemy.lastCounterMove = 0; enemy.lastMove = 0; enemy.sub = [0, 0, 0, 0, 0, 0]; enemy.disableCount = 0; enemy.furyCutterCount = 0; enemy.protectCount = 0; enemy.rageCounter = 0; enemy.disabledMove = 0; enemy.minimized = 0; this.player.wrapCount = 0; enemy.wrapCount = 0; enemy.turnsTaken = 0; this.player.reset(SUB5, C.SUBSTATUS_CANT_RUN); } private breakAttraction() { this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE); this.enemy.reset(SUB1, C.SUBSTATUS_IN_LOVE); } /** UpdateUsedMoves: append a new move, dropping the oldest when full. */ private updateUsedMoves(move: number) { const used = this.playerUsedMoves; if (used.includes(move) && move) return; const empty = used.indexOf(0); if (empty >= 0) used[empty] = move; else this.playerUsedMoves = [used[1], used[2], used[3], move]; } /** CheckTypeMatchup's wTypeMatchup (EFFECTIVE is 10). */ private typeMatchup(moveType: number, types: readonly number[], identified: boolean) { return applyTypeMatchups(this.matchups, { damage: 0, modifier: 0, moveType, userTypes: [], targetTypes: types, identified, }).matchup; } /** FindMonInOTPartyToSwitchIntoBattle and ScoreMonTypeMatchups: prefer * the first Pokémon with a super-effective move that the player's types * don't also threaten, then the first unthreatened one, else a random * standing one (drawn with BattleRandom). */ private findMonInOTPartyToSwitchIntoBattle(): number { const party = this.options.trainer!.party, data = this.options.data, count = party.length; // wEnemyEffectivenessVsPlayerMons / wPlayerEffectivenessVsEnemyMons, // each shifted left once per Pokémon from a sentinel bit. let enemyBits = 1, playerBits = 1; for (let index = 0; index < count; index++) { enemyBits = (enemyBits << 1) & 0xff; playerBits = (playerBits << 1) & 0xff; const mon = party[index]; if (index === this.curOTMon || !mon.hp) { playerBits |= 1; continue; } // LookUpTheEffectivenessOfEveryMove, on the enemy's turn. for (const move of mon.moves) { if (!move) break; const identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED); if (this.typeMatchup(data.move(move).type, this.player.mon.types, identified) > C.EFFECTIVE) { enemyBits |= 1; break; } } // IsThePlayerMonTypesEffectiveAgainstOTMon, on the player's turn. const types = data.baseData(mon.species).types, identified = this.enemy.has(SUB1, C.SUBSTATUS_IDENTIFIED); if (this.player.mon.types.some((type) => this.typeMatchup(type, types, identified) > C.EFFECTIVE)) { if (enemyBits & 1) enemyBits &= ~1; else playerBits |= 1; } } enemyBits = (enemyBits << 1) & 0xff; playerBits = (playerBits << 1) & 0xff; // Align the first Pokémon to bit 7 by shifting the sentinel out. for (;;) { const carry = playerBits & 0x80; enemyBits = (enemyBits << 1) & 0xff; playerBits = (playerBits << 1) & 0xff; if (carry) break; } let discouraged = 0; while (discouraged < count && playerBits & (0x80 >> discouraged)) discouraged++; if (discouraged < count) { const bits = enemyBits || (~playerBits & 0xff); for (let index = 0; index < 8; index++) if (bits & (0x80 >> index)) return index; } for (let guard = 0; guard < 4096; guard++) { const index = this.random() & 7; if (index < count && index !== this.curOTMon && party[index].hp) return index; } throw new UnsupportedBattle("The trainer has no Pokémon to send out."); } /** AI_SwitchOrTryItem (engine/battle/ai/items.asm). Returns true when the * enemy spent its turn. Trainers holding items are refused before battle, * so AI_TryItem finds none. */ private *aiSwitchOrTryItem(): Gen { const trainer = this.options.trainer; if (!trainer || this.checkEnemyLockedIn()) return false; if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false; const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex( (bit) => trainer.switchFlags & bit, ); if (style < 0) return false; const param = this.checkAbleToSwitch(), tier = param & 0xf0; if (!tier) return false; const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1]; if (this.random() < threshold !== below) return false; this.enemySwitchMonIndex = (param & 0x0f) + 1; // AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts. if (trainer.party.filter((mon) => mon.hp).length < 2) return false; yield* this.aiSwitch(); return true; } /** AI_Switch: withdraw the enemy Pokémon and send out the chosen one. */ private *aiSwitch(): Gen { const trainer = this.options.trainer!; this.enemyGoesFirst = 1; this.enemy.reset(SUB4, C.SUBSTATUS_RAGE); this.turn = 0; // PursuitSwitch reads wCurPlayerMove's effect. if (this.player.curMove && this.options.data.move(this.player.curMove).effect === C.EFFECT_PURSUIT) throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet."); // Status and HP go back to the party (MON_STATUS through MON_HP). const outgoing = trainer.party[this.curOTMon]; outgoing.status = this.enemy.mon.status; outgoing.hp = this.enemy.mon.hp; yield* this.say(BATTLE.EnemyWithdrewText); this.battleHasJustStarted = 1; this.newEnemyMonStatus(); this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE); yield* this.enemySwitch(); this.resetBattleParticipants(); this.battleHasJustStarted = 0; } /** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam, * a chance tier in the high nibble and a party index in the low one. * CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its * callers load, so with hBattleTurn on the enemy every matchup here uses * the enemy's chosen move type (docs/bugs_and_glitches.md). */ private checkAbleToSwitch(): number { const party = this.options.trainer!.party, data = this.options.data; const matchup = (types: readonly number[]) => this.typeMatchup(this.enemy.move.type, types, this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED)); const bit = (index: number) => 0x20 >> index; const damaging = (move: number) => data.move(move).power !== 0; const baseTypes = (index: number) => data.baseData(party[index].species).types; // FindAliveEnemyMons: the others still standing. const aliveOthers = () => { let mask = 0; if (party.length >= 2) party.forEach((mon, index) => { if (index !== this.curOTMon && mon.hp) mask |= bit(index); }); return mask; }; // FindEnemyMonsWithAtLeastQuarterMaxHP. BUG: srl c / rl b rotate the low // HP byte into the high byte instead of multiplying the HP by four. const quarterHP = (mask: number) => { let chosen = 0; party.forEach((mon, index) => { const high = mon.hp >> 8, low = mon.hp & 0xff; const rotated = ((((high << 2) | ((low & 1) << 1) | ((low >> 1) & 1)) & 0xff) << 8) | (low >> 2); if (rotated > mon.stats[0]) chosen |= bit(index); }); return mask & chosen; }; // FindEnemyMonsThatResistPlayer: every branch reduces to one matchup. const resisting = (mask: number) => { let chosen = 0; party.forEach((_, index) => { if (matchup(baseTypes(index)) <= C.EFFECTIVE) chosen |= bit(index); }); return mask & chosen; }; // FindEnemyMonsWithASuperEffectiveMove. `e` is not reset between // Pokémon, so one neutral move encourages every later candidate. const superEffective = (mask: number) => { let e = 0, encouraged = 0; for (let index = 0; index < 6; index++) { if (!(mask & bit(index))) continue; for (const move of party[index].moves) { if (!move) break; if (!damaging(move)) continue; const value = matchup(this.player.mon.types); if (value < C.EFFECTIVE) continue; e = 1; if (value === C.EFFECTIVE) continue; e = 2; break; } if (e === 2) return { e, score: index }; if (e === 1) encouraged |= bit(index); } for (let index = 0; index < 6; index++) if (encouraged & bit(index)) return { e, score: index }; return { e, score: 0xff }; }; // FindAliveEnemyMonsWithASuperEffectiveMove: standing, including the // current Pokémon. const standingSuperEffective = (mask: number) => { let standing = 0; party.forEach((mon, index) => { if (mon.hp) standing |= bit(index); }); return superEffective(mask & standing); }; if (!aliveOthers()) return 0; if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH)) throw new UnsupportedBattle("Perish Song is not ported yet."); if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0; const lastCounter = this.player.lastCounterMove; if (lastCounter) { // FindEnemyMonsImmuneToLastCounterMove. let immune = 0; party.forEach((mon, index) => { if (index === this.curOTMon || !mon.hp || !damaging(lastCounter)) return; if (matchup(baseTypes(index)) === 0) immune |= bit(index); }); if (immune) { const { e, score } = superEffective(immune); if (score === 0xff) return 0; if (e !== 2) { if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0; return score + 0x10; } return score + (this.checkPlayerMoveTypeMatchups() < C.EFFECTIVE ? 0x20 : 0x10); } } if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0; const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers()))); return e === 2 ? score + 0x10 : 0; } /** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE * (10); lower means the enemy is worse off. Matchups use the enemy's move * type (see checkAbleToSwitch). */ private checkPlayerMoveTypeMatchups(): number { const data = this.options.data, identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED), type = this.enemy.move.type; const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified), vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified); let score = 10; const used = this.playerUsedMoves; if (!used[0]) { if (vsEnemy() > C.EFFECTIVE) score--; if (this.player.mon.types[1] !== this.player.mon.types[0] && vsEnemy() > C.EFFECTIVE) score--; } else { let e = 0, superEffective = false; for (const move of used) { if (!move) break; if (!data.move(move).power) continue; const value = vsEnemy(); if (value > C.EFFECTIVE) { score--; superEffective = true; break; } if (!value) continue; if (value === C.EFFECTIVE) e = 2; else if (e < 1) e = 1; } if (!superEffective && e !== 2) score += e ? 1 : 2; } // .CheckEnemyMoveMatchups let c = 0; for (const move of this.enemy.mon.moves) { if (!move) break; if (!data.move(move).power) continue; const value = vsPlayer(); if (!value) continue; c++; if (value < C.EFFECTIVE) continue; c += 5; if (value > C.EFFECTIVE) c = 100; } if (!c) score -= 2; else if (c < 5) score--; else if (c >= 100) score++; return score & 0xff; } private *faintYourPokemon(): Gen { yield* this.say(BATTLE.BattleText_MonFainted); } private *handlePlayerMonFaint(): Gen { yield* this.faintYourPokemon(); if (!this.enemy.mon.hp) yield* this.say(BATTLE.BattleText_EnemyMonFainted); this.updateFaintedPlayerMon(); if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle(); if (!this.enemy.mon.hp) { yield* this.updateStateAndExperienceAfterEnemyFaint(true); if (!this.options.trainer || this.enemyTrainerDefeated()) { this.battleEnded = true; return; } } if (yield* this.askUseNextPokemon()) { this.battleEnded = true; return; } if (yield* this.forcePlayerMonChoice()) return; this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM; yield* this.doubleSwitch(); } private updateFaintedPlayerMon() { this.participants &= ~(1 << this.curBattleMon); this.enemy.reset(SUB3, C.SUBSTATUS_IN_LOOP); this.player.damageTaken = 0; this.player.mon.status = 0; this.updateBattleMonInParty(); // ChangeHappiness: fainting, or fainting to a much stronger foe. const strong = this.enemy.mon.level >= this.player.mon.level + 30; this.changeHappiness(this.curBattleMon, strong ? C.HAPPINESS_BEATENBYSTRONGFOE : C.HAPPINESS_FAINTED); this.battleResult = (this.battleResult & 0xc0) + C.LOSE; } /** ChangeHappiness: HappinessChanges by tier (below 100, below 200, * above). Eggs are skipped. In battle the battle Pokémon's copy follows * 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; 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), ); if (index === this.partyMenuCursor) this.player.mon.happiness = mon.happiness; } /** ResetBattleParticipants: only the Pokémon out, in both sets. */ private resetBattleParticipants() { this.participants = this.participantsAll = 0; this.addBattleParticipant(); } /** AddBattleParticipant. */ private addBattleParticipant() { this.participants |= 1 << this.curBattleMon; this.participantsAll |= 1 << this.curBattleMon; } private *lostBattle(): Gen { this.battleEnded = true; this.battleResult = (this.battleResult & 0xc0) + C.LOSE; } /** UpdateBattleStateAndExperienceAfterEnemyFaint and GiveExperiencePoints. */ private *updateStateAndExperienceAfterEnemyFaint(playerFaintedFirst: boolean): Gen { this.updateBattleMonInParty(); const trainer = this.options.trainer; if (trainer) trainer.party[this.curOTMon].hp = 0; this.player.reset(SUB3, C.SUBSTATUS_IN_LOOP); this.enemy.damageTaken = 0; this.newEnemyMonStatus(); this.breakAttraction(); if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon(); if (!this.options.party.some((mon) => mon.hp > 0)) return; this.battleResult &= 0xc0; // WIN if (this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE)) throw new UnsupportedBattle("Exp. Share is not ported yet."); yield* this.giveExperience(); } private *giveExperience(): Gen { const party = this.options.party, data = this.options.data; const count = [0, 1, 2, 3, 4, 5].filter((index) => this.participants & (1 << index)).length; const base = { ...this.enemyBase, stats: [...this.enemyBase.stats] }; if (count >= 2) { base.stats = base.stats.map((value) => Math.floor(value / count)); base.catchRate = Math.floor(base.catchRate / count); base.exp = Math.floor(base.exp / count); } for (let index = 0; index < party.length; index++) { const mon = party[index]; if (!mon.hp || !(this.participants & (1 << index))) continue; // Stat experience: the defeated Pokémon's base stats, twice with Pokérus. for (let stat = 0; stat < 5; stat++) { let value = mon.statExp[stat] + base.stats[stat]; if (mon.pokerus) value += base.stats[stat]; mon.statExp[stat] = Math.min(0xffff, value); } let exp = Math.floor((base.exp * this.enemy.mon.level) / 7); const traded = mon.otId !== this.options.playerId; const boost = (value: number) => value + (value >> 1); if (traded) exp = boost(exp); if (this.battleMode !== C.WILD_BATTLE) exp = boost(exp); if (mon.item === C.LUCKY_EGG) exp = boost(exp); exp &= 0xffff; this.numbers[RAM.stringBuffer2] = exp; this.numbers[RAM.stringBuffer2 + 2] = traded ? 1 : 0; this.strings[RAM.stringBuffer1] = mon.nickname; yield* this.say(BATTLE.Text_MonGainedExpPoint); const growth = data.baseData(mon.species).growthRate; mon.exp = Math.min(data.expAtLevel(growth, C.MAX_LEVEL), Math.min(0xffffff, mon.exp + exp)); yield this.hud(); const newLevel = this.levelForExp(mon.species, mon.exp); if (mon.level >= C.MAX_LEVEL || newLevel === mon.level) continue; const oldLevel = mon.level; mon.level = newLevel; const oldMax = mon.stats[0]; mon.stats = calcStats(data, mon.species, mon.dvs, newLevel, mon.statExp); mon.hp = Math.min(mon.stats[0], mon.hp + (mon.stats[0] - oldMax)); if (index === this.curBattleMon) { const side = this.player; side.mon.hp = mon.hp; side.mon.maxHp = mon.stats[0]; side.mon.level = newLevel; side.stats = mon.stats.slice(1); this.calcStats(side); } // LevelUpHappinessMod: more where it was caught. this.changeHappiness( index, (mon.caughtData & 0x7f) === this.options.landmark ? C.HAPPINESS_GAINLEVELATHOME : C.HAPPINESS_GAINLEVEL, ); this.strings[RAM.stringBuffer1] = mon.nickname; this.numbers[RAM.curPartyLevel] = newLevel; yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel); yield this.hud(); // PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def, Speed. yield { kind: "stats", labels: readString(this.reader, BATTLE["PrintTempMonStats.StatNames"], this.context(), 64).split("\n").filter(Boolean), values: [mon.stats[1], mon.stats[2], mon.stats[4], mon.stats[5], mon.stats[3]], }; for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level); this.evolvable |= 1 << index; } this.resetBattleParticipants(); } /** CalcLevel: the highest level whose exp does not exceed the total. */ private levelForExp(species: number, exp: number) { return levelForExp(this.options.data, species, exp); } /** LearnLevelMoves: moves the species learns at exactly this level. */ private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen { for (const learned of this.options.data.learnset(mon.species)) { if (learned.level !== level || mon.moves.includes(learned.move)) continue; yield* this.learnMove(mon, index, learned.move); } } /** LearnMove, including the choice of a move to forget. */ private *learnMove(mon: PartyMon, index: number, move: number): Gen { const data = this.options.data; this.strings[RAM.stringBuffer1] = mon.nickname; this.strings[RAM.monOrItemNameBuffer] = mon.nickname; this.strings[RAM.stringBuffer2] = data.moveName(move); for (;;) { let slot = mon.moves.indexOf(0); if (slot < 0) { slot = yield* this.forgetMove(mon); if (slot < 0) { yield* this.say(BATTLE.StopLearningMoveText); if (!(yield { kind: "yesno", text: "" })) continue; yield* this.say(BATTLE.DidNotLearnMoveText); return; } const forgotten = mon.moves[slot]; if (this.player.disabledMove === forgotten) { this.player.disabledMove = 0; this.player.disableCount = 0; } this.strings[RAM.stringBuffer1] = data.moveName(forgotten); yield* this.say(BATTLE.Text_1_2_and_Poof); } mon.moves[slot] = move; mon.pp[slot] = data.move(move).pp; if (index === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED)) { this.player.mon.moves = [...mon.moves]; this.player.mon.pp = [...mon.pp]; } yield* this.say(BATTLE.LearnedMoveText); return; } } /** ForgetMove: returns the slot to replace, or -1 to stop. */ private *forgetMove(mon: PartyMon): Gen { yield* this.say(BATTLE.AskForgetMoveText); if (!(yield { kind: "yesno", text: "" })) return -1; for (;;) { yield* this.say(BATTLE.MoveAskForgetText); const choice = yield { kind: "forget", moves: [...mon.moves], learning: 0 }; if (typeof choice !== "number" || choice < 0 || choice > 3) return -1; if (this.data.byteList(BATTLE["IsHMMove.HMMoves"]).includes(mon.moves[choice])) { yield* this.say(BATTLE.MoveCantForgetHMText); continue; } return choice; } } }