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>
3645 lines
146 KiB
TypeScript
3645 lines
146 KiB
TypeScript
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;
|
||
}
|
||
}
|
||
}
|