Files
Crystal/src/game/battle/battle.ts
T
Sébastien QUEROLandClaude Opus 5.5 f259163da9 Port Headbutt and Rock Smash encounters
Facing a Headbutt tree asks to HEADBUTT it, and HEADBUTT from the party
menu skips the question. ShakeHeadbuttTree shakes the tree as a sprite over
grass, then TreeMonEncounter scores the facing tile against the trainer ID
and draws from the map's common or rare tree table. A Pokémon that falls
fights as BATTLETYPE_TREE: "fell out of the tree!", and the time of day's
AsleepTreeMons species start asleep.

Smashable rocks run SmashRockScript, and ROCK SMASH from the party menu
acts on the facing rock; after the rock breaks, RockMonEncounter gives the
map's rock set a 40% chance of a wild battle.

Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:20:13 +02:00

3645 lines
146 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;
wrapCount = 0;
damageTaken = 0;
screens = 0;
lightScreenCount = 0;
reflectCount = 0;
turnsTaken = 0;
charging = 0;
lastMove = 0;
lastCounterMove = 0;
minimized = 0;
justGotFrozen = 0;
has(byte: number, bit: number) {
return Boolean(this.sub[byte] & (1 << bit));
}
set(byte: number, bit: number) {
this.sub[byte] |= 1 << bit;
}
reset(byte: number, bit: number) {
this.sub[byte] &= ~(1 << bit);
}
}
export type BattleEffect =
/** A battle message; prompt texts wait for a button, others continue. */
| { kind: "text"; text: string; prompt: boolean }
/** HP, status or level changed; renderers re-read the battle state. */
| { kind: "hud" }
/** The battle menu: answered with an action. */
| { kind: "action" }
| { kind: "yesno"; text: string }
/** Choose a move to forget (0-3); 4 cancels, like B on the list. */
| { kind: "forget"; moves: number[]; learning: number }
/** The level-up stat box (PrintTempMonStats), acknowledged with a button. */
| { kind: "stats"; labels: string[]; values: number[] }
/** The naming screen for a caught Pokémon: answered with the name, or ""
* to keep `current`. */
| { kind: "name"; title: string; current: string; max: number }
/** The battle party menu: answered with a party index, or -1 to go back
* when `cancel` allows it. With `menu`, the choice goes through the
* SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
| { kind: "move"; text: string; moves: number[]; pp: number[] };
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
type ItemUse = { item: number; slot: number };
export type BattleAction =
| { action: "fight"; move: number }
| { action: "run" }
/** PKMN: open the party menu to switch. */
| { action: "pkmn" }
/** PACK: use the item in this slot of its pocket (only Balls so far). */
| { action: "item"; item: number; slot: number };
export type BattleInput = number | string | BattleAction | undefined;
/** wBattleResult, the prize money and wEvolvableFlags (a bit per party
* index that levelled up). */
export type BattleOutcome = {
result: number;
prizeMoney?: number;
/** The part of the prize sent to Mom's savings. */
momMoney?: number;
evolvable?: number;
};
export interface BattleTrainer {
name: string;
className: string;
/** wOTPartyMons: HP and status change only when a Pokémon faints or is
* withdrawn, as in the original. */
party: PartyMon[];
aiFlags: number;
/** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */
switchFlags: number;
reward: number;
winText?: number;
lossText?: number;
}
type Gen<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;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark?: number;
/** wPartyMenuCursor as the last party menu outside battle left it. */
partyMenuCursor?: number;
/** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much
* of a trainer's prize goes to Mom. */
mom?: { saving: number; money: number };
/** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */
shift?: boolean;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
const BATTLERESULT_BOX_FULL = 0x80;
const SUB1 = 1,
SUB2 = 2,
SUB3 = 3,
SUB4 = 4,
SUB5 = 5;
/** "n percent" in the original: n * $ff / 100, rounded down. */
const percent = (n: number) => Math.floor((n * 0xff) / 100);
/** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */
const SWITCH_OFTEN = 1 << 0,
SWITCH_RARELY = 1 << 1,
SWITCH_SOMETIMES = 1 << 2;
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
* tier ($10, $20, $30), a Random threshold and whether a roll below it
* switches (true) or keeps the Pokémon in (false). */
const SWITCH_CHANCES: readonly (readonly [number, boolean])[][] = [
[[percent(50) + 1, true], [percent(79) - 1, true], [percent(4), false]],
[[percent(8), true], [percent(12), true], [percent(79) - 1, false]],
[[percent(20) - 1, true], [percent(50) + 1, true], [percent(20) - 1, false]],
];
export interface TypeMatchup {
attacker: number;
defender: number;
multiplier: number;
}
/** BattleCommand_DamageCalc's arithmetic. `boost` is a type-boosting held
* item's percentage; `current` is the damage already in wCurDamage.
* Returns undefined when the original leaves wCurDamage unchanged. */
export function calculateDamage(input: {
power: number;
level: number;
attack: number;
defense: number;
effect: number;
critical: boolean;
boost?: number;
current: number;
}): number | undefined {
let { defense } = input;
const { power, effect } = input;
// Selfdestruct and Explosion halve defense.
if (effect === C.EFFECT_SELFDESTRUCT) defense = defense >> 1 || 1;
if (effect !== C.EFFECT_MULTI_HIT && effect !== C.EFFECT_CONVERSION && !power) return undefined;
if (!defense) defense = 1;
let value = Math.floor(((input.level * 2) & 0x1ff) / 5) + 2;
value = Math.floor((value * power * input.attack) / defense);
value = Math.floor(value / 50);
if (input.boost !== undefined) value = Math.floor((value * (input.boost + 100)) / 100);
if (input.critical) {
// Only the low 16 bits are doubled, capped at $ffff.
const high = value - (value & 0xffff);
const doubled = (value & 0xffff) * 2;
value = high + (doubled > 0xffff ? 0xffff : doubled);
}
// Add to wCurDamage, capped at 997, then add 2. The original first adds
// the high byte of wCurDamage to the result's low byte.
const current = input.current;
let low = (value & 0xff) + (current >> 8);
let high = (value >> 8) & 0xff;
if (low > 0xff) {
low &= 0xff;
high = (high + 1) & 0xff;
if (!high) return 999;
}
if ((value >> 16) & 0xff) return 999; // only hQuotient + 1 is checked
const result = (high << 8) | low;
if (result >= 998) return 999;
const total = result + current;
if (total > 0xffff || total >= 998) return 999;
return total + 2;
}
/** BattleCommand_Stab and BattleCheckTypeMatchup. */
export function applyTypeMatchups(
matchups: readonly TypeMatchup[],
input: {
damage: number;
modifier: number;
moveType: number;
userTypes: readonly number[];
targetTypes: readonly number[];
identified: boolean;
},
): { damage: number; modifier: number; matchup: number; missed: boolean } {
let { damage, modifier } = input;
let missed = false;
const type = input.moveType;
if (type === input.userTypes[0] || type === input.userTypes[1]) {
damage = (damage + (damage >> 1)) & 0xffff;
modifier |= C.STAB_DAMAGE;
}
const hits = (row: TypeMatchup) =>
row.attacker === type &&
(row.defender === input.targetTypes[0] || row.defender === input.targetTypes[1]);
for (const row of matchups) {
// Rows after the -2 marker (Ghost immunities) stop under Foresight.
if (row.attacker === 0xfe) {
if (input.identified) break;
continue;
}
if (!hits(row)) continue;
if (!row.multiplier) missed = true;
modifier = (modifier & C.STAB_DAMAGE) + row.multiplier;
const product = damage * row.multiplier;
damage = product ? Math.floor(product / 10) & 0xffff || 1 : 0;
}
let matchup: number = C.EFFECTIVE;
for (const row of matchups) {
if (row.attacker === 0xfe) {
if (input.identified) break;
continue;
}
if (hits(row)) matchup = Math.floor((row.multiplier * matchup) / 10) & 0xff;
}
return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed };
}
/** A native translation of Crystal's battle engine.
* The move effect scripts are the ROM's own; each battle command is a
* native translation. Unsupported AI, switching, contest and item paths are refused. */
/** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in
* the original 8- and 16-bit registers. Returns undefined when the
* formula would divide by zero (e.g. maximum HP of 342 or 683). */
export function catchRate(input: {
rate: number;
ball: number;
hp: number;
maxHp: number;
status: number;
/** The player's battle Pokémon's held item effect. */
held: { effect: number; parameter: number };
}): number | undefined {
let rate = input.rate;
// BallMultiplierFunctionTable
if (input.ball === C.ULTRA_BALL) rate = Math.min(0xff, rate << 1);
else if (input.ball === C.GREAT_BALL) rate = Math.min(0xff, rate + (rate >> 1));
// A maximum HP above 341 overflows the HP term (a kept bug).
let hp = (input.hp << 1) & 0xffff,
max = (input.maxHp * 3) & 0xffff;
if (max >> 8) {
max >>= 2;
hp >>= 2;
if (!(hp & 0xff)) hp |= 1;
}
const divisor = max & 0xff,
remaining = hp & 0xff;
if (!divisor) return undefined;
let chance = Math.floor((rate * ((divisor - remaining) & 0xff)) / divisor) & 0xff;
if (!chance) chance = 1;
// Only sleep and freeze add to the chance; BRN/PSN/PAR do not (a kept bug).
if (input.status & ((1 << C.FRZ) | C.SLP_MASK)) chance = Math.min(0xff, chance + 10);
// HELD_CATCH_CHANCE adds its parameter, but no item has that effect.
if (input.held.effect === C.HELD_CATCH_CHANCE)
chance = Math.min(0xff, chance + input.held.parameter);
return chance;
}
/** HP × 25 / (max HP / 4) as a byte, as SendOutMonText and WithdrawMonText
* compute it. Undefined when max HP / 4 is zero: the original divides by
* zero and freezes. */
function cheerPercent(hp: number, maxHp: number): number | undefined {
const divisor = (maxHp >> 2) & 0xff;
return divisor ? Math.floor((hp * 25) / divisor) & 0xff : undefined;
}
/** SendOutMonText's cheer from the enemy's remaining HP ("Go!" when it
* has fainted). */
export function sendOutText(hp: number, maxHp: number) {
if (!hp) return "GoMonText" as const;
const percent = cheerPercent(hp, maxHp);
if (percent === undefined) return undefined;
return percent >= 70
? ("GoMonText" as const)
: percent >= 40
? ("DoItMonText" as const)
: percent >= 10
? ("GoForItMonText" as const)
: ("YourFoesWeakGetmMonText" as const);
}
/** WithdrawMonText's choice from the HP lost since the send-out, in 16
* bits. The multiplicand's high byte is left over from earlier arithmetic
* in the original; it is taken as zero here. */
export function withdrawText(hpAtSwitch: number, hp: number, maxHp: number) {
const percent = cheerPercent((hpAtSwitch - hp) & 0xffff, maxHp);
if (percent === undefined) return undefined;
return !percent
? ("ThatsEnoughComeBackText" as const)
: percent < 30
? ("ComeBackText" as const)
: percent < 70
? ("OKComeBackText" as const)
: ("GoodComeBackText" as const);
}
/** RestoreHealth: a 16-bit sum that overflows or passes the maximum is
* the maximum. */
export function restoreHealth(hp: number, maxHp: number, amount: number) {
const sum = hp + amount;
return sum > 0xffff || sum > maxHp ? maxHp : sum;
}
/** RestorePP's new PP byte (PP Ups kept), or undefined when the move's PP
* is already at `max` (GetMaxPPOfMove). MAX items fill the move; others add
* 10, or 5 for the MysteryBerry. */
export function restoredPP(pp: number, max: number, item: number) {
const current = pp & C.PP_MASK;
if (current >= max) return undefined;
const full = item === C.MAX_ELIXER || item === C.MAX_ETHER;
return (pp & ~C.PP_MASK) | (full ? max : Math.min(max, current + (item === C.MYSTERYBERRY ? 5 : 10)));
}
/** ChangeHappiness' arithmetic with a HappinessChanges byte: bytes from
* $64 up (`cp $64`) are decreases that stop at zero. */
export function changedHappiness(happiness: number, delta: number) {
const sum = happiness + delta;
return delta >= 0x64 ? (sum > 0xff ? sum & 0xff : 0) : Math.min(0xff, sum);
}
export class Battle {
readonly data: BattleData;
readonly player = new Side();
readonly enemy = new Side();
/** hBattleTurn: 0 for the player's turn, 1 for the enemy's. */
turn = 0;
curBattleMon = 0;
battleMode: number = C.WILD_BATTLE;
battleType: number = C.BATTLETYPE_NORMAL;
battleResult: number = C.WIN;
battleEnded = false;
battlePlayerAction = 0;
enemyGoesFirst = 0;
numFleeAttempts = 0;
/** wBattleParticipantsNotFainted, which shares the experience. */
participants = 0;
/** wEvolvableFlags: party members that levelled up, which
* EvolveAfterBattle checks after a win. */
private evolvable = 0;
/** wBattleParticipantsIncludingFainted, which revives consult. */
private participantsAll = 0;
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
weather: number = C.WEATHER_NONE;
/** wCurOTMon: the trainer's party index in battle. */
curOTMon = 0;
/** wWildMon after a ball: the caught species, or 0. */
wildMon = 0;
/** wFinalCatchRate from the last ball that ran the catch formula. */
private finalCatchRate = 0;
/** wWildMonMoves/wWildMonPP: the wild Pokémon's moves and PP when it
* appeared, which a caught Pokémon keeps. */
private wildMoves: number[] = [];
private wildPP: number[] = [];
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0;
/** wCurPartyMon: the party index a menu picked. */
private curPartyMon = 0;
/** wPartyMenuCursor: the 1-based cursor of the last party menu, from
* the start menu's until the battle's own party menu sets it. */
private partyMenuCursor: number;
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
lastPlayerMon = 0;
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
private enemyIsSwitching = 0;
/** wEnemyHPAtTimeOfPlayerSwitch, which WithdrawMonText compares with. */
private enemyHPAtSwitch = 0;
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
private playerUsedMoves = [0, 0, 0, 0];
private curDamage = 0;
private criticalHit = 0;
private attackMissed = 0;
private effectFailed = 0;
private typeModifier: number = C.EFFECTIVE;
private alreadyDisobeyed = 0;
private alreadyFailed = 0;
private turnEnded = 0;
private loweredStat = 0;
private failedMessage = 0;
private script: number[] = [];
private scriptIndex = 0;
private strings: Record<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;
}
private get reader(): RomReader {
return this.options.data.reader;
}
private random() {
return this.options.random() & 0xff;
}
private get user() {
return this.turn === 0 ? this.player : this.enemy;
}
private get target() {
return this.turn === 0 ? this.enemy : this.player;
}
get partyMon(): PartyMon {
return this.options.party[this.curBattleMon];
}
// ------------------------------------------------------------- messages
private context(): TextContext {
const base = this.options.text();
return {
...base,
// CatchTutorial copies "DUDE" over wPlayerName for the battle.
player:
this.battleType === C.BATTLETYPE_TUTORIAL
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
: base.player,
trainer: this.options.trainer
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
: base.trainer,
strings: {
...base.strings,
[RAM.battleMonNickname]: this.player.mon?.nickname ?? "",
[RAM.enemyMonNickname]: this.enemy.mon?.nickname ?? "",
...this.strings,
},
numbers: this.numbers,
battle: {
turn: this.turn,
player: this.player.mon?.nickname ?? "",
enemy: this.enemy.mon?.nickname ?? "",
enemyPrefix: readString(this.reader, BATTLE.EnemyText, base),
},
asm: (offset) => this.textAsm(offset),
};
}
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
private *say(address: number, button = false): Gen {
const { text, prompt } = readTextEnd(this.reader, address, this.context());
// The tutorial's auto input presses A at every prompt.
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
for (const page of text.split("\n\n").filter(Boolean))
yield { kind: "text", text: page, prompt: waits };
}
/** text_asm routines that battle texts run between fragments. */
private textAsm(offset: number): number {
const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment;
switch (offset) {
case at("UsedMoveText"): {
// Updates the last move, then picks the grammar for "used".
if (this.turn === 0) this.updateUsedMoves(this.player.move.anim);
const user = this.user;
if (!user.charging) {
user.lastCounterMove = user.move.anim;
user.lastMove = user.move.anim;
}
const grammar = this.grammar(user.move.anim);
return this.alreadyDisobeyed || grammar < 3 ? BATTLE.UsedMove2Text : BATTLE.UsedMove1Text;
}
case at("UsedMove1Text"):
case at("UsedMove2Text"):
return this.alreadyDisobeyed ? BATTLE["UsedMoveText_CheckObedience.UsedInsteadText"] : BATTLE.MoveNameText;
case at("UsedMoveText_CheckObedience.UsedInsteadText"):
return BATTLE.MoveNameText;
case at("MoveNameText"): {
const ends = BATTLE["MoveNameText.endusedmovetexts"];
const grammar = this.grammar(this.user.move.anim);
return this.reader.near(ends + grammar * 2, this.reader.bank(ends));
}
case at("BattleCommand_StatUpMessage.stat"):
return this.loweredStat & 0xf0
? BATTLE["BattleCommand_StatUpMessage.BattleStatWentWayUpText"]
: BATTLE["BattleCommand_StatUpMessage.BattleStatWentUpText"];
case at("BattleCommand_StatDownMessage.stat"):
return this.loweredStat & 0xf0
? BATTLE["BattleCommand_StatDownMessage.BattleStatSharplyFellText"]
: BATTLE["BattleCommand_StatDownMessage.BattleStatFellText"];
case at("Text_MonGainedExpPoint"):
return this.numbers[RAM.stringBuffer2 + 2] ? BATTLE.BoostedExpPointsText : BATTLE.ExpPointsText;
case at("GoMonText"):
case at("DoItMonText"):
case at("GoForItMonText"):
case at("YourFoesWeakGetmMonText"):
return BATTLE.BattleMonNicknameText;
case at("WithdrawMonText.WithdrawMonText"): {
const text = withdrawText(this.enemyHPAtSwitch, this.enemy.mon.hp, this.enemy.mon.maxHp);
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
return BATTLE[text];
}
case at("Text_1_2_and_Poof"):
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
case at("Text_GotchaMonWasCaught"):
// Capture music (not ported), then WaitButtonText.
return BATTLE.WaitButtonText;
default:
throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`);
}
}
private grammar(move: number) {
let value = this.moveGrammarCache.get(move);
if (value === undefined) {
value = this.data.moveGrammar(move);
this.moveGrammarCache.set(move, value);
}
return value;
}
private hud(): BattleEffect {
return { kind: "hud" };
}
// --------------------------------------------------------------- setup
/** LoadEnemyMon: the trainer's wCurOTMon, or a wild Pokémon from a grass
* or water table. */
private loadEnemyMon() {
const trainer = this.options.trainer;
if (trainer) {
const mon = trainer.party[this.curOTMon],
base = this.options.data.baseData(mon.species);
this.enemy.mon = {
species: mon.species,
item: mon.item,
moves: [...mon.moves],
dvs: mon.dvs,
pp: [...mon.pp],
happiness: mon.happiness,
level: mon.level,
status: mon.status,
hp: mon.hp,
maxHp: mon.stats[0],
stats: mon.stats.slice(1),
types: [...base.types],
nickname: mon.nickname,
};
this.enemyBase = {
stats: base.stats.slice(0, 5),
catchRate: base.catchRate,
exp: base.baseExp,
};
this.enemy.stats = [...this.enemy.mon.stats];
this.applyStatusEffects(this.enemy);
this.options.pokedex.seen.add(mon.species);
return;
}
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
const { species, level } = this.options.wild;
if (species === C.UNOWN || species === C.MAGIKARP)
throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet.");
const data = this.options.data,
base = data.baseData(species);
// 25% chance of an item: 23% the first, 2% the second.
let item = 0;
if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
const dvs = (this.random() << 8) | this.random();
const stats = calcStats(data, species, dvs, level);
const moves: number[] = [];
for (const learned of data.learnset(species)) {
if (learned.level > level) break;
if (moves.includes(learned.move)) continue;
if (moves.length === 4) moves.shift();
moves.push(learned.move);
}
while (moves.length < 4) moves.push(0);
this.enemy.mon = {
species,
item,
moves,
dvs,
pp: moves.map((move) => (move ? data.move(move).pp : 0)),
happiness: C.BASE_HAPPINESS,
level,
// .TreeMon: some Pokémon fall from trees asleep.
status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0,
hp: stats[0],
maxHp: stats[0],
stats: stats.slice(1),
types: [base.types[0], base.types[1]],
nickname: data.speciesName(species),
};
this.enemyBase = {
stats: base.stats.slice(0, 5),
catchRate: base.catchRate,
exp: base.baseExp,
};
this.options.pokedex.seen.add(species);
this.enemy.stats = [...this.enemy.mon.stats];
this.wildMoves = [...moves];
this.wildPP = [...this.enemy.mon.pp];
}
/** CheckSleepingTreeMon: in a tree battle, the time of day's
* AsleepTreeMons list names the species that sleep. */
private sleepingTreeMon(species: number) {
if (this.battleType !== C.BATTLETYPE_TREE) return false;
const time = this.options.timeOfDay;
let p =
time < C.DAY_F
? BATTLE.AsleepTreeMonsMorn
: time === C.DAY_F
? BATTLE.AsleepTreeMonsDay
: BATTLE.AsleepTreeMonsNite;
for (const r = this.reader; r.byte(p) !== 0xff; p++)
if (r.byte(p) === species) return true;
return false;
}
/** InitBattleMon from the party, then status penalties. */
private initBattleMon() {
const mon = this.partyMon,
base = this.options.data.baseData(mon.species);
this.player.mon = {
species: mon.species,
item: mon.item,
moves: [...mon.moves],
dvs: mon.dvs,
pp: [...mon.pp],
happiness: mon.happiness,
level: mon.level,
status: mon.status,
hp: mon.hp,
maxHp: mon.stats[0],
stats: mon.stats.slice(1),
types: [base.types[0], base.types[1]],
nickname: mon.nickname,
};
this.player.stats = mon.stats.slice(1);
this.applyStatusEffects(this.player);
// No badges exist yet, so BadgeStatBoosts changes nothing.
}
/** ApplyPrzEffectOnSpeed and ApplyBrnEffectOnAttack for one side. */
private applyStatusEffects(side: Side) {
const mon = side.mon;
if (mon.status & (1 << C.PAR)) mon.stats[C.SPEED] = Math.max(1, mon.stats[C.SPEED] >> 2);
if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1);
}
/** NewBattleMonStatus (with ResetPlayerStatLevels) or NewEnemyMonStatus
* at the start of a battle. */
private newStatus(side: Side) {
if (side === this.player) {
this.playerUsedMoves = [0, 0, 0, 0];
this.enemy.lastCounterMove = 0;
this.enemy.wrapCount = 0;
this.enemy.reset(SUB5, C.SUBSTATUS_CANT_RUN);
}
side.sub = [0, 0, 0, 0, 0, 0];
side.levels = [7, 7, 7, 7, 7, 7, 7];
side.lastMove = 0;
side.lastCounterMove = 0;
side.disableCount = 0;
side.disabledMove = 0;
side.furyCutterCount = 0;
side.protectCount = 0;
side.rageCounter = 0;
side.minimized = 0;
side.wrapCount = 0;
side.turnsTaken = 0;
}
/** UpdateBattleMonInParty: level, status and HP go back to the party. */
private updateBattleMonInParty() {
const mon = this.partyMon;
mon.level = this.player.mon.level;
mon.status = this.player.mon.status;
mon.hp = this.player.mon.hp;
}
// ---------------------------------------------------------------- flow
/** StartBattle through ExitBattle. */
*run(): Gen<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_TREE
? BATTLE.PokemonFellFromTreeText
: BATTLE.WildPokemonAppearedText,
);
this.resetBattleParticipants();
this.initBattleMon();
const amuletCoin =
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
this.newStatus(this.player);
this.newStatus(this.enemy);
yield* this.sendOutMonText();
yield this.hud();
this.turn = 0; // SetPlayerTurn before SpikesDamage
yield* this.battleTurn();
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
if (this.options.canLose) {
for (const mon of this.options.party) {
mon.hp = mon.stats[0];
mon.status = 0;
mon.pp = mon.moves.map((move, slot) => {
if (!move) return 0;
const ups = mon.pp[slot] >> 6,
base = this.options.data.move(move).pp;
return (
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
);
}) as PartyMon["pp"];
}
this.initBattleMon();
yield this.hud();
}
if (trainer.winText) yield* this.say(trainer.winText);
return {
result: this.battleResult,
...(yield* this.giveMoney(trainer.reward, amuletCoin)),
evolvable: this.evolvable,
};
}
if (trainer && this.options.canLose && trainer.lossText)
yield* this.say(trainer.lossText);
return { result: this.battleResult, evolvable: this.evolvable };
}
/** WinTrainerBattle.give_money: the reward (TrainerClassAttributes'
* base times the last Pokémon's level, which `reward` holds four times)
* is added four times, to Mom for as many quarters as her saving mode
* takes unless her account is full, and to the wallet for the rest. Each
* account stops at MAX_MONEY. The text shows four times the reward. */
private *giveMoney(
reward: number,
amuletCoin: boolean,
): Gen<{ prizeMoney: number; momMoney?: number }> {
const quarter = (reward / 4) * (amuletCoin ? 2 : 1);
const mom = this.options.mom ?? { saving: 0, money: 0 },
maxedOut = mom.money >= C.MAX_MONEY;
if (![0, 1, 2, 3].includes(mom.saving))
throw new UnsupportedBattle("Unsupported Mom saving mode.");
// .CheckMaxedOutMomMoney, then SOME|HALF counts as all four quarters.
const toMom = maxedOut ? 0 : mom.saving === 3 ? 4 : mom.saving;
this.numbers[RAM.battleReward] = quarter * 4;
yield* this.say(
maxedOut || !mom.saving
? BATTLE.GotMoneyForWinningText
: [
BATTLE.SentSomeToMomText,
BATTLE.SentHalfToMomText,
BATTLE.SentAllToMomText,
][mom.saving - 1],
);
return {
prizeMoney: quarter * (4 - toMom),
...(toMom ? { momMoney: quarter * toMom } : {}),
};
}
private *battleTurn(): Gen {
for (let turns = 0; turns < 1000; turns++) {
this.curDamage = 0;
this.enemyIsSwitching = 0;
this.player.justGotFrozen = 0;
this.enemy.justGotFrozen = 0;
this.updateBattleMonInParty();
// AIChooseMove runs once before BattleMenu, even with exhausted PP.
if (this.options.trainer && !this.checkEnemyLockedIn()) {
const choice = chooseTrainerMove({
weights: this.options.trainer.aiFlags,
player: this.player, enemy: this.enemy,
data: this.options.data, battleData: this.data, weather: this.weather,
random: () => this.random(),
damage: (move) => this.enemyAIDamage(move),
});
this.enemy.moveNum = choice.index;
this.enemy.curMove = choice.move;
if (choice.enemyTurn) this.turn = 1;
}
if (!this.checkPlayerLockedIn()) {
for (;;) {
const escaped = yield* this.battleMenu();
if (escaped || this.battleEnded) return;
if (yield* this.parsePlayerAction()) break;
}
} else if (!(yield* this.parsePlayerAction())) continue;
if (this.determineMoveOrder()) yield* this.battlePlayerFirst();
else yield* this.battleEnemyFirst();
if (this.battleEnded) return;
yield* this.handleBetweenTurnEffects();
if (this.battleEnded) return;
}
throw new UnsupportedBattle("The battle did not end.");
}
/** AIDamageCalc: pre-variation damage used only for scoring. It omits
* accuracy and does not roll a critical hit, but, as in the ROM, a
* wCriticalHit left over from an earlier move still selects unboosted
* stats and doubles the estimate. */
private enemyAIDamage(moveId: number): number {
const move = this.options.data.move(moveId);
switch (move.effect) {
case C.EFFECT_SUPER_FANG:
return Math.max(1, this.player.mon.hp >> 1);
case C.EFFECT_STATIC_DAMAGE:
return move.power;
case C.EFFECT_LEVEL_DAMAGE:
return this.enemy.mon.level;
case C.EFFECT_PSYWAVE: {
const limit = this.enemy.mon.level + (this.enemy.mon.level >> 1);
for (let attempt = 0; attempt < 4096; attempt++) {
const damage = this.random();
if (damage && damage < limit) return damage;
}
throw new UnsupportedBattle("Trainer Psywave AI random source did not produce damage.");
}
}
if (!move.power) return 0;
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
const special = move.type >= C.SPECIAL,
attackStat = special ? C.SP_ATTACK : C.ATTACK,
defenseStat = special ? C.SP_DEFENSE : C.DEFENSE,
screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT;
const unboosted =
this.criticalHit && this.player.levels[defenseStat] >= this.enemy.levels[attackStat];
let attack: number, defense: number;
if (unboosted) {
attack = this.enemy.stats[attackStat];
defense = this.player.stats[defenseStat];
} else {
attack = this.enemy.mon.stats[attackStat];
defense = this.player.mon.stats[defenseStat];
if (this.player.screens & (1 << screen)) defense = (defense * 2) & 0xffff;
}
const boosted = special
? this.enemy.mon.species === C.PIKACHU && this.enemy.mon.item === C.LIGHT_BALL
: (this.enemy.mon.species === C.CUBONE || this.enemy.mon.species === C.MAROWAK) && this.enemy.mon.item === C.THICK_CLUB;
if (boosted) attack = (attack * 2) & 0xffff;
[attack, defense] = this.truncate(attack, defense);
if (this.player.mon.species === C.DITTO && this.player.mon.item === C.METAL_POWDER)
throw new UnsupportedBattle("Metal Powder is not ported yet.");
const held = this.data.heldEffect(this.enemy.mon.item),
typeBoost = this.data.typeBoost(held.effect),
boost = held.effect && typeBoost === move.type ? held.parameter : undefined,
baseDamage = calculateDamage({
power: move.power,
level: this.enemy.mon.level,
attack,
defense,
effect: move.effect,
critical: Boolean(this.criticalHit),
boost,
current: 0,
});
if (baseDamage === undefined) return 0;
return applyTypeMatchups(this.matchups, {
damage: baseDamage,
modifier: C.EFFECTIVE,
moveType: move.type,
userTypes: this.enemy.mon.types,
targetTypes: this.player.mon.types,
identified: this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
}).damage;
}
/** BattleMenu: FIGHT chooses a move; RUN tries to flee. Returns true if
* the battle ended from the menu. */
private *battleMenu(): Gen<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;
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;
if (this.checkPlayerLockedIn() || player.has(SUB5, C.SUBSTATUS_ENCORED) || player.has(SUB3, C.SUBSTATUS_BIDE))
throw new UnsupportedBattle("Locked-in, encored and Bide moves are not ported yet.");
if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) {
// PlayerSwitch already ran ParseEnemyAction.
player.furyCutterCount = 0;
player.protectCount = 0;
player.rageCounter = 0;
player.reset(SUB4, C.SUBSTATUS_RAGE);
return true;
}
if (this.battlePlayerAction === 0) {
this.turn = 0;
this.updateMoveData();
player.charging = 0;
if (player.move.effect !== C.EFFECT_FURY_CUTTER) player.furyCutterCount = 0;
if (player.move.effect !== C.EFFECT_RAGE) {
player.reset(SUB4, C.SUBSTATUS_RAGE);
player.rageCounter = 0;
}
if (player.move.effect !== C.EFFECT_PROTECT && player.move.effect !== C.EFFECT_ENDURE)
player.protectCount = 0;
} else {
player.reset(SUB3, C.SUBSTATUS_BIDE);
player.furyCutterCount = 0;
player.protectCount = 0;
player.rageCounter = 0;
player.reset(SUB4, C.SUBSTATUS_RAGE);
}
this.parseEnemyAction();
return true;
}
/** ParseEnemyAction: use the trainer AI selection or a random wild move. */
private parseEnemyAction() {
if (this.enemyIsSwitching) return;
const enemy = this.enemy,
mon = enemy.mon;
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn())
throw new UnsupportedBattle(
"Locked-in and encored enemy moves are not ported yet.",
);
let move: number = C.STRUGGLE;
const usable = mon.moves.some(
(candidate, slot) =>
candidate &&
candidate !== enemy.disabledMove &&
mon.pp[slot] & C.PP_MASK,
);
if (this.options.trainer && usable) {
move = enemy.curMove;
} else
for (let slot = 0; slot < 4; slot++) {
const candidate = mon.moves[slot];
if (!candidate) break;
if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK))
continue;
for (let guard = 0; guard < 4096; guard++) {
const choice = this.random() & 3;
if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue;
if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue;
enemy.moveNum = choice;
move = mon.moves[choice];
break;
}
break;
}
enemy.curMove = move;
this.turn = 1;
this.updateMoveData();
if (!this.checkEnemyLockedIn()) enemy.charging = 0;
if (enemy.move.effect !== C.EFFECT_FURY_CUTTER) enemy.furyCutterCount = 0;
if (enemy.move.effect !== C.EFFECT_RAGE) {
enemy.reset(SUB4, C.SUBSTATUS_RAGE);
enemy.rageCounter = 0;
}
if (enemy.move.effect !== C.EFFECT_PROTECT && enemy.move.effect !== C.EFFECT_ENDURE)
enemy.protectCount = 0;
}
private checkEnemyLockedIn() {
const enemy = this.enemy;
return (
enemy.has(SUB4, C.SUBSTATUS_RECHARGE) ||
Boolean(
enemy.sub[SUB3] &
((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE)),
) ||
enemy.has(SUB1, C.SUBSTATUS_ROLLOUT)
);
}
/** UpdateMoveData: the move struct for the current turn's move. */
private updateMoveData() {
const side = this.user;
const move = this.options.data.move(side.curMove);
side.move = {
anim: move.id,
effect: move.effect,
power: move.power,
type: move.type,
accuracy: move.accuracy,
pp: move.pp,
chance: move.chance,
};
// GetMoveName fills wStringBuffer1; CopyName1 copies it to wStringBuffer2.
const name = this.options.data.moveName(side.curMove);
this.strings[RAM.stringBuffer1] = name;
this.strings[RAM.stringBuffer2] = name;
}
/** DetermineMoveOrder: true if the player moves first. */
private determineMoveOrder(): boolean {
if (this.battlePlayerAction) return true;
const player = this.data.priority(
this.options.data.move(this.player.curMove).effect,
C.BASE_PRIORITY,
);
const enemy = this.data.priority(
this.options.data.move(this.enemy.curMove).effect,
C.BASE_PRIORITY,
);
const priority = (move: number, value: number) =>
move === C.VITAL_THROW ? 0 : value;
const playerPriority = priority(this.player.curMove, player),
enemyPriority = priority(this.enemy.curMove, enemy);
if (playerPriority !== enemyPriority) return playerPriority > enemyPriority;
const playerItem = this.data.heldEffect(this.player.mon.item),
enemyItem = this.data.heldEffect(this.enemy.mon.item);
if (playerItem.effect === C.HELD_QUICK_CLAW && enemyItem.effect === C.HELD_QUICK_CLAW) {
if (this.random() < enemyItem.parameter) return false;
if (this.random() < playerItem.parameter) return true;
} else if (playerItem.effect === C.HELD_QUICK_CLAW) {
if (this.random() < playerItem.parameter) return true;
} else if (enemyItem.effect === C.HELD_QUICK_CLAW) {
if (this.random() < enemyItem.parameter) return false;
}
const playerSpeed = this.player.mon.stats[C.SPEED],
enemySpeed = this.enemy.mon.stats[C.SPEED];
if (playerSpeed !== enemySpeed) return playerSpeed > enemySpeed;
return this.random() < percent(50) + 1;
}
private *battlePlayerFirst(): Gen {
this.enemyGoesFirst = 0;
this.turn = 1;
const switched = yield* this.aiSwitchOrTryItem();
yield* this.doTurnFor(0);
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
this.turn = 0;
if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint();
yield this.hud();
if (!switched) {
if (this.tryEnemyFlee()) return yield* this.wildFled();
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
this.battlePlayerAction = 0;
}
private *battleEnemyFirst(): Gen {
if (this.tryEnemyFlee()) return yield* this.wildFled();
this.turn = 1;
this.enemyGoesFirst = 1;
if (!(yield* this.aiSwitchOrTryItem())) {
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
yield* this.doTurnFor(0);
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
this.turn = 0;
if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint();
yield this.hud();
this.battlePlayerAction = 0;
}
/** PlayerTurn/EnemyTurn_EndOpponentProtectEndureDestinyBond. */
private *doTurnFor(turn: number): Gen {
this.turn = turn;
this.user.reset(SUB5, C.SUBSTATUS_DESTINY_BOND);
if (turn === 0 && this.battlePlayerAction) {
// DoPlayerTurn does nothing after a failed escape or an item.
} else yield* this.doTurn();
const opponent = this.target;
opponent.reset(SUB1, C.SUBSTATUS_PROTECT);
opponent.reset(SUB1, C.SUBSTATUS_ENDURE);
opponent.reset(SUB5, C.SUBSTATUS_DESTINY_BOND);
}
private hasFainted(side: Side) {
return side.mon.hp === 0;
}
/** TryEnemyFlee: only some species flee, and never while asleep. */
private tryEnemyFlee(): boolean {
if (this.battleMode !== C.WILD_BATTLE) return false;
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false;
if (this.enemy.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return false;
const species = this.enemy.mon.species;
if (this.data.byteList(BATTLE.AlwaysFleeMons).includes(species)) return true;
const roll = this.random();
if (roll >= percent(50) + 1) return false;
if (this.data.byteList(BATTLE.OftenFleeMons).includes(species)) return true;
if (roll >= percent(10) + 1) return false;
return this.data.byteList(BATTLE.SometimesFleeMons).includes(species);
}
private *wildFled(): Gen {
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
yield* this.say(BATTLE.BattleText_WildFled);
this.battleEnded = true;
}
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape.
* The Speed compared is the battle Pokémon's, except after a faint. */
private *tryToRunAway(playerSpeed = this.player.mon.stats[C.SPEED]): Gen<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();
this.script = this.data.effectScript(this.user.move.effect);
this.scriptIndex = 0;
for (let steps = 0; steps < 256; steps++) {
const command = this.script[this.scriptIndex++];
if (command >= COMMAND.endturn) return;
yield* this.command(command);
}
throw new UnsupportedBattle("Move effect script did not end.");
}
/** EndMoveEffect: stop reading the effect script. */
private endMoveEffect() {
this.script.splice(this.scriptIndex, this.script.length, 0xff, 0xff, 0xff);
}
private *endTurn(): Gen {
this.turnEnded = 1;
this.curDamage = 0;
}
private *checkTurn(): Gen {
const user = this.user,
mon = user.mon;
if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE
this.attackMissed = 0;
this.effectFailed = 0;
this.alreadyDisobeyed = 0;
this.alreadyFailed = 0;
this.typeModifier = C.EFFECTIVE;
if (user.has(SUB4, C.SUBSTATUS_RECHARGE)) {
user.reset(SUB4, C.SUBSTATUS_RECHARGE);
yield* this.say(BATTLE.MustRechargeText);
this.cantMove();
return yield* this.endTurn();
}
if (mon.status & C.SLP_MASK) {
mon.status = (mon.status & ~C.SLP_MASK) | (((mon.status & C.SLP_MASK) - 1) & C.SLP_MASK);
// The player checks the sleep counter; the enemy checks the whole status.
const awake = this.turn === 0 ? !(mon.status & C.SLP_MASK) : mon.status === 0;
if (awake) {
yield* this.say(BATTLE.WokeUpText);
this.cantMove();
if (this.turn === 0) this.updateBattleMonInParty();
yield this.hud();
user.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
} else {
yield* this.say(BATTLE.FastAsleepText);
if (user.curMove !== C.SNORE && user.curMove !== C.SLEEP_TALK) {
this.cantMove();
return yield* this.endTurn();
}
}
}
if (mon.status & (1 << C.FRZ) && user.curMove !== C.FLAME_WHEEL && user.curMove !== C.SACRED_FIRE) {
yield* this.say(BATTLE.FrozenSolidText);
this.cantMove();
return yield* this.endTurn();
}
if (user.has(SUB3, C.SUBSTATUS_FLINCHED)) {
user.reset(SUB3, C.SUBSTATUS_FLINCHED);
yield* this.say(BATTLE.FlinchedText);
this.cantMove();
return yield* this.endTurn();
}
if (user.disableCount) {
user.disableCount = (user.disableCount - 1) & 0xff;
if (!(user.disableCount & 0xf)) {
user.disableCount = 0;
user.disabledMove = 0;
yield* this.say(BATTLE.DisabledNoMoreText);
}
}
if (user.has(SUB3, C.SUBSTATUS_CONFUSED)) {
user.confuseCount = (user.confuseCount - 1) & 0xff;
if (!user.confuseCount) {
user.reset(SUB3, C.SUBSTATUS_CONFUSED);
yield* this.say(BATTLE.ConfusedNoMoreText);
} else {
yield* this.say(BATTLE.IsConfusedText);
if (this.random() < percent(50) + 1) {
user.sub[SUB3] &= 1 << C.SUBSTATUS_CONFUSED;
yield* this.hitConfusion();
this.cantMove();
return yield* this.endTurn();
}
}
}
if (user.has(SUB1, C.SUBSTATUS_IN_LOVE))
throw new UnsupportedBattle("Attraction is not ported yet.");
if (user.disabledMove && user.disabledMove === user.curMove) {
user.reset(SUB3, C.SUBSTATUS_CHARGED);
this.strings[RAM.stringBuffer1] = this.options.data.moveName(user.curMove);
yield* this.say(BATTLE.DisabledMoveText);
this.cantMove();
return yield* this.endTurn();
}
if (mon.status & (1 << C.PAR) && this.random() < percent(25)) {
yield* this.say(BATTLE.FullyParalyzedText);
this.cantMove();
return yield* this.endTurn();
}
}
/** CantMove: moves that span turns are interrupted. */
private cantMove() {
const user = this.user;
user.reset(SUB1, C.SUBSTATUS_ROLLOUT);
user.sub[SUB3] &= ~((1 << C.SUBSTATUS_BIDE) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_CHARGED));
user.furyCutterCount = 0;
if (user.move.anim === C.FLY || user.move.anim === C.DIG) {
user.reset(SUB3, C.SUBSTATUS_UNDERGROUND);
user.reset(SUB3, C.SUBSTATUS_FLYING);
}
}
/** HitConfusion / HitSelfInConfusion: a typeless 40-power hit on itself. */
private *hitConfusion(): Gen {
yield* this.say(BATTLE.HurtItselfText);
this.criticalHit = 0;
const user = this.user,
mon = user.mon;
this.curDamage = 0;
let defense = mon.stats[C.DEFENSE];
if (user.screens & (1 << C.SCREENS_REFLECT)) defense *= 2;
const [attack, def] = this.truncate(mon.stats[C.ATTACK], defense);
this.damageCalc(40, mon.level, attack, def);
this.doDamage(this.user, true);
yield this.hud();
}
private *command(command: number): Gen {
switch (command) {
case COMMAND.checkobedience:
return this.checkObedience();
case COMMAND.usedmovetext:
return yield* this.say(BATTLE.UsedMoveText);
case COMMAND.doturn:
return yield* this.doTurnCommand();
case COMMAND.critical:
return this.critical();
case COMMAND.damagestats:
return this.damageStats();
case COMMAND.damagecalc:
return this.damageCalcCommand();
case COMMAND.stab:
return this.stab();
case COMMAND.damagevariation:
return this.damageVariation();
case COMMAND.checkhit:
return yield* this.checkHit();
case COMMAND.effectchance:
return this.effectChance();
case COMMAND.lowersub:
case COMMAND.raisesub:
case COMMAND.moveanimnosub:
case COMMAND.moveanim:
case COMMAND.statupanim:
case COMMAND.statdownanim:
case COMMAND.movedelay:
return this.requireNoSubstitute();
case COMMAND.failuretext:
return yield* this.failureText();
case COMMAND.applydamage:
return yield* this.applyDamage();
case COMMAND.criticaltext:
return yield* this.criticalText();
case COMMAND.supereffectivetext:
return yield* this.superEffectiveText();
case COMMAND.checkfaint:
return yield* this.checkFaint();
case COMMAND.buildopponentrage:
return yield* this.buildOpponentRage();
case COMMAND.kingsrock:
return this.kingsRock();
case COMMAND.poisontarget:
return yield* this.poisonTarget();
case COMMAND.burntarget:
return yield* this.burnTarget();
case COMMAND.paralyzetarget:
return yield* this.paralyzeTarget();
case COMMAND.flinchtarget:
return this.flinchTarget();
case COMMAND.sleeptarget:
return yield* this.sleepTarget();
case COMMAND.poison:
return yield* this.poisonCommand();
case COMMAND.paralyze:
return yield* this.paralyzeCommand();
case COMMAND.confuse:
return yield* this.confuseCommand();
case COMMAND.draintarget:
return yield* this.drainTarget();
case COMMAND.recoil:
return yield* this.recoil();
case COMMAND.checksafeguard:
return yield* this.checkSafeguard();
case COMMAND.splash:
return yield* this.say(BATTLE.NothingHappenedText);
case COMMAND.foresight:
return yield* this.foresight();
case COMMAND.rage:
return this.user.set(SUB4, C.SUBSTATUS_RAGE);
case COMMAND.ragedamage:
return this.rageDamage();
case COMMAND.screen:
return yield* this.screen();
case COMMAND.curl:
return this.user.set(SUB2, C.SUBSTATUS_CURLED);
case COMMAND.doubleflyingdamage:
if (this.target.has(SUB3, C.SUBSTATUS_FLYING)) this.curDamage = Math.min(0xffff, this.curDamage * 2);
return;
case COMMAND.clearmissdamage:
if (this.attackMissed) this.curDamage = 0;
return;
case COMMAND.switchturn:
this.turn ^= 1;
return;
case COMMAND.healmorn:
case COMMAND.healday:
case COMMAND.healnite:
return yield* this.timeBasedHeal(command - COMMAND.healmorn);
case COMMAND.statupmessage:
return yield* this.statUpMessage();
case COMMAND.statdownmessage:
return yield* this.statDownMessage();
case COMMAND.statupfailtext:
return yield* this.statUpFailText();
case COMMAND.statdownfailtext:
return yield* this.statDownFailText();
default:
if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) {
const stat = (command - COMMAND.attackup) % 7;
return this.raiseStat(stat | (command >= COMMAND.attackup2 ? 0x10 : 0));
}
if (command >= COMMAND.attackdown && command <= COMMAND.evasiondown2) {
const stat = (command - COMMAND.attackdown) % 7;
return this.statDown(stat | (command >= COMMAND.attackdown2 ? 0x10 : 0));
}
throw new UnsupportedBattle(`Battle command 0x${command.toString(16)} is not ported.`);
}
}
/** Substitute presentation is only safe once substitutes exist. */
private requireNoSubstitute() {
if (this.user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
throw new UnsupportedBattle("Substitutes are not ported yet.");
}
/** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */
private checkObedience() {
if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return;
if (this.partyMon.otId !== this.options.playerId)
throw new UnsupportedBattle("Traded Pokémon obedience is not ported yet.");
}
/** BattleCommand_DoTurn: count the turn and use one PP. */
private *doTurnCommand(): Gen {
const user = this.user;
if (user.charging) return;
user.turnsTaken = (user.turnsTaken + 1) & 0xff;
if (user.curMove === C.STRUGGLE) return;
if (user.sub[SUB3] & ((1 << C.SUBSTATUS_IN_LOOP) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE)))
return;
const slot = user.moveNum;
if (!(user.mon.pp[slot] & C.PP_MASK)) {
const continuous = [
C.EFFECT_RAZOR_WIND,
C.EFFECT_SKY_ATTACK,
C.EFFECT_SKULL_BASH,
C.EFFECT_SOLARBEAM,
C.EFFECT_FLY,
C.EFFECT_ROLLOUT,
C.EFFECT_BIDE,
C.EFFECT_RAMPAGE,
] as number[];
yield* this.say(continuous.includes(user.move.effect) ? BATTLE.HasNoPPLeftText : BATTLE.NoPPLeftText);
return this.endMoveEffect();
}
user.mon.pp[slot]--;
if (user.has(SUB5, C.SUBSTATUS_TRANSFORMED)) return;
if (this.turn === 0) {
// The party copy loses PP too, unless the move came from Mimic.
if (user.curMove === C.MIMIC || this.partyMon.moves[slot] !== C.MIMIC) this.partyMon.pp[slot]--;
}
}
private critical() {
this.criticalHit = 0;
const user = this.user,
mon = user.mon;
if (!user.move.power) return;
let stage = 0;
if (mon.species === C.CHANSEY && mon.item === C.LUCKY_PUNCH) stage = 2;
else if (mon.species === C.FARFETCH_D && mon.item === C.STICK) stage = 2;
else {
if (user.has(SUB4, C.SUBSTATUS_FOCUS_ENERGY)) stage++;
if (this.data.byteList(BATTLE.CriticalHitMoves).includes(user.move.anim)) stage += 2;
if (this.data.heldEffect(mon.item).effect === C.HELD_CRITICAL_UP) stage++;
}
if (this.random() < this.data.criticalChance(stage)) this.criticalHit = 1;
}
/** BattleCommand_Screen: a repeated screen fails without renewing it. */
private *screen(): Gen {
const side = this.user;
const light = side.move.effect === C.EFFECT_LIGHT_SCREEN;
const bit = 1 << (light ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT);
if (side.screens & bit) return yield* this.say(BATTLE.ButItFailedText);
side.screens |= bit;
if (light) side.lightScreenCount = 5;
else side.reflectCount = 5;
yield* this.say(light ? BATTLE.LightScreenEffectText : BATTLE.ReflectEffectText);
}
/** HandleScreens, in non-Link order. Screens belong to the side and
* survive a send-out; their setup turn is the first of five ticks. */
private *handleScreens(): Gen {
for (const [turn, side] of [this.player, this.enemy].entries()) {
this.turn = turn;
this.strings[RAM.stringBuffer1] = turn === 0 ? "Your" : "Enemy";
if (side.screens & (1 << C.SCREENS_LIGHT_SCREEN)) {
side.lightScreenCount = (side.lightScreenCount - 1) & 0xff;
if (!side.lightScreenCount) {
side.screens &= ~(1 << C.SCREENS_LIGHT_SCREEN);
yield* this.say(BATTLE.BattleText_MonsLightScreenFell);
}
}
if (side.screens & (1 << C.SCREENS_REFLECT)) {
side.reflectCount = (side.reflectCount - 1) & 0xff;
if (!side.reflectCount) {
side.screens &= ~(1 << C.SCREENS_REFLECT);
yield* this.say(BATTLE.BattleText_MonsReflectFaded);
}
}
}
}
private damageArgs = { power: 0, level: 0, attack: 0, defense: 0 };
/** PlayerAttackDamage / EnemyAttackDamage. */
private damageStats() {
this.curDamage = 0;
const user = this.user,
target = this.target,
power = user.move.power;
this.damageArgs.power = power;
if (!power) return;
const special = user.move.type >= C.SPECIAL;
const stat = special ? C.SP_ATTACK : C.ATTACK,
defenseStat = special ? C.SP_DEFENSE : C.DEFENSE;
const screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT;
// Criticals ignore stages when the target's are at least as high.
const unboosted =
this.criticalHit && target.levels[defenseStat] >= user.levels[stat];
let attack: number, defense: number;
if (unboosted) {
attack = user.stats[stat];
defense = target.stats[defenseStat];
} else {
attack = user.mon.stats[stat];
defense = target.mon.stats[defenseStat];
if (target.screens & (1 << screen)) defense = (defense * 2) & 0xffff;
}
attack = this.speciesItemBoost(attack, special);
[attack, defense] = this.truncate(attack, defense);
// DittoMetalPowder boosts the defender's Defense.
if (target.mon.species === C.DITTO && target.mon.item === C.METAL_POWDER)
throw new UnsupportedBattle("Metal Powder is not ported yet.");
this.damageArgs = { power, level: user.mon.level, attack, defense };
}
/** ThickClubBoost / LightBallBoost. */
private speciesItemBoost(attack: number, special: boolean) {
const mon = this.user.mon;
const boosted = special
? mon.species === C.PIKACHU && mon.item === C.LIGHT_BALL
: (mon.species === C.CUBONE || mon.species === C.MAROWAK) && mon.item === C.THICK_CLUB;
return boosted ? (attack * 2) & 0xffff : attack;
}
/** TruncateHL_BC: scale both stats down by 4 until they fit a byte. */
private truncate(attack: number, defense: number): [number, number] {
while (attack > 0xff || defense > 0xff) {
defense = defense >> 2 || 1;
attack = attack >> 2 || 1;
}
return [attack, defense];
}
private damageCalcCommand() {
const { power, level, attack, defense } = this.damageArgs;
this.damageCalc(power, level, attack, defense);
}
/** BattleCommand_DamageCalc. */
private damageCalc(power: number, level: number, attack: number, defense: number) {
const held = this.data.heldEffect(this.user.mon.item);
const boost =
held.effect && this.data.typeBoost(held.effect) === this.user.move.type ? held.parameter : undefined;
const damage = calculateDamage({
power,
level,
attack,
defense,
effect: this.user.move.effect,
critical: Boolean(this.criticalHit),
boost,
current: this.curDamage,
});
if (damage !== undefined) this.curDamage = damage;
}
/** BattleCommand_Stab, including type effectiveness. */
private stab() {
const user = this.user,
target = this.target;
if (user.move.anim === C.STRUGGLE) return;
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
const result = applyTypeMatchups(this.matchups, {
damage: this.curDamage,
modifier: this.typeModifier,
moveType: user.move.type,
userTypes: user.mon.types,
targetTypes: target.mon.types,
identified: target.has(SUB1, C.SUBSTATUS_IDENTIFIED),
});
this.curDamage = result.damage;
this.typeModifier = result.modifier;
if (result.missed) this.attackMissed = 1;
}
/** BattleCommand_DamageVariation: 217-255 / 255. */
private damageVariation() {
if (this.curDamage < 2) return;
let multiplier: number;
do {
const r = this.random();
multiplier = ((r >> 1) | ((r & 1) << 7)) & 0xff;
} while (multiplier < percent(85) + 1);
this.curDamage = Math.floor((this.curDamage * multiplier) / 255) & 0xffff;
}
/** BattleCommand_CheckHit. */
private *checkHit(): Gen {
const user = this.user,
target = this.target,
effect = user.move.effect;
const miss = () => {
if (effect !== C.EFFECT_JUMP_KICK) this.curDamage = 0;
this.attackMissed = 1;
};
if (effect === C.EFFECT_DREAM_EATER && !(target.mon.status & C.SLP_MASK)) return miss();
if (target.has(SUB1, C.SUBSTATUS_PROTECT)) {
yield* this.say(BATTLE.ProtectingItselfText);
return miss();
}
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && (effect === C.EFFECT_LEECH_HIT || effect === C.EFFECT_DREAM_EATER))
return miss();
if (target.has(SUB5, C.SUBSTATUS_LOCK_ON))
throw new UnsupportedBattle("Lock-On is not ported yet.");
if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)))
throw new UnsupportedBattle("Fly and Dig are not ported yet.");
if (effect === C.EFFECT_THUNDER && this.weather === C.WEATHER_RAIN) return;
if (user.has(SUB4, C.SUBSTATUS_X_ACCURACY)) return;
if (effect === C.EFFECT_ALWAYS_HIT) return;
this.statModifiers();
let accuracy = user.move.accuracy;
const opponentItem = this.data.heldEffect(target.mon.item);
if (opponentItem.effect === C.HELD_BRIGHTPOWDER) accuracy = Math.max(0, accuracy - opponentItem.parameter);
if (accuracy === 0xff) return;
if (this.random() >= accuracy) miss();
}
/** CheckHit.StatModifiers: accuracy and evasion stages on the move. */
private statModifiers() {
const user = this.user,
target = this.target;
const accuracyLevel = user.levels[C.ACCURACY],
evasionLevel = target.levels[C.EVASION];
if (evasionLevel >= accuracyLevel && target.has(SUB1, C.SUBSTATUS_IDENTIFIED)) return;
let value = user.move.accuracy;
for (const level of [accuracyLevel, C.MAX_STAT_LEVEL + 1 - evasionLevel]) {
const [numerator, denominator] = this.data.accuracyMultiplier(level);
value = Math.floor((value * numerator) / denominator) || 1;
}
user.move.accuracy = value > 0xff ? 0xff : value;
}
private effectChance() {
this.effectFailed = 0;
if (this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || !(this.random() < this.user.move.chance))
this.effectFailed = 1;
}
/** BattleCommand_FailureText. */
private *failureText(): Gen {
if (!this.attackMissed) return;
yield* this.getFailureResultText();
const user = this.user;
if (user.move.anim === C.FLY || user.move.anim === C.DIG)
throw new UnsupportedBattle("Fly and Dig are not ported yet.");
this.endMoveEffect();
}
private *getFailureResultText(): Gen {
let text: number = BATTLE.DoesntAffectText,
protectedText: number = BATTLE.DoesntAffectText;
if (this.typeModifier & C.EFFECTIVENESS_MASK) {
if (this.user.move.effect === C.EFFECT_FUTURE_SIGHT) {
text = BATTLE.ButItFailedText;
protectedText = BATTLE.ItFailedText;
} else {
text = BATTLE.AttackMissedText;
protectedText = BATTLE.AttackMissed2Text;
if (this.criticalHit === 0xff) text = BATTLE.UnaffectedText;
}
}
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text);
this.criticalHit = 0;
if (this.user.move.effect === C.EFFECT_JUMP_KICK && this.typeModifier & C.EFFECTIVENESS_MASK)
throw new UnsupportedBattle("Jump Kick crash damage is not ported yet.");
}
/** BattleCommand_ApplyDamage. */
private *applyDamage(): Gen {
const target = this.target;
let hungOn = 0;
if (target.has(SUB1, C.SUBSTATUS_ENDURE)) throw new UnsupportedBattle("Endure is not ported yet.");
const item = this.data.heldEffect(target.mon.item);
if (item.effect === C.HELD_FOCUS_BAND && this.random() < item.parameter)
hungOn = this.falseSwipe() ? 2 : 0;
if (!target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
target.damageTaken = Math.min(0xffff, target.damageTaken + this.curDamage);
this.doDamage(target, false);
yield this.hud();
if (hungOn === 2) {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
yield* this.say(BATTLE.HungOnText);
}
}
/** BattleCommand_FalseSwipe's rule: leave 1 HP if the hit would faint. */
private falseSwipe(): boolean {
const hp = this.target.mon.hp;
if (this.curDamage < hp) return false;
this.curDamage = hp - 1;
if (this.curDamage <= 0) {
this.curDamage = 0;
return false;
}
return true;
}
/** DoEnemyDamage / DoPlayerDamage. */
private doDamage(side: Side, ignoreSubstitute: boolean) {
if (!this.curDamage) return;
if (!ignoreSubstitute && side.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
throw new UnsupportedBattle("Substitutes are not ported yet.");
const hp = side.mon.hp;
if (this.curDamage > hp) this.curDamage = hp;
side.mon.hp = hp - this.curDamage;
if (side === this.player) this.updateBattleMonInParty();
}
private *criticalText(): Gen {
if (!this.criticalHit) return;
yield* this.say(this.criticalHit === 1 ? BATTLE.CriticalHitText : BATTLE.OneHitKOText);
this.criticalHit = 0;
}
private *superEffectiveText(): Gen {
const effectiveness = this.typeModifier & C.EFFECTIVENESS_MASK;
if (effectiveness === C.EFFECTIVE) return;
yield* this.say(effectiveness > C.EFFECTIVE ? BATTLE.SuperEffectiveText : BATTLE.NotVeryEffectiveText);
}
/** BattleCommand_CheckFaint: end the move if the target fainted. */
private *checkFaint(): Gen {
if (this.target.mon.hp) return;
if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND))
throw new UnsupportedBattle("Destiny Bond is not ported yet.");
const effect = this.user.move.effect;
if ([C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never))
throw new UnsupportedBattle("Multi-hit moves are not ported yet.");
this.endMoveEffect();
}
private *buildOpponentRage(): Gen {
if (this.attackMissed) return;
const target = this.target;
if (!target.has(SUB4, C.SUBSTATUS_RAGE)) return;
if (target.rageCounter === 0xff) return;
target.rageCounter++;
this.turn ^= 1;
yield* this.say(BATTLE.RageBuildingText);
this.turn ^= 1;
}
private rageDamage() {
let damage = this.curDamage;
for (let count = this.user.rageCounter; count > 0; count--) {
damage += this.curDamage;
if (damage > 0xffff) {
damage = 0xffff;
break;
}
}
this.curDamage = damage;
}
/** BattleCommand_HeldFlinch (kingsrock). */
private kingsRock() {
if (this.attackMissed) return;
const item = this.data.heldEffect(this.user.mon.item);
if (item.effect !== C.HELD_FLINCH || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
if (this.random() >= item.parameter) return;
this.target.reset(SUB4, C.SUBSTATUS_RECHARGE);
this.target.set(SUB3, C.SUBSTATUS_FLINCHED);
}
// ------------------------------------------------------------- statuses
private safeguarded() {
return Boolean(this.target.screens & (1 << C.SCREENS_SAFEGUARD));
}
private isType(side: Side, type: number) {
return side.mon.types[0] === type || side.mon.types[1] === type;
}
/** UseHeldStatusHealingItem for the opponent. Returns true if it cured. */
private *useHeldStatusHealingItem(): Gen<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) {
if (target.mon.status & (1 << C.FRZ)) throw new UnsupportedBattle("Defrosting is not ported yet.");
return;
}
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
if (this.user.move.type !== C.NORMAL && this.isType(target, this.user.move.type)) return;
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_BURN) return;
if (this.effectFailed || this.safeguarded()) return;
target.mon.status |= 1 << C.BRN;
this.updateOpponentInParty();
this.applyStatusEffects(target);
yield this.hud();
yield* this.say(BATTLE.WasBurnedText);
yield* this.useHeldStatusHealingItem();
}
private *paralyzeTarget(): Gen {
const target = this.target;
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return;
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) return;
if (this.effectFailed || this.safeguarded()) return;
target.mon.status |= 1 << C.PAR;
this.updateOpponentInParty();
this.applyStatusEffects(target);
yield this.hud();
yield* this.say(BATTLE.ParalyzedText);
yield* this.useHeldStatusHealingItem();
}
private flinchTarget() {
const target = this.target;
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
if (target.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return;
if (this.enemyGoesFirst ^ this.turn) return;
if (this.effectFailed) return;
target.set(SUB3, C.SUBSTATUS_FLINCHED);
target.reset(SUB4, C.SUBSTATUS_RECHARGE);
}
/** The AI's 25% failure for status moves (not for the player). */
private aiRandomFail() {
if (this.turn === 0 || this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false;
return this.random() < percent(25) + 1;
}
private *didntAffect2(): Gen {
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.DidntAffect2Text : BATTLE.DidntAffect1Text);
}
private *sleepTarget(): Gen {
const target = this.target;
const item = this.data.heldEffect(target.mon.item);
if (item.effect === C.HELD_PREVENT_SLEEP) {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
return yield* this.say(BATTLE.ProtectedByText);
}
if (target.mon.status & C.SLP_MASK) return yield* this.say(BATTLE.AlreadyAsleepText);
if (this.attackMissed) return yield* this.didntAffect2();
if (this.aiRandomFail() || target.mon.status || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
return yield* this.say(BATTLE.DidntAffect1Text);
let turns: number;
do turns = this.random() & C.SLP_MASK;
while (!turns || turns === C.SLP_MASK);
target.mon.status = turns + 1;
this.updateOpponentInParty();
yield this.hud();
yield* this.say(BATTLE.FellAsleepText);
// A sleeping opponent cannot finish a move that spans turns.
if (!(yield* this.useHeldStatusHealingItem())) {
this.turn ^= 1;
this.cantMove();
this.turn ^= 1;
}
}
private *poisonCommand(): Gen {
const target = this.target;
if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON))
return yield* this.say(BATTLE.DoesntAffectText);
if (target.mon.status & (1 << C.PSN)) return yield* this.say(BATTLE.AlreadyPoisonedText);
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
return yield* this.say(BATTLE.ProtectedByText);
}
if (target.mon.status || this.aiRandomFail() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed)
return yield* this.say(BATTLE.DidntAffect1Text);
if (this.user.move.effect === C.EFFECT_TOXIC) throw new UnsupportedBattle("Toxic is not ported yet.");
target.mon.status |= 1 << C.PSN;
this.updateOpponentInParty();
yield this.hud();
yield* this.say(BATTLE.WasPoisonedText);
yield* this.useHeldStatusHealingItem();
}
private *paralyzeCommand(): Gen {
const target = this.target;
if (target.mon.status & (1 << C.PAR)) return yield* this.say(BATTLE.AlreadyParalyzedText);
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return yield* this.say(BATTLE.DoesntAffectText);
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
return yield* this.say(BATTLE.ProtectedByText);
}
if (this.aiRandomFail() || target.mon.status || this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
return yield* this.didntAffect2();
target.mon.status |= 1 << C.PAR;
this.updateOpponentInParty();
this.applyStatusEffects(target);
yield this.hud();
yield* this.say(BATTLE.ParalyzedText);
yield* this.useHeldStatusHealingItem();
}
private *confuseCommand(): Gen {
const target = this.target;
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) {
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
return yield* this.say(BATTLE.ProtectedByText);
}
if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.say(BATTLE.AlreadyConfusedText);
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) {
const effect = this.user.move.effect;
if (effect === C.EFFECT_CONFUSE_HIT || effect === C.EFFECT_SNORE || effect === C.EFFECT_SWAGGER) return;
return yield* this.didntAffect2();
}
target.set(SUB3, C.SUBSTATUS_CONFUSED);
target.confuseCount = (this.random() & 3) + 2;
yield* this.say(BATTLE.BecameConfusedText);
yield* this.useConfusionHealingItem();
}
/** BattleCommand_DrainTarget: SapHealth restores half the damage. */
private *drainTarget(): Gen {
const mon = this.user.mon;
const amount = this.curDamage >> 1 || 1;
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
if (this.turn === 0) this.updateBattleMonInParty();
yield this.hud();
yield* this.say(BATTLE.SuckedHealthText);
}
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
private *recoil(): Gen {
const mon = this.user.mon;
mon.hp = Math.max(0, mon.hp - (this.curDamage >> 2 || 1));
if (this.turn === 0) this.updateBattleMonInParty();
yield this.hud();
yield* this.say(BATTLE.RecoilText);
}
private *checkSafeguard(): Gen {
if (!this.safeguarded()) return;
this.attackMissed = 1;
yield* this.say(BATTLE.SafeguardProtectText);
this.endMoveEffect();
}
private *foresight(): Gen {
const target = this.target;
if (this.attackMissed || target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)) || target.has(SUB1, C.SUBSTATUS_IDENTIFIED))
return yield* this.say(target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText);
target.set(SUB1, C.SUBSTATUS_IDENTIFIED);
yield* this.say(BATTLE.IdentifiedText);
}
/** BattleCommand_TimeBasedHealContinue (Morning Sun, Synthesis, Moonlight). */
private *timeBasedHeal(time: number): Gen {
const mon = this.user.mon;
if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText);
let index = 2;
if (this.options.timeOfDay !== time) index--;
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
const quarter = mon.maxHp >> 2 || 1;
const amount = [quarter >> 1 || 1, quarter, mon.maxHp >> 1 || 1, mon.maxHp][index];
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
if (this.turn === 0) this.updateBattleMonInParty();
yield this.hud();
yield* this.say(BATTLE.RegainedHealthText);
}
private updateOpponentInParty() {
if (this.turn === 1) this.updateBattleMonInParty();
}
// ------------------------------------------------------ stat changes
/** RaiseStat (BattleCommand_StatUp). */
private raiseStat(stat: number) {
this.loweredStat = stat;
const user = this.user,
index = stat & 0xf;
if (this.attackMissed || this.effectFailed) {
this.failedMessage = 1;
return;
}
let level = user.levels[index] + 1;
if (level > C.MAX_STAT_LEVEL) {
this.failedMessage = 2;
this.attackMissed = 1;
return;
}
if (stat & 0xf0) level = Math.min(C.MAX_STAT_LEVEL, level + 1);
user.levels[index] = level;
if (index < C.ACCURACY) {
if (user.mon.stats[index] === 999) {
user.levels[index]--;
this.failedMessage = 2;
this.attackMissed = 1;
return;
}
this.calcStats(user);
}
this.failedMessage = 0;
if (user.move.anim === C.MINIMIZE) throw new UnsupportedBattle("Minimize is not ported yet.");
}
/** BattleCommand_StatDown. */
private statDown(stat: number) {
this.loweredStat = stat;
const effect = this.user.move.effect;
const mistEffect =
(effect >= C.EFFECT_ATTACK_DOWN && effect <= C.EFFECT_EVASION_DOWN) ||
(effect >= C.EFFECT_ATTACK_DOWN_2 && effect <= C.EFFECT_EVASION_DOWN_2) ||
(effect >= C.EFFECT_ATTACK_DOWN_HIT && effect <= C.EFFECT_EVASION_DOWN_HIT);
const target = this.target;
if (mistEffect && target.has(SUB4, C.SUBSTATUS_MIST)) {
this.failedMessage = 2;
this.attackMissed = 1;
return;
}
const index = stat & 0xf;
let level = target.levels[index] - 1;
const cantLower = () => {
this.failedMessage = 3;
this.attackMissed = 1;
};
if (!level) return cantLower();
if (stat & 0xf0) {
level--;
if (!level) level = 1;
}
const failed = () => {
this.failedMessage = 1;
this.attackMissed = 1;
};
if (this.turn === 1 && effect !== C.EFFECT_ACCURACY_DOWN_HIT && !this.player.has(SUB5, C.SUBSTATUS_LOCK_ON))
if (this.random() < percent(25) + 1) return failed();
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed || this.effectFailed) return failed();
if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))) return failed();
target.levels[index] = level;
if (index < C.ACCURACY) {
// TryLowerStat: a stat already at 1 cannot drop further. The level is
// raised back by one, even after a two-stage drop.
if (target.mon.stats[index] === 1) {
target.levels[index] = level + 1;
return cantLower();
}
this.calcStats(target);
}
this.failedMessage = 0;
}
/** CalcPlayerStats / CalcEnemyStats: stages, then status penalties. */
private calcStats(side: Side) {
for (let stat = 0; stat < 5; stat++) {
const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]);
const value = Math.floor((side.stats[stat] * numerator) / denominator);
side.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
}
this.applyStatusEffects(side);
}
private statName() {
this.strings[RAM.stringBuffer2] = readString(
this.reader,
this.data.statNameAddress(this.loweredStat & 0xf),
this.context(),
);
}
private *statUpMessage(): Gen {
if (this.failedMessage) return;
this.statName();
yield* this.say(BATTLE["BattleCommand_StatUpMessage.stat"]);
}
private *statDownMessage(): Gen {
if (this.failedMessage) return;
this.statName();
yield* this.say(BATTLE["BattleCommand_StatDownMessage.stat"]);
}
private *statUpFailText(): Gen {
if (!this.failedMessage) return;
if (this.failedMessage === 1) {
if (!this.alreadyFailed) yield* this.say(BATTLE.ButItFailedText);
return;
}
this.statName();
yield* this.say(BATTLE.WontRiseAnymoreText);
}
private *statDownFailText(): Gen {
if (!this.failedMessage) return;
if (this.failedMessage === 1) {
if (!this.alreadyFailed) yield* this.say(BATTLE.ButItFailedText);
return;
}
if (this.failedMessage === 2) return yield* this.say(BATTLE.ProtectedByMistText);
this.statName();
yield* this.say(BATTLE.WontDropAnymoreText);
}
// ------------------------------------------------------- turn ending
/** ResidualDamage for the side whose turn it is. Returns false if it fainted. */
private *residualDamage(): Gen<boolean> {
const user = this.user,
mon = user.mon;
if (!mon.hp) return false;
if (mon.status & ((1 << C.PSN) | (1 << C.BRN))) {
yield* this.say(mon.status & (1 << C.BRN) ? BATTLE.HurtByBurnText : BATTLE.HurtByPoisonText);
if (user.has(SUB5, C.SUBSTATUS_TOXIC)) throw new UnsupportedBattle("Toxic is not ported yet.");
const quarter = mon.maxHp >> 2 || 1;
this.subtractHP(user, quarter >> 1 || 1);
yield this.hud();
}
if (!mon.hp) return false;
if (user.sub[SUB4] & (1 << C.SUBSTATUS_LEECH_SEED) || user.sub[SUB1] & ((1 << C.SUBSTATUS_NIGHTMARE) | (1 << C.SUBSTATUS_CURSE)))
throw new UnsupportedBattle("Leech Seed, Nightmare and Curse are not ported yet.");
return mon.hp > 0;
}
private subtractHP(side: Side, amount: number) {
side.mon.hp = Math.max(0, side.mon.hp - amount);
if (side === this.player) this.updateBattleMonInParty();
}
/** HandleBetweenTurnEffects. Effects no early move can cause are refused. */
private *handleBetweenTurnEffects(): Gen {
if (yield* this.checkFaintPlayerThenEnemy()) return;
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
for (const side of [this.player, this.enemy]) {
if (side.wrapCount || side.has(SUB1, C.SUBSTATUS_PERISH) || side.has(SUB5, C.SUBSTATUS_ENCORED))
throw new UnsupportedBattle("Wrap, Perish Song and Encore are not ported yet.");
if (side.screens & ~((1 << C.SCREENS_LIGHT_SCREEN) | (1 << C.SCREENS_REFLECT)))
throw new UnsupportedBattle("Other screen effects are not ported yet.");
const effect = this.data.heldEffect(side.mon.item).effect;
if (effect === C.HELD_RESTORE_PP || (effect >= C.HELD_ATTACK_UP && effect <= C.HELD_EVASION_UP))
throw new UnsupportedBattle("Mystery Berry and stat-raising held items are not ported yet.");
}
// HandleLeftovers: each side heals itself; the text names it as target.
for (const side of [this.player, this.enemy]) {
const mon = side.mon;
if (this.data.heldEffect(mon.item).effect !== C.HELD_LEFTOVERS || mon.hp === mon.maxHp) continue;
this.strings[RAM.stringBuffer1] = this.options.data.itemName(mon.item);
const quarter = mon.maxHp >> 2 || 1;
mon.hp = Math.min(mon.maxHp, mon.hp + (quarter >> 2 || 1));
if (side === this.player) this.updateBattleMonInParty();
this.turn = side === this.player ? 1 : 0;
yield this.hud();
yield* this.say(BATTLE.BattleText_TargetRecoveredWithItem);
}
// HandleDefrost: a 10% thaw for a side that did not just freeze.
for (const side of [this.player, this.enemy]) {
if (!(side.mon.status & (1 << C.FRZ)) || side.justGotFrozen) continue;
if (this.random() >= percent(10)) continue;
side.mon.status = 0;
if (side === this.player) this.updateBattleMonInParty();
this.turn = side === this.player ? 1 : 0;
yield this.hud();
yield* this.say(BATTLE.DefrostedOpponentText);
}
yield* this.handleScreens();
// HandleHealingItems: turn 0 handles the enemy's items, then turn 1.
for (const turn of [0, 1]) {
this.turn = turn;
yield* this.handleHPHealingItem();
yield* this.useHeldStatusHealingItem();
yield* this.useConfusionHealingItem();
}
this.updateBattleMonInParty();
}
private *checkFaintPlayerThenEnemy(): Gen<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;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
this.enemy.move.anim = 0;
this.battlePlayerAction = 0;
}
/** EnemySwitch: in Shift mode the player may switch too (OfferSwitch). */
private *enemySwitch(): Gen {
if (!this.checkWhetherToAskSwitch()) return yield* this.enemySwitchSetMode();
this.loadEnemyMonToSwitchTo();
const switching = yield* this.offerSwitch();
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
if (!switching) return;
this.participants = this.participantsAll = 0;
this.battlePlayerAction = 0;
this.enemyIsSwitching = 1;
yield* this.playerSwitch();
}
private *enemySwitchSetMode(): Gen {
this.loadEnemyMonToSwitchTo();
this.enemyIsSwitching = 1;
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
}
/** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the
* type-matchup choice) and LoadEnemyMonToSwitchTo. */
private loadEnemyMonToSwitchTo() {
this.resetEnemyBattleVars();
const index = this.enemySwitchMonIndex
? this.enemySwitchMonIndex - 1
: this.battleHasJustStarted
? 0
: this.findMonInOTPartyToSwitchIntoBattle();
if (!this.options.trainer!.party[index]?.hp)
throw new UnsupportedBattle("Invalid trainer switch.");
// LoadEnemyMon records wCurOTMon.
this.curOTMon = index;
this.loadEnemyMon();
}
/** CheckWhetherToAskSwitch: the SHIFT battle style asks when the player
* has another Pokémon and its own is still up. */
private checkWhetherToAskSwitch() {
return (
!this.battleHasJustStarted &&
this.options.shift !== false &&
this.options.party.length > 1 &&
this.partyMon.hp > 0
);
}
/** OfferSwitch. Returns true if the player chose a Pokémon to send out. */
private *offerSwitch(): Gen<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.parseEnemyAction();
yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]);
this.player.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 1;
// PursuitSwitch reads the effect of wCurEnemyMove as GetMoveEffect
// does, even for no move (0 reads past the table).
const r = this.reader;
if (r.byte(SYMBOLS.moves + ((this.enemy.curMove - 1) & 0xff) * 7 + 1) === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
this.curPartyMon = this.curBattleMon;
yield* this.sendOutPlayerMon();
}
/** PlayerPartyMonEntrance: send out wCurPartyMon. */
private *playerPartyMonEntrance(): Gen {
this.lastPlayerMon = this.curBattleMon;
this.curBattleMon = this.curPartyMon;
yield* this.sendOutPlayerMon();
}
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
* PlayerPartyMonEntrance. */
private *sendOutPlayerMon(): Gen {
this.addBattleParticipant();
this.initBattleMon();
yield* this.sendOutMonText();
this.newStatus(this.player); // with ResetPlayerStatLevels
this.breakAttraction();
yield this.hud();
this.turn = 0;
if (this.player.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
}
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
private *sendOutMonText(): Gen {
const { hp, maxHp } = this.enemy.mon;
if (hp) this.enemyHPAtSwitch = hp;
const text = sendOutText(hp, maxHp);
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
yield* this.say(BATTLE[text]);
}
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
* with the first party Pokémon's Speed. Returns true if the player fled. */
private *askUseNextPokemon(): Gen<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.enemyGoesFirst = 1;
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 0;
// PursuitSwitch reads wCurPlayerMove's effect.
if (this.player.curMove && this.options.data.move(this.player.curMove).effect === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
// Status and HP go back to the party (MON_STATUS through MON_HP).
const outgoing = trainer.party[this.curOTMon];
outgoing.status = this.enemy.mon.status;
outgoing.hp = this.enemy.mon.hp;
yield* this.say(BATTLE.EnemyWithdrewText);
this.battleHasJustStarted = 1;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
yield* this.enemySwitch();
this.resetBattleParticipants();
this.battleHasJustStarted = 0;
}
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
* a chance tier in the high nibble and a party index in the low one.
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its
* callers load, so with hBattleTurn on the enemy every matchup here uses
* the enemy's chosen move type (docs/bugs_and_glitches.md). */
private checkAbleToSwitch(): number {
const party = this.options.trainer!.party,
data = this.options.data;
const matchup = (types: readonly number[]) =>
this.typeMatchup(this.enemy.move.type, types, this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED));
const bit = (index: number) => 0x20 >> index;
const damaging = (move: number) => data.move(move).power !== 0;
const baseTypes = (index: number) => data.baseData(party[index].species).types;
// FindAliveEnemyMons: the others still standing.
const aliveOthers = () => {
let mask = 0;
if (party.length >= 2)
party.forEach((mon, index) => {
if (index !== this.curOTMon && mon.hp) mask |= bit(index);
});
return mask;
};
// FindEnemyMonsWithAtLeastQuarterMaxHP. BUG: srl c / rl b rotate the low
// HP byte into the high byte instead of multiplying the HP by four.
const quarterHP = (mask: number) => {
let chosen = 0;
party.forEach((mon, index) => {
const high = mon.hp >> 8,
low = mon.hp & 0xff;
const rotated = ((((high << 2) | ((low & 1) << 1) | ((low >> 1) & 1)) & 0xff) << 8) | (low >> 2);
if (rotated > mon.stats[0]) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsThatResistPlayer: every branch reduces to one matchup.
const resisting = (mask: number) => {
let chosen = 0;
party.forEach((_, index) => {
if (matchup(baseTypes(index)) <= C.EFFECTIVE) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsWithASuperEffectiveMove. `e` is not reset between
// Pokémon, so one neutral move encourages every later candidate.
const superEffective = (mask: number) => {
let e = 0,
encouraged = 0;
for (let index = 0; index < 6; index++) {
if (!(mask & bit(index))) continue;
for (const move of party[index].moves) {
if (!move) break;
if (!damaging(move)) continue;
const value = matchup(this.player.mon.types);
if (value < C.EFFECTIVE) continue;
e = 1;
if (value === C.EFFECTIVE) continue;
e = 2;
break;
}
if (e === 2) return { e, score: index };
if (e === 1) encouraged |= bit(index);
}
for (let index = 0; index < 6; index++) if (encouraged & bit(index)) return { e, score: index };
return { e, score: 0xff };
};
// FindAliveEnemyMonsWithASuperEffectiveMove: standing, including the
// current Pokémon.
const standingSuperEffective = (mask: number) => {
let standing = 0;
party.forEach((mon, index) => {
if (mon.hp) standing |= bit(index);
});
return superEffective(mask & standing);
};
if (!aliveOthers()) return 0;
if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH))
throw new UnsupportedBattle("Perish Song is not ported yet.");
if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0;
const lastCounter = this.player.lastCounterMove;
if (lastCounter) {
// FindEnemyMonsImmuneToLastCounterMove.
let immune = 0;
party.forEach((mon, index) => {
if (index === this.curOTMon || !mon.hp || !damaging(lastCounter)) return;
if (matchup(baseTypes(index)) === 0) immune |= bit(index);
});
if (immune) {
const { e, score } = superEffective(immune);
if (score === 0xff) return 0;
if (e !== 2) {
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
return score + 0x10;
}
return score + (this.checkPlayerMoveTypeMatchups() < C.EFFECTIVE ? 0x20 : 0x10);
}
}
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers())));
return e === 2 ? score + 0x10 : 0;
}
/** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE
* (10); lower means the enemy is worse off. Matchups use the enemy's move
* type (see checkAbleToSwitch). */
private checkPlayerMoveTypeMatchups(): number {
const data = this.options.data,
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
type = this.enemy.move.type;
const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified),
vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified);
let score = 10;
const used = this.playerUsedMoves;
if (!used[0]) {
if (vsEnemy() > C.EFFECTIVE) score--;
if (this.player.mon.types[1] !== this.player.mon.types[0] && vsEnemy() > C.EFFECTIVE) score--;
} else {
let e = 0,
superEffective = false;
for (const move of used) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsEnemy();
if (value > C.EFFECTIVE) {
score--;
superEffective = true;
break;
}
if (!value) continue;
if (value === C.EFFECTIVE) e = 2;
else if (e < 1) e = 1;
}
if (!superEffective && e !== 2) score += e ? 1 : 2;
}
// .CheckEnemyMoveMatchups
let c = 0;
for (const move of this.enemy.mon.moves) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsPlayer();
if (!value) continue;
c++;
if (value < C.EFFECTIVE) continue;
c += 5;
if (value > C.EFFECTIVE) c = 100;
}
if (!c) score -= 2;
else if (c < 5) score--;
else if (c >= 100) score++;
return score & 0xff;
}
private *faintYourPokemon(): Gen {
yield* this.say(BATTLE.BattleText_MonFainted);
}
private *handlePlayerMonFaint(): Gen {
yield* this.faintYourPokemon();
if (!this.enemy.mon.hp) yield* this.say(BATTLE.BattleText_EnemyMonFainted);
this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
if (!this.enemy.mon.hp) {
yield* this.updateStateAndExperienceAfterEnemyFaint(true);
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
}
if (yield* this.askUseNextPokemon()) {
this.battleEnded = true;
return;
}
if (yield* this.forcePlayerMonChoice()) return;
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
yield* this.doubleSwitch();
}
private updateFaintedPlayerMon() {
this.participants &= ~(1 << this.curBattleMon);
this.enemy.reset(SUB3, C.SUBSTATUS_IN_LOOP);
this.player.damageTaken = 0;
this.player.mon.status = 0;
this.updateBattleMonInParty();
// ChangeHappiness: fainting, or fainting to a much stronger foe.
const strong = this.enemy.mon.level >= this.player.mon.level + 30;
this.changeHappiness(this.curBattleMon, strong ? C.HAPPINESS_BEATENBYSTRONGFOE : C.HAPPINESS_FAINTED);
this.battleResult = (this.battleResult & 0xc0) + C.LOSE;
}
/** ChangeHappiness: HappinessChanges by tier (below 100, below 200,
* above). Eggs are skipped. In battle the battle Pokémon's copy follows
* only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
private changeHappiness(index: number, action: number) {
const mon = this.options.party[index];
if (mon.species === C.EGG) return;
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness(
mon.happiness,
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
);
if (index === this.partyMenuCursor) this.player.mon.happiness = mon.happiness;
}
/** ResetBattleParticipants: only the Pokémon out, in both sets. */
private resetBattleParticipants() {
this.participants = this.participantsAll = 0;
this.addBattleParticipant();
}
/** AddBattleParticipant. */
private addBattleParticipant() {
this.participants |= 1 << this.curBattleMon;
this.participantsAll |= 1 << this.curBattleMon;
}
private *lostBattle(): Gen {
this.battleEnded = true;
this.battleResult = (this.battleResult & 0xc0) + C.LOSE;
}
/** UpdateBattleStateAndExperienceAfterEnemyFaint and GiveExperiencePoints. */
private *updateStateAndExperienceAfterEnemyFaint(playerFaintedFirst: boolean): Gen {
this.updateBattleMonInParty();
const trainer = this.options.trainer;
if (trainer) trainer.party[this.curOTMon].hp = 0;
this.player.reset(SUB3, C.SUBSTATUS_IN_LOOP);
this.enemy.damageTaken = 0;
this.newEnemyMonStatus();
this.breakAttraction();
if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return;
this.battleResult &= 0xc0; // WIN
if (this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE))
throw new UnsupportedBattle("Exp. Share is not ported yet.");
yield* this.giveExperience();
}
private *giveExperience(): Gen {
const party = this.options.party,
data = this.options.data;
const count = [0, 1, 2, 3, 4, 5].filter((index) => this.participants & (1 << index)).length;
const base = { ...this.enemyBase, stats: [...this.enemyBase.stats] };
if (count >= 2) {
base.stats = base.stats.map((value) => Math.floor(value / count));
base.catchRate = Math.floor(base.catchRate / count);
base.exp = Math.floor(base.exp / count);
}
for (let index = 0; index < party.length; index++) {
const mon = party[index];
if (!mon.hp || !(this.participants & (1 << index))) continue;
// Stat experience: the defeated Pokémon's base stats, twice with Pokérus.
for (let stat = 0; stat < 5; stat++) {
let value = mon.statExp[stat] + base.stats[stat];
if (mon.pokerus) value += base.stats[stat];
mon.statExp[stat] = Math.min(0xffff, value);
}
let exp = Math.floor((base.exp * this.enemy.mon.level) / 7);
const traded = mon.otId !== this.options.playerId;
const boost = (value: number) => value + (value >> 1);
if (traded) exp = boost(exp);
if (this.battleMode !== C.WILD_BATTLE) exp = boost(exp);
if (mon.item === C.LUCKY_EGG) exp = boost(exp);
exp &= 0xffff;
this.numbers[RAM.stringBuffer2] = exp;
this.numbers[RAM.stringBuffer2 + 2] = traded ? 1 : 0;
this.strings[RAM.stringBuffer1] = mon.nickname;
yield* this.say(BATTLE.Text_MonGainedExpPoint);
const growth = data.baseData(mon.species).growthRate;
mon.exp = Math.min(data.expAtLevel(growth, C.MAX_LEVEL), Math.min(0xffffff, mon.exp + exp));
yield this.hud();
const newLevel = this.levelForExp(mon.species, mon.exp);
if (mon.level >= C.MAX_LEVEL || newLevel === mon.level) continue;
const oldLevel = mon.level;
mon.level = newLevel;
const oldMax = mon.stats[0];
mon.stats = calcStats(data, mon.species, mon.dvs, newLevel, mon.statExp);
mon.hp = Math.min(mon.stats[0], mon.hp + (mon.stats[0] - oldMax));
if (index === this.curBattleMon) {
const side = this.player;
side.mon.hp = mon.hp;
side.mon.maxHp = mon.stats[0];
side.mon.level = newLevel;
side.stats = mon.stats.slice(1);
this.calcStats(side);
}
// LevelUpHappinessMod: more where it was caught.
this.changeHappiness(
index,
(mon.caughtData & 0x7f) === this.options.landmark ? C.HAPPINESS_GAINLEVELATHOME : C.HAPPINESS_GAINLEVEL,
);
this.strings[RAM.stringBuffer1] = mon.nickname;
this.numbers[RAM.curPartyLevel] = newLevel;
yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel);
yield this.hud();
// PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def, Speed.
yield {
kind: "stats",
labels: readString(this.reader, BATTLE["PrintTempMonStats.StatNames"], this.context(), 64).split("\n").filter(Boolean),
values: [mon.stats[1], mon.stats[2], mon.stats[4], mon.stats[5], mon.stats[3]],
};
for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level);
this.evolvable |= 1 << index;
}
this.resetBattleParticipants();
}
/** CalcLevel: the highest level whose exp does not exceed the total. */
private levelForExp(species: number, exp: number) {
return levelForExp(this.options.data, species, exp);
}
/** LearnLevelMoves: moves the species learns at exactly this level. */
private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen {
for (const learned of this.options.data.learnset(mon.species)) {
if (learned.level !== level || mon.moves.includes(learned.move)) continue;
yield* this.learnMove(mon, index, learned.move);
}
}
/** LearnMove, including the choice of a move to forget. */
private *learnMove(mon: PartyMon, index: number, move: number): Gen {
const data = this.options.data;
this.strings[RAM.stringBuffer1] = mon.nickname;
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
this.strings[RAM.stringBuffer2] = data.moveName(move);
for (;;) {
let slot = mon.moves.indexOf(0);
if (slot < 0) {
slot = yield* this.forgetMove(mon);
if (slot < 0) {
yield* this.say(BATTLE.StopLearningMoveText);
if (!(yield { kind: "yesno", text: "" })) continue;
yield* this.say(BATTLE.DidNotLearnMoveText);
return;
}
const forgotten = mon.moves[slot];
if (this.player.disabledMove === forgotten) {
this.player.disabledMove = 0;
this.player.disableCount = 0;
}
this.strings[RAM.stringBuffer1] = data.moveName(forgotten);
yield* this.say(BATTLE.Text_1_2_and_Poof);
}
mon.moves[slot] = move;
mon.pp[slot] = data.move(move).pp;
if (index === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED)) {
this.player.mon.moves = [...mon.moves];
this.player.mon.pp = [...mon.pp];
}
yield* this.say(BATTLE.LearnedMoveText);
return;
}
}
/** ForgetMove: returns the slot to replace, or -1 to stop. */
private *forgetMove(mon: PartyMon): Gen<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;
}
}
}