Files
Crystal/src/game/battle/battle.ts
T
Sébastien QUEROLandClaude Opus 5.5 8f5b464efc Port the status and field move effects
Toxic, Leech Seed, Nightmare and Curse hurt in ResidualDamage's order.
Protect, Detect, Endure, Destiny Bond, Spite, Heal Bell, Disable, Encore,
Attract, Mist, Focus Energy, Haze, the healing moves and Rest, Mean Look,
Pain Split, Belly Drum, Psych Up, Safeguard, Perish Song, Spikes, Rapid
Spin, AncientPower and Future Sight now run, with Future Sight, Perish
Song, Safeguard and Encore handled between turns and Spikes on entry.

A trainer's party struct now follows its Pokémon where the original
updates it, and the enemy's moves spend PP there or in the wild
Pokémon's kept PP, so a caught Pokémon keeps the PP it used.

Part of #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:11:19 +02:00

5156 lines
206 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T = void> = Generator<BattleEffect, T, BattleInput>;
export interface BattleOptions {
data: PokemonData;
party: PartyMon[];
pokedex: { seen: Set<number>; caught: Set<number> };
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<number, string> = {};
private numbers: Record<number, number> = {};
private matchups;
private moveGrammarCache = new Map<number, number>();
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<BattleOutcome> {
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<number> {
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<BattleOutcome> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
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: "<PLAYER> used <ITEM>!", 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<number> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
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<number> {
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<number> {
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<boolean> {
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<string> {
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (!(yield { kind: "yesno", text: "" })) return name;
// "<SPECIES>'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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
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<boolean> {
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<number> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
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;
}
}
}