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; /** wWrapCount: turns this side stays trapped, by trappingMove. */ wrapCount = 0; trappingMove = 0; damageTaken = 0; screens = 0; lightScreenCount = 0; reflectCount = 0; turnsTaken = 0; charging = 0; lastMove = 0; lastCounterMove = 0; minimized = 0; justGotFrozen = 0; perishCount = 0; safeguardCount = 0; /** wFutureSightCount and the damage Future Sight stored. */ futureSightCount = 0; futureSightDamage = 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; /** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */ payDayMoney?: number; evolvable?: number; }; export interface BattleTrainer { name: string; className: string; /** wOTPartyMons: level, status and HP follow the Pokémon out only where * the original updates them (UpdateEnemyMonInParty: status changes and * heals, a faint or a withdrawal); damage alone leaves them stale. */ 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; /** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */ fish?: boolean; /** GetWorldMapLocation for the current map (LevelUpHappinessMod). */ landmark?: number; /** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of * Rage for wild Magikarp. */ mapId?: string; /** 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; /** wOptions' BATTLE_SCENE clear: battle animations on (the default). * Some texts only show with them off (_CheckBattleScene). */ battleScene?: 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. */ /** MAP_LAKE_OF_RAGE (constants/map_constants.asm). */ const LAKE_OF_RAGE = { group: 9, number: 6 }; /** CalcMagikarpLength (engine/events/magikarp.asm): a length in feet and * inches from the DVs and the trainer ID. The index is rrc(rrc(DV byte)) * xor rrc(ID byte), per byte. Its .BCLessThanDE compares only the high * bytes, so MagikarpLengths' thresholds act on the high byte alone and * the difference below them wraps around 16 bits, as in the original. */ export function magikarpLength( r: RomReader, dvs: number, playerId: number, ): { feet: number; inches: number } { const rrc = (value: number) => ((value >> 1) | (value << 7)) & 0xff; const b = rrc(rrc(dvs >> 8)) ^ rrc((playerId >> 8) & 0xff), c = rrc(rrc(dvs & 0xff)) ^ rrc(playerId & 0xff), bc = (b << 8) | c; let mm: number | undefined; let p = BATTLE.MagikarpLengths; if (bc < 10) mm = bc + 190; else for (let z = 2; z < 16; z++, p += 3) if (b < r.byte(p + 1)) { const quotient = Math.floor(((bc - r.word(p)) & 0xffff) / r.byte(p + 2)); mm = ((quotient & 0xff) + 100 * z) & 0xffff; break; } // Past the table: bc minus its last threshold, plus 1600. mm ??= (((bc - r.word(p - 3)) & 0xffff) + 1600) & 0xffff; // Millimetres × 10 / 254 in a byte, then feet and inches. const total = Math.floor(((mm * 10) & 0xffff) / 254) & 0xff; return { feet: Math.floor(total / 12), inches: total % 12 }; } 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; /** wPlayerIsSwitching, which Pursuit doubles against. */ private playerIsSwitching = 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; /** wTypeMatchup from the last BattleCheckTypeMatchup. */ private matchup: number = C.EFFECTIVE; /** wBattleAnimParam, which Triple Kick also reads as its kick count. */ private animParam = 0; /** wPayDayMoney: Pay Day's coins, paid out after a win. */ private payDayMoney = 0; /** wAmuletCoin: a Pokémon sent out held an Amulet Coin. */ private amuletCoin = false; /** wCurBeatUpPartyMon and wBeatUpHitAtLeastOnce. */ private beatUpMon = 0; private beatUpHit = 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; else if (options.fish) this.battleType = C.BATTLETYPE_FISH; } 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]; } /** ParsePlayerAction skips MoveSelectionScreen: FIGHT goes on with the * current move, as while biding. */ get fightKeepsMove() { return this.player.has(SUB3, C.SUBSTATUS_BIDE) || this.player.has(SUB5, C.SUBSTATUS_ENCORED); } // ------------------------------------------------------------- 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("BattleCommand_Charge.UsedText"): { const text = { [C.RAZOR_WIND]: "BattleCommand_Charge.BattleMadeWhirlwindText", [C.SOLARBEAM]: "BattleCommand_Charge.BattleTookSunlightText", [C.SKULL_BASH]: "BattleCommand_Charge.BattleLoweredHeadText", [C.SKY_ATTACK]: "BattleCommand_Charge.BattleGlowingText", [C.FLY]: "BattleCommand_Charge.BattleFlewText", }[this.user.move.anim]; // Any other move falls through to the Dig text. return BATTLE[(text ?? "BattleCommand_Charge.BattleDugText") as keyof typeof BATTLE]; } 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) throw new UnsupportedBattle("Unown 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]; let dvs: number; do dvs = (this.random() << 8) | this.random(); while (species === C.MAGIKARP && this.rerollMagikarp(dvs)); 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]; } /** LoadEnemyMon's .Magikarp filters, true when the DVs are drawn again. * Their limits are in millimetres but CalcMagikarpLength gives feet and * inches: the two size checks need 6 feet, which no Magikarp reaches * (they only draw from Random), and the floor of 1024 mm compares * feet with 4. The Lake of Rage test matches its map group or its map * number on their own, and skips the floor there. */ private rerollMagikarp(dvs: number): boolean { const { feet, inches } = magikarpLength( this.reader, dvs, this.options.playerId, ); if (feet === 1536 >> 8 && this.random() >= percent(5)) { if (inches >= (1616 & 0xff)) return true; if (this.random() >= percent(20) - 1 && inches >= (1600 & 0xff)) return true; } const [group, number] = (this.options.mapId ?? "0:0").split(":").map(Number); if (group === LAKE_OF_RAGE.group || number === LAKE_OF_RAGE.number) return false; if (this.random() < percent(39) + 1) return false; return feet < 1024 >> 8; } /** 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 outcome = yield* this.doBattle(); // ExitBattle: CheckPayDay after a win. if (outcome.result & 0x0f) return outcome; const payDayMoney = yield* this.checkPayDay(); return payDayMoney ? { ...outcome, payDayMoney } : outcome; } /** CheckPayDay: the coins, doubled by an Amulet Coin, for the wallet. */ private *checkPayDay(): Gen { let money = this.payDayMoney; if (!money) return 0; if (this.amuletCoin) money = Math.min(0xffffff, money * 2); this.numbers[RAM.payDayMoney] = money; yield* this.say(BATTLE.BattleText_PlayerPickedUpPayDayMoney); return money; } /** CheckAmuletCoin, at every SendOutPlayerMon. */ private checkAmuletCoin() { if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN) this.amuletCoin = true; } private *doBattle(): 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_FISH ? BATTLE.HookedPokemonAttackedText : this.battleType === C.BATTLETYPE_TREE ? BATTLE.PokemonFellFromTreeText : BATTLE.WildPokemonAppearedText, ); this.resetBattleParticipants(); this.initBattleMon(); this.checkAmuletCoin(); 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, this.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.playerIsSwitching = 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; // ParsePlayerAction: Bide and Encore go on without MoveSelectionScreen. if (this.fightKeepsMove) return false; 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; // A locked-in or biding Pokémon keeps last turn's move and move struct. if (this.checkPlayerLockedIn()) return this.playerLockedIn(); // An encored Pokémon takes its last move, whatever the action. const encored = player.has(SUB5, C.SUBSTATUS_ENCORED); if (encored) player.curMove = player.lastMove; else 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 (encored || this.battlePlayerAction === 0) { if (!encored && player.has(SUB3, C.SUBSTATUS_BIDE)) return this.playerLockedIn(); 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); return this.playerLockedIn(); } this.parseEnemyAction(); return true; } /** ParsePlayerAction's .locked_in. */ private playerLockedIn() { const player = this.player; 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; const encored = enemy.has(SUB5, C.SUBSTATUS_ENCORED); if (!encored && this.checkEnemyLockedIn()) { // ResetVarsForSubstatusRage: the move and its struct stay. enemy.furyCutterCount = 0; enemy.protectCount = 0; enemy.rageCounter = 0; enemy.reset(SUB4, C.SUBSTATUS_RAGE); return; } let move: number = encored ? enemy.lastMove : C.STRUGGLE; const usable = !encored && mon.moves.some( (candidate, slot) => candidate && candidate !== enemy.disabledMove && mon.pp[slot] & C.PP_MASK, ); if (encored) { // The move number stays the AI's or last turn's choice. } else 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(); yield* this.doMove(); } /** DoMove: run the user's move effect script. */ private *doMove(): Gen { 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, 3, 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.animParam = 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(); this.updateUserInParty(); 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)) { yield* this.say(BATTLE.InLoveWithText); if (this.random() >= percent(50) + 1) { yield* this.say(BATTLE.InfatuationText); this.cantMove(); return yield* this.endTurn(); } } 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.lowersubnoanim: case COMMAND.raisesubnoanim: case COMMAND.movedelay: return this.requireNoSubstitute(); case COMMAND.moveanimnosub: case COMMAND.moveanim: this.requireNoSubstitute(); return this.moveAnimNoSub(); case COMMAND.statupanim: case COMMAND.statdownanim: this.requireNoSubstitute(); // BattleCommand_StatUpDownAnim if (!this.attackMissed) this.animParam = 0; return; case COMMAND.cleartext: return; // An empty BattleTextbox. case COMMAND.startloop: this.user.rolloutCount = 0; return; case COMMAND.endloop: return yield* this.endLoop(); case COMMAND.supereffectivelooptext: if (this.user.has(SUB3, C.SUBSTATUS_IN_LOOP)) return; return yield* this.superEffectiveText(); case COMMAND.triplekick: // The damage once more for each kick so far, up to $ffff. this.curDamage = Math.min(0xffff, this.curDamage * (this.animParam + 1)); return; case COMMAND.kickcounter: this.animParam = (this.animParam + 1) & 0xff; return; case COMMAND.beatup: return yield* this.beatUp(); case COMMAND.beatupfailtext: return yield* this.beatUpFailText(); case COMMAND.constantdamage: return this.constantDamage(); case COMMAND.resettypematchup: this.resetTypeMatchup(); return; case COMMAND.ohko: return yield* this.ohko(); case COMMAND.counter: return this.counter(false); case COMMAND.mirrorcoat: return this.counter(true); case COMMAND.payday: return yield* this.payDay(); case COMMAND.thief: return yield* this.thief(); case COMMAND.selfdestruct: return yield* this.selfdestruct(); case COMMAND.eatdream: return yield* this.drainTarget(BATTLE.DreamEatenText); case COMMAND.freezetarget: return yield* this.freezeTarget(); case COMMAND.tristatuschance: return yield* this.triStatusChance(); case COMMAND.defrost: return yield* this.defrostUser(); case COMMAND.confusetarget: return yield* this.confuseTarget(); case COMMAND.getmagnitude: return yield* this.getMagnitude(); case COMMAND.present: return yield* this.present(); case COMMAND.hiddenpower: return this.hiddenPower(); case COMMAND.happinesspower: // BattleCommand_HappinessPower: happiness × 10 / 25, so a // Pokémon below 3 happiness deals no damage (a kept bug). this.damageArgs.power = Math.floor((this.user.mon.happiness * 10) / 25) & 0xff; return; case COMMAND.frustrationpower: this.damageArgs.power = Math.floor(((0xff - this.user.mon.happiness) * 10) / 25) & 0xff; return; case COMMAND.falseswipe: this.falseSwipe(); return; case COMMAND.doubleundergrounddamage: if (this.target.has(SUB3, C.SUBSTATUS_UNDERGROUND)) this.curDamage = Math.min(0xffff, this.curDamage * 2); return; case COMMAND.doubleminimizedamage: if (this.target.minimized) this.curDamage = Math.min(0xffff, this.curDamage * 2); return; case COMMAND.thunderaccuracy: // Overwrites the move's accuracy; rain's 100% is redundant with // CheckHit's own rain check. if (this.weather === C.WEATHER_RAIN) this.user.move.accuracy = 0xff; else if (this.weather === C.WEATHER_SUN) this.user.move.accuracy = percent(50) + 1; return; case COMMAND.fakeout: return this.fakeOut(); case COMMAND.checkcharge: return this.checkCharge(); case COMMAND.charge: return yield* this.charge(); case COMMAND.skipsuncharge: // A sunny day skips the charging turn. if (this.weather === C.WEATHER_SUN) this.skipTo(COMMAND.charge); return; case COMMAND.rechargenextturn: this.user.set(SUB4, C.SUBSTATUS_RECHARGE); return; case COMMAND.checkrampage: return this.checkRampage(); case COMMAND.rampage: return this.rampage(); case COMMAND.checkrollout: return this.checkRollout(); case COMMAND.rolloutpower: return this.rolloutPower(); case COMMAND.furycutter: return this.furyCutter(); case COMMAND.storeenergy: return yield* this.storeEnergy(); case COMMAND.unleashenergy: return this.unleashEnergy(); case COMMAND.bidefailtext: if (!this.attackMissed) return; return yield* this.say(this.typeModifier & C.EFFECTIVENESS_MASK ? BATTLE.ButItFailedText : BATTLE.DoesntAffectText); case COMMAND.traptarget: return yield* this.trapTarget(); case COMMAND.lockon: return yield* this.lockOn(); case COMMAND.leechseed: return yield* this.leechSeed(); case COMMAND.nightmare: return yield* this.nightmare(); case COMMAND.curse: return yield* this.curse(); case COMMAND.destinybond: this.user.set(SUB5, C.SUBSTATUS_DESTINY_BOND); return yield* this.say(BATTLE.DestinyBondEffectText); case COMMAND.protect: case COMMAND.endure: return yield* this.protect(command === COMMAND.endure); case COMMAND.spite: return yield* this.spite(); case COMMAND.healbell: return yield* this.healBell(); case COMMAND.disable: return yield* this.disable(); case COMMAND.encore: return yield* this.encore(); case COMMAND.attract: return yield* this.attract(); case COMMAND.mist: case COMMAND.focusenergy: { const mist = command === COMMAND.mist, bit = mist ? C.SUBSTATUS_MIST : C.SUBSTATUS_FOCUS_ENERGY; if (this.user.has(SUB4, bit)) return yield* this.say(BATTLE.ButItFailedText); this.user.set(SUB4, bit); return yield* this.say(mist ? BATTLE.MistText : BATTLE.GettingPumpedText); } case COMMAND.resetstats: return yield* this.resetStats(); case COMMAND.heal: return yield* this.heal(); case COMMAND.arenatrap: // Mean Look marks the user: its opponent can't run or switch. if (this.hiddenOpponent() || this.user.has(SUB5, C.SUBSTATUS_CANT_RUN)) return yield* this.say(BATTLE.ButItFailedText); this.user.set(SUB5, C.SUBSTATUS_CANT_RUN); return yield* this.say(BATTLE.CantEscapeNowText); case COMMAND.painsplit: return yield* this.painSplit(); case COMMAND.bellydrum: return yield* this.bellyDrum(); case COMMAND.psychup: return yield* this.psychUp(); case COMMAND.safeguard: { const user = this.user; if (user.screens & (1 << C.SCREENS_SAFEGUARD)) return yield* this.say(BATTLE.ButItFailedText); user.screens |= 1 << C.SCREENS_SAFEGUARD; user.safeguardCount = 5; return yield* this.say(BATTLE.CoveredByVeilText); } case COMMAND.perishsong: return yield* this.perishSong(); case COMMAND.spikes: { // Spikes go on the target's side. const target = this.target; if (target.screens & (1 << C.SCREENS_SPIKES)) return yield* this.failMove(); target.screens |= 1 << C.SCREENS_SPIKES; return yield* this.say(BATTLE.SpikesText); } case COMMAND.clearhazards: return yield* this.clearHazards(); case COMMAND.allstatsup: // AncientPower: each stat in turn, without a failure text. for (let stat = C.ATTACK; stat <= C.SP_DEFENSE; stat++) { this.attackMissed = 0; this.statUp(stat); yield* this.statUpMessage(); } return; case COMMAND.checkfuturesight: return this.checkFutureSight(); case COMMAND.futuresight: return yield* this.futureSight(); case COMMAND.pursuit: // Double damage against a Pokémon being withdrawn. if (this.turn === 0 ? this.enemyIsSwitching : this.playerIsSwitching) this.curDamage = Math.min(0xffff, this.curDamage * 2); return; 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.statUp(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; // The party struct loses the PP too, or a wild Pokémon's wWildMonPP, // which it keeps if caught; not for a move learnt by Mimic // (CheckMimicUsed). const party = this.turn === 0 ? this.partyMon : this.battleMode !== C.WILD_BATTLE ? this.options.trainer!.party[this.curOTMon] : undefined; if (party) { if (user.curMove !== C.MIMIC && party.moves[slot] === C.MIMIC) return; this.consumePP(party.pp, slot); } else { if (user.mon.moves[slot] !== C.MIMIC && this.wildMoves[slot] === C.MIMIC) return; this.consumePP(this.wildPP, slot); } } /** DoTurn's .consume_pp on a party struct, which keeps step with the * battle struct here. */ private consumePP(pp: number[], slot: number) { if (!(pp[slot] & C.PP_MASK)) throw new UnsupportedBattle("Party PP out of step with the battle's."); 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; this.matchup = result.matchup; if (result.missed) this.attackMissed = 1; } /** BattleCheckTypeMatchup: wTypeMatchup for the move against the target. */ private battleCheckTypeMatchup() { const target = this.target; return (this.matchup = this.typeMatchup(this.user.move.type, target.mon.types, target.has(SUB1, C.SUBSTATUS_IDENTIFIED))); } /** BattleCommand_ResetTypeMatchup: an immune target makes the move miss; * otherwise wTypeMatchup becomes EFFECTIVE. Returns wTypeMatchup. */ private resetTypeMatchup() { if (this.battleCheckTypeMatchup()) return (this.matchup = C.EFFECTIVE); this.curDamage = 0; this.typeModifier = 0; this.attackMissed = 1; return 0; } /** 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(); // .LockOn: a locked-on target is hit, unless it is flying from an // Earthquake, Fissure or Magnitude; the lock is spent either way. const anim = user.move.anim, groundMoves: number[] = [C.EARTHQUAKE, C.FISSURE, C.MAGNITUDE]; if (target.has(SUB5, C.SUBSTATUS_LOCK_ON)) { target.reset(SUB5, C.SUBSTATUS_LOCK_ON); if (!(target.has(SUB3, C.SUBSTATUS_FLYING) && groundMoves.includes(anim))) return; } // .FlyDigMoves if (target.has(SUB3, C.SUBSTATUS_FLYING)) { if (!([C.GUST, C.WHIRLWIND, C.THUNDER, C.TWISTER] as number[]).includes(anim)) return miss(); } else if (target.has(SUB3, C.SUBSTATUS_UNDERGROUND) && !groundMoves.includes(anim)) return miss(); 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) { user.reset(SUB3, C.SUBSTATUS_UNDERGROUND); user.reset(SUB3, C.SUBSTATUS_FLYING); this.requireNoSubstitute(); // AppearUserRaiseSub return this.endMoveEffect(); } // Multi-hit moves other than Triple Kick and Beat Up raise the // substitute again (Beat Up not doing so is a kept bug). const effect = user.move.effect; if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT) this.requireNoSubstitute(); 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)) return; // (Hi) Jump Kick crashes for an eighth of the damage it would have // dealt, at least 1, ignoring the user's substitute. this.curDamage = this.curDamage >> 3 || 1; yield* this.say(BATTLE.CrashedText); this.doDamage(this.user, true); yield this.hud(); } /** BattleCommand_ApplyDamage. */ private *applyDamage(): Gen { const target = this.target; let hungOn = 0; const item = this.data.heldEffect(target.mon.item); if (target.has(SUB1, C.SUBSTATUS_ENDURE)) hungOn = this.falseSwipe() ? 1 : 0; else 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 === 1) yield* this.say(BATTLE.EnduredText); if (hungOn === 2) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); yield* this.say(BATTLE.HungOnText); } } /** BattleCommand_FalseSwipe: a hit that would faint leaves 1 HP (no * damage at all against 1 HP) and is no longer a one-hit KO. Returns * true (carry) when it changed the damage. */ private falseSwipe(): boolean { const hp = this.target.mon.hp; if (this.curDamage < hp) return false; this.curDamage = (hp - 1) & 0xffff; if (this.criticalHit === 2) this.criticalHit = 0; 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)) { // The user faints too, and the move ends there. yield* this.say(BATTLE.TookDownWithItText); this.user.mon.hp = 0; yield this.hud(); return this.endMoveEffect(); } // A multi-hit move stops here, without "Hit n times!" and with // SUBSTATUS_IN_LOOP still set; the faint handlers clear it. 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)) this.requireNoSubstitute(); // BattleCommand_RaiseSub 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_MoveAnimNoSub's effect on wBattleAnimParam: multi-hit * moves alternate it, Triple Kick keeps its kick count, others clear it. */ private moveAnimNoSub() { if (this.attackMissed) return; const effect = this.user.move.effect; if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_CONVERSION || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT) this.animParam = (this.animParam & 1) ^ 1; else if (effect !== C.EFFECT_TRIPLE_KICK) this.animParam = 0; } // ------------------------------------------------------ multi-hit moves /** BattleCommand_EndLoop. The first pass draws the extra hits into the * user's rollout count and the total into the high byte of its * wDamageTaken, which "Hit n times!" prints; later passes count down. * Each repeat resumes at the script's `critical`. */ private *endLoop(): Gen { const user = this.user, effect = user.move.effect; if (user.has(SUB3, C.SUBSTATUS_IN_LOOP)) { user.rolloutCount = (user.rolloutCount - 1) & 0xff; if (user.rolloutCount) return this.loopBackToCritical(); return yield* this.doneLoop(); } user.set(SUB3, C.SUBSTATUS_IN_LOOP); let extra: number; if (effect === C.EFFECT_POISON_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT) extra = 1; else if (effect === C.EFFECT_BEAT_UP) { const count = this.turn === 0 ? this.options.party.length : this.battleMode === C.WILD_BATTLE ? 1 : this.options.trainer!.party.length; if (count === 1) { // .only_one_beatup: no raisesub or King's Rock (a kept bug). user.reset(SUB3, C.SUBSTATUS_IN_LOOP); yield* this.beatUpFailText(); return this.endMoveEffect(); } extra = count - 1; } else if (effect === C.EFFECT_TRIPLE_KICK) { let kicks: number; do kicks = this.random() & 3; while (!kicks); if (kicks === 1) { this.setHitCount(user, 1); return yield* this.doneLoop(); } extra = kicks - 1; } else { // Two or three hits, or a second draw for two to five. let draw = this.random() & 3; if (draw >= 2) draw = this.random() & 3; extra = draw + 1; } user.rolloutCount = extra; this.setHitCount(user, extra + 1); this.loopBackToCritical(); } private setHitCount(side: Side, hits: number) { side.damageTaken = ((hits & 0xff) << 8) | (side.damageTaken & 0xff); } /** EndLoop's .done_loop: the hit count text (not for Beat Up), then the * high byte of wDamageTaken is cleared. */ private *doneLoop(): Gen { const user = this.user; user.reset(SUB3, C.SUBSTATUS_IN_LOOP); if (user.move.effect !== C.EFFECT_BEAT_UP) { this.numbers[this.turn === 0 ? RAM.playerDamageTaken : RAM.enemyDamageTaken] = user.damageTaken >> 8; yield* this.say(this.turn === 0 ? BATTLE.PlayerHitTimesText : BATTLE.EnemyHitTimesText); } user.damageTaken &= 0xff; } /** EndLoop's .loop_back_to_critical: search back for `critical`. */ private loopBackToCritical() { let index = this.scriptIndex; while (index >= 0 && this.script[index] !== COMMAND.critical) index--; if (index < 0) throw new UnsupportedBattle("Multi-hit script has no critical command."); this.scriptIndex = index; } /** BattleCommand_BeatUp: the next party member's base Attack and level * against the target's base Defense. A fainted or statused member skips * to after buildopponentrage. Wild Pokémon hit with their own stats but * never count a hit, so "But it failed!" follows (a kept bug). */ private *beatUp(): Gen { this.curDamage = 0; const user = this.user, data = this.options.data; const player = this.turn === 0, party = player ? this.options.party : this.options.trainer?.party; if (!user.has(SUB3, C.SUBSTATUS_IN_LOOP)) { user.rolloutCount = 0; this.beatUpMon = 0; this.beatUpHit = 0; } else { const count = player ? this.options.party.length : this.options.trainer!.party.length; this.beatUpMon = (count - user.rolloutCount) & 0xff; } if (!party) { this.strings[RAM.stringBuffer1] = data.speciesName(this.enemy.mon.species); yield* this.say(BATTLE.BeatUpAttackText); return this.damageStats(); // EnemyAttackDamage } const mon = party[this.beatUpMon]; if (!mon) throw new UnsupportedBattle("Beat Up ran past the party."); if (party.some((member) => member.species === C.EGG)) throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet."); // The player's members go by nickname, a trainer's by species name. this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species); const skip = () => this.skipTo(COMMAND.buildopponentrage); if (!mon.hp) return skip(); // The player's side takes the battle status when wCurBattleMon equals // the member's HP low byte rather than its index (a kept bug). const active = player ? this.curBattleMon === (mon.hp & 0xff) : this.curOTMon === this.beatUpMon; if ((active ? user.mon.status : mon.status) !== 0) return skip(); this.beatUpHit = 1; yield* this.say(BATTLE.BeatUpAttackText); this.damageArgs = { power: user.move.power, level: mon.level, attack: data.baseData(mon.species).stats[C.ATTACK + 1], defense: data.baseData(this.target.mon.species).stats[C.DEFENSE + 1], }; } /** BattleCommand_BeatUpFailText. */ private *beatUpFailText(): Gen { if (!this.beatUpHit) yield* this.say(BATTLE.ButItFailedText); } // ------------------------------------------------- special damage moves /** BattleCommand_ConstantDamage: the level, the move's power, Psywave's * random damage below 1.5 × level, half the target's HP, or Flail and * Reversal's power from the user's HP bar. */ private constantDamage() { const user = this.user, level = user.mon.level; switch (user.move.effect) { case C.EFFECT_LEVEL_DAMAGE: this.curDamage = level; return; case C.EFFECT_PSYWAVE: { const limit = (level + (level >> 1)) & 0xff; // A limit of 1 or less loops forever in the original. if (limit <= 1) throw new UnsupportedBattle("Psywave at this level freezes the original."); for (let attempt = 0; attempt < 4096; attempt++) { const damage = this.random(); if (damage && damage < limit) { this.curDamage = damage; return; } } throw new UnsupportedBattle("Psywave random source did not produce damage."); } case C.EFFECT_SUPER_FANG: this.curDamage = this.target.mon.hp >> 1 || 1; return; case C.EFFECT_REVERSAL: return this.reversalDamage(); default: this.curDamage = user.move.power; } } /** ConstantDamage's .reversal: HP bar pixels (HP × 48 / max HP, scaled * down by 4 when max HP has a high byte) pick FlailReversalPower's power * for a normal damage calculation, then the move's power becomes 1. */ private reversalDamage() { const user = this.user, { hp, maxHp } = user.mon; let dividend = hp * 48, divisor = maxHp & 0xff; if (maxHp >> 8) { divisor = (maxHp >> 2) & 0xff; dividend = (dividend >> 2) & 0xffff; } if (!divisor) throw new UnsupportedBattle("Reversal divides by zero."); const pixels = Math.floor(dividend / divisor) & 0xff; let p = BATTLE.FlailReversalPower; while (this.reader.byte(p) < pixels) p += 2; user.move.power = this.reader.byte(p + 1); this.damageStats(); this.damageCalcCommand(); user.move.power = 1; } /** BattleCommand_OHKO: fails against a higher level; otherwise each * level of difference adds 2 to the accuracy before CheckHit. */ private *ohko(): Gen { this.curDamage = 0; const user = this.user, difference = user.mon.level - this.target.mon.level; if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || difference < 0) { this.criticalHit = 0xff; // "is unaffected!" this.attackMissed = 1; return; } user.move.accuracy = Math.min(0xff, user.move.accuracy + ((difference * 2) & 0xff)); yield* this.checkHit(); this.curDamage = 0xffff; this.criticalHit = 2; } /** BattleCommand_Counter and BattleCommand_MirrorCoat: twice the damage * of the opponent's last physical (special) move, if the opponent moved * first. wCurDamage still holds that damage, even after the opponent * used an item instead (a kept bug). */ private counter(special: boolean) { this.attackMissed = 1; const data = this.options.data, last = this.target.lastCounterMove; if (!last) return; const move = data.move(last); if (move.effect === (special ? C.EFFECT_MIRROR_COAT : C.EFFECT_COUNTER)) return; if (!this.resetTypeMatchup()) return; if (!(this.enemyGoesFirst ^ this.turn)) return; if (!move.power || move.type >= C.SPECIAL !== special) return; if (!this.curDamage) return; this.curDamage = Math.min(0xffff, this.curDamage * 2); this.attackMissed = 0; } /** BattleCommand_GetMagnitude: MagnitudePower's power for damagecalc. */ private *getMagnitude(): Gen { const roll = this.random(); let p = BATTLE.MagnitudePower; while (this.reader.byte(p) < roll) p += 3; this.damageArgs.power = this.reader.byte(p + 1); this.numbers[RAM.textDecimalByte] = this.reader.byte(p + 2); yield* this.say(BATTLE.MagnitudeText); } /** BattleCommand_Present: a PresentPower power for damagecalc, or a * quarter of the target's maximum HP as a gift. A target at full HP * refuses it, which is only said with battle animations off. */ private *present(): Gen { this.stab(); if (!this.matchup || this.attackMissed) return; const roll = this.random(), r = this.reader; let p = BATTLE.PresentPower; for (let index = 0; r.byte(p) !== 0xff; index++, p += 2) if (r.byte(p) >= roll) { this.animParam = index; this.damageArgs.power = r.byte(p + 1); return; } this.animParam = 3; const target = this.target.mon; if (target.hp === target.maxHp) { if (this.options.battleScene === false) yield* this.say(BATTLE.PresentFailedText); return this.endMoveEffect(); } // GetQuarterMaxHP of the target, then RestoreHP. target.hp = Math.min(target.maxHp, (target.hp + (target.maxHp >> 2 || 1)) & 0xffff); yield this.hud(); this.turn ^= 1; yield* this.say(BATTLE.RegainedHealthText); this.turn ^= 1; this.updateOpponentInParty(); this.endMoveEffect(); } /** HiddenPowerDamage: the type and power from the user's DVs, then the * damage stats for that type. */ private hiddenPower() { if (this.attackMissed) return; const dvs = this.user.mon.dvs, attackDefense = dvs >> 8, speedSpecial = dvs & 0xff; const bits = ((attackDefense >> 4) & 8) | ((attackDefense & 8) >> 1) | ((speedSpecial >> 6) & 2) | ((speedSpecial & 8) >> 3); const power = ((bits * 5 + (speedSpecial & 3)) >> 1) + 31; let type = ((attackDefense & 3) | (((attackDefense >> 4) & 3) << 2)) + 1; if (type >= C.BIRD) type++; if (type >= C.UNUSED_TYPES) type += C.UNUSED_TYPES_END - C.UNUSED_TYPES; this.user.move.type = type; this.damageStats(); this.damageArgs.power = power; } /** BattleCommand_FakeOut: flinches unless the target has a substitute, * sleeps, is frozen or moved first; otherwise the move misses. */ private fakeOut() { if (this.attackMissed) return; const target = this.target; if ( target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status & ((1 << C.FRZ) | C.SLP_MASK) || this.enemyGoesFirst ^ this.turn ) { this.attackMissed = 1; return; } target.set(SUB3, C.SUBSTATUS_FLINCHED); target.reset(SUB4, C.SUBSTATUS_RECHARGE); } /** BattleCommand_PayDay: twice the user's level in coins. */ private *payDay(): Gen { this.payDayMoney = (this.payDayMoney + ((this.user.mon.level * 2) & 0xff)) & 0xffffff; yield* this.say(BATTLE.CoinsScatteredText); } /** BattleCommand_Thief: an item-less user takes the target's item, but * not MAIL. A trainer's Pokémon keeps a stolen item for the rest of the * battle, so the player loses it for good. */ private *thief(): Gen { const data = this.options.data, user = this.user, target = this.target; if (user.mon.item) return; const item = target.mon.item; if (!item || data.itemIsMail(item) || this.effectFailed) return; const otMon = this.options.trainer?.party[this.curOTMon]; target.mon.item = 0; user.mon.item = item; if (this.turn === 0) { this.partyMon.item = item; if (otMon) otMon.item = 0; } else { this.partyMon.item = 0; if (otMon) otMon.item = item; } this.strings[RAM.stringBuffer1] = data.itemName(item); yield* this.say(BATTLE.StoleText); } /** BattleCommand_Selfdestruct: the user faints, hit or miss, and loses * Leech Seed; the target's Destiny Bond is cleared. */ private *selfdestruct(): Gen { const user = this.user; user.mon.status = 0; user.mon.hp = 0; this.requireNoSubstitute(); // BattleCommand_LowerSub user.reset(SUB4, C.SUBSTATUS_LEECH_SEED); this.target.reset(SUB5, C.SUBSTATUS_DESTINY_BOND); yield this.hud(); } // ------------------------------------------------- status and field moves /** CheckHiddenOpponent: the target is flying or underground. */ private hiddenOpponent() { return Boolean(this.target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))); } /** FailMove: "But it failed!", or "It failed!" against Protect. */ private *failMove(): Gen { yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText); } /** The slot of the move an opponent last used. The original searches on * past the move list when the move is not there (after Metronome, for * instance), which is refused. */ private moveSlot(side: Side, move: number) { const slot = side.mon.moves.indexOf(move); if (slot < 0) throw new UnsupportedBattle("A move that is not in the move list is not ported yet."); return slot; } /** BattleCommand_LeechSeed: not on a Grass type or a substitute. */ private *leechSeed(): Gen { const target = this.target; if (this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.say(BATTLE.EvadedText); if (this.isType(target, C.GRASS)) return yield* this.say(BATTLE.DoesntAffectText); if (target.has(SUB4, C.SUBSTATUS_LEECH_SEED)) return yield* this.say(BATTLE.EvadedText); target.set(SUB4, C.SUBSTATUS_LEECH_SEED); yield* this.say(BATTLE.WasSeededText); } /** BattleCommand_Nightmare: only on a sleeping target in reach. */ private *nightmare(): Gen { const target = this.target; if ( this.hiddenOpponent() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || !(target.mon.status & C.SLP_MASK) || target.has(SUB1, C.SUBSTATUS_NIGHTMARE) ) return yield* this.say(BATTLE.ButItFailedText); target.set(SUB1, C.SUBSTATUS_NIGHTMARE); yield* this.say(BATTLE.StartedNightmareText); } /** BattleCommand_Curse: a Ghost type halves its HP to curse the target; * others lower their Speed and raise Attack and Defense. */ private *curse(): Gen { const user = this.user, target = this.target; if (this.isType(user, C.GHOST)) { if (this.hiddenOpponent() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.has(SUB1, C.SUBSTATUS_CURSE)) return yield* this.say(BATTLE.ButItFailedText); target.set(SUB1, C.SUBSTATUS_CURSE); this.subtractHP(user, user.mon.maxHp >> 1 || 1); this.updateUserInParty(); yield this.hud(); return yield* this.say(BATTLE.PutACurseText); } if (user.levels[C.ATTACK] >= C.MAX_STAT_LEVEL && user.levels[C.DEFENSE] >= C.MAX_STAT_LEVEL) { // GetStatName's eighth name: "ABILITY won't rise anymore!" this.loweredStat = C.ABILITY; this.statName(); return yield* this.say(BATTLE.WontRiseAnymoreText); } this.animParam = 1; this.lowerOwnStat(C.SPEED); this.turn ^= 1; yield* this.statDownMessage(); this.attackMissed = 0; this.turn ^= 1; this.statUp(C.ATTACK); yield* this.statUpMessage(); this.attackMissed = 0; this.statUp(C.DEFENSE); yield* this.statUpMessage(); } /** LowerStat: a stat of the side whose turn it is, without Mist or a * random failure. */ private lowerOwnStat(stat: number) { this.loweredStat = stat; const side = this.user, index = stat & 0xf; let level = side.levels[index] - 1; if (!level) { this.failedMessage = 2; return; } if (stat & 0xf0) level = level - 1 || 1; side.levels[index] = level; if (index < C.ACCURACY) { if (side.mon.stats[index] === 1) { side.levels[index] = level + 1; this.failedMessage = 2; return; } this.calcStats(side); } this.failedMessage = 0; } /** BattleCommand_Protect and BattleCommand_Endure (ProtectChance): fails * for the side that moves last or behind a substitute; each use in a row * halves the chance. */ private *protect(endure: boolean): Gen { const user = this.user; let chance = 0xff; let failed = Boolean(this.enemyGoesFirst ^ this.turn) || user.has(SUB4, C.SUBSTATUS_SUBSTITUTE); for (let count = user.protectCount; !failed && count; count--) { chance >>= 1; if (!chance) failed = true; } if (!failed) { let roll: number; do roll = this.random(); while (!roll); failed = roll - 1 >= chance; } if (failed) { user.protectCount = 0; return yield* this.say(BATTLE.ButItFailedText); } user.protectCount = (user.protectCount + 1) & 0xff; user.set(SUB1, endure ? C.SUBSTATUS_ENDURE : C.SUBSTATUS_PROTECT); yield* this.say(endure ? BATTLE.BracedItselfText : BATTLE.ProtectedItselfText); } /** BattleCommand_Spite: 2-5 PP off the target's last move, in its party * struct too unless it is transformed (a wild Pokémon's kept PP). */ private *spite(): Gen { const target = this.target, last = target.lastCounterMove; if (this.attackMissed || !last || last === C.STRUGGLE) return yield* this.didntAffect2(); const slot = this.moveSlot(target, last), pp = target.mon.pp[slot]; if (!(pp & C.PP_MASK)) return yield* this.didntAffect2(); this.strings[RAM.stringBuffer1] = this.options.data.moveName(last); const amount = Math.min(pp & C.PP_MASK, (this.random() & 3) + 2); target.mon.pp[slot] = pp - amount; if (!target.has(SUB5, C.SUBSTATUS_TRANSFORMED)) { if (this.turn === 1) this.partyMon.pp[slot] = pp - amount; else if (this.battleMode === C.WILD_BATTLE) this.wildPP[slot] = pp - amount; else this.options.trainer!.party[this.curOTMon].pp[slot] = pp - amount; } this.numbers[RAM.textDecimalByte] = amount; yield* this.say(BATTLE.SpiteEffectText); } /** BattleCommand_HealBell: the user and all six party slots lose their * status; Nightmare ends, Toxic's flag does not. */ private *healBell(): Gen { const user = this.user; user.reset(SUB1, C.SUBSTATUS_NIGHTMARE); user.mon.status = 0; for (const mon of (this.turn === 0 ? this.options.party : this.options.trainer?.party) ?? []) mon.status = 0; yield this.hud(); yield* this.say(BATTLE.BellChimedText); this.calcStats(user); } /** BattleCommand_Disable: the target's last move for 1-7 more turns. */ private *disable(): Gen { const target = this.target, last = target.lastCounterMove; if (this.attackMissed || target.disableCount || !last || last === C.STRUGGLE) return yield* this.failMove(); const slot = this.moveSlot(target, last); if (!target.mon.pp[slot]) return yield* this.failMove(); let turns: number; do turns = this.random() & 7; while (!turns); target.disableCount = turns + 1 + ((slot + 1) << 4); target.disabledMove = last; this.strings[RAM.stringBuffer1] = this.options.data.moveName(last); yield* this.say(BATTLE.WasDisabledText); } /** BattleCommand_Encore: the target repeats its last move for 3-6 turns; * if it has not moved yet this turn, it switches to that move now. */ private *encore(): Gen { const target = this.target, last = target.lastMove; if (!last || last === C.STRUGGLE || last === C.ENCORE || last === C.MIRROR_MOVE) return yield* this.didntAffect2(); if (!(target.mon.pp[this.moveSlot(target, last)] & C.PP_MASK)) return yield* this.didntAffect2(); if (this.attackMissed || target.has(SUB5, C.SUBSTATUS_ENCORED)) return yield* this.didntAffect2(); target.set(SUB5, C.SUBSTATUS_ENCORED); target.encoreCount = (this.random() & 3) + 3; if (!(this.enemyGoesFirst ^ this.turn)) { target.moveNum = this.moveSlot(target, last); target.curMove = last; target.move = this.moveStruct(last); } yield* this.say(BATTLE.GotAnEncoreText); } /** GetMoveData into a move struct. */ private moveStruct(id: number): MoveStruct { const move = this.options.data.move(id); return { anim: move.id, effect: move.effect, power: move.power, type: move.type, accuracy: move.accuracy, pp: move.pp, chance: move.chance, }; } /** BattleCommand_Attract: the opposite gender falls in love. The * player's side counts its party Pokémon's gender. */ private *attract(): Gen { const target = this.target; if (this.attackMissed || !this.oppositeGender() || this.hiddenOpponent() || target.has(SUB1, C.SUBSTATUS_IN_LOVE)) return yield* this.failMove(); target.set(SUB1, C.SUBSTATUS_IN_LOVE); yield* this.say(BATTLE.FellInLoveText); } /** CheckOppositeGender. */ private oppositeGender() { if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) throw new UnsupportedBattle("Attract against a transformed Pokémon is not ported yet."); const data = this.options.data, player = data.gender(this.partyMon.species, this.partyMon.dvs), enemy = data.gender(this.enemy.mon.species, this.enemy.mon.dvs); return player !== undefined && enemy !== undefined && player !== enemy; } /** BattleCommand_ResetStats (Haze): every stat level back to normal. */ private *resetStats(): Gen { const turn = this.turn; for (const [side, sideTurn] of [[this.player, 0], [this.enemy, 1]] as const) { side.levels = [7, 7, 7, 7, 7, 7, 7]; this.turn = sideTurn; this.calcStats(side); } this.turn = turn; yield* this.say(BATTLE.EliminatedStatsText); } /** BattleCommand_Heal: half the maximum HP, or all of it with Rest, * which also puts the user to sleep for two turns. */ private *heal(): Gen { const user = this.user, mon = user.mon; if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText); const rest = user.move.anim === C.REST; if (rest) { user.reset(SUB5, C.SUBSTATUS_TOXIC); const statused = mon.status !== 0; mon.status = C.REST_SLEEP_TURNS + 1; yield this.hud(); yield* this.say(statused ? BATTLE.RestedText : BATTLE.WentToSleepText); this.calcStats(user); } mon.hp = Math.min(mon.maxHp, (mon.hp + (rest ? mon.maxHp : mon.maxHp >> 1 || 1)) & 0xffff); this.updateUserInParty(); yield this.hud(); yield* this.say(BATTLE.RegainedHealthText); } /** BattleCommand_PainSplit: both take the average of their HP, up to * their maximum. The party structs are left as they were. */ private *painSplit(): Gen { if (this.attackMissed || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.didntAffect2(); const average = (this.player.mon.hp + this.enemy.mon.hp) >> 1; for (const side of [this.player, this.enemy]) side.mon.hp = Math.min(side.mon.maxHp, average); this.curDamage = 0; yield this.hud(); yield* this.say(BATTLE.SharedPainText); } /** BattleCommand_BellyDrum: half the maximum HP for the highest Attack. * The first two stages are raised before the HP check, so they stay * even when it fails (a kept bug). */ private *bellyDrum(): Gen { const user = this.user; this.statUp(C.ATTACK | 0x10); const half = user.mon.maxHp >> 1 || 1; if (this.attackMissed || half >= user.mon.hp) return yield* this.say(BATTLE.ButItFailedText); this.subtractHP(user, half); this.updateUserInParty(); yield this.hud(); for (let times = C.MAX_STAT_LEVEL - C.BASE_STAT_LEVEL - 1; times > 0; times--) this.statUp(C.ATTACK | 0x10); yield* this.say(BATTLE.BellyDrumText); } /** BattleCommand_PsychUp: copy the target's stat levels, unless they * are all normal. */ private *psychUp(): Gen { const user = this.user, target = this.target; if (target.levels.every((level) => level === C.BASE_STAT_LEVEL)) return yield* this.say(BATTLE.ButItFailedText); user.levels = [...target.levels]; this.calcStats(user); yield* this.say(BATTLE.CopiedStatsText); } /** BattleCommand_PerishSong: both sides get a count of 4, unless both * already have one. */ private *perishSong(): Gen { const sides = [this.player, this.enemy]; if (sides.every((side) => side.has(SUB1, C.SUBSTATUS_PERISH))) return yield* this.say(BATTLE.ButItFailedText); for (const side of sides) if (!side.has(SUB1, C.SUBSTATUS_PERISH)) { side.set(SUB1, C.SUBSTATUS_PERISH); side.perishCount = 4; } yield* this.say(BATTLE.StartPerishText); } /** BattleCommand_ClearHazards (Rapid Spin): the user sheds Leech Seed, * its side's Spikes and a trap. */ private *clearHazards(): Gen { const user = this.user; if (user.has(SUB4, C.SUBSTATUS_LEECH_SEED)) { user.reset(SUB4, C.SUBSTATUS_LEECH_SEED); yield* this.say(BATTLE.ShedLeechSeedText); } if (user.screens & (1 << C.SCREENS_SPIKES)) { user.screens &= ~(1 << C.SCREENS_SPIKES); yield* this.say(BATTLE.BlewSpikesText); } if (!user.wrapCount) return; user.wrapCount = 0; yield* this.say(BATTLE.ReleasedByText); } /** BattleCommand_CheckFutureSight: on the turn it strikes, the stored * damage skips to after `futuresight`. */ private checkFutureSight() { const user = this.user; if (user.futureSightCount !== 1) return; user.futureSightCount = 0; this.curDamage = user.futureSightDamage; this.skipTo(COMMAND.futuresight); } /** BattleCommand_FutureSight: store this turn's damage for three turns * later, unless one is already coming. */ private *futureSight(): Gen { const user = this.user; if (!user.charging) { user.lastCounterMove = user.move.anim; user.lastMove = user.move.anim; } if (user.futureSightCount) { this.curDamage = 0; yield* this.say(BATTLE.ButItFailedText); return this.endMoveEffect(); } user.futureSightCount = 4; this.requireNoSubstitute(); // BattleCommand_LowerSub and RaiseSub yield* this.say(BATTLE.ForesawAttackText); user.futureSightDamage = this.curDamage; this.curDamage = 0; this.endMoveEffect(); } /** SkipToBattleCommand: go on after the next `command`. */ private skipTo(command: number) { while (this.script[this.scriptIndex++] !== command) if (this.scriptIndex >= this.script.length) throw new UnsupportedBattle(`Battle script has no command 0x${command.toString(16)}.`); } // ------------------------------------------------------ multi-turn moves /** BattleCommand_CheckCharge: a charged move's second turn skips to * after `charge`. */ private checkCharge() { const user = this.user; if (!user.has(SUB3, C.SUBSTATUS_CHARGED)) return; user.reset(SUB3, C.SUBSTATUS_CHARGED); user.reset(SUB3, C.SUBSTATUS_UNDERGROUND); user.reset(SUB3, C.SUBSTATUS_FLYING); this.skipTo(COMMAND.charge); } /** BattleCommand_Charge: the charging turn. Fly and Dig take the user * out of reach; Skull Bash goes on to its Defense raise. A sleeping * user (through Sleep Talk) fails. */ private *charge(): Gen { const user = this.user; if (user.mon.status & C.SLP_MASK) { this.requireNoSubstitute(); // BattleCommand_RaiseSub yield* this.say(BATTLE.ButItFailedText); return this.endMoveEffect(); } user.set(SUB3, C.SUBSTATUS_CHARGED); if (this.alreadyDisobeyed) yield* this.say(BATTLE.IgnoredOrders2Text); this.requireNoSubstitute(); // BattleCommand_LowerSub and RaiseSub this.animParam = 1; const anim = user.move.anim; if (anim === C.FLY) user.set(SUB3, C.SUBSTATUS_FLYING); else if (anim === C.DIG) user.set(SUB3, C.SUBSTATUS_UNDERGROUND); if (!user.charging) { user.lastCounterMove = anim; user.lastMove = anim; } this.curDamage = 0; yield* this.say(BATTLE["BattleCommand_Charge.UsedText"]); if (user.move.effect === C.EFFECT_SKULL_BASH) return this.skipTo(COMMAND.endturn); this.endMoveEffect(); } /** BattleCommand_CheckRampage: a rampage's later turns skip to after * `rampage`; the last one confuses the user for 2-3 turns unless its * side has Safeguard. */ private checkRampage() { const user = this.user; if (!user.has(SUB3, C.SUBSTATUS_RAMPAGE)) return; user.rolloutCount = (user.rolloutCount - 1) & 0xff; if (!user.rolloutCount) { user.reset(SUB3, C.SUBSTATUS_RAMPAGE); if (!(user.screens & (1 << C.SCREENS_SAFEGUARD))) { user.set(SUB3, C.SUBSTATUS_CONFUSED); user.confuseCount = (this.random() & 1) + 2; } } this.skipTo(COMMAND.rampage); } /** BattleCommand_Rampage: 1 or 2 more turns, not while asleep. */ private rampage() { const user = this.user; if (user.mon.status & C.SLP_MASK) return; user.set(SUB3, C.SUBSTATUS_RAMPAGE); user.rolloutCount = (this.random() & 1) + 1; } /** BattleCommand_CheckRollout: a rolling Pokémon skips to after doturn. */ private checkRollout() { const user = this.user; if (user.has(SUB1, C.SUBSTATUS_ROLLOUT)) return this.skipTo(COMMAND.doturn); user.rolloutCount = 0; } /** BattleCommand_RolloutPower: the damage doubles for each hit so far * (once more after Defense Curl), for up to five hits; a miss stops. */ private rolloutPower() { const user = this.user; if (user.mon.status & C.SLP_MASK) return; if (this.attackMissed) return user.reset(SUB1, C.SUBSTATUS_ROLLOUT); user.rolloutCount = (user.rolloutCount + 1) & 0xff; let doublings = user.rolloutCount; if (doublings >= 5) user.reset(SUB1, C.SUBSTATUS_ROLLOUT); else user.set(SUB1, C.SUBSTATUS_ROLLOUT); if (user.has(SUB2, C.SUBSTATUS_CURLED)) doublings++; this.doubleDamage(doublings - 1); } /** BattleCommand_FuryCutter: doubles for each hit in a row, up to 16×; * a miss starts over. */ private furyCutter() { const user = this.user; if (this.attackMissed) { user.furyCutterCount = 0; return; } user.furyCutterCount = (user.furyCutterCount + 1) & 0xff; this.doubleDamage(Math.min(user.furyCutterCount, 5) - 1); } /** Double wCurDamage `times` times, stopping at $ffff. */ private doubleDamage(times: number) { for (; times > 0; times--) { this.curDamage *= 2; if (this.curDamage > 0xffff) { this.curDamage = 0xffff; return; } } } /** BattleCommand_StoreEnergy: while biding, the user stores energy; on * the last turn it unleashes twice the damage it took. */ private *storeEnergy(): Gen { const user = this.user; if (!user.has(SUB3, C.SUBSTATUS_BIDE)) return; user.rolloutCount = (user.rolloutCount - 1) & 0xff; if (user.rolloutCount) { yield* this.say(BATTLE.StoringEnergyText); return this.endMoveEffect(); } user.reset(SUB3, C.SUBSTATUS_BIDE); yield* this.say(BATTLE.UnleashedEnergyText); user.move.power = 1; this.curDamage = Math.min(0xffff, user.damageTaken * 2); if (!this.curDamage) this.attackMissed = 1; user.damageTaken = 0; user.charging = 0; user.move.anim = C.BIDE; this.skipTo(COMMAND.unleashenergy); } /** BattleCommand_UnleashEnergy: Bide's first turn. It also clears both * move structs' effects. */ private unleashEnergy() { const user = this.user; user.set(SUB3, C.SUBSTATUS_BIDE); user.damageTaken = 0; this.player.move.effect = 0; this.enemy.move.effect = 0; user.rolloutCount = (this.random() & 1) + 2; this.animParam = 1; this.endMoveEffect(); } /** BattleCommand_TrapTarget: trapped for 2-5 more turns, unless already * trapped or behind a substitute. */ private *trapTarget(): Gen { if (this.attackMissed) return; const target = this.target; if (target.wrapCount || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; target.wrapCount = (this.random() & 3) + 3; const anim = this.user.move.anim; target.trappingMove = anim; const text = { [C.BIND]: BATTLE.UsedBindText, [C.WRAP]: BATTLE.WrappedByText, [C.FIRE_SPIN]: BATTLE.FireSpinTrapText, [C.CLAMP]: BATTLE.ClampedByText, [C.WHIRLPOOL]: BATTLE.WhirlpoolTrapText, }[anim]; if (text === undefined) throw new UnsupportedBattle("Unknown trapping move."); yield* this.say(text); } /** BattleCommand_LockOn: the user's next move cannot miss the target. */ private *lockOn(): Gen { const target = this.target; if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) return yield* this.say(BATTLE.DidntAffect1Text); target.set(SUB5, C.SUBSTATUS_LOCK_ON); yield* this.say(BATTLE.TookAimText); } /** 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; } /** CheckMoveTypeMatchesTarget: the move shares a type with the target, * unless it is Normal (Tri Attack). */ private moveTypeMatchesTarget() { const type = this.user.move.type; return type !== C.NORMAL && this.isType(this.target, type); } /** Defrost: a fire move thaws a frozen target, in the party struct too * (wOTPartyMons for a trainer; a wild Pokémon has none to keep). */ private *defrostOpponent(): Gen { const target = this.target; if (!(target.mon.status & (1 << C.FRZ))) return; target.mon.status = 0; if (this.turn === 1) this.partyMon.status = 0; else if (this.options.trainer) this.options.trainer.party[this.curOTMon].status = 0; this.updateOpponentInParty(); yield this.hud(); yield* this.say(BATTLE.DefrostedOpponentText); } /** BattleCommand_Defrost: Flame Wheel and Sacred Fire thaw their user; * a wild Pokémon's party struct is left alone. */ private *defrostUser(): Gen { const user = this.user; if (!(user.mon.status & (1 << C.FRZ))) return; user.mon.status &= ~(1 << C.FRZ); if (this.turn === 0) this.partyMon.status &= ~(1 << C.FRZ); else if (this.battleMode !== C.WILD_BATTLE) this.options.trainer!.party[this.curOTMon].status &= ~(1 << C.FRZ); yield this.hud(); yield* this.say(BATTLE.WasDefrostedText); } /** BattleCommand_FreezeTarget. */ private *freezeTarget(): 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.weather === C.WEATHER_SUN) return; if (this.moveTypeMatchesTarget()) return; // no freezing an Ice type if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_FREEZE) return; if (this.effectFailed || this.safeguarded()) return; target.mon.status |= 1 << C.FRZ; this.updateOpponentInParty(); yield this.hud(); yield* this.say(BATTLE.WasFrozenText); if (yield* this.useHeldStatusHealingItem()) return; // OpponentCantMove and EndRechargeOpp; a freeze this turn skips // HandleDefrost. this.turn ^= 1; this.cantMove(); this.turn ^= 1; target.reset(SUB4, C.SUBSTATUS_RECHARGE); target.justGotFrozen = 1; } /** BattleCommand_TriStatusChance: one of paralysis, freeze or burn. */ private *triStatusChance(): Gen { this.effectChance(); let choice: number; do choice = (this.random() >> 4) & 3; while (!choice); if (choice === 1) yield* this.paralyzeTarget(); else if (choice === 2) yield* this.freezeTarget(); else yield* this.burnTarget(); } /** 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) return yield* this.defrostOpponent(); if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return; if (this.moveTypeMatchesTarget()) return; // no burning a Fire type 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); // Toxic badly poisons, starting its damage counter over. const toxic = this.user.move.effect === C.EFFECT_TOXIC; if (toxic) { target.set(SUB5, C.SUBSTATUS_TOXIC); target.toxicCount = 0; } target.mon.status |= 1 << C.PSN; this.updateOpponentInParty(); yield this.hud(); yield* this.say(toxic ? BATTLE.BadlyPoisonedText : 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(); } yield* this.finishConfusingTarget(); } /** BattleCommand_ConfuseTarget: a move's added confusion, silent when * it fails. */ private *confuseTarget(): Gen { const target = this.target; if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) return; if (this.effectFailed || this.safeguarded() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return; yield* this.finishConfusingTarget(); } /** BattleCommand_FinishConfusingTarget: confused for 2-5 turns. */ private *finishConfusingTarget(): Gen { const target = this.target; target.set(SUB3, C.SUBSTATUS_CONFUSED); target.confuseCount = (this.random() & 3) + 2; yield* this.say(BATTLE.BecameConfusedText); yield* this.useConfusionHealingItem(); } /** BattleCommand_DrainTarget and BattleCommand_EatDream: SapHealth * restores half the damage. */ private *drainTarget(text: number = BATTLE.SuckedHealthText): 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(text); } /** 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); this.updateUserInParty(); yield this.hud(); yield* this.say(BATTLE.RegainedHealthText); } /** UpdateOpponentInParty and UpdateUserInParty. */ private updateOpponentInParty() { if (this.turn === 1) this.updateBattleMonInParty(); else this.updateEnemyMonInParty(); } private updateUserInParty() { if (this.turn === 0) this.updateBattleMonInParty(); else this.updateEnemyMonInParty(); } /** UpdateEnemyMonInParty: a trainer's Pokémon's level, status and HP go * back to wOTPartyMons; wild Pokémon have none. */ private updateEnemyMonInParty() { if (this.battleMode === C.WILD_BATTLE) return; const mon = this.options.trainer?.party[this.curOTMon]; if (!mon) return; mon.level = this.enemy.mon.level; mon.status = this.enemy.mon.status; mon.hp = this.enemy.mon.hp; } // ------------------------------------------------------ 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; } /** BattleCommand_StatUp: RaiseStat, then MinimizeDropSub records a * successful Minimize for Stomp. */ private statUp(stat: number) { this.raiseStat(stat); if (!this.failedMessage && this.user.move.anim === C.MINIMIZE) this.user.minimized = 1; } /** 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: poison and burn (Toxic * a growing number of sixteenths), Leech Seed, Nightmare and Curse. * Returns false if it fainted. */ private *residualDamage(): Gen { const user = this.user, mon = user.mon, quarter = mon.maxHp >> 2 || 1; 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); let damage = quarter >> 1 || 1; if (user.has(SUB5, C.SUBSTATUS_TOXIC)) { user.toxicCount = (user.toxicCount + 1) & 0xff; damage = ((quarter >> 2 || 1) * (user.toxicCount || 0x100)) & 0xffff; } this.subtractHP(user, damage); yield this.hud(); } if (!mon.hp) return false; if (user.has(SUB4, C.SUBSTATUS_LEECH_SEED)) { // The seeder regains the HP actually drained. const drained = Math.min(mon.hp, quarter >> 1 || 1); this.subtractHP(user, drained); const seeder = this.target; seeder.mon.hp = Math.min(seeder.mon.maxHp, seeder.mon.hp + drained); if (seeder === this.player) this.updateBattleMonInParty(); yield this.hud(); yield* this.say(BATTLE.LeechSeedSapsText); } if (!mon.hp) return false; if (user.has(SUB1, C.SUBSTATUS_NIGHTMARE)) { this.subtractHP(user, quarter); yield this.hud(); yield* this.say(BATTLE.HasANightmareText); } if (!mon.hp) return false; if (user.has(SUB1, C.SUBSTATUS_CURSE)) { this.subtractHP(user, quarter); yield this.hud(); yield* this.say(BATTLE.HurtByCurseText); } 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; yield* this.handleFutureSight(); if (yield* this.checkFaintPlayerThenEnemy()) return; if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); yield* this.handleWrap(); if (yield* this.checkFaintPlayerThenEnemy()) return; yield* this.handlePerishSong(); if (yield* this.checkFaintPlayerThenEnemy()) return; for (const side of [this.player, this.enemy]) { 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.handleSafeguard(); 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(); yield* this.handleEncore(); } /** HandleFutureSight: three turns after its use, the stored attack hits * through DoMove, without CheckTurn. */ private *handleFutureSight(): Gen { for (const [turn, side] of [this.player, this.enemy].entries()) { this.turn = turn; if (!side.futureSightCount) continue; side.futureSightCount--; if (side.futureSightCount !== 1) continue; yield* this.say(BATTLE.BattleText_TargetWasHitByFutureSight); const move = side.curMove; side.curMove = C.FUTURE_SIGHT; this.updateMoveData(); this.attackMissed = 0; this.alreadyDisobeyed = 0; this.typeModifier = C.EFFECTIVE; yield* this.doMove(); this.curDamage = 0; side.curMove = move; this.updateBattleMonInParty(); this.updateEnemyMonInParty(); } } /** HandlePerishSong: each count goes down; at 0 the Pokémon faints. */ private *handlePerishSong(): Gen { for (const [turn, side] of [this.player, this.enemy].entries()) { this.turn = turn; if (!side.has(SUB1, C.SUBSTATUS_PERISH)) continue; side.perishCount = (side.perishCount - 1) & 0xff; this.numbers[RAM.textDecimalByte] = side.perishCount; yield* this.say(BATTLE.PerishCountText); if (side.perishCount) continue; side.reset(SUB1, C.SUBSTATUS_PERISH); side.mon.hp = 0; if (turn === 0) this.partyMon.hp = 0; else if (this.battleMode !== C.WILD_BATTLE) this.options.trainer!.party[this.curOTMon].hp = 0; yield this.hud(); } } /** HandleSafeguard: five turns, the use included. */ private *handleSafeguard(): Gen { for (const [turn, side] of [this.player, this.enemy].entries()) { if (!(side.screens & (1 << C.SCREENS_SAFEGUARD))) continue; side.safeguardCount = (side.safeguardCount - 1) & 0xff; if (side.safeguardCount) continue; side.screens &= ~(1 << C.SCREENS_SAFEGUARD); this.turn = turn; yield* this.say(BATTLE.BattleText_SafeguardFaded); } } /** HandleEncore: an encore ends after its turns, or when the move in * the chosen slot runs out of PP. */ private *handleEncore(): Gen { for (const [turn, side] of [this.player, this.enemy].entries()) { if (!side.has(SUB5, C.SUBSTATUS_ENCORED)) continue; side.encoreCount = (side.encoreCount - 1) & 0xff; if (side.encoreCount && side.mon.pp[side.moveNum] & C.PP_MASK) continue; side.reset(SUB5, C.SUBSTATUS_ENCORED); this.turn = turn ^ 1; yield* this.say(BATTLE.BattleText_TargetsEncoreEnded); } } /** SpikesDamage: a Pokémon sent out onto Spikes loses an eighth of its * maximum HP, unless it is a Flying type. */ private *spikesDamage(side: Side): Gen { if (!(side.screens & (1 << C.SCREENS_SPIKES)) || this.isType(side, C.FLYING)) return; yield* this.say(BATTLE.BattleText_UserHurtBySpikes); this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 1 || 1); yield this.hud(); } /** HandleWrap: a trapped side loses 1/16 of its maximum HP, or is * released on its last turn. A substitute shields it and stops the * count. */ private *handleWrap(): Gen { for (const [turn, side] of [this.player, this.enemy].entries()) { this.turn = turn; if (!side.wrapCount || side.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) continue; this.strings[RAM.stringBuffer1] = this.options.data.moveName(side.trappingMove); side.wrapCount--; if (!side.wrapCount) { yield* this.say(BATTLE.BattleText_UserWasReleasedFromStringBuffer1); continue; } // GetSixteenthMaxHP this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 2 || 1); yield this.hud(); yield* this.say(BATTLE.BattleText_UsersHurtByStringBuffer1); } } 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; yield* this.spikesDamage(this.enemy); 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.playerIsSwitching = 1; this.parseEnemyAction(); yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]); this.player.reset(SUB4, C.SUBSTATUS_RAGE); this.turn = 1; yield* this.pursuitSwitch(); this.curPartyMon = this.curBattleMon; yield* this.sendOutPlayerMon(); } /** PursuitSwitch: an opponent that chose Pursuit uses it on the Pokémon * being withdrawn, then cannot move this turn. A withdrawn Pokémon that * faints skips the usual faint handling and keeps its status (a kept * bug). Returns true if it fainted. */ private *pursuitSwitch(): Gen { const user = this.user, turn = this.turn; // GetMoveEffect, which reads past the table for no move. if (this.reader.byte(SYMBOLS.moves + ((user.curMove - 1) & 0xff) * 7 + 1) !== C.EFFECT_PURSUIT) return false; const current = this.curBattleMon; if (turn === 1) this.curBattleMon = this.lastPlayerMon; // DoPlayerTurn does nothing after an item or a switch. if (turn === 1 || !this.battlePlayerAction) yield* this.doTurn(); this.turn = turn; user.curMove = 0xff; // CANNOT_MOVE this.curBattleMon = current; if (turn === 0) { if (this.enemy.mon.hp) return false; yield* this.say(BATTLE.BattleText_EnemyMonFainted); return true; } // UpdateBattleMon: the withdrawn Pokémon's level, status and HP. const outgoing = this.options.party[this.lastPlayerMon]; outgoing.level = this.player.mon.level; outgoing.status = this.player.mon.status; outgoing.hp = this.player.mon.hp; if (this.player.mon.hp) return false; this.participants &= ~(1 << this.lastPlayerMon); yield* this.say(BATTLE.BattleText_MonFainted); return true; } /** 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(); this.checkAmuletCoin(); yield* this.sendOutMonText(); this.newStatus(this.player); // with ResetPlayerStatLevels this.breakAttraction(); yield this.hud(); this.turn = 0; yield* this.spikesDamage(this.player); } /** 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.enemyIsSwitching = 1; this.enemyGoesFirst = 1; this.enemy.reset(SUB4, C.SUBSTATUS_RAGE); this.turn = 0; const fainted = yield* this.pursuitSwitch(); // 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; if (!fainted) 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) && this.enemy.perishCount === 1) { // A last Perish count switches at the maximum chance, to the best // candidate or else the first one standing. const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers()))); if (e === 2) return (score + 0x30) & 0xff; const alive = aliveOthers(); for (let index = 0; index < 6; index++) if (alive & bit(index)) return index + 0x30; } 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; } } }