The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte copy of its audio WRAM and a sound register model. A reference test runs the cartridge's routines beside it and matches the WRAM and registers after every UpdateSound for all songs, effects and cries, fades, the danger tone and effect priority. apu.ts synthesizes the four channels in an AudioWorklet; its output matches the reference emulator's APU by spectrum. The world runs the engine twice per tick; Play or the first press unlocks sound, pausing suspends it, Settings sets the volume. Wired in: map music by map setup and special map music, script audio commands, text fanfares, WaitSFX and cries that hold the script frozen (the last page stays shown), battle music, cries, fainting, victory, capture and the low HP tone, Pokémon Center healing, evolution, the intro, the Pokédex's cries, PCs, marts, healing items, talk and menu clicks, bumps, ledges, warps, boulders, the Bicycle and Surf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6542 lines
267 KiB
TypeScript
6542 lines
267 KiB
TypeScript
import { RomReader } from "../rom/reader";
|
||
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
|
||
import { 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,
|
||
updateUnownDex,
|
||
type PartyMon,
|
||
type UnownRecord,
|
||
} from "../party/pokemon";
|
||
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
||
import { BattleData } from "./data";
|
||
import { C, COMMAND } from "./constants";
|
||
import { chooseTrainerMove, validateTrainerAI } from "./ai";
|
||
|
||
/** Raised for battle rules that have not been translated. The battle is
|
||
* discarded, as if it never started. */
|
||
export class UnsupportedBattle extends Error {}
|
||
|
||
/** A battle_struct: the Pokémon on the field, with stage-modified stats. */
|
||
export interface BattleMon {
|
||
species: number;
|
||
item: number;
|
||
moves: number[];
|
||
dvs: number;
|
||
pp: number[];
|
||
happiness: number;
|
||
level: number;
|
||
status: number;
|
||
hp: number;
|
||
maxHp: number;
|
||
/** Attack, Defense, Speed, Sp. Atk, Sp. Def after stages and status. */
|
||
stats: number[];
|
||
types: [number, number];
|
||
nickname: string;
|
||
}
|
||
interface MoveStruct {
|
||
anim: number;
|
||
effect: number;
|
||
power: number;
|
||
type: number;
|
||
accuracy: number;
|
||
pp: number;
|
||
chance: number;
|
||
}
|
||
|
||
/** One side's battle variables (wPlayer* or wEnemy*). */
|
||
class Side {
|
||
mon!: BattleMon;
|
||
/** Unmodified stats (wPlayerStats), used for stages and criticals. */
|
||
stats = [0, 0, 0, 0, 0];
|
||
levels = [7, 7, 7, 7, 7, 7, 7];
|
||
/** SubStatus1-5; index 0 is unused so indices match the names. */
|
||
sub = [0, 0, 0, 0, 0, 0];
|
||
move: MoveStruct = { anim: 0, effect: 0, power: 0, type: 0, accuracy: 0, pp: 0, chance: 0 };
|
||
curMove = 0;
|
||
moveNum = 0;
|
||
confuseCount = 0;
|
||
toxicCount = 0;
|
||
disableCount = 0;
|
||
disabledMove = 0;
|
||
encoreCount = 0;
|
||
rolloutCount = 0;
|
||
furyCutterCount = 0;
|
||
protectCount = 0;
|
||
rageCounter = 0;
|
||
/** wWrapCount: turns this side stays trapped, by trappingMove. */
|
||
wrapCount = 0;
|
||
trappingMove = 0;
|
||
damageTaken = 0;
|
||
screens = 0;
|
||
lightScreenCount = 0;
|
||
reflectCount = 0;
|
||
turnsTaken = 0;
|
||
charging = 0;
|
||
lastMove = 0;
|
||
lastCounterMove = 0;
|
||
minimized = 0;
|
||
justGotFrozen = 0;
|
||
perishCount = 0;
|
||
safeguardCount = 0;
|
||
/** wSubstituteHP: a byte, a quarter of the maximum HP. */
|
||
substituteHp = 0;
|
||
/** wEnemyBackupDVs: the enemy's own DVs while transformed. */
|
||
backupDvs = 0;
|
||
/** wFutureSightCount and the damage Future Sight stored. */
|
||
futureSightCount = 0;
|
||
futureSightDamage = 0;
|
||
has(byte: number, bit: number) {
|
||
return Boolean(this.sub[byte] & (1 << bit));
|
||
}
|
||
set(byte: number, bit: number) {
|
||
this.sub[byte] |= 1 << bit;
|
||
}
|
||
reset(byte: number, bit: number) {
|
||
this.sub[byte] &= ~(1 << bit);
|
||
}
|
||
}
|
||
|
||
export type BattleEffect =
|
||
/** A battle message; prompt texts wait for a button, others continue. */
|
||
| { kind: "text"; text: string; prompt: boolean }
|
||
/** HP, status or level changed; renderers re-read the battle state. */
|
||
| { kind: "hud" }
|
||
/** The battle menu: answered with an action. In the Bug-Catching
|
||
* Contest, PACK is PARKBALL× with this wParkBallsRemaining. */
|
||
/** BattleMenu. `noItems`: PACK only says items can't be used (link and
|
||
* Battle Tower battles), without opening the pack. */
|
||
| { kind: "action"; parkBalls?: number; noItems?: boolean }
|
||
/** DisplayCaughtContestMonStats: the stocked contest Pokémon beside the
|
||
* one just caught, with ContestAskSwitchText; answered Yes/No (1/0). */
|
||
| { kind: "contestswitch"; text: string; stock: ContestMonSummary; caught: ContestMonSummary }
|
||
| { 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[] };
|
||
/** PlayStereoCry from a side, sound effects one after another (WaitSFX
|
||
* between them), PlayVictoryMusic or the capture music, and
|
||
* StopDangerSound. */
|
||
export type BattleSound =
|
||
| { cry: number; side: "player" | "enemy" }
|
||
| { sfx: number[] }
|
||
| { music: "victory" | "capture" }
|
||
| { stopDanger: true };
|
||
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
|
||
export type ContestMonSummary = { name: string; level: number; maxHp: number };
|
||
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
|
||
export interface BugContest {
|
||
parkBalls: number;
|
||
mon: PartyMon | null;
|
||
}
|
||
/** A wRoamMon struct: Raikou's, Entei's or Suicune's. An HP of 0 means
|
||
* the DVs and HP are drawn at the next encounter. */
|
||
export interface RoamMon {
|
||
species: number;
|
||
level: number;
|
||
/** The map it roams, or null (GROUP_N_A/MAP_N_A) once defeated or caught. */
|
||
mapId: string | null;
|
||
/** The low byte of its HP. */
|
||
hp: number;
|
||
dvs: number;
|
||
}
|
||
/** BATTLERESULT_CAUGHT_CELEBI, which CheckCaughtCelebi reads. */
|
||
export const BATTLERESULT_CAUGHT_CELEBI = 0x40;
|
||
/** wBattleType values that BattleOptions.battleType accepts. */
|
||
const SPECIAL_BATTLE_TYPES: readonly number[] = [
|
||
C.BATTLETYPE_ROAMING,
|
||
C.BATTLETYPE_CONTEST,
|
||
C.BATTLETYPE_FORCESHINY,
|
||
C.BATTLETYPE_TRAP,
|
||
C.BATTLETYPE_FORCEITEM,
|
||
C.BATTLETYPE_CELEBI,
|
||
C.BATTLETYPE_SUICUNE,
|
||
];
|
||
/** 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. In the Bug-Catching
|
||
* Contest it throws a PARK_BALL (any slot). */
|
||
| { action: "item"; item: number; slot: number };
|
||
export type BattleInput = number | string | BattleAction | undefined;
|
||
/** wBattleResult, the prize money and wEvolvableFlags (a bit per party
|
||
* index that levelled up). */
|
||
export type BattleOutcome = {
|
||
result: number;
|
||
prizeMoney?: number;
|
||
/** The part of the prize sent to Mom's savings. */
|
||
momMoney?: number;
|
||
/** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */
|
||
payDayMoney?: number;
|
||
evolvable?: number;
|
||
/** BattleEnd_HandleRoamMons called UpdateRoamMons: after a roaming
|
||
* battle that was not won, and after one battle in sixteen otherwise. */
|
||
updateRoamMons?: true;
|
||
};
|
||
|
||
export interface BattleTrainer {
|
||
name: string;
|
||
className: string;
|
||
/** wOTPartyMons: level, status and HP follow the Pokémon out only where
|
||
* the original updates them (UpdateEnemyMonInParty: status changes and
|
||
* heals, a faint or a withdrawal); damage alone leaves them stale. */
|
||
party: PartyMon[];
|
||
aiFlags: number;
|
||
/** TrainerClassAttributes' AI item/switch word (SWITCH_*, ALWAYS_USE and
|
||
* CONTEXT_USE in the low byte). */
|
||
switchFlags: number;
|
||
/** wEnemyTrainerItem1/2: the trainer's two held items, consumed (set to 0)
|
||
* as AI_TryItem uses them. */
|
||
items: [number, number];
|
||
/** wEnemyTrainerBaseReward: the final reward uses this, and AI_TryItem's
|
||
* item-pointer bug can misread it as a third item slot. */
|
||
baseReward: number;
|
||
reward: number;
|
||
winText?: number;
|
||
lossText?: number;
|
||
}
|
||
|
||
type Gen<T = void> = Generator<BattleEffect, T, BattleInput>;
|
||
// Sound effect ids (constants/sfx_constants.asm; audio/constants.ts).
|
||
const SFX_KINESIS = 0x2f,
|
||
SFX_FAINT = 0x2a,
|
||
SFX_RUN = 0x2b;
|
||
|
||
export interface BattleOptions {
|
||
data: PokemonData;
|
||
/** Where the battle's sounds go (cries, sound effects, music). */
|
||
sound?: (sound: BattleSound) => void;
|
||
party: PartyMon[];
|
||
pokedex: { seen: Set<number>; caught: Set<number> };
|
||
playerId: number;
|
||
playerName: string;
|
||
/** wJohtoBadges, for traded Pokémon obedience and the badges' boosts. */
|
||
johtoBadges?: number;
|
||
/** wKantoBadges, for DoBadgeTypeBoosts. */
|
||
kantoBadges?: number;
|
||
/** wInBattleTowerBattle: a Battle Tower battle (no obedience, badge
|
||
* boosts, experience, items, prize money or whiteout; the AI's status
|
||
* moves never fail at random; Sleep lasts one to three turns). */
|
||
battleTower?: boolean;
|
||
/** wTimeOfDay: morning 0, day 1, night 2. */
|
||
timeOfDay: number;
|
||
random: () => number;
|
||
wild?: { species: number; level: number };
|
||
trainer?: BattleTrainer;
|
||
canLose?: boolean;
|
||
text: () => TextContext;
|
||
/** The player's pack, needed to throw Poké Balls. */
|
||
bag?: Bag;
|
||
/** Bill's PC: a catch with a full party goes to the current box. */
|
||
storage?: PokemonStorage;
|
||
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
|
||
hasPokedex?: boolean;
|
||
/** SetCaughtData's word for a Pokémon caught at this level. */
|
||
caughtData?: (level: number) => number;
|
||
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
|
||
tutorial?: boolean;
|
||
/** BATTLETYPE_TREE: a wild Pokémon that fell from a Headbutt tree. */
|
||
tree?: boolean;
|
||
/** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */
|
||
fish?: boolean;
|
||
/** wBattleType for the other wild battles: BATTLETYPE_FORCESHINY, TRAP,
|
||
* FORCEITEM, CELEBI, SUICUNE, CONTEST (with `contest`) or ROAMING (with
|
||
* `roamer`). */
|
||
battleType?: number;
|
||
/** The contest's Park Balls and caught Pokémon, which the battle updates. */
|
||
contest?: BugContest;
|
||
/** The roaming Pokémon's struct, which the battle updates. */
|
||
roamer?: RoamMon;
|
||
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||
landmark?: number;
|
||
/** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of
|
||
* Rage for wild Magikarp. */
|
||
mapId?: string;
|
||
/** wPartyMenuCursor as the last party menu outside battle left it. */
|
||
partyMenuCursor?: number;
|
||
/** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much
|
||
* of a trainer's prize goes to Mom. */
|
||
mom?: { saving: number; money: number };
|
||
/** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */
|
||
shift?: boolean;
|
||
/** wOptions' BATTLE_SCENE clear: battle animations on (the default).
|
||
* Some texts only show with them off (_CheckBattleScene). */
|
||
battleScene?: boolean;
|
||
/** wUnownDex and wFirstUnownSeen, which meeting and catching Unown
|
||
* update, and wUnlockedUnowns, the letter sets wild Unown may have. */
|
||
unown?: UnownRecord;
|
||
unlockedUnowns?: number;
|
||
}
|
||
|
||
/** CheckUnownLetter: whether an Unown with these DVs has a letter in one
|
||
* of the sets wUnlockedUnowns has unlocked (UnlockedUnownLetterSets, each
|
||
* a list of UNOWN_* letters ending with -1). */
|
||
export function unownUnlocked(
|
||
data: PokemonData,
|
||
unlocked: number,
|
||
dvs: number,
|
||
): boolean {
|
||
const r = data.reader,
|
||
letter = PokemonData.unownLetter(dvs),
|
||
table = JOHTO.UnlockedUnownLetterSets;
|
||
for (let set = 0; set < NUM_UNLOCKED_UNOWN_SETS; set++) {
|
||
if (!(unlocked & (1 << set))) continue;
|
||
for (let p = r.near(table + set * 2, r.bank(table)); r.byte(p) !== 0xff; p++)
|
||
if (r.byte(p) === letter) return true;
|
||
}
|
||
return false;
|
||
}
|
||
/** NUM_UNLOCKED_UNOWN_SETS (constants/ram_constants.asm). */
|
||
const NUM_UNLOCKED_UNOWN_SETS = 4;
|
||
|
||
/** 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,
|
||
ALWAYS_USE = 1 << 4,
|
||
UNKNOWN_USE = 1 << 5,
|
||
CONTEXT_USE = 1 << 6;
|
||
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
|
||
* tier ($10, $20, $30), a Random threshold and whether a roll below it
|
||
* switches (true) or keeps the Pokémon in (false). */
|
||
const SWITCH_CHANCES: readonly (readonly [number, boolean])[][] = [
|
||
[[percent(50) + 1, true], [percent(79) - 1, true], [percent(4), false]],
|
||
[[percent(8), true], [percent(12), true], [percent(79) - 1, false]],
|
||
[[percent(20) - 1, true], [percent(50) + 1, true], [percent(20) - 1, false]],
|
||
];
|
||
|
||
export interface TypeMatchup {
|
||
attacker: number;
|
||
defender: number;
|
||
multiplier: number;
|
||
}
|
||
|
||
/** BattleCommand_DamageCalc's arithmetic. `boost` is a type-boosting held
|
||
* item's percentage; `current` is the damage already in wCurDamage.
|
||
* Returns undefined when the original leaves wCurDamage unchanged. */
|
||
/** MAP_LAKE_OF_RAGE (constants/map_constants.asm). */
|
||
const LAKE_OF_RAGE = { group: 9, number: 6 };
|
||
|
||
/** CalcMagikarpLength (engine/events/magikarp.asm): a length in feet and
|
||
* inches from the DVs and the trainer ID. The index is rrc(rrc(DV byte))
|
||
* xor rrc(ID byte), per byte. Its .BCLessThanDE compares only the high
|
||
* bytes, so MagikarpLengths' thresholds act on the high byte alone and
|
||
* the difference below them wraps around 16 bits, as in the original. */
|
||
export function magikarpLength(
|
||
r: RomReader,
|
||
dvs: number,
|
||
playerId: number,
|
||
): { feet: number; inches: number } {
|
||
const rrc = (value: number) => ((value >> 1) | (value << 7)) & 0xff;
|
||
const b = rrc(rrc(dvs >> 8)) ^ rrc((playerId >> 8) & 0xff),
|
||
c = rrc(rrc(dvs & 0xff)) ^ rrc(playerId & 0xff),
|
||
bc = (b << 8) | c;
|
||
let mm: number | undefined;
|
||
let p = BATTLE.MagikarpLengths;
|
||
if (bc < 10) mm = bc + 190;
|
||
else
|
||
for (let z = 2; z < 16; z++, p += 3)
|
||
if (b < r.byte(p + 1)) {
|
||
const quotient = Math.floor(((bc - r.word(p)) & 0xffff) / r.byte(p + 2));
|
||
mm = ((quotient & 0xff) + 100 * z) & 0xffff;
|
||
break;
|
||
}
|
||
// Past the table: bc minus its last threshold, plus 1600.
|
||
mm ??= (((bc - r.word(p - 3)) & 0xffff) + 1600) & 0xffff;
|
||
// Millimetres × 10 / 254 in a byte, then feet and inches.
|
||
const total = Math.floor(((mm * 10) & 0xffff) / 254) & 0xff;
|
||
return { feet: Math.floor(total / 12), inches: total % 12 };
|
||
}
|
||
|
||
export function calculateDamage(input: {
|
||
power: number;
|
||
level: number;
|
||
attack: number;
|
||
defense: number;
|
||
effect: number;
|
||
critical: boolean;
|
||
boost?: number;
|
||
current: number;
|
||
}): number | undefined {
|
||
let { defense } = input;
|
||
const { power, effect } = input;
|
||
// Selfdestruct and Explosion halve defense.
|
||
if (effect === C.EFFECT_SELFDESTRUCT) defense = defense >> 1 || 1;
|
||
if (effect !== C.EFFECT_MULTI_HIT && effect !== C.EFFECT_CONVERSION && !power) return undefined;
|
||
if (!defense) defense = 1;
|
||
let value = Math.floor(((input.level * 2) & 0x1ff) / 5) + 2;
|
||
value = Math.floor((value * power * input.attack) / defense);
|
||
value = Math.floor(value / 50);
|
||
if (input.boost !== undefined) value = Math.floor((value * (input.boost + 100)) / 100);
|
||
if (input.critical) {
|
||
// Only the low 16 bits are doubled, capped at $ffff.
|
||
const high = value - (value & 0xffff);
|
||
const doubled = (value & 0xffff) * 2;
|
||
value = high + (doubled > 0xffff ? 0xffff : doubled);
|
||
}
|
||
// Add to wCurDamage, capped at 997, then add 2. The original first adds
|
||
// the high byte of wCurDamage to the result's low byte.
|
||
const current = input.current;
|
||
let low = (value & 0xff) + (current >> 8);
|
||
let high = (value >> 8) & 0xff;
|
||
if (low > 0xff) {
|
||
low &= 0xff;
|
||
high = (high + 1) & 0xff;
|
||
if (!high) return 999;
|
||
}
|
||
if ((value >> 16) & 0xff) return 999; // only hQuotient + 1 is checked
|
||
const result = (high << 8) | low;
|
||
if (result >= 998) return 999;
|
||
const total = result + current;
|
||
if (total > 0xffff || total >= 998) return 999;
|
||
return total + 2;
|
||
}
|
||
|
||
/** BattleCommand_Stab and BattleCheckTypeMatchup. */
|
||
export function applyTypeMatchups(
|
||
matchups: readonly TypeMatchup[],
|
||
input: {
|
||
damage: number;
|
||
modifier: number;
|
||
moveType: number;
|
||
userTypes: readonly number[];
|
||
targetTypes: readonly number[];
|
||
identified: boolean;
|
||
},
|
||
): { damage: number; modifier: number; matchup: number; missed: boolean } {
|
||
let { damage, modifier } = input;
|
||
let missed = false;
|
||
const type = input.moveType;
|
||
if (type === input.userTypes[0] || type === input.userTypes[1]) {
|
||
damage = (damage + (damage >> 1)) & 0xffff;
|
||
modifier |= C.STAB_DAMAGE;
|
||
}
|
||
const hits = (row: TypeMatchup) =>
|
||
row.attacker === type &&
|
||
(row.defender === input.targetTypes[0] || row.defender === input.targetTypes[1]);
|
||
for (const row of matchups) {
|
||
// Rows after the -2 marker (Ghost immunities) stop under Foresight.
|
||
if (row.attacker === 0xfe) {
|
||
if (input.identified) break;
|
||
continue;
|
||
}
|
||
if (!hits(row)) continue;
|
||
if (!row.multiplier) missed = true;
|
||
modifier = (modifier & C.STAB_DAMAGE) + row.multiplier;
|
||
const product = damage * row.multiplier;
|
||
damage = product ? Math.floor(product / 10) & 0xffff || 1 : 0;
|
||
}
|
||
let matchup: number = C.EFFECTIVE;
|
||
for (const row of matchups) {
|
||
if (row.attacker === 0xfe) {
|
||
if (input.identified) break;
|
||
continue;
|
||
}
|
||
if (hits(row)) matchup = Math.floor((row.multiplier * matchup) / 10) & 0xff;
|
||
}
|
||
return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed };
|
||
}
|
||
|
||
/** A native translation of Crystal's battle engine.
|
||
* The move effect scripts are the ROM's own; each battle command is a
|
||
* native translation. Unsupported AI, switching, contest and item paths are refused. */
|
||
/** PokeBallEffect's wFinalCatchRate in the original 8- and 16-bit
|
||
* registers. `rate` already has the battle-dependent Kurt's ball multipliers
|
||
* applied (Battle.kurtBallRate). 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 || input.ball === C.PARK_BALL)
|
||
rate = Math.min(0xff, rate + (rate >> 1));
|
||
// The Level Ball skips the HP, status and held-item terms.
|
||
if (input.ball === C.LEVEL_BALL) return rate;
|
||
// 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;
|
||
/** wWeatherCount: turns left of the weather. */
|
||
private weatherCount = 0;
|
||
/** 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;
|
||
/** wTempEnemyMonSpecies: the enemy's species before any Transform. */
|
||
private enemySpecies = 0;
|
||
/** wForcedSwitch: Roar, Whirlwind or Teleport ended a wild battle. */
|
||
private forcedSwitch = false;
|
||
/** wCurPartyMon: the party index a menu picked. */
|
||
private curPartyMon = 0;
|
||
/** wPartyMenuCursor: the 1-based cursor of the last party menu, from
|
||
* the start menu's until the battle's own party menu sets it. */
|
||
private partyMenuCursor: number;
|
||
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
|
||
lastPlayerMon = 0;
|
||
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
|
||
private enemyIsSwitching = 0;
|
||
/** wPlayerIsSwitching, which Pursuit doubles against. */
|
||
private playerIsSwitching = 0;
|
||
/** wEnemyHPAtTimeOfPlayerSwitch, which WithdrawMonText compares with. */
|
||
private enemyHPAtSwitch = 0;
|
||
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
|
||
private playerUsedMoves = [0, 0, 0, 0];
|
||
private curDamage = 0;
|
||
private criticalHit = 0;
|
||
private attackMissed = 0;
|
||
private effectFailed = 0;
|
||
private typeModifier: number = C.EFFECTIVE;
|
||
private alreadyDisobeyed = 0;
|
||
private alreadyFailed = 0;
|
||
/** wTypeMatchup from the last BattleCheckTypeMatchup. */
|
||
private matchup: number = C.EFFECTIVE;
|
||
/** wBattleAnimParam, which Triple Kick also reads as its kick count. */
|
||
private animParam = 0;
|
||
/** wPayDayMoney: Pay Day's coins, paid out after a win. */
|
||
private payDayMoney = 0;
|
||
/** wAmuletCoin: a Pokémon sent out held an Amulet Coin. */
|
||
private amuletCoin = false;
|
||
/** wCurBeatUpPartyMon and wBeatUpHitAtLeastOnce. */
|
||
private beatUpMon = 0;
|
||
private beatUpHit = 0;
|
||
private turnEnded = 0;
|
||
private loweredStat = 0;
|
||
private failedMessage = 0;
|
||
private script: number[] = [];
|
||
private scriptIndex = 0;
|
||
private strings: Record<number, string> = {};
|
||
private numbers: Record<number, number> = {};
|
||
private matchups;
|
||
private moveGrammarCache = new Map<number, number>();
|
||
|
||
constructor(private options: BattleOptions) {
|
||
this.data = new BattleData(options.data.reader);
|
||
this.matchups = this.data.typeMatchups();
|
||
this.partyMenuCursor = options.partyMenuCursor ?? 0;
|
||
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
|
||
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
|
||
else if (options.tree) this.battleType = C.BATTLETYPE_TREE;
|
||
else if (options.fish) this.battleType = C.BATTLETYPE_FISH;
|
||
else if (options.battleType !== undefined) this.battleType = options.battleType;
|
||
}
|
||
private get reader(): RomReader {
|
||
return this.options.data.reader;
|
||
}
|
||
private random() {
|
||
return this.options.random() & 0xff;
|
||
}
|
||
private get user() {
|
||
return this.turn === 0 ? this.player : this.enemy;
|
||
}
|
||
private get target() {
|
||
return this.turn === 0 ? this.enemy : this.player;
|
||
}
|
||
get partyMon(): PartyMon {
|
||
return this.options.party[this.curBattleMon];
|
||
}
|
||
/** ParsePlayerAction skips MoveSelectionScreen: FIGHT goes on with the
|
||
* current move, as while biding. */
|
||
get fightKeepsMove() {
|
||
return this.player.has(SUB3, C.SUBSTATUS_BIDE) || this.player.has(SUB5, C.SUBSTATUS_ENCORED);
|
||
}
|
||
|
||
// ------------------------------------------------------------- messages
|
||
|
||
private context(): TextContext {
|
||
const base = this.options.text();
|
||
return {
|
||
...base,
|
||
// CatchTutorial copies "DUDE" over wPlayerName for the battle.
|
||
player:
|
||
this.battleType === C.BATTLETYPE_TUTORIAL
|
||
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
|
||
: base.player,
|
||
trainer: this.options.trainer
|
||
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
|
||
: base.trainer,
|
||
strings: {
|
||
...base.strings,
|
||
[RAM.battleMonNickname]: this.player.mon?.nickname ?? "",
|
||
[RAM.enemyMonNickname]: this.enemy.mon?.nickname ?? "",
|
||
...this.strings,
|
||
},
|
||
numbers: this.numbers,
|
||
battle: {
|
||
turn: this.turn,
|
||
player: this.player.mon?.nickname ?? "",
|
||
enemy: this.enemy.mon?.nickname ?? "",
|
||
enemyPrefix: readString(this.reader, BATTLE.EnemyText, base),
|
||
},
|
||
asm: (offset) => this.textAsm(offset),
|
||
};
|
||
}
|
||
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
|
||
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
|
||
private *say(address: number, button = false): Gen {
|
||
const sfx: number[] = [];
|
||
const { text, prompt } = readTextEnd(this.reader, address, {
|
||
...this.context(),
|
||
onSound: (id) => sfx.push(id),
|
||
});
|
||
// Sounds of the text itself (a fanfare) and of its text_asm routines.
|
||
const sounds = this.textSounds.splice(0);
|
||
if (sfx.length) sounds.push({ sfx });
|
||
for (const sound of sounds) this.playSound(sound);
|
||
// 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 };
|
||
}
|
||
/** Sounds text_asm routines play, given out by the text that ran them. */
|
||
private textSounds: BattleSound[] = [];
|
||
/** A sound for the presentation; the battle never waits for it. */
|
||
private playSound(sound: BattleSound) {
|
||
this.options.sound?.(sound);
|
||
}
|
||
/** wBattleLowHealthAlarm: set once the battle is won, which keeps the
|
||
* danger tone off for the rest of it. */
|
||
lowHealthAlarmOff = false;
|
||
/** StopDangerSound, and wBattleLowHealthAlarm for the rest of the battle. */
|
||
private winSounds() {
|
||
this.lowHealthAlarmOff = true;
|
||
this.playSound({ stopDanger: true });
|
||
}
|
||
/** text_asm routines that battle texts run between fragments. */
|
||
private textAsm(offset: number): number {
|
||
const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment;
|
||
switch (offset) {
|
||
case at("UsedMoveText"): {
|
||
// Updates the last move, then picks the grammar for "used".
|
||
if (this.turn === 0) this.updateUsedMoves(this.player.move.anim);
|
||
const user = this.user;
|
||
if (!user.charging) {
|
||
user.lastCounterMove = user.move.anim;
|
||
user.lastMove = user.move.anim;
|
||
}
|
||
const grammar = this.grammar(user.move.anim);
|
||
return this.alreadyDisobeyed || grammar < 3 ? BATTLE.UsedMove2Text : BATTLE.UsedMove1Text;
|
||
}
|
||
case at("UsedMove1Text"):
|
||
case at("UsedMove2Text"):
|
||
return this.alreadyDisobeyed ? BATTLE["UsedMoveText_CheckObedience.UsedInsteadText"] : BATTLE.MoveNameText;
|
||
case at("UsedMoveText_CheckObedience.UsedInsteadText"):
|
||
return BATTLE.MoveNameText;
|
||
case at("MoveNameText"): {
|
||
const ends = BATTLE["MoveNameText.endusedmovetexts"];
|
||
const grammar = this.grammar(this.user.move.anim);
|
||
return this.reader.near(ends + grammar * 2, this.reader.bank(ends));
|
||
}
|
||
case at("BattleCommand_StatUpMessage.stat"):
|
||
return this.loweredStat & 0xf0
|
||
? BATTLE["BattleCommand_StatUpMessage.BattleStatWentWayUpText"]
|
||
: BATTLE["BattleCommand_StatUpMessage.BattleStatWentUpText"];
|
||
case at("BattleCommand_StatDownMessage.stat"):
|
||
return this.loweredStat & 0xf0
|
||
? BATTLE["BattleCommand_StatDownMessage.BattleStatSharplyFellText"]
|
||
: BATTLE["BattleCommand_StatDownMessage.BattleStatFellText"];
|
||
case at("Text_MonGainedExpPoint"):
|
||
return this.numbers[RAM.stringBuffer2 + 2] ? BATTLE.BoostedExpPointsText : BATTLE.ExpPointsText;
|
||
case at("GoMonText"):
|
||
case at("DoItMonText"):
|
||
case at("GoForItMonText"):
|
||
case at("YourFoesWeakGetmMonText"):
|
||
return BATTLE.BattleMonNicknameText;
|
||
case at("WithdrawMonText.WithdrawMonText"): {
|
||
const text = withdrawText(this.enemyHPAtSwitch, this.enemy.mon.hp, this.enemy.mon.maxHp);
|
||
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||
return BATTLE[text];
|
||
}
|
||
case at("Text_1_2_and_Poof"):
|
||
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
||
case at("BattleCommand_Charge.UsedText"): {
|
||
const text = {
|
||
[C.RAZOR_WIND]: "BattleCommand_Charge.BattleMadeWhirlwindText",
|
||
[C.SOLARBEAM]: "BattleCommand_Charge.BattleTookSunlightText",
|
||
[C.SKULL_BASH]: "BattleCommand_Charge.BattleLoweredHeadText",
|
||
[C.SKY_ATTACK]: "BattleCommand_Charge.BattleGlowingText",
|
||
[C.FLY]: "BattleCommand_Charge.BattleFlewText",
|
||
}[this.user.move.anim];
|
||
// Any other move falls through to the Dig text.
|
||
return BATTLE[(text ?? "BattleCommand_Charge.BattleDugText") as keyof typeof BATTLE];
|
||
}
|
||
case at("Text_GotchaMonWasCaught"):
|
||
// WaitSFX, MUSIC_CAPTURE, then WaitButtonText.
|
||
this.textSounds.push({ music: "capture" });
|
||
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,
|
||
};
|
||
// The paralysis and burn penalties are not applied to a trainer's
|
||
// Pokémon sent back in with a status (a kept bug).
|
||
this.enemy.stats = [...this.enemy.mon.stats];
|
||
this.enemySpecies = mon.species;
|
||
this.options.pokedex.seen.add(mon.species);
|
||
// LoadEnemyMonToSwitchTo: a trainer's Unown can be the first seen.
|
||
const unown = this.options.unown;
|
||
if (mon.species === C.UNOWN && unown && !unown.firstUnownSeen)
|
||
unown.firstUnownSeen = PokemonData.unownLetter(mon.dvs);
|
||
return;
|
||
}
|
||
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
|
||
const { species, level } = this.options.wild;
|
||
const unown = this.options.unown;
|
||
if (species === C.UNOWN && !unown)
|
||
throw new UnsupportedBattle("Missing the Unown Dex.");
|
||
const data = this.options.data,
|
||
base = data.baseData(species);
|
||
// 25% chance of an item: 23% the first, 2% the second. Ho-Oh, Lugia
|
||
// and Snorlax (BATTLETYPE_FORCEITEM) always hold the first.
|
||
let item = 0;
|
||
if (this.battleType === C.BATTLETYPE_FORCEITEM) item = base.items[0];
|
||
else if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
|
||
// Roaming Pokémon keep their DVs, drawn (Speed/Special first) at the
|
||
// first encounter; the Red Gyarados is shiny.
|
||
const roamer = this.battleType === C.BATTLETYPE_ROAMING ? this.options.roamer! : undefined;
|
||
let dvs: number;
|
||
if (roamer) {
|
||
if (!roamer.hp) {
|
||
const low = this.random();
|
||
roamer.dvs = (this.random() << 8) | low;
|
||
}
|
||
dvs = roamer.dvs;
|
||
} else if (this.battleType === C.BATTLETYPE_FORCESHINY) dvs = 0xeaaa;
|
||
else dvs = (this.random() << 8) | this.random();
|
||
// CheckUnownLetter: a letter outside the unlocked sets draws again.
|
||
while (species === C.UNOWN && !unownUnlocked(data, this.options.unlockedUnowns ?? 0, dvs))
|
||
dvs = (this.random() << 8) | this.random();
|
||
while (species === C.MAGIKARP && this.rerollMagikarp(dvs)) dvs = (this.random() << 8) | this.random();
|
||
// InitEnemyWildmon: the first Unown met names wFirstUnownSeen.
|
||
if (species === C.UNOWN && !unown!.firstUnownSeen)
|
||
unown!.firstUnownSeen = PokemonData.unownLetter(dvs);
|
||
const stats = calcStats(data, species, dvs, level);
|
||
const moves = this.levelMoves(species, level);
|
||
this.enemySpecies = species;
|
||
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,
|
||
// A roaming Pokémon's struct keeps only its HP's low byte.
|
||
hp: roamer?.hp ? (stats[0] & 0xff00) | roamer.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,
|
||
};
|
||
if (roamer && !roamer.hp) roamer.hp = stats[0] & 0xff;
|
||
this.options.pokedex.seen.add(species);
|
||
this.enemy.stats = [...this.enemy.mon.stats];
|
||
this.wildMoves = [...moves];
|
||
this.wildPP = [...this.enemy.mon.pp];
|
||
}
|
||
/** The last four moves learnt by the level, as a wild Pokémon has them. */
|
||
private levelMoves(species: number, level: number) {
|
||
const moves: number[] = [];
|
||
for (const learned of this.options.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);
|
||
return moves;
|
||
}
|
||
/** LoadEnemyMon's .Magikarp filters, true when the DVs are drawn again.
|
||
* Their limits are in millimetres but CalcMagikarpLength gives feet and
|
||
* inches: the two size checks need 6 feet, which no Magikarp reaches
|
||
* (they only draw from Random), and the floor of 1024 mm compares
|
||
* feet with 4. The Lake of Rage test matches its map group or its map
|
||
* number on their own, and skips the floor there. */
|
||
private rerollMagikarp(dvs: number): boolean {
|
||
const { feet, inches } = magikarpLength(
|
||
this.reader,
|
||
dvs,
|
||
this.options.playerId,
|
||
);
|
||
if (feet === 1536 >> 8 && this.random() >= percent(5)) {
|
||
if (inches >= (1616 & 0xff)) return true;
|
||
if (this.random() >= percent(20) - 1 && inches >= (1600 & 0xff))
|
||
return true;
|
||
}
|
||
const [group, number] = (this.options.mapId ?? "0:0").split(":").map(Number);
|
||
if (group === LAKE_OF_RAGE.group || number === LAKE_OF_RAGE.number)
|
||
return false;
|
||
if (this.random() < percent(39) + 1) return false;
|
||
return feet < 1024 >> 8;
|
||
}
|
||
/** CheckSleepingTreeMon: in a tree battle, the time of day's
|
||
* AsleepTreeMons list names the species that sleep. */
|
||
private sleepingTreeMon(species: number) {
|
||
if (this.battleType !== C.BATTLETYPE_TREE) return false;
|
||
const time = this.options.timeOfDay;
|
||
let p =
|
||
time < C.DAY_F
|
||
? BATTLE.AsleepTreeMonsMorn
|
||
: time === C.DAY_F
|
||
? BATTLE.AsleepTreeMonsDay
|
||
: BATTLE.AsleepTreeMonsNite;
|
||
for (const r = this.reader; r.byte(p) !== 0xff; p++)
|
||
if (r.byte(p) === species) return true;
|
||
return false;
|
||
}
|
||
/** InitBattleMon from the party, then status penalties. */
|
||
private initBattleMon() {
|
||
const mon = this.partyMon,
|
||
base = this.options.data.baseData(mon.species);
|
||
this.player.mon = {
|
||
species: mon.species,
|
||
item: mon.item,
|
||
moves: [...mon.moves],
|
||
dvs: mon.dvs,
|
||
pp: [...mon.pp],
|
||
happiness: mon.happiness,
|
||
level: mon.level,
|
||
status: mon.status,
|
||
hp: mon.hp,
|
||
maxHp: mon.stats[0],
|
||
stats: mon.stats.slice(1),
|
||
types: [base.types[0], base.types[1]],
|
||
nickname: mon.nickname,
|
||
};
|
||
this.player.stats = mon.stats.slice(1);
|
||
this.applyStatusEffects(this.player);
|
||
this.badgeStatBoosts();
|
||
}
|
||
/** BadgeStatBoosts: Johto badges raise the battle Pokémon's Attack,
|
||
* Defense, Speed and Special Attack by an eighth (to at most 999). The
|
||
* PlainBadge and MineralBadge bits are swapped first. The Glacier Badge's
|
||
* Special Defense boost tests the register that BoostStat leaves after
|
||
* Special Attack, so it depends on that stat (a kept bug). */
|
||
private badgeStatBoosts() {
|
||
if (this.options.battleTower) return;
|
||
const badges = this.options.johtoBadges ?? 0;
|
||
// ZEPHYR, HIVE, FOG, STORM, GLACIER and RISING stay; PLAIN (bit 2)
|
||
// moves to bit 4 and MINERAL (bit 4) to bit 2.
|
||
let b = (badges & 0xeb) | ((badges & 0x04) << 2) | ((badges & 0x10) >> 2);
|
||
const stats = this.player.mon.stats;
|
||
// BoostStat, returning the A register it leaves.
|
||
const boost = (index: number) => {
|
||
const value = stats[index] + (stats[index] >> 3);
|
||
if (value >= C.MAX_STAT_VALUE) {
|
||
stats[index] = C.MAX_STAT_VALUE;
|
||
return C.MAX_STAT_VALUE & 0xff;
|
||
}
|
||
stats[index] = value;
|
||
return ((value >> 8) - (C.MAX_STAT_VALUE >> 8) - ((value & 0xff) < (C.MAX_STAT_VALUE & 0xff) ? 1 : 0)) & 0xff;
|
||
};
|
||
let a = 0;
|
||
for (let index = 0; index < 4; index++) {
|
||
a = b;
|
||
const carry = b & 1;
|
||
b >>= 2;
|
||
if (carry) a = boost(index);
|
||
}
|
||
if (a & 1) boost(4);
|
||
}
|
||
/** 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> {
|
||
let outcome = yield* this.doBattle();
|
||
// ExitBattle: CheckPayDay after a win. EvolveAfterBattle follows in
|
||
// the world, before CleanUpBattleRAM's roaming update below.
|
||
if (!(outcome.result & 0x0f)) {
|
||
const payDayMoney = yield* this.checkPayDay();
|
||
if (payDayMoney) outcome = { ...outcome, payDayMoney };
|
||
}
|
||
return this.handleRoamMons() ? { ...outcome, updateRoamMons: true } : outcome;
|
||
}
|
||
/** BattleEnd_HandleRoamMons: a roaming Pokémon keeps its HP, or leaves
|
||
* the map for good when defeated or caught. Other battles move the
|
||
* roaming Pokémon one time in sixteen. True when UpdateRoamMons runs. */
|
||
private handleRoamMons(): boolean {
|
||
if (this.battleType !== C.BATTLETYPE_ROAMING) return !(this.random() & 0x0f);
|
||
const roamer = this.options.roamer!;
|
||
if (!(this.battleResult & 0x0f)) {
|
||
Object.assign(roamer, { hp: 0, mapId: null, species: 0 });
|
||
return false;
|
||
}
|
||
roamer.hp = this.enemy.mon.hp & 0xff;
|
||
return true;
|
||
}
|
||
/** CheckPayDay: the coins, doubled by an Amulet Coin, for the wallet. */
|
||
private *checkPayDay(): Gen<number> {
|
||
let money = this.payDayMoney;
|
||
if (!money) return 0;
|
||
if (this.amuletCoin) money = Math.min(0xffffff, money * 2);
|
||
this.numbers[RAM.payDayMoney] = money;
|
||
yield* this.say(BATTLE.BattleText_PlayerPickedUpPayDayMoney);
|
||
return money;
|
||
}
|
||
/** CheckAmuletCoin, at every SendOutPlayerMon. */
|
||
private checkAmuletCoin() {
|
||
if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN)
|
||
this.amuletCoin = true;
|
||
}
|
||
|
||
private *doBattle(): Gen<BattleOutcome> {
|
||
const trainer = this.options.trainer;
|
||
if (trainer) {
|
||
validateTrainerAI(trainer.aiFlags, trainer.party.flatMap((mon) =>
|
||
mon.moves.filter(Boolean).map((move) => this.options.data.move(move).effect),
|
||
));
|
||
if (trainer.party.length < 1 || trainer.party.length > 6)
|
||
throw new UnsupportedBattle("Invalid trainer party.");
|
||
if (
|
||
!Number.isInteger(trainer.reward) ||
|
||
trainer.reward < 0 ||
|
||
trainer.reward > C.MAX_MONEY
|
||
)
|
||
throw new UnsupportedBattle("Invalid trainer reward.");
|
||
}
|
||
if (this.options.tutorial && (trainer || !this.options.wild))
|
||
throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon.");
|
||
if (this.options.battleType !== undefined) {
|
||
const type = this.options.battleType;
|
||
if (!SPECIAL_BATTLE_TYPES.includes(type) || trainer || !this.options.wild)
|
||
throw new UnsupportedBattle(`Battle type ${type} is not supported here.`);
|
||
if (type === C.BATTLETYPE_CONTEST && !this.options.contest)
|
||
throw new UnsupportedBattle("A contest battle needs the contest's state.");
|
||
if (type === C.BATTLETYPE_ROAMING && this.options.roamer?.species !== this.options.wild.species)
|
||
throw new UnsupportedBattle("A roaming battle needs its roaming 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);
|
||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||
} else {
|
||
// The wild Pokémon's picture animates with its cry before the text.
|
||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||
yield* this.say(
|
||
this.battleType === C.BATTLETYPE_FISH
|
||
? BATTLE.HookedPokemonAttackedText
|
||
: this.battleType === C.BATTLETYPE_TREE
|
||
? BATTLE.PokemonFellFromTreeText
|
||
: this.battleType === C.BATTLETYPE_CELEBI
|
||
? BATTLE.WildCelebiAppearedText
|
||
: BATTLE.WildPokemonAppearedText,
|
||
);
|
||
}
|
||
this.resetBattleParticipants();
|
||
this.initBattleMon();
|
||
this.checkAmuletCoin();
|
||
this.newStatus(this.player);
|
||
this.newStatus(this.enemy);
|
||
yield* this.sendOutMonText();
|
||
yield this.hud();
|
||
this.turn = 0; // SetPlayerTurn before SpikesDamage
|
||
yield* this.battleTurn();
|
||
if (trainer && this.options.battleTower) {
|
||
// WinTrainerBattle and LostBattle's .battle_tower: BattleTowerText
|
||
// instead of the prize money or the whiteout.
|
||
const won = (this.battleResult & 0x3f) === C.WIN;
|
||
if (won) {
|
||
this.winSounds();
|
||
this.playSound({ music: "victory" });
|
||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||
}
|
||
const text = won ? trainer.winText : trainer.lossText;
|
||
if (text) yield* this.say(text);
|
||
return { result: this.battleResult, evolvable: this.evolvable };
|
||
}
|
||
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
||
// WinTrainerBattle: StopDangerSound, PlayVictoryMusic.
|
||
this.winSounds();
|
||
this.playSound({ music: "victory" });
|
||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||
if (this.options.canLose) {
|
||
for (const mon of this.options.party) {
|
||
mon.hp = mon.stats[0];
|
||
mon.status = 0;
|
||
mon.pp = mon.moves.map((move, slot) => {
|
||
if (!move) return 0;
|
||
const ups = mon.pp[slot] >> 6,
|
||
base = this.options.data.move(move).pp;
|
||
return (
|
||
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
|
||
);
|
||
}) as PartyMon["pp"];
|
||
}
|
||
this.initBattleMon();
|
||
yield this.hud();
|
||
}
|
||
if (trainer.winText) yield* this.say(trainer.winText);
|
||
return {
|
||
result: this.battleResult,
|
||
...(yield* this.giveMoney(trainer.reward, this.amuletCoin)),
|
||
evolvable: this.evolvable,
|
||
};
|
||
}
|
||
if (trainer && this.options.canLose && trainer.lossText)
|
||
yield* this.say(trainer.lossText);
|
||
return { result: this.battleResult, evolvable: this.evolvable };
|
||
}
|
||
|
||
/** WinTrainerBattle.give_money: the reward (TrainerClassAttributes'
|
||
* base times the last Pokémon's level, which `reward` holds four times)
|
||
* is added four times, to Mom for as many quarters as her saving mode
|
||
* takes unless her account is full, and to the wallet for the rest. Each
|
||
* account stops at MAX_MONEY. The text shows four times the reward. */
|
||
private *giveMoney(
|
||
reward: number,
|
||
amuletCoin: boolean,
|
||
): Gen<{ prizeMoney: number; momMoney?: number }> {
|
||
const quarter = (reward / 4) * (amuletCoin ? 2 : 1);
|
||
const mom = this.options.mom ?? { saving: 0, money: 0 },
|
||
maxedOut = mom.money >= C.MAX_MONEY;
|
||
if (![0, 1, 2, 3].includes(mom.saving))
|
||
throw new UnsupportedBattle("Unsupported Mom saving mode.");
|
||
// .CheckMaxedOutMomMoney, then SOME|HALF counts as all four quarters.
|
||
const toMom = maxedOut ? 0 : mom.saving === 3 ? 4 : mom.saving;
|
||
this.numbers[RAM.battleReward] = quarter * 4;
|
||
yield* this.say(
|
||
maxedOut || !mom.saving
|
||
? BATTLE.GotMoneyForWinningText
|
||
: [
|
||
BATTLE.SentSomeToMomText,
|
||
BATTLE.SentHalfToMomText,
|
||
BATTLE.SentAllToMomText,
|
||
][mom.saving - 1],
|
||
);
|
||
return {
|
||
prizeMoney: quarter * (4 - toMom),
|
||
...(toMom ? { momMoney: quarter * toMom } : {}),
|
||
};
|
||
}
|
||
|
||
private *battleTurn(): Gen {
|
||
for (let turns = 0; turns < 1000; turns++) {
|
||
// CheckContestBattleOver: out of Park Balls, the battle ends a draw.
|
||
if (this.battleType === C.BATTLETYPE_CONTEST && !this.options.contest!.parkBalls) {
|
||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||
return;
|
||
}
|
||
this.curDamage = 0;
|
||
this.enemyIsSwitching = 0;
|
||
this.playerIsSwitching = 0;
|
||
this.player.justGotFrozen = 0;
|
||
this.enemy.justGotFrozen = 0;
|
||
yield* this.handleBerserkGene();
|
||
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(),
|
||
turn: this.turn ? 1 : 0,
|
||
damage: (move) => this.enemyAIDamage(move),
|
||
playerUsedMoves: this.playerUsedMoves,
|
||
playerHasReserve: this.options.party.some((mon, i) => i !== this.curBattleMon && mon.hp > 0),
|
||
enemyHasReserve: this.options.trainer.party.some((mon, i) => i !== this.curOTMon && mon.hp > 0),
|
||
enemyParty: this.options.trainer.party,
|
||
switchScore: (type, identified) => this.checkPlayerMoveTypeMatchups(type, identified),
|
||
conversion2Type: this.reader.byte(SYMBOLS.moves + 255 * 7 + 3),
|
||
genders: [
|
||
this.options.data.gender(this.partyMon.species, this.partyMon.dvs),
|
||
this.options.data.gender(this.enemySpecies, this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED) ? this.enemy.backupDvs : this.enemy.mon.dvs),
|
||
].map((gender) => gender === undefined ? -1 : gender === "male" ? 1 : 0) as [number, number],
|
||
});
|
||
this.enemy.moveNum = choice.index;
|
||
this.enemy.curMove = choice.move;
|
||
if (choice.enemyTurn) this.turn = 1;
|
||
else if (choice.playerTurn) this.turn = 0;
|
||
}
|
||
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;
|
||
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);
|
||
[attack, defense] = this.dittoMetalPowder(this.player, attack, defense);
|
||
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: this.weatherModifier(baseDamage, move.type, move.effect),
|
||
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 contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||
const input = yield contest
|
||
? { kind: "action", parkBalls: contest.parkBalls }
|
||
: this.options.battleTower
|
||
? { kind: "action", noItems: true }
|
||
: { kind: "action" };
|
||
if (!input || typeof input !== "object") continue;
|
||
if (input.action === "item" && this.options.battleTower) {
|
||
// BattleMenu_Pack.ItemsCantBeUsed.
|
||
yield* this.say(BATTLE.BattleText_ItemsCantBeUsedHere);
|
||
continue;
|
||
}
|
||
if (input.action === "item") {
|
||
// BattleMenu_Pack's .contest: PARKBALL× throws a Park Ball.
|
||
const used = contest
|
||
? yield* this.pokeBallEffect(C.PARK_BALL, -1)
|
||
: yield* this.battleMenuPack(input.item, input.slot);
|
||
if (this.wildMon) {
|
||
// BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the
|
||
// battle as a WIN, keeping the result's flag bits.
|
||
this.wildMon = 0;
|
||
this.battleResult &= 0xc0;
|
||
this.battleEnded = true;
|
||
return true;
|
||
}
|
||
if (!used) continue;
|
||
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
|
||
return false;
|
||
}
|
||
if (input.action === "pkmn") {
|
||
if (yield* this.battleMenuPKMN()) return false;
|
||
continue;
|
||
}
|
||
if (input.action === "run") {
|
||
const escaped = yield* this.tryToRunAway();
|
||
if (escaped) {
|
||
this.battleEnded = true;
|
||
return true;
|
||
}
|
||
// A failed escape uses the turn (BATTLEPLAYERACTION_USEITEM).
|
||
if (this.battlePlayerAction) return false;
|
||
continue;
|
||
}
|
||
this.battlePlayerAction = 0;
|
||
// ParsePlayerAction: Bide and Encore go on without MoveSelectionScreen.
|
||
if (this.fightKeepsMove) return false;
|
||
if (!(yield* this.moveSelection(input.move))) continue;
|
||
return false;
|
||
}
|
||
}
|
||
/** BattleMenu_Pack with BattlePack's USE on a Balls- or Items-pocket
|
||
* slot. Returns true if the item was used, which takes the player's turn
|
||
* (wItemEffectSucceeded shares wBattlePlayerAction's byte). */
|
||
private *battleMenuPack(item: number, slot: number): Gen<boolean> {
|
||
const data = this.options.data,
|
||
kind = data.itemPocket(item);
|
||
const pocket = kind === "ball" ? this.options.bag?.balls : kind === "item" ? this.options.bag?.items : undefined;
|
||
if (!pocket || !Number.isInteger(slot) || pocket[slot]?.item !== item) return false;
|
||
if (kind === "ball") return yield* this.pokeBallEffect(item, slot);
|
||
// _DoItemEffect names the item in wStringBuffer1 and wStringBuffer2.
|
||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||
// CheckItemContext: the low nibble of the item's help byte.
|
||
const context = this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 6) & 0x0f;
|
||
if (context < C.ITEMMENU_CURRENT) {
|
||
yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
||
return false;
|
||
}
|
||
// ItemEffects: the routine this item runs, chosen by its ROM address.
|
||
const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects));
|
||
const use = { item, slot };
|
||
if (context === C.ITEMMENU_PARTY) {
|
||
// BattlePack's .BattleField: anything but success stays in the pack.
|
||
switch (effect) {
|
||
case BATTLE.RestoreHPEffect:
|
||
return yield* this.statusHealerResult(yield* this.itemRestoreHP(use));
|
||
case BATTLE.StatusHealingEffect:
|
||
return yield* this.statusHealingEffect(use);
|
||
case BATTLE.FullRestoreEffect:
|
||
return yield* this.fullRestoreEffect(use);
|
||
case BATTLE.ReviveEffect:
|
||
return yield* this.reviveEffect(use, 0);
|
||
case BATTLE.RevivalHerbEffect:
|
||
return yield* this.reviveEffect(use, C.HAPPINESS_REVIVALHERB);
|
||
case BATTLE.EnergypowderEffect:
|
||
case BATTLE.EnergyRootEffect:
|
||
return yield* this.bitterRestoreHP(use, effect === BATTLE.EnergyRootEffect ? C.HAPPINESS_ENERGYROOT : C.HAPPINESS_BITTERPOWDER);
|
||
case BATTLE.HealPowderEffect:
|
||
return yield* this.healPowderEffect(use);
|
||
case BATTLE.BitterBerryEffect:
|
||
return yield* this.bitterBerryEffect(use);
|
||
case BATTLE.RestorePPEffect:
|
||
return yield* this.restorePPEffect(use);
|
||
}
|
||
} else if (context !== C.ITEMMENU_CURRENT) {
|
||
// BattlePack's .BattleOnly: failure gives Oak's warning, and the
|
||
// "won't have any effect" value 2 returns to the pack.
|
||
let result: number | undefined;
|
||
switch (effect) {
|
||
case BATTLE.XItemEffect:
|
||
result = yield* this.xItemEffect(use);
|
||
break;
|
||
case BATTLE.XAccuracyEffect:
|
||
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_X_ACCURACY);
|
||
break;
|
||
case BATTLE.GuardSpecEffect:
|
||
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_MIST);
|
||
break;
|
||
case BATTLE.DireHitEffect:
|
||
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_FOCUS_ENERGY);
|
||
break;
|
||
case BATTLE.PokeDollEffect:
|
||
result = yield* this.pokeDollEffect(use);
|
||
break;
|
||
}
|
||
if (result !== undefined) {
|
||
if (!result) yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
||
return result === 1;
|
||
}
|
||
}
|
||
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
|
||
}
|
||
/** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */
|
||
private *statusHealerResult(code: number): Gen<boolean> {
|
||
if (code === 1) yield* this.say(BATTLE.ItemWontHaveEffectText);
|
||
return code === 0;
|
||
}
|
||
/** UseItem_SelectMon: the party menu for an item. Returns the party
|
||
* index, or -1 when cancelled or an Egg was chosen. */
|
||
private *useItemSelectMon(): Gen<number> {
|
||
const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true);
|
||
if (slot < 0) return -1;
|
||
if (this.options.party[slot].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.badgeStatBoosts();
|
||
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,
|
||
contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
|
||
// .return_from_capture: the tutorial keeps the Dude's ball, the contest
|
||
// counts down its Park Balls, and other balls leave the pack.
|
||
const useBall = () => {
|
||
if (contest) contest.parkBalls = (contest.parkBalls - 1) & 0xff;
|
||
else if (!tutorial) this.tossItem("balls", item, slot);
|
||
};
|
||
// _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.");
|
||
// The contest's menu would go on to the player's move.
|
||
if (contest) throw new UnsupportedBattle("A Park 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;
|
||
}
|
||
// The Park Ball outside the Bug-Catching Contest only skips the throw
|
||
// animation's setup (a graphical bug).
|
||
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL, C.HEAVY_BALL, C.LEVEL_BALL,
|
||
C.LURE_BALL, C.FAST_BALL, C.FRIEND_BALL, C.MOON_BALL, C.LOVE_BALL, C.PARK_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],
|
||
);
|
||
useBall();
|
||
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. A transformed Pokémon is reloaded as
|
||
// a Ditto with its backup DVs and keeps that Ditto's moves (a kept bug
|
||
// for anything else).
|
||
if (this.battleType !== C.BATTLETYPE_FORCEITEM && this.random() >= percent(75) + 1) this.random();
|
||
if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) {
|
||
const moves = this.levelMoves(C.DITTO, this.enemy.mon.level);
|
||
Object.assign(this.enemy.mon, {
|
||
species: C.DITTO,
|
||
dvs: this.enemy.backupDvs,
|
||
moves,
|
||
pp: moves.map((move) => (move ? data.move(move).pp : 0)),
|
||
nickname: data.speciesName(C.DITTO),
|
||
});
|
||
this.wildMoves = [...moves];
|
||
this.wildPP = [...this.enemy.mon.pp];
|
||
}
|
||
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 (contest) {
|
||
yield* this.setCaughtContestMon(contest, species, level);
|
||
useBall();
|
||
return true;
|
||
}
|
||
if (this.battleType === C.BATTLETYPE_CELEBI) this.battleResult |= BATTLERESULT_CAUGHT_CELEBI;
|
||
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, item);
|
||
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: item === C.FRIEND_BALL ? C.FRIEND_BALL_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);
|
||
if (this.options.unown) updateUnownDex(this.options.unown, mon);
|
||
mon.nickname = yield* this.askNickname(name);
|
||
this.tossItem("balls", item, slot);
|
||
return true;
|
||
}
|
||
/** BugContest_SetCaughtContestMon: a second catch shows both Pokémon
|
||
* (DisplayCaughtContestMonStats) and asks to switch. The kept one is
|
||
* GeneratePartyMonStats' copy of the wild Pokémon, which has no caught
|
||
* data; the contest names it later. */
|
||
private *setCaughtContestMon(contest: BugContest, species: number, level: number): Gen {
|
||
const data = this.options.data,
|
||
enemy = this.enemy.mon;
|
||
if (contest.mon) {
|
||
const stock = contest.mon;
|
||
this.strings[RAM.stringBuffer1] = data.speciesName(stock.species);
|
||
yield* this.say(BATTLE["DisplayAlreadyCaughtText.ContestAlreadyCaughtText"]);
|
||
const answer = yield {
|
||
kind: "contestswitch",
|
||
text: readTextEnd(this.reader, BATTLE.ContestAskSwitchText, this.context()).text,
|
||
stock: { name: data.speciesName(stock.species), level: stock.level, maxHp: stock.stats[0] },
|
||
caught: { name: enemy.nickname, level: enemy.level, maxHp: enemy.maxHp },
|
||
};
|
||
if (!answer) return;
|
||
}
|
||
contest.mon = {
|
||
species,
|
||
item: enemy.item,
|
||
moves: [...this.wildMoves] as PartyMon["moves"],
|
||
otId: this.options.playerId,
|
||
ot: this.options.playerName,
|
||
nickname: data.speciesName(species),
|
||
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: 0,
|
||
level,
|
||
status: enemy.status,
|
||
hp: enemy.hp,
|
||
stats: calcStats(data, species, enemy.dvs, level),
|
||
};
|
||
this.strings[RAM.stringBuffer1] = data.speciesName(species);
|
||
yield* this.say(BATTLE["BugContest_SetCaughtContestMon.ContestCaughtMonText"]);
|
||
}
|
||
/** 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.kurtBallRate(item, 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) };
|
||
}
|
||
|
||
/** BallMultiplierFunctionTable's Kurt's balls, which depend on the battle.
|
||
* Their documented bugs are kept: the Heavy Ball reads three species'
|
||
* weights from the wrong bank, the Fast Ball checks only FleeMons' first
|
||
* three bytes, the Moon Ball compares the wrong byte with BURN_HEAL and
|
||
* the Love Ball wants the same gender. */
|
||
private kurtBallRate(item: number, rate: number): number {
|
||
const r = this.reader,
|
||
data = this.options.data,
|
||
// Each doubling saturates at 255, as the original's carry checks do.
|
||
double = (times: number) => {
|
||
for (let i = 0; i < times; i++) rate = Math.min(0xff, rate << 1);
|
||
return rate;
|
||
};
|
||
switch (item) {
|
||
case C.HEAVY_BALL: {
|
||
// wEnemyMonSpecies, which Transform changes.
|
||
const species = this.enemy.mon.species;
|
||
const bank = r.byte(BATTLE["HeavyBall_GetDexEntryBank.PokedexEntryBanks"] + ((species >> 6) & 3));
|
||
let pointer = r.word(BATTLE.PokedexDataPointerTable + (species - 1) * 2);
|
||
// Kadabra's and Sunflora's scans run off the wrong bank into VRAM.
|
||
const at = (address: number) => {
|
||
if (address < 0x4000 || address > 0x7fff)
|
||
throw new UnsupportedBattle("The Heavy Ball reads this Pokémon's weight from VRAM.");
|
||
return r.byte(r.address(bank, address));
|
||
};
|
||
while (at(pointer++) !== 0x50);
|
||
let hl = (at(pointer + 2) | (at(pointer + 3) << 8)) >> 1;
|
||
let bc = hl >> 4;
|
||
hl = (hl - bc) & 0xffff;
|
||
bc >>= 1;
|
||
hl = (hl - bc) & 0xffff;
|
||
const weight = hl >> 8;
|
||
if (weight < 4) return rate >= 20 ? rate - 20 : 1;
|
||
let entry = BATTLE["HeavyBallMultiplier.WeightsTable"];
|
||
while (weight >= r.byte(entry)) entry += 2;
|
||
return Math.min(0xff, rate + r.byte(entry + 1));
|
||
}
|
||
case C.LEVEL_BALL: {
|
||
let player = this.player.mon.level;
|
||
const enemy = this.enemy.mon.level;
|
||
for (let i = 0; i < 3; i++) {
|
||
if (enemy >= player) break;
|
||
double(1);
|
||
if (rate === 0xff) break;
|
||
player >>= 1;
|
||
}
|
||
return rate;
|
||
}
|
||
case C.LURE_BALL:
|
||
if (this.battleType !== C.BATTLETYPE_FISH) return rate;
|
||
return rate * 2 > 0xff ? 0xff : Math.min(0xff, rate * 3);
|
||
case C.FAST_BALL:
|
||
// The loop counts bytes rather than lists, stopping after three.
|
||
if (r.slice(BATTLE.FleeMons, 3).includes(this.enemySpecies)) double(2);
|
||
return rate;
|
||
case C.MOON_BALL: {
|
||
const p = r.near(SYMBOLS.evosAttacks + (this.enemySpecies - 1) * 2, r.bank(SYMBOLS.evosAttacks));
|
||
if (r.byte(p) === C.EVOLVE_ITEM && r.byte(p + 3) === C.MOON_STONE_RED) double(2);
|
||
return rate;
|
||
}
|
||
case C.LOVE_BALL: {
|
||
if (this.enemySpecies !== this.partyMon.species) return rate;
|
||
const player = data.gender(this.partyMon.species, this.partyMon.dvs),
|
||
wild = data.gender(this.enemySpecies, this.enemy.mon.dvs);
|
||
if (player && wild && player === wild) double(3);
|
||
return rate;
|
||
}
|
||
}
|
||
return rate;
|
||
}
|
||
|
||
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, item: number): 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: item === C.FRIEND_BALL ? C.FRIEND_BALL_HAPPINESS : C.BASE_HAPPINESS,
|
||
pokerus: 0,
|
||
caughtData: this.options.caughtData!(level),
|
||
level,
|
||
};
|
||
restorePP(data, mon);
|
||
box.unshift(mon);
|
||
if (this.options.unown) updateUnownDex(this.options.unown, mon, false);
|
||
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
|
||
mon.nickname = yield* this.askNickname(name);
|
||
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
|
||
yield* this.say(BATTLE.BallSentToPCText);
|
||
}
|
||
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
|
||
* selected slot if it matches, otherwise from the first matching slot. */
|
||
|
||
/** MoveSelectionScreen's checks for a chosen move. */
|
||
private *moveSelection(index: number): Gen<boolean> {
|
||
const mon = this.player.mon;
|
||
if (!this.checkPlayerHasUsableMoves()) {
|
||
yield* this.say(BATTLE.BattleText_MonHasNoMovesLeft);
|
||
this.player.curMove = C.STRUGGLE;
|
||
return true;
|
||
}
|
||
if (!Number.isInteger(index) || index < 0 || index > 3 || !mon.moves[index]) return false;
|
||
if (this.player.disableCount && index === ((this.player.disableCount >> 4) & 0xf) - 1) {
|
||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(mon.moves[index]);
|
||
yield* this.say(BATTLE.BattleText_TheMoveIsDisabled);
|
||
return false;
|
||
}
|
||
if (!(mon.pp[index] & C.PP_MASK)) {
|
||
yield* this.say(BATTLE.BattleText_TheresNoPPLeftForThisMove);
|
||
return false;
|
||
}
|
||
this.player.moveNum = index;
|
||
this.player.curMove = mon.moves[index];
|
||
return true;
|
||
}
|
||
private checkPlayerHasUsableMoves() {
|
||
const side = this.player;
|
||
if (side.disableCount) {
|
||
const disabled = ((side.disableCount >> 4) & 0xf) - 1;
|
||
return side.mon.pp.some((pp, index) => index !== disabled && pp & C.PP_MASK);
|
||
}
|
||
return Boolean(side.mon.pp.reduce((a, b) => a | b, 0) & C.PP_MASK);
|
||
}
|
||
private checkPlayerLockedIn(): boolean {
|
||
const player = this.player;
|
||
if (player.has(SUB4, C.SUBSTATUS_RECHARGE)) return true;
|
||
this.enemy.reset(SUB3, C.SUBSTATUS_FLINCHED);
|
||
player.reset(SUB3, C.SUBSTATUS_FLINCHED);
|
||
if (player.sub[SUB3] & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE))) return true;
|
||
return player.has(SUB1, C.SUBSTATUS_ROLLOUT);
|
||
}
|
||
/** ParsePlayerAction then ParseEnemyAction. Returns false to re-open the menu. */
|
||
private *parsePlayerAction(): Gen<boolean> {
|
||
const player = this.player;
|
||
// A locked-in or biding Pokémon keeps last turn's move and move struct.
|
||
if (this.checkPlayerLockedIn()) return this.playerLockedIn();
|
||
// An encored Pokémon takes its last move, whatever the action.
|
||
const encored = player.has(SUB5, C.SUBSTATUS_ENCORED);
|
||
if (encored) player.curMove = player.lastMove;
|
||
else if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) {
|
||
// PlayerSwitch already ran ParseEnemyAction.
|
||
player.furyCutterCount = 0;
|
||
player.protectCount = 0;
|
||
player.rageCounter = 0;
|
||
player.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
return true;
|
||
}
|
||
if (encored || this.battlePlayerAction === 0) {
|
||
if (!encored && player.has(SUB3, C.SUBSTATUS_BIDE)) return this.playerLockedIn();
|
||
this.turn = 0;
|
||
this.updateMoveData();
|
||
player.charging = 0;
|
||
if (player.move.effect !== C.EFFECT_FURY_CUTTER) player.furyCutterCount = 0;
|
||
if (player.move.effect !== C.EFFECT_RAGE) {
|
||
player.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
player.rageCounter = 0;
|
||
}
|
||
if (player.move.effect !== C.EFFECT_PROTECT && player.move.effect !== C.EFFECT_ENDURE)
|
||
player.protectCount = 0;
|
||
} else {
|
||
player.reset(SUB3, C.SUBSTATUS_BIDE);
|
||
return this.playerLockedIn();
|
||
}
|
||
this.parseEnemyAction();
|
||
return true;
|
||
}
|
||
/** ParsePlayerAction's .locked_in. */
|
||
private playerLockedIn() {
|
||
const player = this.player;
|
||
player.furyCutterCount = 0;
|
||
player.protectCount = 0;
|
||
player.rageCounter = 0;
|
||
player.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
this.parseEnemyAction();
|
||
return true;
|
||
}
|
||
/** ParseEnemyAction: use the trainer AI selection or a random wild move. */
|
||
private parseEnemyAction() {
|
||
if (this.enemyIsSwitching) return;
|
||
const enemy = this.enemy,
|
||
mon = enemy.mon;
|
||
const encored = enemy.has(SUB5, C.SUBSTATUS_ENCORED);
|
||
if (!encored && this.checkEnemyLockedIn()) {
|
||
// ResetVarsForSubstatusRage: the move and its struct stay.
|
||
enemy.furyCutterCount = 0;
|
||
enemy.protectCount = 0;
|
||
enemy.rageCounter = 0;
|
||
enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
return;
|
||
}
|
||
let move: number = encored ? enemy.lastMove : C.STRUGGLE;
|
||
const usable = !encored && mon.moves.some(
|
||
(candidate, slot) =>
|
||
candidate &&
|
||
candidate !== enemy.disabledMove &&
|
||
mon.pp[slot] & C.PP_MASK,
|
||
);
|
||
if (encored) {
|
||
// The move number stays the AI's or last turn's choice.
|
||
} else if (this.options.trainer && usable) {
|
||
move = enemy.curMove;
|
||
} else
|
||
for (let slot = 0; slot < 4; slot++) {
|
||
const candidate = mon.moves[slot];
|
||
if (!candidate) break;
|
||
if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK))
|
||
continue;
|
||
for (let guard = 0; guard < 4096; guard++) {
|
||
const choice = this.random() & 3;
|
||
if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue;
|
||
if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue;
|
||
enemy.moveNum = choice;
|
||
move = mon.moves[choice];
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
enemy.curMove = move;
|
||
this.turn = 1;
|
||
this.updateMoveData();
|
||
if (!this.checkEnemyLockedIn()) enemy.charging = 0;
|
||
if (enemy.move.effect !== C.EFFECT_FURY_CUTTER) enemy.furyCutterCount = 0;
|
||
if (enemy.move.effect !== C.EFFECT_RAGE) {
|
||
enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
enemy.rageCounter = 0;
|
||
}
|
||
if (enemy.move.effect !== C.EFFECT_PROTECT && enemy.move.effect !== C.EFFECT_ENDURE)
|
||
enemy.protectCount = 0;
|
||
}
|
||
private checkEnemyLockedIn() {
|
||
const enemy = this.enemy;
|
||
return (
|
||
enemy.has(SUB4, C.SUBSTATUS_RECHARGE) ||
|
||
Boolean(
|
||
enemy.sub[SUB3] &
|
||
((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE)),
|
||
) ||
|
||
enemy.has(SUB1, C.SUBSTATUS_ROLLOUT)
|
||
);
|
||
}
|
||
/** UpdateMoveData: the move struct for the current turn's move. */
|
||
private updateMoveData() {
|
||
const side = this.user;
|
||
const move = this.options.data.move(side.curMove);
|
||
side.move = {
|
||
anim: move.id,
|
||
effect: move.effect,
|
||
power: move.power,
|
||
type: move.type,
|
||
accuracy: move.accuracy,
|
||
pp: move.pp,
|
||
chance: move.chance,
|
||
};
|
||
// GetMoveName fills wStringBuffer1; CopyName1 copies it to wStringBuffer2.
|
||
const name = this.options.data.moveName(side.curMove);
|
||
this.strings[RAM.stringBuffer1] = name;
|
||
this.strings[RAM.stringBuffer2] = name;
|
||
}
|
||
|
||
/** DetermineMoveOrder: true if the player moves first. */
|
||
private determineMoveOrder(): boolean {
|
||
if (this.battlePlayerAction) return true;
|
||
const player = this.data.priority(
|
||
this.options.data.move(this.player.curMove).effect,
|
||
C.BASE_PRIORITY,
|
||
);
|
||
const enemy = this.data.priority(
|
||
this.options.data.move(this.enemy.curMove).effect,
|
||
C.BASE_PRIORITY,
|
||
);
|
||
const priority = (move: number, value: number) =>
|
||
move === C.VITAL_THROW ? 0 : value;
|
||
const playerPriority = priority(this.player.curMove, player),
|
||
enemyPriority = priority(this.enemy.curMove, enemy);
|
||
if (playerPriority !== enemyPriority) return playerPriority > enemyPriority;
|
||
const playerItem = this.data.heldEffect(this.player.mon.item),
|
||
enemyItem = this.data.heldEffect(this.enemy.mon.item);
|
||
if (playerItem.effect === C.HELD_QUICK_CLAW && enemyItem.effect === C.HELD_QUICK_CLAW) {
|
||
if (this.random() < enemyItem.parameter) return false;
|
||
if (this.random() < playerItem.parameter) return true;
|
||
} else if (playerItem.effect === C.HELD_QUICK_CLAW) {
|
||
if (this.random() < playerItem.parameter) return true;
|
||
} else if (enemyItem.effect === C.HELD_QUICK_CLAW) {
|
||
if (this.random() < enemyItem.parameter) return false;
|
||
}
|
||
const playerSpeed = this.player.mon.stats[C.SPEED],
|
||
enemySpeed = this.enemy.mon.stats[C.SPEED];
|
||
if (playerSpeed !== enemySpeed) return playerSpeed > enemySpeed;
|
||
return this.random() < percent(50) + 1;
|
||
}
|
||
|
||
private *battlePlayerFirst(): Gen {
|
||
this.enemyGoesFirst = 0;
|
||
this.turn = 1;
|
||
const switched = yield* this.aiSwitchOrTryItem();
|
||
yield* this.doTurnFor(0);
|
||
if (this.forcedSwitch) return this.endForced();
|
||
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.forcedSwitch) return this.endForced();
|
||
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.forcedSwitch) return this.endForced();
|
||
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.forcedSwitch) return this.endForced();
|
||
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;
|
||
}
|
||
/** BattleTurn quits after a move set wForcedSwitch. */
|
||
private endForced() {
|
||
this.battleEnded = true;
|
||
}
|
||
/** 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> {
|
||
// The contest always lets the player go; the event battles never do.
|
||
if (this.battleType === C.BATTLETYPE_CONTEST) {
|
||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||
this.playSound({ stopDanger: true });
|
||
this.playSound({ sfx: [SFX_RUN] });
|
||
this.playSound({ stopDanger: true });
|
||
this.playSound({ sfx: [SFX_RUN] });
|
||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||
return true;
|
||
}
|
||
if (this.cannotFlee()) {
|
||
yield* this.say(BATTLE.BattleText_CantEscape);
|
||
return false;
|
||
}
|
||
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) {
|
||
this.turn = 0;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(this.player.mon.item);
|
||
yield* this.say(BATTLE.BattleText_UserFledUsingAStringBuffer1);
|
||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||
this.playSound({ stopDanger: true });
|
||
this.playSound({ sfx: [SFX_RUN] });
|
||
this.playSound({ stopDanger: true });
|
||
this.playSound({ sfx: [SFX_RUN] });
|
||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||
return true;
|
||
}
|
||
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;
|
||
this.playSound({ stopDanger: true });
|
||
this.playSound({ sfx: [SFX_RUN] });
|
||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||
return true;
|
||
}
|
||
|
||
// ------------------------------------------------------ move execution
|
||
|
||
/** DoTurn: CheckTurn, then the move's effect script. */
|
||
private *doTurn(): Gen {
|
||
this.turnEnded = 0;
|
||
yield* this.checkTurn();
|
||
if (this.turnEnded) return;
|
||
this.updateMoveData();
|
||
yield* this.doMove();
|
||
}
|
||
/** DoMove: run the user's move effect script. */
|
||
private *doMove(): Gen {
|
||
this.script = this.data.effectScript(this.user.move.effect);
|
||
this.scriptIndex = 0;
|
||
for (let steps = 0; steps < 256; steps++) {
|
||
const command = this.script[this.scriptIndex++];
|
||
if (command >= COMMAND.endturn) return;
|
||
yield* this.command(command);
|
||
}
|
||
throw new UnsupportedBattle("Move effect script did not end.");
|
||
}
|
||
/** EndMoveEffect: stop reading the effect script. */
|
||
private endMoveEffect() {
|
||
this.script.splice(this.scriptIndex, 3, 0xff, 0xff, 0xff);
|
||
}
|
||
private *endTurn(): Gen {
|
||
this.turnEnded = 1;
|
||
this.curDamage = 0;
|
||
}
|
||
|
||
private *checkTurn(): Gen {
|
||
const user = this.user,
|
||
mon = user.mon;
|
||
if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE
|
||
this.attackMissed = 0;
|
||
this.effectFailed = 0;
|
||
this.animParam = 0;
|
||
this.alreadyDisobeyed = 0;
|
||
this.alreadyFailed = 0;
|
||
this.typeModifier = C.EFFECTIVE;
|
||
if (user.has(SUB4, C.SUBSTATUS_RECHARGE)) {
|
||
user.reset(SUB4, C.SUBSTATUS_RECHARGE);
|
||
yield* this.say(BATTLE.MustRechargeText);
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
if (mon.status & C.SLP_MASK) {
|
||
mon.status = (mon.status & ~C.SLP_MASK) | (((mon.status & C.SLP_MASK) - 1) & C.SLP_MASK);
|
||
// The player checks the sleep counter; the enemy checks the whole status.
|
||
const awake = this.turn === 0 ? !(mon.status & C.SLP_MASK) : mon.status === 0;
|
||
if (awake) {
|
||
yield* this.say(BATTLE.WokeUpText);
|
||
this.cantMove();
|
||
this.updateUserInParty();
|
||
yield this.hud();
|
||
user.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
|
||
} else {
|
||
yield* this.say(BATTLE.FastAsleepText);
|
||
if (user.curMove !== C.SNORE && user.curMove !== C.SLEEP_TALK) {
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
}
|
||
}
|
||
if (mon.status & (1 << C.FRZ) && user.curMove !== C.FLAME_WHEEL && user.curMove !== C.SACRED_FIRE) {
|
||
yield* this.say(BATTLE.FrozenSolidText);
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
if (user.has(SUB3, C.SUBSTATUS_FLINCHED)) {
|
||
user.reset(SUB3, C.SUBSTATUS_FLINCHED);
|
||
yield* this.say(BATTLE.FlinchedText);
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
if (user.disableCount) {
|
||
user.disableCount = (user.disableCount - 1) & 0xff;
|
||
if (!(user.disableCount & 0xf)) {
|
||
user.disableCount = 0;
|
||
user.disabledMove = 0;
|
||
yield* this.say(BATTLE.DisabledNoMoreText);
|
||
}
|
||
}
|
||
if (user.has(SUB3, C.SUBSTATUS_CONFUSED)) {
|
||
user.confuseCount = (user.confuseCount - 1) & 0xff;
|
||
if (!user.confuseCount) {
|
||
user.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||
yield* this.say(BATTLE.ConfusedNoMoreText);
|
||
} else {
|
||
yield* this.say(BATTLE.IsConfusedText);
|
||
if (this.random() < percent(50) + 1) {
|
||
user.sub[SUB3] &= 1 << C.SUBSTATUS_CONFUSED;
|
||
yield* this.hitConfusion();
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
}
|
||
}
|
||
if (user.has(SUB1, C.SUBSTATUS_IN_LOVE)) {
|
||
yield* this.say(BATTLE.InLoveWithText);
|
||
if (this.random() >= percent(50) + 1) {
|
||
yield* this.say(BATTLE.InfatuationText);
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
}
|
||
if (user.disabledMove && user.disabledMove === user.curMove) {
|
||
user.reset(SUB3, C.SUBSTATUS_CHARGED);
|
||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(user.curMove);
|
||
yield* this.say(BATTLE.DisabledMoveText);
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
if (mon.status & (1 << C.PAR) && this.random() < percent(25)) {
|
||
yield* this.say(BATTLE.FullyParalyzedText);
|
||
this.cantMove();
|
||
return yield* this.endTurn();
|
||
}
|
||
}
|
||
/** CantMove: moves that span turns are interrupted. */
|
||
private cantMove() {
|
||
const user = this.user;
|
||
user.reset(SUB1, C.SUBSTATUS_ROLLOUT);
|
||
user.sub[SUB3] &= ~((1 << C.SUBSTATUS_BIDE) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_CHARGED));
|
||
user.furyCutterCount = 0;
|
||
if (user.move.anim === C.FLY || user.move.anim === C.DIG) {
|
||
user.reset(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||
user.reset(SUB3, C.SUBSTATUS_FLYING);
|
||
}
|
||
}
|
||
/** HitConfusion / HitSelfInConfusion: a typeless 40-power hit on itself. */
|
||
private *hitConfusion(): Gen {
|
||
yield* this.say(BATTLE.HurtItselfText);
|
||
this.criticalHit = 0;
|
||
const user = this.user,
|
||
mon = user.mon;
|
||
this.curDamage = 0;
|
||
let defense = mon.stats[C.DEFENSE];
|
||
if (user.screens & (1 << C.SCREENS_REFLECT)) defense *= 2;
|
||
const [attack, def] = this.truncate(mon.stats[C.ATTACK], defense);
|
||
this.damageCalc(40, mon.level, attack, def);
|
||
this.doDamage(this.user);
|
||
yield this.hud();
|
||
}
|
||
|
||
private *command(command: number): Gen {
|
||
switch (command) {
|
||
case COMMAND.checkobedience:
|
||
return yield* 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:
|
||
return this.lowerSub();
|
||
case COMMAND.raisesub:
|
||
return this.raiseSub();
|
||
case COMMAND.lowersubnoanim:
|
||
case COMMAND.raisesubnoanim:
|
||
case COMMAND.movedelay:
|
||
return; // Presentation only.
|
||
case COMMAND.moveanimnosub:
|
||
return this.moveAnimNoSub();
|
||
case COMMAND.moveanim:
|
||
this.lowerSub();
|
||
this.moveAnimNoSub();
|
||
return this.raiseSub();
|
||
case COMMAND.statupanim:
|
||
case COMMAND.statdownanim:
|
||
// BattleCommand_StatUpDownAnim
|
||
if (!this.attackMissed) this.animParam = 0;
|
||
return;
|
||
case COMMAND.cleartext:
|
||
return; // An empty BattleTextbox.
|
||
case COMMAND.startloop:
|
||
this.user.rolloutCount = 0;
|
||
return;
|
||
case COMMAND.endloop:
|
||
return yield* this.endLoop();
|
||
case COMMAND.supereffectivelooptext:
|
||
if (this.user.has(SUB3, C.SUBSTATUS_IN_LOOP)) return;
|
||
return yield* this.superEffectiveText();
|
||
case COMMAND.triplekick:
|
||
// The damage once more for each kick so far, up to $ffff.
|
||
this.curDamage = Math.min(0xffff, this.curDamage * (this.animParam + 1));
|
||
return;
|
||
case COMMAND.kickcounter:
|
||
this.animParam = (this.animParam + 1) & 0xff;
|
||
return;
|
||
case COMMAND.beatup:
|
||
return yield* this.beatUp();
|
||
case COMMAND.beatupfailtext:
|
||
return yield* this.beatUpFailText();
|
||
case COMMAND.constantdamage:
|
||
return this.constantDamage();
|
||
case COMMAND.resettypematchup:
|
||
this.resetTypeMatchup();
|
||
return;
|
||
case COMMAND.ohko:
|
||
return yield* this.ohko();
|
||
case COMMAND.counter:
|
||
return this.counter(false);
|
||
case COMMAND.mirrorcoat:
|
||
return this.counter(true);
|
||
case COMMAND.payday:
|
||
return yield* this.payDay();
|
||
case COMMAND.thief:
|
||
return yield* this.thief();
|
||
case COMMAND.selfdestruct:
|
||
return yield* this.selfdestruct();
|
||
case COMMAND.eatdream:
|
||
return yield* this.drainTarget(BATTLE.DreamEatenText);
|
||
case COMMAND.freezetarget:
|
||
return yield* this.freezeTarget();
|
||
case COMMAND.tristatuschance:
|
||
return yield* this.triStatusChance();
|
||
case COMMAND.defrost:
|
||
return yield* this.defrostUser();
|
||
case COMMAND.confusetarget:
|
||
return yield* this.confuseTarget();
|
||
case COMMAND.getmagnitude:
|
||
return yield* this.getMagnitude();
|
||
case COMMAND.present:
|
||
return yield* this.present();
|
||
case COMMAND.hiddenpower:
|
||
return this.hiddenPower();
|
||
case COMMAND.happinesspower:
|
||
// BattleCommand_HappinessPower: happiness × 10 / 25, so a
|
||
// Pokémon below 3 happiness deals no damage (a kept bug).
|
||
this.damageArgs.power = Math.floor((this.user.mon.happiness * 10) / 25) & 0xff;
|
||
return;
|
||
case COMMAND.frustrationpower:
|
||
this.damageArgs.power = Math.floor(((0xff - this.user.mon.happiness) * 10) / 25) & 0xff;
|
||
return;
|
||
case COMMAND.falseswipe:
|
||
this.falseSwipe();
|
||
return;
|
||
case COMMAND.doubleundergrounddamage:
|
||
if (this.target.has(SUB3, C.SUBSTATUS_UNDERGROUND)) this.curDamage = Math.min(0xffff, this.curDamage * 2);
|
||
return;
|
||
case COMMAND.doubleminimizedamage:
|
||
if (this.target.minimized) this.curDamage = Math.min(0xffff, this.curDamage * 2);
|
||
return;
|
||
case COMMAND.thunderaccuracy:
|
||
// Overwrites the move's accuracy; rain's 100% is redundant with
|
||
// CheckHit's own rain check.
|
||
if (this.weather === C.WEATHER_RAIN) this.user.move.accuracy = 0xff;
|
||
else if (this.weather === C.WEATHER_SUN) this.user.move.accuracy = percent(50) + 1;
|
||
return;
|
||
case COMMAND.fakeout:
|
||
return this.fakeOut();
|
||
case COMMAND.checkcharge:
|
||
return this.checkCharge();
|
||
case COMMAND.charge:
|
||
return yield* this.charge();
|
||
case COMMAND.skipsuncharge:
|
||
// A sunny day skips the charging turn.
|
||
if (this.weather === C.WEATHER_SUN) this.skipTo(COMMAND.charge);
|
||
return;
|
||
case COMMAND.rechargenextturn:
|
||
this.user.set(SUB4, C.SUBSTATUS_RECHARGE);
|
||
return;
|
||
case COMMAND.checkrampage:
|
||
return this.checkRampage();
|
||
case COMMAND.rampage:
|
||
return this.rampage();
|
||
case COMMAND.checkrollout:
|
||
return this.checkRollout();
|
||
case COMMAND.rolloutpower:
|
||
return this.rolloutPower();
|
||
case COMMAND.furycutter:
|
||
return this.furyCutter();
|
||
case COMMAND.storeenergy:
|
||
return yield* this.storeEnergy();
|
||
case COMMAND.unleashenergy:
|
||
return this.unleashEnergy();
|
||
case COMMAND.bidefailtext:
|
||
if (!this.attackMissed) return;
|
||
return yield* this.say(this.typeModifier & C.EFFECTIVENESS_MASK ? BATTLE.ButItFailedText : BATTLE.DoesntAffectText);
|
||
case COMMAND.traptarget:
|
||
return yield* this.trapTarget();
|
||
case COMMAND.lockon:
|
||
return yield* this.lockOn();
|
||
case COMMAND.leechseed:
|
||
return yield* this.leechSeed();
|
||
case COMMAND.nightmare:
|
||
return yield* this.nightmare();
|
||
case COMMAND.curse:
|
||
return yield* this.curse();
|
||
case COMMAND.destinybond:
|
||
this.user.set(SUB5, C.SUBSTATUS_DESTINY_BOND);
|
||
return yield* this.say(BATTLE.DestinyBondEffectText);
|
||
case COMMAND.protect:
|
||
case COMMAND.endure:
|
||
return yield* this.protect(command === COMMAND.endure);
|
||
case COMMAND.spite:
|
||
return yield* this.spite();
|
||
case COMMAND.healbell:
|
||
return yield* this.healBell();
|
||
case COMMAND.disable:
|
||
return yield* this.disable();
|
||
case COMMAND.encore:
|
||
return yield* this.encore();
|
||
case COMMAND.attract:
|
||
return yield* this.attract();
|
||
case COMMAND.mist:
|
||
case COMMAND.focusenergy: {
|
||
const mist = command === COMMAND.mist,
|
||
bit = mist ? C.SUBSTATUS_MIST : C.SUBSTATUS_FOCUS_ENERGY;
|
||
if (this.user.has(SUB4, bit)) return yield* this.say(BATTLE.ButItFailedText);
|
||
this.user.set(SUB4, bit);
|
||
return yield* this.say(mist ? BATTLE.MistText : BATTLE.GettingPumpedText);
|
||
}
|
||
case COMMAND.resetstats:
|
||
return yield* this.resetStats();
|
||
case COMMAND.heal:
|
||
return yield* this.heal();
|
||
case COMMAND.arenatrap:
|
||
// Mean Look marks the user: its opponent can't run or switch.
|
||
if (this.hiddenOpponent() || this.user.has(SUB5, C.SUBSTATUS_CANT_RUN))
|
||
return yield* this.say(BATTLE.ButItFailedText);
|
||
this.user.set(SUB5, C.SUBSTATUS_CANT_RUN);
|
||
return yield* this.say(BATTLE.CantEscapeNowText);
|
||
case COMMAND.painsplit:
|
||
return yield* this.painSplit();
|
||
case COMMAND.bellydrum:
|
||
return yield* this.bellyDrum();
|
||
case COMMAND.psychup:
|
||
return yield* this.psychUp();
|
||
case COMMAND.safeguard: {
|
||
const user = this.user;
|
||
if (user.screens & (1 << C.SCREENS_SAFEGUARD)) return yield* this.say(BATTLE.ButItFailedText);
|
||
user.screens |= 1 << C.SCREENS_SAFEGUARD;
|
||
user.safeguardCount = 5;
|
||
return yield* this.say(BATTLE.CoveredByVeilText);
|
||
}
|
||
case COMMAND.perishsong:
|
||
return yield* this.perishSong();
|
||
case COMMAND.spikes: {
|
||
// Spikes go on the target's side.
|
||
const target = this.target;
|
||
if (target.screens & (1 << C.SCREENS_SPIKES)) return yield* this.failMove();
|
||
target.screens |= 1 << C.SCREENS_SPIKES;
|
||
return yield* this.say(BATTLE.SpikesText);
|
||
}
|
||
case COMMAND.clearhazards:
|
||
return yield* this.clearHazards();
|
||
case COMMAND.allstatsup:
|
||
// AncientPower: each stat in turn, without a failure text.
|
||
for (let stat = C.ATTACK; stat <= C.SP_DEFENSE; stat++) {
|
||
this.attackMissed = 0;
|
||
this.statUp(stat);
|
||
yield* this.statUpMessage();
|
||
}
|
||
return;
|
||
case COMMAND.checkfuturesight:
|
||
return this.checkFutureSight();
|
||
case COMMAND.futuresight:
|
||
return yield* this.futureSight();
|
||
case COMMAND.substitute:
|
||
return yield* this.substitute();
|
||
case COMMAND.transform:
|
||
return yield* this.transform();
|
||
case COMMAND.mimic:
|
||
return yield* this.mimic();
|
||
case COMMAND.metronome:
|
||
return yield* this.metronome();
|
||
case COMMAND.mirrormove:
|
||
return yield* this.mirrorMove();
|
||
case COMMAND.sleeptalk:
|
||
return yield* this.sleepTalk();
|
||
case COMMAND.snore:
|
||
// Fails unless the user is asleep.
|
||
if (this.user.mon.status & C.SLP_MASK) return;
|
||
this.curDamage = 0;
|
||
this.attackMissed = 1;
|
||
yield* this.failMove();
|
||
return this.endMoveEffect();
|
||
case COMMAND.conversion:
|
||
return yield* this.conversion();
|
||
case COMMAND.conversion2:
|
||
return yield* this.conversion2();
|
||
case COMMAND.sketch:
|
||
return yield* this.sketch();
|
||
case COMMAND.batonpass:
|
||
return yield* this.batonPass();
|
||
case COMMAND.forceswitch:
|
||
return yield* this.forceSwitch();
|
||
case COMMAND.teleport:
|
||
return yield* this.teleport();
|
||
case COMMAND.startrain:
|
||
case COMMAND.startsun:
|
||
case COMMAND.startsandstorm: {
|
||
// Rain Dance and Sunny Day renew their weather; Sandstorm fails.
|
||
const weather =
|
||
command === COMMAND.startrain ? C.WEATHER_RAIN : command === COMMAND.startsun ? C.WEATHER_SUN : C.WEATHER_SANDSTORM;
|
||
if (weather === C.WEATHER_SANDSTORM && this.weather === weather) return yield* this.say(BATTLE.ButItFailedText);
|
||
this.weather = weather;
|
||
this.weatherCount = 5;
|
||
return yield* this.say(
|
||
weather === C.WEATHER_RAIN ? BATTLE.DownpourText : weather === C.WEATHER_SUN ? BATTLE.SunGotBrightText : BATTLE.SandstormBrewedText,
|
||
);
|
||
}
|
||
case COMMAND.pursuit:
|
||
// Double damage against a Pokémon being withdrawn.
|
||
if (this.turn === 0 ? this.enemyIsSwitching : this.playerIsSwitching)
|
||
this.curDamage = Math.min(0xffff, this.curDamage * 2);
|
||
return;
|
||
case COMMAND.failuretext:
|
||
return yield* this.failureText();
|
||
case COMMAND.applydamage:
|
||
return yield* this.applyDamage();
|
||
case COMMAND.criticaltext:
|
||
return yield* this.criticalText();
|
||
case COMMAND.supereffectivetext:
|
||
return yield* this.superEffectiveText();
|
||
case COMMAND.checkfaint:
|
||
return yield* this.checkFaint();
|
||
case COMMAND.buildopponentrage:
|
||
return yield* this.buildOpponentRage();
|
||
case COMMAND.kingsrock:
|
||
return this.kingsRock();
|
||
case COMMAND.poisontarget:
|
||
return yield* this.poisonTarget();
|
||
case COMMAND.burntarget:
|
||
return yield* this.burnTarget();
|
||
case COMMAND.paralyzetarget:
|
||
return yield* this.paralyzeTarget();
|
||
case COMMAND.flinchtarget:
|
||
return this.flinchTarget();
|
||
case COMMAND.sleeptarget:
|
||
return yield* this.sleepTarget();
|
||
case COMMAND.poison:
|
||
return yield* this.poisonCommand();
|
||
case COMMAND.paralyze:
|
||
return yield* this.paralyzeCommand();
|
||
case COMMAND.confuse:
|
||
return yield* this.confuseCommand();
|
||
case COMMAND.draintarget:
|
||
return yield* this.drainTarget();
|
||
case COMMAND.recoil:
|
||
return yield* this.recoil();
|
||
case COMMAND.checksafeguard:
|
||
return yield* this.checkSafeguard();
|
||
case COMMAND.splash:
|
||
return yield* this.say(BATTLE.NothingHappenedText);
|
||
case COMMAND.foresight:
|
||
return yield* this.foresight();
|
||
case COMMAND.rage:
|
||
return this.user.set(SUB4, C.SUBSTATUS_RAGE);
|
||
case COMMAND.ragedamage:
|
||
return this.rageDamage();
|
||
case COMMAND.screen:
|
||
return yield* this.screen();
|
||
case COMMAND.curl:
|
||
return this.user.set(SUB2, C.SUBSTATUS_CURLED);
|
||
case COMMAND.doubleflyingdamage:
|
||
if (this.target.has(SUB3, C.SUBSTATUS_FLYING)) this.curDamage = Math.min(0xffff, this.curDamage * 2);
|
||
return;
|
||
case COMMAND.clearmissdamage:
|
||
if (this.attackMissed) this.curDamage = 0;
|
||
return;
|
||
case COMMAND.switchturn:
|
||
this.turn ^= 1;
|
||
return;
|
||
case COMMAND.healmorn:
|
||
case COMMAND.healday:
|
||
case COMMAND.healnite:
|
||
return yield* this.timeBasedHeal(command - COMMAND.healmorn);
|
||
case COMMAND.statupmessage:
|
||
return yield* this.statUpMessage();
|
||
case COMMAND.statdownmessage:
|
||
return yield* this.statDownMessage();
|
||
case COMMAND.statupfailtext:
|
||
return yield* this.statUpFailText();
|
||
case COMMAND.statdownfailtext:
|
||
return yield* this.statDownFailText();
|
||
default:
|
||
if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) {
|
||
const stat = (command - COMMAND.attackup) % 7;
|
||
return this.statUp(stat | (command >= COMMAND.attackup2 ? 0x10 : 0));
|
||
}
|
||
if (command >= COMMAND.attackdown && command <= COMMAND.evasiondown2) {
|
||
const stat = (command - COMMAND.attackdown) % 7;
|
||
return this.statDown(stat | (command >= COMMAND.attackdown2 ? 0x10 : 0));
|
||
}
|
||
throw new UnsupportedBattle(`Battle command 0x${command.toString(16)} is not ported.`);
|
||
}
|
||
}
|
||
/** BattleCommand_LowerSub: only its wBattleAnimParam outlives the
|
||
* animation, which Triple Kick counts from. A substitute is lowered
|
||
* (with animations on) for a charging turn, and otherwise unless the
|
||
* user is charging. wSomeoneIsRampaging, which also decides it for
|
||
* Rollout and rampages, only changes what is drawn and is not kept. */
|
||
private lowerSub() {
|
||
const user = this.user,
|
||
effect = user.move.effect;
|
||
if (!user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.options.battleScene === false) return;
|
||
const charging =
|
||
!user.has(SUB3, C.SUBSTATUS_CHARGED) &&
|
||
([C.EFFECT_RAZOR_WIND, C.EFFECT_SKY_ATTACK, C.EFFECT_SKULL_BASH, C.EFFECT_SOLARBEAM, C.EFFECT_FLY] as number[]).includes(effect);
|
||
if (charging || effect === C.EFFECT_ROLLOUT || effect === C.EFFECT_RAMPAGE || !user.charging) this.animParam = 1;
|
||
}
|
||
/** BattleCommand_RaiseSub's wBattleAnimParam. */
|
||
private raiseSub() {
|
||
if (this.user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && this.options.battleScene !== false) this.animParam = 2;
|
||
}
|
||
|
||
/** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */
|
||
private *checkObedience(): Gen {
|
||
if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return;
|
||
if (this.options.battleTower) return;
|
||
if (this.partyMon.otId === this.options.playerId) return;
|
||
const user = this.user, badges = this.options.johtoBadges ?? 0;
|
||
const limit = badges & 0x80 ? 101 : badges & 0x20 ? 70 : badges & 8 ? 50 : badges & 2 ? 30 : 10;
|
||
if (user.mon.level <= limit) return;
|
||
const total = Math.min(255, limit + user.mon.level);
|
||
const swap = (byte: number) => ((byte << 4) | (byte >> 4)) & 255;
|
||
const rollBelow = (swapped: boolean) => {
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const value = this.random(), roll = swapped ? swap(value) : value;
|
||
if (roll < total) return roll;
|
||
}
|
||
throw new UnsupportedBattle("Obedience random source did not produce a number.");
|
||
};
|
||
if (rollBelow(true) < limit) return;
|
||
if ((user.curMove === C.SNORE || user.curMove === C.SLEEP_TALK) && user.mon.status & C.SLP_MASK) {
|
||
yield* this.say(BATTLE.IgnoredSleepingText);
|
||
return this.endMoveEffect();
|
||
}
|
||
if (rollBelow(false) < limit) {
|
||
const pp = user.mon.pp;
|
||
let totalPP = 0;
|
||
for (let slot = 0; slot < C.NUM_MOVES && user.mon.moves[slot]; slot++)
|
||
totalPP = (totalPP + (pp[slot] & C.PP_MASK)) & 255;
|
||
if (user.mon.moves[1] && !user.disabledMove && totalPP !== (pp[user.moveNum] & C.PP_MASK)) {
|
||
this.alreadyDisobeyed = 1;
|
||
const originalSlot = user.moveNum;
|
||
let slot = -1;
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const candidate = this.random() & 3;
|
||
if (user.mon.moves[candidate] && candidate !== originalSlot && pp[candidate] & C.PP_MASK) {
|
||
slot = candidate;
|
||
break;
|
||
}
|
||
}
|
||
if (slot < 0) throw new UnsupportedBattle("Obedience random source did not choose a move.");
|
||
user.moveNum = slot;
|
||
user.curMove = user.mon.moves[slot];
|
||
this.updateMoveData();
|
||
yield* this.doMove();
|
||
user.moveNum = originalSlot;
|
||
} else yield* this.disobedienceNothing();
|
||
} else {
|
||
const difference = user.mon.level - limit, roll = swap(this.random());
|
||
if (roll < difference) {
|
||
let sleep = 0;
|
||
for (let attempt = 0; attempt < 4096 && !sleep; attempt++)
|
||
sleep = swap((this.random() * 2) & 255) & C.SLP_MASK;
|
||
if (!sleep) throw new UnsupportedBattle("Obedience random source did not produce sleep.");
|
||
user.mon.status = sleep;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.BeganToNapText);
|
||
} else if (roll - difference < difference) {
|
||
yield* this.say(BATTLE.WontObeyText);
|
||
yield* this.hitConfusion();
|
||
} else yield* this.disobedienceNothing();
|
||
}
|
||
user.lastMove = user.lastCounterMove = 0;
|
||
user.reset(SUB5, C.SUBSTATUS_ENCORED);
|
||
user.encoreCount = 0;
|
||
this.endMoveEffect();
|
||
}
|
||
private *disobedienceNothing(): Gen {
|
||
yield* this.say([
|
||
BATTLE.LoafingAroundText, BATTLE.WontObeyText,
|
||
BATTLE.TurnedAwayText, BATTLE.IgnoredOrdersText,
|
||
][this.random() & 3]);
|
||
}
|
||
/** BattleCommand_DoTurn: count the turn and use one PP. */
|
||
private *doTurnCommand(): Gen {
|
||
const user = this.user;
|
||
if (user.charging) return;
|
||
user.turnsTaken = (user.turnsTaken + 1) & 0xff;
|
||
if (user.curMove === C.STRUGGLE) return;
|
||
if (user.sub[SUB3] & ((1 << C.SUBSTATUS_IN_LOOP) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE)))
|
||
return;
|
||
const slot = user.moveNum;
|
||
if (!(user.mon.pp[slot] & C.PP_MASK)) {
|
||
const continuous = [
|
||
C.EFFECT_RAZOR_WIND,
|
||
C.EFFECT_SKY_ATTACK,
|
||
C.EFFECT_SKULL_BASH,
|
||
C.EFFECT_SOLARBEAM,
|
||
C.EFFECT_FLY,
|
||
C.EFFECT_ROLLOUT,
|
||
C.EFFECT_BIDE,
|
||
C.EFFECT_RAMPAGE,
|
||
] as number[];
|
||
yield* this.say(continuous.includes(user.move.effect) ? BATTLE.HasNoPPLeftText : BATTLE.NoPPLeftText);
|
||
return this.endMoveEffect();
|
||
}
|
||
user.mon.pp[slot]--;
|
||
if (user.has(SUB5, C.SUBSTATUS_TRANSFORMED)) return;
|
||
// The party struct loses the PP too, or a wild Pokémon's wWildMonPP,
|
||
// which it keeps if caught; not for a move learnt by Mimic
|
||
// (CheckMimicUsed).
|
||
const party =
|
||
this.turn === 0
|
||
? this.partyMon
|
||
: this.battleMode !== C.WILD_BATTLE
|
||
? this.options.trainer!.party[this.curOTMon]
|
||
: undefined;
|
||
if (party) {
|
||
if (user.curMove !== C.MIMIC && party.moves[slot] === C.MIMIC) return;
|
||
this.consumePP(party.pp, slot);
|
||
} else {
|
||
if (user.mon.moves[slot] !== C.MIMIC && this.wildMoves[slot] === C.MIMIC) return;
|
||
this.consumePP(this.wildPP, slot);
|
||
}
|
||
}
|
||
/** DoTurn's .consume_pp on a party struct, which keeps step with the
|
||
* battle struct here. */
|
||
private consumePP(pp: number[], slot: number) {
|
||
if (!(pp[slot] & C.PP_MASK)) throw new UnsupportedBattle("Party PP out of step with the battle's.");
|
||
pp[slot]--;
|
||
}
|
||
|
||
private critical() {
|
||
this.criticalHit = 0;
|
||
const user = this.user,
|
||
mon = user.mon;
|
||
if (!user.move.power) return;
|
||
let stage = 0;
|
||
if (mon.species === C.CHANSEY && mon.item === C.LUCKY_PUNCH) stage = 2;
|
||
else if (mon.species === C.FARFETCH_D && mon.item === C.STICK) stage = 2;
|
||
else {
|
||
if (user.has(SUB4, C.SUBSTATUS_FOCUS_ENERGY)) stage++;
|
||
if (this.data.byteList(BATTLE.CriticalHitMoves).includes(user.move.anim)) stage += 2;
|
||
if (this.data.heldEffect(mon.item).effect === C.HELD_CRITICAL_UP) stage++;
|
||
}
|
||
if (this.random() < this.data.criticalChance(stage)) this.criticalHit = 1;
|
||
}
|
||
|
||
/** BattleCommand_Screen: a repeated screen fails without renewing it. */
|
||
private *screen(): Gen {
|
||
const side = this.user;
|
||
const light = side.move.effect === C.EFFECT_LIGHT_SCREEN;
|
||
const bit = 1 << (light ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT);
|
||
if (side.screens & bit) return yield* this.say(BATTLE.ButItFailedText);
|
||
side.screens |= bit;
|
||
if (light) side.lightScreenCount = 5;
|
||
else side.reflectCount = 5;
|
||
yield* this.say(light ? BATTLE.LightScreenEffectText : BATTLE.ReflectEffectText);
|
||
}
|
||
|
||
/** HandleScreens, in non-Link order. Screens belong to the side and
|
||
* survive a send-out; their setup turn is the first of five ticks. */
|
||
private *handleScreens(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
this.turn = turn;
|
||
this.strings[RAM.stringBuffer1] = turn === 0 ? "Your" : "Enemy";
|
||
if (side.screens & (1 << C.SCREENS_LIGHT_SCREEN)) {
|
||
side.lightScreenCount = (side.lightScreenCount - 1) & 0xff;
|
||
if (!side.lightScreenCount) {
|
||
side.screens &= ~(1 << C.SCREENS_LIGHT_SCREEN);
|
||
yield* this.say(BATTLE.BattleText_MonsLightScreenFell);
|
||
}
|
||
}
|
||
if (side.screens & (1 << C.SCREENS_REFLECT)) {
|
||
side.reflectCount = (side.reflectCount - 1) & 0xff;
|
||
if (!side.reflectCount) {
|
||
side.screens &= ~(1 << C.SCREENS_REFLECT);
|
||
yield* this.say(BATTLE.BattleText_MonsReflectFaded);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private damageArgs = { power: 0, level: 0, attack: 0, defense: 0 };
|
||
/** PlayerAttackDamage / EnemyAttackDamage. */
|
||
private damageStats() {
|
||
this.curDamage = 0;
|
||
const user = this.user,
|
||
target = this.target,
|
||
power = user.move.power;
|
||
this.damageArgs.power = power;
|
||
if (!power) return;
|
||
const special = user.move.type >= C.SPECIAL;
|
||
const stat = special ? C.SP_ATTACK : C.ATTACK,
|
||
defenseStat = special ? C.SP_DEFENSE : C.DEFENSE;
|
||
const screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT;
|
||
// Criticals ignore stages when the target's are at least as high.
|
||
const unboosted =
|
||
this.criticalHit && target.levels[defenseStat] >= user.levels[stat];
|
||
let attack: number, defense: number;
|
||
if (unboosted) {
|
||
attack = user.stats[stat];
|
||
defense = target.stats[defenseStat];
|
||
} else {
|
||
attack = user.mon.stats[stat];
|
||
defense = target.mon.stats[defenseStat];
|
||
if (target.screens & (1 << screen)) defense = (defense * 2) & 0xffff;
|
||
}
|
||
attack = this.speciesItemBoost(attack, special);
|
||
[attack, defense] = this.truncate(attack, defense);
|
||
// DittoMetalPowder boosts the defender's Defense.
|
||
[attack, defense] = this.dittoMetalPowder(target, attack, defense);
|
||
this.damageArgs = { power, level: user.mon.level, attack, defense };
|
||
}
|
||
/** DittoMetalPowder checks the original species, even after Transform.
|
||
* On byte overflow it halves Attack too, keeping the original quirk. */
|
||
private dittoMetalPowder(target: Side, attack: number, defense: number): [number, number] {
|
||
const species = target === this.player ? this.partyMon.species : this.enemySpecies;
|
||
if (species !== C.DITTO || target.mon.item !== C.METAL_POWDER) return [attack, defense];
|
||
defense += defense >> 1;
|
||
if (defense > 255) {
|
||
attack = attack >> 1 || 1;
|
||
defense = 128 | ((defense & 255) >> 1);
|
||
}
|
||
return [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;
|
||
const result = applyTypeMatchups(this.matchups, {
|
||
damage: this.badgeTypeBoost(
|
||
this.weatherModifier(this.curDamage, user.move.type, user.move.effect),
|
||
user.move.type,
|
||
),
|
||
modifier: this.typeModifier,
|
||
moveType: user.move.type,
|
||
userTypes: user.mon.types,
|
||
targetTypes: target.mon.types,
|
||
identified: target.has(SUB1, C.SUBSTATUS_IDENTIFIED),
|
||
});
|
||
this.curDamage = result.damage;
|
||
this.typeModifier = result.modifier;
|
||
this.matchup = result.matchup;
|
||
if (result.missed) this.attackMissed = 1;
|
||
}
|
||
/** DoWeatherModifiers, before STAB: rain and sun scale Water and Fire
|
||
* damage, else rain halves Solarbeam's; the result is at least 1. */
|
||
private weatherModifier(damage: number, type: number, effect: number) {
|
||
if (!this.weather) return damage;
|
||
const r = this.reader,
|
||
find = (table: number, value: number) => {
|
||
for (let p = table; r.byte(p) !== 0xff; p += 3)
|
||
if (r.byte(p) === this.weather && r.byte(p + 1) === value) return r.byte(p + 2);
|
||
};
|
||
const multiplier = find(BATTLE.WeatherTypeModifiers, type) ?? find(BATTLE.WeatherMoveModifiers, effect);
|
||
if (multiplier === undefined) return damage;
|
||
const value = Math.floor((damage * multiplier) / 10);
|
||
return value > 0xffff ? 0xffff : value || 1;
|
||
}
|
||
/** DoBadgeTypeBoosts, after the weather: on the player's turn, the first
|
||
* badge (Johto, then Kanto) whose BadgeTypeBoosts type is the move's adds
|
||
* an eighth of the damage (at least 1, to at most $ffff). */
|
||
private badgeTypeBoost(damage: number, type: number) {
|
||
if (this.options.battleTower || this.turn !== 0) return damage;
|
||
const r = this.reader;
|
||
let johto = this.options.johtoBadges ?? 0,
|
||
kanto = this.options.kantoBadges ?? 0;
|
||
for (let p = BATTLE.BadgeTypeBoosts; r.byte(p) !== 0xff; p++) {
|
||
// srl b / rr c: the Johto byte's low bit, then Kanto's shifted in.
|
||
const carry = johto & 1;
|
||
johto = (johto >> 1) | ((kanto & 1) << 7);
|
||
kanto >>= 1;
|
||
if (carry && r.byte(p) === type)
|
||
return Math.min(0xffff, damage + ((damage >> 3) || 1));
|
||
}
|
||
return damage;
|
||
}
|
||
/** BattleCheckTypeMatchup: wTypeMatchup for the move against the target. */
|
||
private battleCheckTypeMatchup() {
|
||
const target = this.target;
|
||
return (this.matchup = this.typeMatchup(this.user.move.type, target.mon.types, target.has(SUB1, C.SUBSTATUS_IDENTIFIED)));
|
||
}
|
||
/** BattleCommand_ResetTypeMatchup: an immune target makes the move miss;
|
||
* otherwise wTypeMatchup becomes EFFECTIVE. Returns wTypeMatchup. */
|
||
private resetTypeMatchup() {
|
||
if (this.battleCheckTypeMatchup()) return (this.matchup = C.EFFECTIVE);
|
||
this.curDamage = 0;
|
||
this.typeModifier = 0;
|
||
this.attackMissed = 1;
|
||
return 0;
|
||
}
|
||
/** BattleCommand_DamageVariation: 217-255 / 255. */
|
||
private damageVariation() {
|
||
if (this.curDamage < 2) return;
|
||
let multiplier: number;
|
||
do {
|
||
const r = this.random();
|
||
multiplier = ((r >> 1) | ((r & 1) << 7)) & 0xff;
|
||
} while (multiplier < percent(85) + 1);
|
||
this.curDamage = Math.floor((this.curDamage * multiplier) / 255) & 0xffff;
|
||
}
|
||
|
||
/** BattleCommand_CheckHit. */
|
||
private *checkHit(): Gen {
|
||
const user = this.user,
|
||
target = this.target,
|
||
effect = user.move.effect;
|
||
const miss = () => {
|
||
if (effect !== C.EFFECT_JUMP_KICK) this.curDamage = 0;
|
||
this.attackMissed = 1;
|
||
};
|
||
if (effect === C.EFFECT_DREAM_EATER && !(target.mon.status & C.SLP_MASK)) return miss();
|
||
if (target.has(SUB1, C.SUBSTATUS_PROTECT)) {
|
||
yield* this.say(BATTLE.ProtectingItselfText);
|
||
return miss();
|
||
}
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && (effect === C.EFFECT_LEECH_HIT || effect === C.EFFECT_DREAM_EATER))
|
||
return miss();
|
||
// .LockOn: a locked-on target is hit, unless it is flying from an
|
||
// Earthquake, Fissure or Magnitude; the lock is spent either way.
|
||
const anim = user.move.anim,
|
||
groundMoves: number[] = [C.EARTHQUAKE, C.FISSURE, C.MAGNITUDE];
|
||
if (target.has(SUB5, C.SUBSTATUS_LOCK_ON)) {
|
||
target.reset(SUB5, C.SUBSTATUS_LOCK_ON);
|
||
if (!(target.has(SUB3, C.SUBSTATUS_FLYING) && groundMoves.includes(anim))) return;
|
||
}
|
||
// .FlyDigMoves
|
||
if (target.has(SUB3, C.SUBSTATUS_FLYING)) {
|
||
if (!([C.GUST, C.WHIRLWIND, C.THUNDER, C.TWISTER] as number[]).includes(anim)) return miss();
|
||
} else if (target.has(SUB3, C.SUBSTATUS_UNDERGROUND) && !groundMoves.includes(anim)) return miss();
|
||
if (effect === C.EFFECT_THUNDER && this.weather === C.WEATHER_RAIN) return;
|
||
if (user.has(SUB4, C.SUBSTATUS_X_ACCURACY)) return;
|
||
if (effect === C.EFFECT_ALWAYS_HIT) return;
|
||
this.statModifiers();
|
||
let accuracy = user.move.accuracy;
|
||
const opponentItem = this.data.heldEffect(target.mon.item);
|
||
if (opponentItem.effect === C.HELD_BRIGHTPOWDER) accuracy = Math.max(0, accuracy - opponentItem.parameter);
|
||
if (accuracy === 0xff) return;
|
||
if (this.random() >= accuracy) miss();
|
||
}
|
||
/** CheckHit.StatModifiers: accuracy and evasion stages on the move. */
|
||
private statModifiers() {
|
||
const user = this.user,
|
||
target = this.target;
|
||
const accuracyLevel = user.levels[C.ACCURACY],
|
||
evasionLevel = target.levels[C.EVASION];
|
||
if (evasionLevel >= accuracyLevel && target.has(SUB1, C.SUBSTATUS_IDENTIFIED)) return;
|
||
let value = user.move.accuracy;
|
||
for (const level of [accuracyLevel, C.MAX_STAT_LEVEL + 1 - evasionLevel]) {
|
||
const [numerator, denominator] = this.data.accuracyMultiplier(level);
|
||
value = Math.floor((value * numerator) / denominator) || 1;
|
||
}
|
||
user.move.accuracy = value > 0xff ? 0xff : value;
|
||
}
|
||
private effectChance() {
|
||
this.effectFailed = 0;
|
||
if (this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || !(this.random() < this.user.move.chance))
|
||
this.effectFailed = 1;
|
||
}
|
||
|
||
/** BattleCommand_FailureText. */
|
||
private *failureText(): Gen {
|
||
if (!this.attackMissed) return;
|
||
yield* this.getFailureResultText();
|
||
const user = this.user;
|
||
if (user.move.anim === C.FLY || user.move.anim === C.DIG) {
|
||
user.reset(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||
user.reset(SUB3, C.SUBSTATUS_FLYING);
|
||
return this.endMoveEffect();
|
||
}
|
||
// Multi-hit moves other than Triple Kick and Beat Up raise the
|
||
// substitute again (Beat Up not doing so is a kept bug).
|
||
const effect = user.move.effect;
|
||
if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT)
|
||
this.raiseSub();
|
||
this.endMoveEffect();
|
||
}
|
||
private *getFailureResultText(): Gen {
|
||
let text: number = BATTLE.DoesntAffectText,
|
||
protectedText: number = BATTLE.DoesntAffectText;
|
||
if (this.typeModifier & C.EFFECTIVENESS_MASK) {
|
||
if (this.user.move.effect === C.EFFECT_FUTURE_SIGHT) {
|
||
text = BATTLE.ButItFailedText;
|
||
protectedText = BATTLE.ItFailedText;
|
||
} else {
|
||
text = BATTLE.AttackMissedText;
|
||
protectedText = BATTLE.AttackMissed2Text;
|
||
if (this.criticalHit === 0xff) text = BATTLE.UnaffectedText;
|
||
}
|
||
}
|
||
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text);
|
||
this.criticalHit = 0;
|
||
if (this.user.move.effect !== C.EFFECT_JUMP_KICK || !(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
|
||
// (Hi) Jump Kick crashes for an eighth of the damage it would have
|
||
// dealt, at least 1, ignoring the user's substitute.
|
||
this.curDamage = this.curDamage >> 3 || 1;
|
||
yield* this.say(BATTLE.CrashedText);
|
||
this.doDamage(this.user);
|
||
yield this.hud();
|
||
}
|
||
|
||
/** BattleCommand_ApplyDamage. */
|
||
private *applyDamage(): Gen {
|
||
const target = this.target;
|
||
let hungOn = 0;
|
||
const item = this.data.heldEffect(target.mon.item);
|
||
if (target.has(SUB1, C.SUBSTATUS_ENDURE)) hungOn = this.falseSwipe() ? 1 : 0;
|
||
else if (item.effect === C.HELD_FOCUS_BAND && this.random() < item.parameter)
|
||
hungOn = this.falseSwipe() ? 2 : 0;
|
||
if (!target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
|
||
target.damageTaken = Math.min(0xffff, target.damageTaken + this.curDamage);
|
||
if (this.curDamage && target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) yield* this.doSubstituteDamage();
|
||
else this.doDamage(target);
|
||
yield this.hud();
|
||
if (hungOn === 1) yield* this.say(BATTLE.EnduredText);
|
||
if (hungOn === 2) {
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
|
||
yield* this.say(BATTLE.HungOnText);
|
||
}
|
||
}
|
||
/** BattleCommand_FalseSwipe: a hit that would faint leaves 1 HP (no
|
||
* damage at all against 1 HP) and is no longer a one-hit KO. Returns
|
||
* true (carry) when it changed the damage. */
|
||
private falseSwipe(): boolean {
|
||
const hp = this.target.mon.hp;
|
||
if (this.curDamage < hp) return false;
|
||
this.curDamage = (hp - 1) & 0xffff;
|
||
if (this.criticalHit === 2) this.criticalHit = 0;
|
||
return true;
|
||
}
|
||
/** DoSubstituteDamage: the substitute takes the hit instead. When it
|
||
* breaks, the move's effect is cleared unless it hits several times;
|
||
* no damage goes through either way. */
|
||
private *doSubstituteDamage(): Gen {
|
||
yield* this.say(BATTLE.SubTookDamageText);
|
||
const target = this.target;
|
||
const left = target.substituteHp - (this.curDamage & 0xff);
|
||
if (this.curDamage > 0xff || left <= 0) {
|
||
target.substituteHp = left & 0xff;
|
||
target.reset(SUB4, C.SUBSTATUS_SUBSTITUTE);
|
||
yield* this.say(BATTLE.SubFadedText);
|
||
const effect = this.user.move.effect;
|
||
if (![C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never))
|
||
this.user.move.effect = 0;
|
||
} else target.substituteHp = left;
|
||
this.curDamage = 0;
|
||
}
|
||
/** DoEnemyDamage / DoPlayerDamage, past any substitute. */
|
||
private doDamage(side: Side) {
|
||
if (!this.curDamage) return;
|
||
const hp = side.mon.hp;
|
||
if (this.curDamage > hp) this.curDamage = hp;
|
||
side.mon.hp = hp - this.curDamage;
|
||
if (side === this.player) this.updateBattleMonInParty();
|
||
}
|
||
private *criticalText(): Gen {
|
||
if (!this.criticalHit) return;
|
||
yield* this.say(this.criticalHit === 1 ? BATTLE.CriticalHitText : BATTLE.OneHitKOText);
|
||
this.criticalHit = 0;
|
||
}
|
||
private *superEffectiveText(): Gen {
|
||
const effectiveness = this.typeModifier & C.EFFECTIVENESS_MASK;
|
||
if (effectiveness === C.EFFECTIVE) return;
|
||
yield* this.say(effectiveness > C.EFFECTIVE ? BATTLE.SuperEffectiveText : BATTLE.NotVeryEffectiveText);
|
||
}
|
||
/** BattleCommand_CheckFaint: end the move if the target fainted. */
|
||
private *checkFaint(): Gen {
|
||
if (this.target.mon.hp) return;
|
||
if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND)) {
|
||
// The user faints too, and the move ends there.
|
||
yield* this.say(BATTLE.TookDownWithItText);
|
||
this.user.mon.hp = 0;
|
||
yield this.hud();
|
||
return this.endMoveEffect();
|
||
}
|
||
// A multi-hit move stops here, without "Hit n times!" and with
|
||
// SUBSTATUS_IN_LOOP still set; the faint handlers clear it.
|
||
const effect = this.user.move.effect;
|
||
if ([C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never))
|
||
this.raiseSub();
|
||
this.endMoveEffect();
|
||
}
|
||
private *buildOpponentRage(): Gen {
|
||
if (this.attackMissed) return;
|
||
const target = this.target;
|
||
if (!target.has(SUB4, C.SUBSTATUS_RAGE)) return;
|
||
if (target.rageCounter === 0xff) return;
|
||
target.rageCounter++;
|
||
this.turn ^= 1;
|
||
yield* this.say(BATTLE.RageBuildingText);
|
||
this.turn ^= 1;
|
||
}
|
||
private rageDamage() {
|
||
let damage = this.curDamage;
|
||
for (let count = this.user.rageCounter; count > 0; count--) {
|
||
damage += this.curDamage;
|
||
if (damage > 0xffff) {
|
||
damage = 0xffff;
|
||
break;
|
||
}
|
||
}
|
||
this.curDamage = damage;
|
||
}
|
||
/** BattleCommand_MoveAnimNoSub's effect on wBattleAnimParam: multi-hit
|
||
* moves alternate it, Triple Kick keeps its kick count, others clear it. */
|
||
private moveAnimNoSub() {
|
||
if (this.attackMissed) return;
|
||
const effect = this.user.move.effect;
|
||
if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_CONVERSION || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT)
|
||
this.animParam = (this.animParam & 1) ^ 1;
|
||
else if (effect !== C.EFFECT_TRIPLE_KICK) this.animParam = 0;
|
||
}
|
||
|
||
// ------------------------------------------------------ multi-hit moves
|
||
|
||
/** BattleCommand_EndLoop. The first pass draws the extra hits into the
|
||
* user's rollout count and the total into the high byte of its
|
||
* wDamageTaken, which "Hit n times!" prints; later passes count down.
|
||
* Each repeat resumes at the script's `critical`. */
|
||
private *endLoop(): Gen {
|
||
const user = this.user,
|
||
effect = user.move.effect;
|
||
if (user.has(SUB3, C.SUBSTATUS_IN_LOOP)) {
|
||
user.rolloutCount = (user.rolloutCount - 1) & 0xff;
|
||
if (user.rolloutCount) return this.loopBackToCritical();
|
||
return yield* this.doneLoop();
|
||
}
|
||
user.set(SUB3, C.SUBSTATUS_IN_LOOP);
|
||
let extra: number;
|
||
if (effect === C.EFFECT_POISON_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT) extra = 1;
|
||
else if (effect === C.EFFECT_BEAT_UP) {
|
||
const count =
|
||
this.turn === 0
|
||
? this.options.party.length
|
||
: this.battleMode === C.WILD_BATTLE
|
||
? 1
|
||
: this.options.trainer!.party.length;
|
||
if (count === 1) {
|
||
// .only_one_beatup: no raisesub or King's Rock (a kept bug).
|
||
user.reset(SUB3, C.SUBSTATUS_IN_LOOP);
|
||
yield* this.beatUpFailText();
|
||
return this.endMoveEffect();
|
||
}
|
||
extra = count - 1;
|
||
} else if (effect === C.EFFECT_TRIPLE_KICK) {
|
||
let kicks: number;
|
||
do kicks = this.random() & 3;
|
||
while (!kicks);
|
||
if (kicks === 1) {
|
||
this.setHitCount(user, 1);
|
||
return yield* this.doneLoop();
|
||
}
|
||
extra = kicks - 1;
|
||
} else {
|
||
// Two or three hits, or a second draw for two to five.
|
||
let draw = this.random() & 3;
|
||
if (draw >= 2) draw = this.random() & 3;
|
||
extra = draw + 1;
|
||
}
|
||
user.rolloutCount = extra;
|
||
this.setHitCount(user, extra + 1);
|
||
this.loopBackToCritical();
|
||
}
|
||
private setHitCount(side: Side, hits: number) {
|
||
side.damageTaken = ((hits & 0xff) << 8) | (side.damageTaken & 0xff);
|
||
}
|
||
/** EndLoop's .done_loop: the hit count text (not for Beat Up), then the
|
||
* high byte of wDamageTaken is cleared. */
|
||
private *doneLoop(): Gen {
|
||
const user = this.user;
|
||
user.reset(SUB3, C.SUBSTATUS_IN_LOOP);
|
||
if (user.move.effect !== C.EFFECT_BEAT_UP) {
|
||
this.numbers[this.turn === 0 ? RAM.playerDamageTaken : RAM.enemyDamageTaken] = user.damageTaken >> 8;
|
||
yield* this.say(this.turn === 0 ? BATTLE.PlayerHitTimesText : BATTLE.EnemyHitTimesText);
|
||
}
|
||
user.damageTaken &= 0xff;
|
||
}
|
||
/** EndLoop's .loop_back_to_critical: search back for `critical`. */
|
||
private loopBackToCritical() {
|
||
let index = this.scriptIndex;
|
||
while (index >= 0 && this.script[index] !== COMMAND.critical) index--;
|
||
if (index < 0) throw new UnsupportedBattle("Multi-hit script has no critical command.");
|
||
this.scriptIndex = index;
|
||
}
|
||
/** BattleCommand_BeatUp: the next party member's base Attack and level
|
||
* against the target's base Defense. A fainted or statused member skips
|
||
* to after buildopponentrage. Wild Pokémon hit with their own stats but
|
||
* never count a hit, so "But it failed!" follows (a kept bug). */
|
||
private *beatUp(): Gen {
|
||
this.curDamage = 0;
|
||
const user = this.user,
|
||
data = this.options.data;
|
||
const player = this.turn === 0,
|
||
party = player ? this.options.party : this.options.trainer?.party;
|
||
if (!user.has(SUB3, C.SUBSTATUS_IN_LOOP)) {
|
||
user.rolloutCount = 0;
|
||
this.beatUpMon = 0;
|
||
this.beatUpHit = 0;
|
||
} else {
|
||
const count = player ? this.options.party.length : this.options.trainer!.party.length;
|
||
this.beatUpMon = (count - user.rolloutCount) & 0xff;
|
||
}
|
||
if (!party) {
|
||
this.strings[RAM.stringBuffer1] = data.speciesName(this.enemy.mon.species);
|
||
yield* this.say(BATTLE.BeatUpAttackText);
|
||
return this.damageStats(); // EnemyAttackDamage
|
||
}
|
||
const mon = party[this.beatUpMon];
|
||
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party.");
|
||
if (party.some((member) => member.egg))
|
||
throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet.");
|
||
// The player's members go by nickname, a trainer's by species name.
|
||
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
|
||
const skip = () => this.skipTo(COMMAND.buildopponentrage);
|
||
if (!mon.hp) return skip();
|
||
// The player's side takes the battle status when wCurBattleMon equals
|
||
// the member's HP low byte rather than its index (a kept bug).
|
||
const active = player ? this.curBattleMon === (mon.hp & 0xff) : this.curOTMon === this.beatUpMon;
|
||
if ((active ? user.mon.status : mon.status) !== 0) return skip();
|
||
this.beatUpHit = 1;
|
||
yield* this.say(BATTLE.BeatUpAttackText);
|
||
this.damageArgs = {
|
||
power: user.move.power,
|
||
level: mon.level,
|
||
attack: data.baseData(mon.species).stats[C.ATTACK + 1],
|
||
defense: data.baseData(this.target.mon.species).stats[C.DEFENSE + 1],
|
||
};
|
||
}
|
||
/** BattleCommand_BeatUpFailText. */
|
||
private *beatUpFailText(): Gen {
|
||
if (!this.beatUpHit) yield* this.say(BATTLE.ButItFailedText);
|
||
}
|
||
|
||
// ------------------------------------------------- special damage moves
|
||
|
||
/** BattleCommand_ConstantDamage: the level, the move's power, Psywave's
|
||
* random damage below 1.5 × level, half the target's HP, or Flail and
|
||
* Reversal's power from the user's HP bar. */
|
||
private constantDamage() {
|
||
const user = this.user,
|
||
level = user.mon.level;
|
||
switch (user.move.effect) {
|
||
case C.EFFECT_LEVEL_DAMAGE:
|
||
this.curDamage = level;
|
||
return;
|
||
case C.EFFECT_PSYWAVE: {
|
||
const limit = (level + (level >> 1)) & 0xff;
|
||
// A limit of 1 or less loops forever in the original.
|
||
if (limit <= 1) throw new UnsupportedBattle("Psywave at this level freezes the original.");
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const damage = this.random();
|
||
if (damage && damage < limit) {
|
||
this.curDamage = damage;
|
||
return;
|
||
}
|
||
}
|
||
throw new UnsupportedBattle("Psywave random source did not produce damage.");
|
||
}
|
||
case C.EFFECT_SUPER_FANG:
|
||
this.curDamage = this.target.mon.hp >> 1 || 1;
|
||
return;
|
||
case C.EFFECT_REVERSAL:
|
||
return this.reversalDamage();
|
||
default:
|
||
this.curDamage = user.move.power;
|
||
}
|
||
}
|
||
/** ConstantDamage's .reversal: HP bar pixels (HP × 48 / max HP, scaled
|
||
* down by 4 when max HP has a high byte) pick FlailReversalPower's power
|
||
* for a normal damage calculation, then the move's power becomes 1. */
|
||
private reversalDamage() {
|
||
const user = this.user,
|
||
{ hp, maxHp } = user.mon;
|
||
let dividend = hp * 48,
|
||
divisor = maxHp & 0xff;
|
||
if (maxHp >> 8) {
|
||
divisor = (maxHp >> 2) & 0xff;
|
||
dividend = (dividend >> 2) & 0xffff;
|
||
}
|
||
if (!divisor) throw new UnsupportedBattle("Reversal divides by zero.");
|
||
const pixels = Math.floor(dividend / divisor) & 0xff;
|
||
let p = BATTLE.FlailReversalPower;
|
||
while (this.reader.byte(p) < pixels) p += 2;
|
||
user.move.power = this.reader.byte(p + 1);
|
||
this.damageStats();
|
||
this.damageCalcCommand();
|
||
user.move.power = 1;
|
||
}
|
||
/** BattleCommand_OHKO: fails against a higher level; otherwise each
|
||
* level of difference adds 2 to the accuracy before CheckHit. */
|
||
private *ohko(): Gen {
|
||
this.curDamage = 0;
|
||
const user = this.user,
|
||
difference = user.mon.level - this.target.mon.level;
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || difference < 0) {
|
||
this.criticalHit = 0xff; // "is unaffected!"
|
||
this.attackMissed = 1;
|
||
return;
|
||
}
|
||
user.move.accuracy = Math.min(0xff, user.move.accuracy + ((difference * 2) & 0xff));
|
||
yield* this.checkHit();
|
||
this.curDamage = 0xffff;
|
||
this.criticalHit = 2;
|
||
}
|
||
/** BattleCommand_Counter and BattleCommand_MirrorCoat: twice the damage
|
||
* of the opponent's last physical (special) move, if the opponent moved
|
||
* first. wCurDamage still holds that damage, even after the opponent
|
||
* used an item instead (a kept bug). */
|
||
private counter(special: boolean) {
|
||
this.attackMissed = 1;
|
||
const data = this.options.data,
|
||
last = this.target.lastCounterMove;
|
||
if (!last) return;
|
||
const move = data.move(last);
|
||
if (move.effect === (special ? C.EFFECT_MIRROR_COAT : C.EFFECT_COUNTER)) return;
|
||
if (!this.resetTypeMatchup()) return;
|
||
if (!(this.enemyGoesFirst ^ this.turn)) return;
|
||
if (!move.power || move.type >= C.SPECIAL !== special) return;
|
||
if (!this.curDamage) return;
|
||
this.curDamage = Math.min(0xffff, this.curDamage * 2);
|
||
this.attackMissed = 0;
|
||
}
|
||
/** BattleCommand_GetMagnitude: MagnitudePower's power for damagecalc. */
|
||
private *getMagnitude(): Gen {
|
||
const roll = this.random();
|
||
let p = BATTLE.MagnitudePower;
|
||
while (this.reader.byte(p) < roll) p += 3;
|
||
this.damageArgs.power = this.reader.byte(p + 1);
|
||
this.numbers[RAM.textDecimalByte] = this.reader.byte(p + 2);
|
||
yield* this.say(BATTLE.MagnitudeText);
|
||
}
|
||
/** BattleCommand_Present: a PresentPower power for damagecalc, or a
|
||
* quarter of the target's maximum HP as a gift. A target at full HP
|
||
* refuses it, which is only said with battle animations off. */
|
||
private *present(): Gen {
|
||
this.stab();
|
||
if (!this.matchup || this.attackMissed) return;
|
||
const roll = this.random(),
|
||
r = this.reader;
|
||
let p = BATTLE.PresentPower;
|
||
for (let index = 0; r.byte(p) !== 0xff; index++, p += 2)
|
||
if (r.byte(p) >= roll) {
|
||
this.animParam = index;
|
||
this.damageArgs.power = r.byte(p + 1);
|
||
return;
|
||
}
|
||
this.animParam = 3;
|
||
const target = this.target.mon;
|
||
if (target.hp === target.maxHp) {
|
||
if (this.options.battleScene === false) yield* this.say(BATTLE.PresentFailedText);
|
||
return this.endMoveEffect();
|
||
}
|
||
// GetQuarterMaxHP of the target, then RestoreHP.
|
||
target.hp = Math.min(target.maxHp, (target.hp + (target.maxHp >> 2 || 1)) & 0xffff);
|
||
yield this.hud();
|
||
this.turn ^= 1;
|
||
yield* this.say(BATTLE.RegainedHealthText);
|
||
this.turn ^= 1;
|
||
this.updateOpponentInParty();
|
||
this.endMoveEffect();
|
||
}
|
||
/** HiddenPowerDamage: the type and power from the user's DVs, then the
|
||
* damage stats for that type. */
|
||
private hiddenPower() {
|
||
if (this.attackMissed) return;
|
||
const dvs = this.user.mon.dvs,
|
||
attackDefense = dvs >> 8,
|
||
speedSpecial = dvs & 0xff;
|
||
const bits =
|
||
((attackDefense >> 4) & 8) | ((attackDefense & 8) >> 1) | ((speedSpecial >> 6) & 2) | ((speedSpecial & 8) >> 3);
|
||
const power = ((bits * 5 + (speedSpecial & 3)) >> 1) + 31;
|
||
let type = ((attackDefense & 3) | (((attackDefense >> 4) & 3) << 2)) + 1;
|
||
if (type >= C.BIRD) type++;
|
||
if (type >= C.UNUSED_TYPES) type += C.UNUSED_TYPES_END - C.UNUSED_TYPES;
|
||
this.user.move.type = type;
|
||
this.damageStats();
|
||
this.damageArgs.power = power;
|
||
}
|
||
/** BattleCommand_FakeOut: flinches unless the target has a substitute,
|
||
* sleeps, is frozen or moved first; otherwise the move misses. */
|
||
private fakeOut() {
|
||
if (this.attackMissed) return;
|
||
const target = this.target;
|
||
if (
|
||
target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) ||
|
||
target.mon.status & ((1 << C.FRZ) | C.SLP_MASK) ||
|
||
this.enemyGoesFirst ^ this.turn
|
||
) {
|
||
this.attackMissed = 1;
|
||
return;
|
||
}
|
||
target.set(SUB3, C.SUBSTATUS_FLINCHED);
|
||
target.reset(SUB4, C.SUBSTATUS_RECHARGE);
|
||
}
|
||
/** BattleCommand_PayDay: twice the user's level in coins. */
|
||
private *payDay(): Gen {
|
||
this.payDayMoney = (this.payDayMoney + ((this.user.mon.level * 2) & 0xff)) & 0xffffff;
|
||
yield* this.say(BATTLE.CoinsScatteredText);
|
||
}
|
||
/** BattleCommand_Thief: an item-less user takes the target's item, but
|
||
* not MAIL. A trainer's Pokémon keeps a stolen item for the rest of the
|
||
* battle, so the player loses it for good. */
|
||
private *thief(): Gen {
|
||
const data = this.options.data,
|
||
user = this.user,
|
||
target = this.target;
|
||
if (user.mon.item) return;
|
||
const item = target.mon.item;
|
||
if (!item || data.itemIsMail(item) || this.effectFailed) return;
|
||
const otMon = this.options.trainer?.party[this.curOTMon];
|
||
target.mon.item = 0;
|
||
user.mon.item = item;
|
||
if (this.turn === 0) {
|
||
this.partyMon.item = item;
|
||
if (otMon) otMon.item = 0;
|
||
} else {
|
||
this.partyMon.item = 0;
|
||
if (otMon) otMon.item = item;
|
||
}
|
||
this.strings[RAM.stringBuffer1] = data.itemName(item);
|
||
yield* this.say(BATTLE.StoleText);
|
||
}
|
||
/** BattleCommand_Selfdestruct: the user faints, hit or miss, and loses
|
||
* Leech Seed; the target's Destiny Bond is cleared. */
|
||
private *selfdestruct(): Gen {
|
||
const user = this.user;
|
||
user.mon.status = 0;
|
||
user.mon.hp = 0;
|
||
this.animParam = 1;
|
||
this.lowerSub();
|
||
user.reset(SUB4, C.SUBSTATUS_LEECH_SEED);
|
||
this.target.reset(SUB5, C.SUBSTATUS_DESTINY_BOND);
|
||
yield this.hud();
|
||
}
|
||
|
||
// ------------------------------------------------- status and field moves
|
||
|
||
/** CheckHiddenOpponent: the target is flying or underground. */
|
||
private hiddenOpponent() {
|
||
return Boolean(this.target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)));
|
||
}
|
||
/** FailMove: "But it failed!", or "It failed!" against Protect. */
|
||
private *failMove(): Gen {
|
||
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText);
|
||
}
|
||
/** The slot of the move an opponent last used. The original searches on
|
||
* past the move list when the move is not there (after Metronome, for
|
||
* instance), which is refused. */
|
||
private moveSlot(side: Side, move: number) {
|
||
const slot = side.mon.moves.indexOf(move);
|
||
if (slot < 0) throw new UnsupportedBattle("A move that is not in the move list is not ported yet.");
|
||
return slot;
|
||
}
|
||
/** BattleCommand_LeechSeed: not on a Grass type or a substitute. */
|
||
private *leechSeed(): Gen {
|
||
const target = this.target;
|
||
if (this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.say(BATTLE.EvadedText);
|
||
if (this.isType(target, C.GRASS)) return yield* this.say(BATTLE.DoesntAffectText);
|
||
if (target.has(SUB4, C.SUBSTATUS_LEECH_SEED)) return yield* this.say(BATTLE.EvadedText);
|
||
target.set(SUB4, C.SUBSTATUS_LEECH_SEED);
|
||
yield* this.say(BATTLE.WasSeededText);
|
||
}
|
||
/** BattleCommand_Nightmare: only on a sleeping target in reach. */
|
||
private *nightmare(): Gen {
|
||
const target = this.target;
|
||
if (
|
||
this.hiddenOpponent() ||
|
||
target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) ||
|
||
!(target.mon.status & C.SLP_MASK) ||
|
||
target.has(SUB1, C.SUBSTATUS_NIGHTMARE)
|
||
)
|
||
return yield* this.say(BATTLE.ButItFailedText);
|
||
target.set(SUB1, C.SUBSTATUS_NIGHTMARE);
|
||
yield* this.say(BATTLE.StartedNightmareText);
|
||
}
|
||
/** BattleCommand_Curse: a Ghost type halves its HP to curse the target;
|
||
* others lower their Speed and raise Attack and Defense. */
|
||
private *curse(): Gen {
|
||
const user = this.user,
|
||
target = this.target;
|
||
if (this.isType(user, C.GHOST)) {
|
||
if (this.hiddenOpponent() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.has(SUB1, C.SUBSTATUS_CURSE))
|
||
return yield* this.say(BATTLE.ButItFailedText);
|
||
target.set(SUB1, C.SUBSTATUS_CURSE);
|
||
this.subtractHP(user, user.mon.maxHp >> 1 || 1);
|
||
this.updateUserInParty();
|
||
yield this.hud();
|
||
return yield* this.say(BATTLE.PutACurseText);
|
||
}
|
||
if (user.levels[C.ATTACK] >= C.MAX_STAT_LEVEL && user.levels[C.DEFENSE] >= C.MAX_STAT_LEVEL) {
|
||
// GetStatName's eighth name: "ABILITY won't rise anymore!"
|
||
this.loweredStat = C.ABILITY;
|
||
this.statName();
|
||
return yield* this.say(BATTLE.WontRiseAnymoreText);
|
||
}
|
||
this.animParam = 1;
|
||
this.lowerOwnStat(C.SPEED);
|
||
this.turn ^= 1;
|
||
yield* this.statDownMessage();
|
||
this.attackMissed = 0;
|
||
this.turn ^= 1;
|
||
this.statUp(C.ATTACK);
|
||
yield* this.statUpMessage();
|
||
this.attackMissed = 0;
|
||
this.statUp(C.DEFENSE);
|
||
yield* this.statUpMessage();
|
||
}
|
||
/** LowerStat: a stat of the side whose turn it is, without Mist or a
|
||
* random failure. */
|
||
private lowerOwnStat(stat: number) {
|
||
this.loweredStat = stat;
|
||
const side = this.user,
|
||
index = stat & 0xf;
|
||
let level = side.levels[index] - 1;
|
||
if (!level) {
|
||
this.failedMessage = 2;
|
||
return;
|
||
}
|
||
if (stat & 0xf0) level = level - 1 || 1;
|
||
side.levels[index] = level;
|
||
if (index < C.ACCURACY) {
|
||
if (side.mon.stats[index] === 1) {
|
||
side.levels[index] = level + 1;
|
||
this.failedMessage = 2;
|
||
return;
|
||
}
|
||
this.calcStats(side);
|
||
}
|
||
this.failedMessage = 0;
|
||
}
|
||
/** BattleCommand_Protect and BattleCommand_Endure (ProtectChance): fails
|
||
* for the side that moves last or behind a substitute; each use in a row
|
||
* halves the chance. */
|
||
private *protect(endure: boolean): Gen {
|
||
const user = this.user;
|
||
let chance = 0xff;
|
||
let failed = Boolean(this.enemyGoesFirst ^ this.turn) || user.has(SUB4, C.SUBSTATUS_SUBSTITUTE);
|
||
for (let count = user.protectCount; !failed && count; count--) {
|
||
chance >>= 1;
|
||
if (!chance) failed = true;
|
||
}
|
||
if (!failed) {
|
||
let roll: number;
|
||
do roll = this.random();
|
||
while (!roll);
|
||
failed = roll - 1 >= chance;
|
||
}
|
||
if (failed) {
|
||
user.protectCount = 0;
|
||
return yield* this.say(BATTLE.ButItFailedText);
|
||
}
|
||
user.protectCount = (user.protectCount + 1) & 0xff;
|
||
user.set(SUB1, endure ? C.SUBSTATUS_ENDURE : C.SUBSTATUS_PROTECT);
|
||
yield* this.say(endure ? BATTLE.BracedItselfText : BATTLE.ProtectedItselfText);
|
||
}
|
||
/** BattleCommand_Spite: 2-5 PP off the target's last move, in its party
|
||
* struct too unless it is transformed (a wild Pokémon's kept PP). */
|
||
private *spite(): Gen {
|
||
const target = this.target,
|
||
last = target.lastCounterMove;
|
||
if (this.attackMissed || !last || last === C.STRUGGLE) return yield* this.didntAffect2();
|
||
const slot = this.moveSlot(target, last),
|
||
pp = target.mon.pp[slot];
|
||
if (!(pp & C.PP_MASK)) return yield* this.didntAffect2();
|
||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(last);
|
||
const amount = Math.min(pp & C.PP_MASK, (this.random() & 3) + 2);
|
||
target.mon.pp[slot] = pp - amount;
|
||
if (!target.has(SUB5, C.SUBSTATUS_TRANSFORMED)) {
|
||
if (this.turn === 1) this.partyMon.pp[slot] = pp - amount;
|
||
else if (this.battleMode === C.WILD_BATTLE) this.wildPP[slot] = pp - amount;
|
||
else this.options.trainer!.party[this.curOTMon].pp[slot] = pp - amount;
|
||
}
|
||
this.numbers[RAM.textDecimalByte] = amount;
|
||
yield* this.say(BATTLE.SpiteEffectText);
|
||
}
|
||
/** BattleCommand_HealBell: the user and all six party slots lose their
|
||
* status; Nightmare ends, Toxic's flag does not. */
|
||
private *healBell(): Gen {
|
||
const user = this.user;
|
||
user.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
|
||
user.mon.status = 0;
|
||
for (const mon of (this.turn === 0 ? this.options.party : this.options.trainer?.party) ?? []) mon.status = 0;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.BellChimedText);
|
||
this.calcStats(user);
|
||
}
|
||
/** BattleCommand_Disable: the target's last move for 1-7 more turns. */
|
||
private *disable(): Gen {
|
||
const target = this.target,
|
||
last = target.lastCounterMove;
|
||
if (this.attackMissed || target.disableCount || !last || last === C.STRUGGLE) return yield* this.failMove();
|
||
const slot = this.moveSlot(target, last);
|
||
if (!target.mon.pp[slot]) return yield* this.failMove();
|
||
let turns: number;
|
||
do turns = this.random() & 7;
|
||
while (!turns);
|
||
target.disableCount = turns + 1 + ((slot + 1) << 4);
|
||
target.disabledMove = last;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(last);
|
||
yield* this.say(BATTLE.WasDisabledText);
|
||
}
|
||
/** BattleCommand_Encore: the target repeats its last move for 3-6 turns;
|
||
* if it has not moved yet this turn, it switches to that move now. */
|
||
private *encore(): Gen {
|
||
const target = this.target,
|
||
last = target.lastMove;
|
||
if (!last || last === C.STRUGGLE || last === C.ENCORE || last === C.MIRROR_MOVE) return yield* this.didntAffect2();
|
||
if (!(target.mon.pp[this.moveSlot(target, last)] & C.PP_MASK)) return yield* this.didntAffect2();
|
||
if (this.attackMissed || target.has(SUB5, C.SUBSTATUS_ENCORED)) return yield* this.didntAffect2();
|
||
target.set(SUB5, C.SUBSTATUS_ENCORED);
|
||
target.encoreCount = (this.random() & 3) + 3;
|
||
if (!(this.enemyGoesFirst ^ this.turn)) {
|
||
target.moveNum = this.moveSlot(target, last);
|
||
target.curMove = last;
|
||
target.move = this.moveStruct(last);
|
||
}
|
||
yield* this.say(BATTLE.GotAnEncoreText);
|
||
}
|
||
/** GetMoveData into a move struct. */
|
||
private moveStruct(id: number): MoveStruct {
|
||
const move = this.options.data.move(id);
|
||
return {
|
||
anim: move.id,
|
||
effect: move.effect,
|
||
power: move.power,
|
||
type: move.type,
|
||
accuracy: move.accuracy,
|
||
pp: move.pp,
|
||
chance: move.chance,
|
||
};
|
||
}
|
||
/** BattleCommand_Attract: the opposite gender falls in love. The
|
||
* player's side counts its party Pokémon's gender. */
|
||
private *attract(): Gen {
|
||
const target = this.target;
|
||
if (this.attackMissed || !this.oppositeGender() || this.hiddenOpponent() || target.has(SUB1, C.SUBSTATUS_IN_LOVE))
|
||
return yield* this.failMove();
|
||
target.set(SUB1, C.SUBSTATUS_IN_LOVE);
|
||
yield* this.say(BATTLE.FellInLoveText);
|
||
}
|
||
/** CheckOppositeGender: the player's party Pokémon against the enemy's
|
||
* own species and DVs, even transformed. */
|
||
private oppositeGender() {
|
||
const data = this.options.data,
|
||
enemy = this.enemy,
|
||
player = data.gender(this.partyMon.species, this.partyMon.dvs),
|
||
dvs = enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED) ? enemy.backupDvs : enemy.mon.dvs,
|
||
opponent = data.gender(this.enemySpecies, dvs);
|
||
return player !== undefined && opponent !== undefined && player !== opponent;
|
||
}
|
||
/** BattleCommand_ResetStats (Haze): every stat level back to normal. */
|
||
private *resetStats(): Gen {
|
||
const turn = this.turn;
|
||
for (const [side, sideTurn] of [[this.player, 0], [this.enemy, 1]] as const) {
|
||
side.levels = [7, 7, 7, 7, 7, 7, 7];
|
||
this.turn = sideTurn;
|
||
this.calcStats(side);
|
||
}
|
||
this.turn = turn;
|
||
yield* this.say(BATTLE.EliminatedStatsText);
|
||
}
|
||
/** BattleCommand_Heal: half the maximum HP, or all of it with Rest,
|
||
* which also puts the user to sleep for two turns. */
|
||
private *heal(): Gen {
|
||
const user = this.user,
|
||
mon = user.mon;
|
||
if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText);
|
||
const rest = user.move.anim === C.REST;
|
||
if (rest) {
|
||
user.reset(SUB5, C.SUBSTATUS_TOXIC);
|
||
const statused = mon.status !== 0;
|
||
mon.status = C.REST_SLEEP_TURNS + 1;
|
||
yield this.hud();
|
||
yield* this.say(statused ? BATTLE.RestedText : BATTLE.WentToSleepText);
|
||
this.calcStats(user);
|
||
}
|
||
mon.hp = Math.min(mon.maxHp, (mon.hp + (rest ? mon.maxHp : mon.maxHp >> 1 || 1)) & 0xffff);
|
||
this.updateUserInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.RegainedHealthText);
|
||
}
|
||
/** BattleCommand_PainSplit: both take the average of their HP, up to
|
||
* their maximum. The party structs are left as they were. */
|
||
private *painSplit(): Gen {
|
||
if (this.attackMissed || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.didntAffect2();
|
||
const average = (this.player.mon.hp + this.enemy.mon.hp) >> 1;
|
||
for (const side of [this.player, this.enemy]) side.mon.hp = Math.min(side.mon.maxHp, average);
|
||
this.curDamage = 0;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.SharedPainText);
|
||
}
|
||
/** BattleCommand_BellyDrum: half the maximum HP for the highest Attack.
|
||
* The first two stages are raised before the HP check, so they stay
|
||
* even when it fails (a kept bug). */
|
||
private *bellyDrum(): Gen {
|
||
const user = this.user;
|
||
this.statUp(C.ATTACK | 0x10);
|
||
const half = user.mon.maxHp >> 1 || 1;
|
||
if (this.attackMissed || half >= user.mon.hp) return yield* this.say(BATTLE.ButItFailedText);
|
||
this.subtractHP(user, half);
|
||
this.updateUserInParty();
|
||
yield this.hud();
|
||
for (let times = C.MAX_STAT_LEVEL - C.BASE_STAT_LEVEL - 1; times > 0; times--) this.statUp(C.ATTACK | 0x10);
|
||
yield* this.say(BATTLE.BellyDrumText);
|
||
}
|
||
/** BattleCommand_PsychUp: copy the target's stat levels, unless they
|
||
* are all normal. */
|
||
private *psychUp(): Gen {
|
||
const user = this.user,
|
||
target = this.target;
|
||
if (target.levels.every((level) => level === C.BASE_STAT_LEVEL)) return yield* this.say(BATTLE.ButItFailedText);
|
||
user.levels = [...target.levels];
|
||
this.calcStats(user);
|
||
yield* this.say(BATTLE.CopiedStatsText);
|
||
}
|
||
/** BattleCommand_PerishSong: both sides get a count of 4, unless both
|
||
* already have one. */
|
||
private *perishSong(): Gen {
|
||
const sides = [this.player, this.enemy];
|
||
if (sides.every((side) => side.has(SUB1, C.SUBSTATUS_PERISH))) return yield* this.say(BATTLE.ButItFailedText);
|
||
for (const side of sides)
|
||
if (!side.has(SUB1, C.SUBSTATUS_PERISH)) {
|
||
side.set(SUB1, C.SUBSTATUS_PERISH);
|
||
side.perishCount = 4;
|
||
}
|
||
yield* this.say(BATTLE.StartPerishText);
|
||
}
|
||
/** BattleCommand_ClearHazards (Rapid Spin): the user sheds Leech Seed,
|
||
* its side's Spikes and a trap. */
|
||
private *clearHazards(): Gen {
|
||
const user = this.user;
|
||
if (user.has(SUB4, C.SUBSTATUS_LEECH_SEED)) {
|
||
user.reset(SUB4, C.SUBSTATUS_LEECH_SEED);
|
||
yield* this.say(BATTLE.ShedLeechSeedText);
|
||
}
|
||
if (user.screens & (1 << C.SCREENS_SPIKES)) {
|
||
user.screens &= ~(1 << C.SCREENS_SPIKES);
|
||
yield* this.say(BATTLE.BlewSpikesText);
|
||
}
|
||
if (!user.wrapCount) return;
|
||
user.wrapCount = 0;
|
||
yield* this.say(BATTLE.ReleasedByText);
|
||
}
|
||
/** BattleCommand_CheckFutureSight: on the turn it strikes, the stored
|
||
* damage skips to after `futuresight`. */
|
||
private checkFutureSight() {
|
||
const user = this.user;
|
||
if (user.futureSightCount !== 1) return;
|
||
user.futureSightCount = 0;
|
||
this.curDamage = user.futureSightDamage;
|
||
this.skipTo(COMMAND.futuresight);
|
||
}
|
||
/** BattleCommand_FutureSight: store this turn's damage for three turns
|
||
* later, unless one is already coming. */
|
||
private *futureSight(): Gen {
|
||
const user = this.user;
|
||
if (!user.charging) {
|
||
user.lastCounterMove = user.move.anim;
|
||
user.lastMove = user.move.anim;
|
||
}
|
||
if (user.futureSightCount) {
|
||
this.curDamage = 0;
|
||
yield* this.say(BATTLE.ButItFailedText);
|
||
return this.endMoveEffect();
|
||
}
|
||
user.futureSightCount = 4;
|
||
this.lowerSub();
|
||
yield* this.say(BATTLE.ForesawAttackText);
|
||
this.raiseSub();
|
||
user.futureSightDamage = this.curDamage;
|
||
this.curDamage = 0;
|
||
this.endMoveEffect();
|
||
}
|
||
|
||
// ------------------------------------------------ substitutes and copies
|
||
|
||
/** BattleCommand_Substitute: a quarter of the maximum HP, which needs
|
||
* more HP than that; it also frees the user from a trap. The party
|
||
* struct keeps its HP until the next update. */
|
||
private *substitute(): Gen {
|
||
const user = this.user,
|
||
mon = user.mon;
|
||
if (user.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) {
|
||
if (user.charging) this.raiseSub();
|
||
return yield* this.say(BATTLE.HasSubstituteText);
|
||
}
|
||
const quarter = (mon.maxHp >> 2) & 0xff;
|
||
user.substituteHp = quarter;
|
||
if (mon.hp <= quarter) {
|
||
if (user.charging) this.raiseSub();
|
||
return yield* this.say(BATTLE.TooWeakSubText);
|
||
}
|
||
mon.hp -= quarter;
|
||
user.set(SUB4, C.SUBSTATUS_SUBSTITUTE);
|
||
user.wrapCount = 0;
|
||
user.trappingMove = 0;
|
||
if (this.options.battleScene !== false) this.animParam = 0;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.MadeSubstituteText);
|
||
}
|
||
/** BattleCommand_Transform: the target's species, moves (5 PP each, 1
|
||
* for Sketch), DVs, stats, types, unboosted stats and stat levels. The
|
||
* enemy keeps its own DVs in wEnemyBackupDVs. */
|
||
private *transform(): Gen {
|
||
const user = this.user,
|
||
target = this.target;
|
||
user.lastCounterMove = user.lastMove = 0; // ClearLastMove
|
||
if (target.has(SUB5, C.SUBSTATUS_TRANSFORMED) || this.hiddenOpponent()) return yield* this.say(BATTLE.ButItFailedText);
|
||
this.animParam = 1;
|
||
user.set(SUB5, C.SUBSTATUS_TRANSFORMED);
|
||
user.disableCount = user.disabledMove = 0; // ResetActorDisable
|
||
if (this.turn === 0) user.moveNum = 0;
|
||
else user.backupDvs = user.mon.dvs;
|
||
const mon = user.mon,
|
||
from = target.mon;
|
||
mon.species = from.species;
|
||
mon.moves = [...from.moves];
|
||
mon.dvs = from.dvs;
|
||
mon.stats = [...from.stats];
|
||
mon.types = [...from.types];
|
||
mon.pp = mon.moves.map((move) => (!move ? 0 : move === C.SKETCH ? 1 : 5));
|
||
user.stats = [...target.stats];
|
||
user.levels = [...target.levels];
|
||
this.strings[RAM.stringBuffer1] = this.options.data.speciesName(from.species);
|
||
this.animParam = 2;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.TransformedText);
|
||
}
|
||
/** BattleCommand_Mimic: the target's last move replaces Mimic, with 5
|
||
* PP, for this battle only. */
|
||
private *mimic(): Gen {
|
||
const user = this.user,
|
||
last = this.target.lastCounterMove;
|
||
user.lastCounterMove = user.lastMove = 0;
|
||
if (this.attackMissed || this.hiddenOpponent() || !last || last === C.STRUGGLE || user.mon.moves.includes(last))
|
||
return yield* this.failMove();
|
||
const slot = user.mon.moves.lastIndexOf(C.MIMIC);
|
||
if (slot < 0) throw new UnsupportedBattle("Mimic without Mimic in the move list is not ported yet.");
|
||
user.mon.moves[slot] = last;
|
||
user.mon.pp[slot] = 5;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(last);
|
||
yield* this.say(BATTLE.MimicLearnedMoveText);
|
||
}
|
||
/** BattleCommand_Metronome: a random move outside MetronomeExcepts that
|
||
* the user does not know. */
|
||
private *metronome(): Gen {
|
||
const user = this.user;
|
||
user.lastCounterMove = user.lastMove = 0;
|
||
if (!user.charging) this.lowerSubKeepingParam();
|
||
const excepts = this.data.byteList(BATTLE.MetronomeExcepts);
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const move = this.random();
|
||
if (move > 251 || excepts.includes(move) || user.mon.moves.includes(move)) continue;
|
||
user.curMove = move;
|
||
this.updateMoveData();
|
||
return yield* this.resetTurn();
|
||
}
|
||
throw new UnsupportedBattle("Metronome random source did not produce a move.");
|
||
}
|
||
/** BattleCommand_MirrorMove: the target's last move, unless the user
|
||
* knows it. */
|
||
private *mirrorMove(): Gen {
|
||
const user = this.user,
|
||
last = this.target.lastCounterMove;
|
||
user.lastCounterMove = user.lastMove = 0;
|
||
if (!last || user.mon.moves.includes(last)) {
|
||
yield* this.say(BATTLE.MirrorMoveFailedText);
|
||
return this.endMoveEffect();
|
||
}
|
||
user.curMove = last;
|
||
user.move = this.moveStruct(last);
|
||
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = this.options.data.moveName(last);
|
||
if (!user.charging) this.lowerSubKeepingParam();
|
||
yield* this.resetTurn();
|
||
}
|
||
/** BattleCommand_SleepTalk: a random other move of a sleeping user, not
|
||
* disabled or two-turn; it needs at least two moves. */
|
||
private *sleepTalk(): Gen {
|
||
const user = this.user,
|
||
mon = user.mon,
|
||
data = this.options.data;
|
||
user.lastCounterMove = user.lastMove = 0;
|
||
const twoTurn = (move: number) =>
|
||
([C.EFFECT_SKULL_BASH, C.EFFECT_RAZOR_WIND, C.EFFECT_SKY_ATTACK, C.EFFECT_SOLARBEAM, C.EFFECT_FLY, C.EFFECT_BIDE] as number[]).includes(
|
||
data.move(move).effect,
|
||
);
|
||
const fails = () => (this.alreadyFailed ? [] : this.say(BATTLE.ButItFailedText));
|
||
if (this.attackMissed || !(mon.status & C.SLP_MASK) || !mon.moves[1]) return yield* fails();
|
||
let usable = false;
|
||
for (const move of mon.moves) {
|
||
if (!move) break;
|
||
if (move === user.curMove || move === user.disabledMove) continue;
|
||
if (!twoTurn(move)) {
|
||
usable = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!usable) return yield* fails();
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const move = mon.moves[this.random() & 3];
|
||
if (!move || move === user.move.anim || move === user.disabledMove || twoTurn(move)) continue;
|
||
user.curMove = move;
|
||
if (!user.charging) this.lowerSubKeepingParam();
|
||
this.updateMoveData();
|
||
return yield* this.resetTurn();
|
||
}
|
||
throw new UnsupportedBattle("Sleep Talk random source did not produce a move.");
|
||
}
|
||
/** LowerSub while keeping wBattleAnimParam, as the move callers do. */
|
||
private lowerSubKeepingParam() {
|
||
const param = this.animParam;
|
||
this.lowerSub();
|
||
this.animParam = param;
|
||
}
|
||
/** ResetTurn: run the called move as if charging (no PP, no last move),
|
||
* then end the caller's script. */
|
||
private *resetTurn(): Gen {
|
||
this.user.charging = 1;
|
||
this.alreadyDisobeyed = 0;
|
||
yield* this.doMove();
|
||
this.endMoveEffect();
|
||
}
|
||
/** BattleCommand_Conversion: a random type among the user's moves that
|
||
* is neither its own nor the ??? type. */
|
||
private *conversion(): Gen {
|
||
const user = this.user,
|
||
data = this.options.data;
|
||
const types: number[] = [];
|
||
for (const move of user.mon.moves) {
|
||
if (!move) break;
|
||
types.push(data.move(move).type);
|
||
}
|
||
const allowed = (type: number | undefined) =>
|
||
type !== undefined && type !== C.CURSE_TYPE && !user.mon.types.includes(type);
|
||
if (!types.some(allowed)) return yield* this.say(BATTLE.ButItFailedText);
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const type = types[this.random() & 3];
|
||
if (!allowed(type)) continue;
|
||
user.mon.types = [type!, type!];
|
||
this.strings[RAM.stringBuffer1] = data.typeName(type!);
|
||
return yield* this.say(BATTLE.TransformedTypeText);
|
||
}
|
||
throw new UnsupportedBattle("Conversion random source did not produce a type.");
|
||
}
|
||
/** BattleCommand_Conversion2: a random type that resists the target's
|
||
* last move. */
|
||
private *conversion2(): Gen {
|
||
const user = this.user,
|
||
last = this.target.lastCounterMove,
|
||
data = this.options.data;
|
||
if (this.attackMissed || !last) return yield* this.failMove();
|
||
const moveType = data.move(last).type;
|
||
if (moveType === C.CURSE_TYPE) return yield* this.failMove();
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const type = this.random() & 0x1f;
|
||
if (!(type < C.UNUSED_TYPES || (type >= C.UNUSED_TYPES_END && type < C.TYPES_END))) continue;
|
||
user.mon.types = [type, type];
|
||
this.matchup = this.typeMatchup(moveType, user.mon.types, user.has(SUB1, C.SUBSTATUS_IDENTIFIED));
|
||
if (this.matchup >= C.EFFECTIVE) continue;
|
||
this.strings[RAM.stringBuffer1] = data.typeName(type);
|
||
return yield* this.say(BATTLE.TransformedTypeText);
|
||
}
|
||
throw new UnsupportedBattle("Conversion2 random source did not produce a type.");
|
||
}
|
||
/** BattleCommand_Sketch: the target's last move replaces Sketch for
|
||
* good, in the party struct too (or the wild Pokémon's kept moves). */
|
||
private *sketch(): Gen {
|
||
const user = this.user,
|
||
target = this.target,
|
||
last = target.lastCounterMove,
|
||
data = this.options.data;
|
||
user.lastCounterMove = user.lastMove = 0;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.has(SUB5, C.SUBSTATUS_TRANSFORMED))
|
||
return yield* this.say(BATTLE.DidntAffect1Text);
|
||
if (!last || last === C.STRUGGLE || user.mon.moves.includes(last)) return yield* this.say(BATTLE.DidntAffect1Text);
|
||
const slot = user.mon.moves.lastIndexOf(C.SKETCH);
|
||
if (slot < 0) throw new UnsupportedBattle("Sketch without Sketch in the move list is not ported yet.");
|
||
const pp = data.move(last).pp;
|
||
user.mon.moves[slot] = last;
|
||
user.mon.pp[slot] = pp;
|
||
if (this.turn === 1 && this.battleMode === C.WILD_BATTLE) {
|
||
this.wildMoves[slot] = last;
|
||
this.wildPP[slot] = pp;
|
||
} else {
|
||
const party = this.turn === 0 ? this.partyMon : this.options.trainer!.party[this.curOTMon];
|
||
party.moves[slot] = last;
|
||
party.pp[slot] = pp;
|
||
}
|
||
this.strings[RAM.stringBuffer1] = data.moveName(last);
|
||
yield* this.say(BATTLE.SketchedText);
|
||
}
|
||
|
||
// ------------------------------------------------------- moves that switch
|
||
|
||
/** BattleCommand_BatonPass: the user's substatuses and stat levels go
|
||
* to the Pokémon chosen to replace it, which is sent out silently. */
|
||
private *batonPass(): Gen {
|
||
if (this.turn === 0) {
|
||
if (!this.options.party.some((mon, index) => index !== this.curBattleMon && mon.hp))
|
||
return yield* this.say(BATTLE.ButItFailedText);
|
||
this.updateBattleMonInParty();
|
||
const slot = yield* this.forcePickSwitchMon();
|
||
// PassedBattleMonEntrance
|
||
this.curPartyMon = this.curBattleMon = slot;
|
||
this.addBattleParticipant();
|
||
this.initBattleMon();
|
||
this.applyStatLevelMultipliers(this.player);
|
||
this.checkAmuletCoin();
|
||
yield this.hud();
|
||
this.turn = 0;
|
||
yield* this.spikesDamage(this.player);
|
||
} else {
|
||
const trainer = this.options.trainer;
|
||
if (this.battleMode === C.WILD_BATTLE || !trainer!.party.some((mon, index) => index !== this.curOTMon && mon.hp))
|
||
return yield* this.say(BATTLE.ButItFailedText);
|
||
this.updateEnemyMonInParty();
|
||
this.enemySwitchMonIndex = 0;
|
||
yield* this.enemySwitchSetMode();
|
||
this.resetBattleParticipants();
|
||
this.applyStatLevelMultipliers(this.enemy);
|
||
this.turn = 1;
|
||
yield* this.spikesDamage(this.enemy);
|
||
}
|
||
// ResetBatonPassStatus
|
||
const user = this.user;
|
||
if (!(user.mon.status & C.SLP_MASK)) user.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
|
||
user.disableCount = user.disabledMove = 0;
|
||
this.breakAttraction();
|
||
user.reset(SUB5, C.SUBSTATUS_TRANSFORMED);
|
||
user.reset(SUB5, C.SUBSTATUS_ENCORED);
|
||
user.lastMove = 0;
|
||
this.player.wrapCount = this.enemy.wrapCount = 0;
|
||
}
|
||
/** ForcePickSwitchMonInBattle: a fit Pokémon other than the one out;
|
||
* the menu cannot be left. */
|
||
private *forcePickSwitchMon(): Gen<number> {
|
||
for (;;) {
|
||
const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, false);
|
||
if (!(yield* this.checkFitToFight(slot))) continue;
|
||
if (slot !== this.curBattleMon) return slot;
|
||
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
|
||
}
|
||
}
|
||
/** ApplyStatLevelMultiplierOnAllStats: the stat levels on the unboosted
|
||
* stats, without the paralysis and burn penalties. */
|
||
private applyStatLevelMultipliers(side: Side) {
|
||
for (let stat = 0; stat < 5; stat++) {
|
||
const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]);
|
||
side.mon.stats[stat] = Math.min(999, Math.floor((side.stats[stat] * numerator) / denominator)) || 1;
|
||
}
|
||
}
|
||
/** The battle types that Roar, Whirlwind and Teleport cannot end. */
|
||
private cannotFlee() {
|
||
return ([C.BATTLETYPE_FORCESHINY, C.BATTLETYPE_TRAP, C.BATTLETYPE_CELEBI, C.BATTLETYPE_SUICUNE] as number[]).includes(this.battleType);
|
||
}
|
||
/** The wild level-based escape: a lower level escapes when a random
|
||
* number below the sum of both levels plus 1 reaches a quarter of the
|
||
* other's level. */
|
||
private levelEscape(level: number, other: number) {
|
||
if (level >= other) return true;
|
||
const limit = (level + other + 1) & 0xff;
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const roll = this.random();
|
||
if (roll < limit) return roll >= other >> 2;
|
||
}
|
||
throw new UnsupportedBattle("Escape random source did not produce a number.");
|
||
}
|
||
/** The wild level: wCurPartyLevel, which holds the wild Pokémon's. */
|
||
private get wildLevel() {
|
||
return this.options.wild?.level ?? this.enemy.mon.level;
|
||
}
|
||
/** BattleCommand_ForceSwitch: Roar and Whirlwind end a wild battle, or
|
||
* drag out a random healthy Pokémon of a trainer's that already moved. */
|
||
private *forceSwitch(): Gen {
|
||
const fail = () => {
|
||
this.lowerSub();
|
||
this.raiseSub();
|
||
return this.say(BATTLE.ButItFailedText);
|
||
};
|
||
if (this.cannotFlee() || this.attackMissed) return yield* fail();
|
||
const player = this.turn === 0;
|
||
if (this.battleMode === C.WILD_BATTLE) {
|
||
const flee = player
|
||
? this.levelEscape(this.player.mon.level, this.wildLevel)
|
||
: this.levelEscape(this.wildLevel, this.player.mon.level);
|
||
if (!flee) return yield* fail();
|
||
this.updateBattleMonInParty();
|
||
this.forcedFlee();
|
||
this.animParam = 1;
|
||
return yield* this.say(this.user.move.anim === C.ROAR ? BATTLE.FledInFearText : BATTLE.BlownAwayText);
|
||
}
|
||
if (player) {
|
||
const party = this.options.trainer!.party;
|
||
if (!party.some((mon, index) => index !== this.curOTMon && mon.hp) || !this.enemyGoesFirst) return yield* fail();
|
||
this.updateEnemyMonInParty();
|
||
this.animParam = 1;
|
||
const slot = this.randomSwitch(party, this.curOTMon);
|
||
// ForceEnemySwitch
|
||
this.enemySwitchMonIndex = slot + 1;
|
||
this.loadEnemyMonToSwitchTo();
|
||
this.newEnemyMonStatus();
|
||
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||
this.turn = 1;
|
||
this.breakAttraction();
|
||
this.resetBattleParticipants();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.DraggedOutText);
|
||
return yield* this.spikesDamage(this.enemy);
|
||
}
|
||
const party = this.options.party;
|
||
if (!party.some((mon, index) => index !== this.curBattleMon && mon.hp) || this.enemyGoesFirst === 1) return yield* fail();
|
||
this.updateBattleMonInParty();
|
||
this.animParam = 1;
|
||
// SwitchPlayerMon
|
||
this.curPartyMon = this.randomSwitch(party, this.curBattleMon);
|
||
this.lastPlayerMon = this.curBattleMon;
|
||
this.curBattleMon = this.curPartyMon;
|
||
this.addBattleParticipant();
|
||
this.initBattleMon();
|
||
this.player.levels = [7, 7, 7, 7, 7, 7, 7];
|
||
this.newStatus(this.player);
|
||
this.breakAttraction();
|
||
this.checkAmuletCoin();
|
||
this.turn = 0;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.DraggedOutText);
|
||
yield* this.spikesDamage(this.player);
|
||
}
|
||
/** ForceSwitch's random pick: a party index (Random & 7) other than the
|
||
* current one, with HP. */
|
||
private randomSwitch(party: readonly PartyMon[], current: number) {
|
||
for (let attempt = 0; attempt < 4096; attempt++) {
|
||
const index = this.random() & 7;
|
||
if (index < party.length && index !== current && party[index].hp) return index;
|
||
}
|
||
throw new UnsupportedBattle("Roar random source did not produce a Pokémon.");
|
||
}
|
||
/** wForcedSwitch with SetBattleDraw: the wild battle ends at once. */
|
||
private forcedFlee() {
|
||
this.battleResult = (this.battleResult & 0xc0) | C.DRAW;
|
||
this.forcedSwitch = true;
|
||
}
|
||
/** BattleCommand_Teleport: escape a wild battle, unless trapped; a wild
|
||
* Pokémon always manages it (a kept bug). */
|
||
private *teleport(): Gen {
|
||
const fail = () => this.say(BATTLE.ButItFailedText);
|
||
if (this.cannotFlee() || this.target.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.battleMode !== C.WILD_BATTLE)
|
||
return yield* fail();
|
||
if (this.turn === 0) {
|
||
if (!this.levelEscape(this.player.mon.level, this.wildLevel)) return yield* fail();
|
||
} else this.levelEscape(this.wildLevel, this.player.mon.level);
|
||
this.updateBattleMonInParty();
|
||
this.forcedFlee();
|
||
this.animParam = 1;
|
||
this.lowerSub();
|
||
yield* this.say(BATTLE.FledFromBattleText);
|
||
}
|
||
|
||
/** SkipToBattleCommand: go on after the next `command`. */
|
||
private skipTo(command: number) {
|
||
while (this.script[this.scriptIndex++] !== command)
|
||
if (this.scriptIndex >= this.script.length)
|
||
throw new UnsupportedBattle(`Battle script has no command 0x${command.toString(16)}.`);
|
||
}
|
||
|
||
// ------------------------------------------------------ multi-turn moves
|
||
|
||
/** BattleCommand_CheckCharge: a charged move's second turn skips to
|
||
* after `charge`. */
|
||
private checkCharge() {
|
||
const user = this.user;
|
||
if (!user.has(SUB3, C.SUBSTATUS_CHARGED)) return;
|
||
user.reset(SUB3, C.SUBSTATUS_CHARGED);
|
||
user.reset(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||
user.reset(SUB3, C.SUBSTATUS_FLYING);
|
||
this.skipTo(COMMAND.charge);
|
||
}
|
||
/** BattleCommand_Charge: the charging turn. Fly and Dig take the user
|
||
* out of reach; Skull Bash goes on to its Defense raise. A sleeping
|
||
* user (through Sleep Talk) fails. */
|
||
private *charge(): Gen {
|
||
const user = this.user;
|
||
if (user.mon.status & C.SLP_MASK) {
|
||
this.raiseSub();
|
||
yield* this.say(BATTLE.ButItFailedText);
|
||
return this.endMoveEffect();
|
||
}
|
||
user.set(SUB3, C.SUBSTATUS_CHARGED);
|
||
if (this.alreadyDisobeyed) yield* this.say(BATTLE.IgnoredOrders2Text);
|
||
this.lowerSub();
|
||
this.animParam = 1;
|
||
const anim = user.move.anim;
|
||
if (anim !== C.FLY && anim !== C.DIG) this.raiseSub();
|
||
if (anim === C.FLY) user.set(SUB3, C.SUBSTATUS_FLYING);
|
||
else if (anim === C.DIG) user.set(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||
if (!user.charging) {
|
||
user.lastCounterMove = anim;
|
||
user.lastMove = anim;
|
||
}
|
||
this.curDamage = 0;
|
||
yield* this.say(BATTLE["BattleCommand_Charge.UsedText"]);
|
||
if (user.move.effect === C.EFFECT_SKULL_BASH) return this.skipTo(COMMAND.endturn);
|
||
this.endMoveEffect();
|
||
}
|
||
/** BattleCommand_CheckRampage: a rampage's later turns skip to after
|
||
* `rampage`; the last one confuses the user for 2-3 turns unless its
|
||
* side has Safeguard. */
|
||
private checkRampage() {
|
||
const user = this.user;
|
||
if (!user.has(SUB3, C.SUBSTATUS_RAMPAGE)) return;
|
||
user.rolloutCount = (user.rolloutCount - 1) & 0xff;
|
||
if (!user.rolloutCount) {
|
||
user.reset(SUB3, C.SUBSTATUS_RAMPAGE);
|
||
if (!(user.screens & (1 << C.SCREENS_SAFEGUARD))) {
|
||
user.set(SUB3, C.SUBSTATUS_CONFUSED);
|
||
user.confuseCount = (this.random() & 1) + 2;
|
||
}
|
||
}
|
||
this.skipTo(COMMAND.rampage);
|
||
}
|
||
/** BattleCommand_Rampage: 1 or 2 more turns, not while asleep. */
|
||
private rampage() {
|
||
const user = this.user;
|
||
if (user.mon.status & C.SLP_MASK) return;
|
||
user.set(SUB3, C.SUBSTATUS_RAMPAGE);
|
||
user.rolloutCount = (this.random() & 1) + 1;
|
||
}
|
||
/** BattleCommand_CheckRollout: a rolling Pokémon skips to after doturn. */
|
||
private checkRollout() {
|
||
const user = this.user;
|
||
if (user.has(SUB1, C.SUBSTATUS_ROLLOUT)) return this.skipTo(COMMAND.doturn);
|
||
user.rolloutCount = 0;
|
||
}
|
||
/** BattleCommand_RolloutPower: the damage doubles for each hit so far
|
||
* (once more after Defense Curl), for up to five hits; a miss stops. */
|
||
private rolloutPower() {
|
||
const user = this.user;
|
||
if (user.mon.status & C.SLP_MASK) return;
|
||
if (this.attackMissed) return user.reset(SUB1, C.SUBSTATUS_ROLLOUT);
|
||
user.rolloutCount = (user.rolloutCount + 1) & 0xff;
|
||
let doublings = user.rolloutCount;
|
||
if (doublings >= 5) user.reset(SUB1, C.SUBSTATUS_ROLLOUT);
|
||
else user.set(SUB1, C.SUBSTATUS_ROLLOUT);
|
||
if (user.has(SUB2, C.SUBSTATUS_CURLED)) doublings++;
|
||
this.doubleDamage(doublings - 1);
|
||
}
|
||
/** BattleCommand_FuryCutter: doubles for each hit in a row, up to 16×;
|
||
* a miss starts over. */
|
||
private furyCutter() {
|
||
const user = this.user;
|
||
if (this.attackMissed) {
|
||
user.furyCutterCount = 0;
|
||
return;
|
||
}
|
||
user.furyCutterCount = (user.furyCutterCount + 1) & 0xff;
|
||
this.doubleDamage(Math.min(user.furyCutterCount, 5) - 1);
|
||
}
|
||
/** Double wCurDamage `times` times, stopping at $ffff. */
|
||
private doubleDamage(times: number) {
|
||
for (; times > 0; times--) {
|
||
this.curDamage *= 2;
|
||
if (this.curDamage > 0xffff) {
|
||
this.curDamage = 0xffff;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
/** BattleCommand_StoreEnergy: while biding, the user stores energy; on
|
||
* the last turn it unleashes twice the damage it took. */
|
||
private *storeEnergy(): Gen {
|
||
const user = this.user;
|
||
if (!user.has(SUB3, C.SUBSTATUS_BIDE)) return;
|
||
user.rolloutCount = (user.rolloutCount - 1) & 0xff;
|
||
if (user.rolloutCount) {
|
||
yield* this.say(BATTLE.StoringEnergyText);
|
||
return this.endMoveEffect();
|
||
}
|
||
user.reset(SUB3, C.SUBSTATUS_BIDE);
|
||
yield* this.say(BATTLE.UnleashedEnergyText);
|
||
user.move.power = 1;
|
||
this.curDamage = Math.min(0xffff, user.damageTaken * 2);
|
||
if (!this.curDamage) this.attackMissed = 1;
|
||
user.damageTaken = 0;
|
||
user.charging = 0;
|
||
user.move.anim = C.BIDE;
|
||
this.skipTo(COMMAND.unleashenergy);
|
||
}
|
||
/** BattleCommand_UnleashEnergy: Bide's first turn. It also clears both
|
||
* move structs' effects. */
|
||
private unleashEnergy() {
|
||
const user = this.user;
|
||
user.set(SUB3, C.SUBSTATUS_BIDE);
|
||
user.damageTaken = 0;
|
||
this.player.move.effect = 0;
|
||
this.enemy.move.effect = 0;
|
||
user.rolloutCount = (this.random() & 1) + 2;
|
||
this.animParam = 1;
|
||
this.endMoveEffect();
|
||
}
|
||
/** BattleCommand_TrapTarget: trapped for 2-5 more turns, unless already
|
||
* trapped or behind a substitute. */
|
||
private *trapTarget(): Gen {
|
||
if (this.attackMissed) return;
|
||
const target = this.target;
|
||
if (target.wrapCount || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||
target.wrapCount = (this.random() & 3) + 3;
|
||
const anim = this.user.move.anim;
|
||
target.trappingMove = anim;
|
||
const text = {
|
||
[C.BIND]: BATTLE.UsedBindText,
|
||
[C.WRAP]: BATTLE.WrappedByText,
|
||
[C.FIRE_SPIN]: BATTLE.FireSpinTrapText,
|
||
[C.CLAMP]: BATTLE.ClampedByText,
|
||
[C.WHIRLPOOL]: BATTLE.WhirlpoolTrapText,
|
||
}[anim];
|
||
if (text === undefined) throw new UnsupportedBattle("Unknown trapping move.");
|
||
yield* this.say(text);
|
||
}
|
||
/** BattleCommand_LockOn: the user's next move cannot miss the target. */
|
||
private *lockOn(): Gen {
|
||
const target = this.target;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed)
|
||
return yield* this.say(BATTLE.DidntAffect1Text);
|
||
target.set(SUB5, C.SUBSTATUS_LOCK_ON);
|
||
yield* this.say(BATTLE.TookAimText);
|
||
}
|
||
|
||
/** BattleCommand_HeldFlinch (kingsrock). */
|
||
private kingsRock() {
|
||
if (this.attackMissed) return;
|
||
const item = this.data.heldEffect(this.user.mon.item);
|
||
if (item.effect !== C.HELD_FLINCH || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||
if (this.random() >= item.parameter) return;
|
||
this.target.reset(SUB4, C.SUBSTATUS_RECHARGE);
|
||
this.target.set(SUB3, C.SUBSTATUS_FLINCHED);
|
||
}
|
||
|
||
// ------------------------------------------------------------- statuses
|
||
|
||
private safeguarded() {
|
||
return Boolean(this.target.screens & (1 << C.SCREENS_SAFEGUARD));
|
||
}
|
||
private isType(side: Side, type: number) {
|
||
return side.mon.types[0] === type || side.mon.types[1] === type;
|
||
}
|
||
/** CheckMoveTypeMatchesTarget: the move shares a type with the target,
|
||
* unless it is Normal (Tri Attack). */
|
||
private moveTypeMatchesTarget() {
|
||
const type = this.user.move.type;
|
||
return type !== C.NORMAL && this.isType(this.target, type);
|
||
}
|
||
/** Defrost: a fire move thaws a frozen target, in the party struct too
|
||
* (wOTPartyMons for a trainer; a wild Pokémon has none to keep). */
|
||
private *defrostOpponent(): Gen {
|
||
const target = this.target;
|
||
if (!(target.mon.status & (1 << C.FRZ))) return;
|
||
target.mon.status = 0;
|
||
if (this.turn === 1) this.partyMon.status = 0;
|
||
else if (this.options.trainer) this.options.trainer.party[this.curOTMon].status = 0;
|
||
this.updateOpponentInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.DefrostedOpponentText);
|
||
}
|
||
/** BattleCommand_Defrost: Flame Wheel and Sacred Fire thaw their user;
|
||
* a wild Pokémon's party struct is left alone. */
|
||
private *defrostUser(): Gen {
|
||
const user = this.user;
|
||
if (!(user.mon.status & (1 << C.FRZ))) return;
|
||
user.mon.status &= ~(1 << C.FRZ);
|
||
if (this.turn === 0) this.partyMon.status &= ~(1 << C.FRZ);
|
||
else if (this.battleMode !== C.WILD_BATTLE)
|
||
this.options.trainer!.party[this.curOTMon].status &= ~(1 << C.FRZ);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.WasDefrostedText);
|
||
}
|
||
/** BattleCommand_FreezeTarget. */
|
||
private *freezeTarget(): Gen {
|
||
const target = this.target;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return;
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
|
||
if (this.weather === C.WEATHER_SUN) return;
|
||
if (this.moveTypeMatchesTarget()) return; // no freezing an Ice type
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_FREEZE) return;
|
||
if (this.effectFailed || this.safeguarded()) return;
|
||
target.mon.status |= 1 << C.FRZ;
|
||
this.updateOpponentInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.WasFrozenText);
|
||
if (yield* this.useHeldStatusHealingItem()) return;
|
||
// OpponentCantMove and EndRechargeOpp; a freeze this turn skips
|
||
// HandleDefrost.
|
||
this.turn ^= 1;
|
||
this.cantMove();
|
||
this.turn ^= 1;
|
||
target.reset(SUB4, C.SUBSTATUS_RECHARGE);
|
||
target.justGotFrozen = 1;
|
||
}
|
||
/** BattleCommand_TriStatusChance: one of paralysis, freeze or burn. */
|
||
private *triStatusChance(): Gen {
|
||
this.effectChance();
|
||
let choice: number;
|
||
do choice = (this.random() >> 4) & 3;
|
||
while (!choice);
|
||
if (choice === 1) yield* this.paralyzeTarget();
|
||
else if (choice === 2) yield* this.freezeTarget();
|
||
else yield* this.burnTarget();
|
||
}
|
||
/** UseHeldStatusHealingItem for the opponent. Returns true if it cured. */
|
||
private *useHeldStatusHealingItem(): Gen<boolean> {
|
||
const side = this.target,
|
||
held = this.data.heldEffect(side.mon.item);
|
||
const mask = this.data.heldStatusHealing(held.effect);
|
||
if (mask === undefined || !(side.mon.status & mask)) return false;
|
||
side.mon.status = 0;
|
||
this.updateOpponentInParty();
|
||
side.reset(SUB5, C.SUBSTATUS_TOXIC);
|
||
side.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
|
||
// ALL_STATUS (a Miracle Berry) also cures confusion.
|
||
if (mask === this.data.heldStatusHealing(C.HELD_HEAL_STATUS)) side.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||
this.calcStats(side);
|
||
yield this.hud();
|
||
yield* this.useOpponentItem();
|
||
return true;
|
||
}
|
||
/** UseOpponentItem: name the item, consume it and report the recovery. */
|
||
private *useOpponentItem(): Gen {
|
||
const side = this.target;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item);
|
||
this.consumeHeldItem(side);
|
||
yield* this.say(BATTLE.RecoveredUsingText);
|
||
}
|
||
/** ConsumeHeldItem: consumable held items are used up for good. */
|
||
private consumeHeldItem(side: Side) {
|
||
const effect = this.data.heldEffect(side.mon.item).effect;
|
||
if (!this.data.byteList(BATTLE.ConsumableEffects).includes(effect)) return;
|
||
this.removeHeldItem(side);
|
||
}
|
||
/** 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);
|
||
this.removeHeldItem(side);
|
||
}
|
||
private *poisonTarget(): Gen {
|
||
const target = this.target;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return;
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON)) return;
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) return;
|
||
if (this.effectFailed || this.safeguarded()) return;
|
||
target.mon.status |= 1 << C.PSN;
|
||
this.updateOpponentInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.WasPoisonedText);
|
||
yield* this.useHeldStatusHealingItem();
|
||
}
|
||
private *burnTarget(): Gen {
|
||
const target = this.target;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||
if (target.mon.status) return yield* this.defrostOpponent();
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
|
||
if (this.moveTypeMatchesTarget()) return; // no burning a Fire type
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_BURN) return;
|
||
if (this.effectFailed || this.safeguarded()) return;
|
||
target.mon.status |= 1 << C.BRN;
|
||
this.updateOpponentInParty();
|
||
this.applyStatusEffects(target);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.WasBurnedText);
|
||
yield* this.useHeldStatusHealingItem();
|
||
}
|
||
private *paralyzeTarget(): Gen {
|
||
const target = this.target;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return;
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) return;
|
||
if (this.effectFailed || this.safeguarded()) return;
|
||
target.mon.status |= 1 << C.PAR;
|
||
this.updateOpponentInParty();
|
||
this.applyStatusEffects(target);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.ParalyzedText);
|
||
yield* this.useHeldStatusHealingItem();
|
||
}
|
||
private flinchTarget() {
|
||
const target = this.target;
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||
if (target.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return;
|
||
if (this.enemyGoesFirst ^ this.turn) return;
|
||
if (this.effectFailed) return;
|
||
target.set(SUB3, C.SUBSTATUS_FLINCHED);
|
||
target.reset(SUB4, C.SUBSTATUS_RECHARGE);
|
||
}
|
||
/** The AI's 25% failure for status moves (not for the player). */
|
||
private aiRandomFail() {
|
||
if (this.turn === 0 || this.options.battleTower) return false;
|
||
if (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);
|
||
// The Battle Tower keeps the sleep counter to %011.
|
||
const mask = this.options.battleTower ? 0b011 : C.SLP_MASK;
|
||
let turns: number;
|
||
do turns = this.random() & mask;
|
||
while (!turns || turns === C.SLP_MASK);
|
||
target.mon.status = turns + 1;
|
||
this.updateOpponentInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.FellAsleepText);
|
||
// A sleeping opponent cannot finish a move that spans turns.
|
||
if (!(yield* this.useHeldStatusHealingItem())) {
|
||
this.turn ^= 1;
|
||
this.cantMove();
|
||
this.turn ^= 1;
|
||
}
|
||
}
|
||
private *poisonCommand(): Gen {
|
||
const target = this.target;
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON))
|
||
return yield* this.say(BATTLE.DoesntAffectText);
|
||
if (target.mon.status & (1 << C.PSN)) return yield* this.say(BATTLE.AlreadyPoisonedText);
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) {
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
|
||
return yield* this.say(BATTLE.ProtectedByText);
|
||
}
|
||
if (target.mon.status || this.aiRandomFail() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed)
|
||
return yield* this.say(BATTLE.DidntAffect1Text);
|
||
// Toxic badly poisons, starting its damage counter over.
|
||
const toxic = this.user.move.effect === C.EFFECT_TOXIC;
|
||
if (toxic) {
|
||
target.set(SUB5, C.SUBSTATUS_TOXIC);
|
||
target.toxicCount = 0;
|
||
}
|
||
target.mon.status |= 1 << C.PSN;
|
||
this.updateOpponentInParty();
|
||
yield this.hud();
|
||
yield* this.say(toxic ? BATTLE.BadlyPoisonedText : BATTLE.WasPoisonedText);
|
||
yield* this.useHeldStatusHealingItem();
|
||
}
|
||
private *paralyzeCommand(): Gen {
|
||
const target = this.target;
|
||
if (target.mon.status & (1 << C.PAR)) return yield* this.say(BATTLE.AlreadyParalyzedText);
|
||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return yield* this.say(BATTLE.DoesntAffectText);
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) {
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
|
||
return yield* this.say(BATTLE.ProtectedByText);
|
||
}
|
||
if (this.aiRandomFail() || target.mon.status || this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE))
|
||
return yield* this.didntAffect2();
|
||
target.mon.status |= 1 << C.PAR;
|
||
this.updateOpponentInParty();
|
||
this.applyStatusEffects(target);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.ParalyzedText);
|
||
yield* this.useHeldStatusHealingItem();
|
||
}
|
||
private *confuseCommand(): Gen {
|
||
const target = this.target;
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) {
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item);
|
||
return yield* this.say(BATTLE.ProtectedByText);
|
||
}
|
||
if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.say(BATTLE.AlreadyConfusedText);
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) {
|
||
const effect = this.user.move.effect;
|
||
if (effect === C.EFFECT_CONFUSE_HIT || effect === C.EFFECT_SNORE || effect === C.EFFECT_SWAGGER) return;
|
||
return yield* this.didntAffect2();
|
||
}
|
||
yield* this.finishConfusingTarget();
|
||
}
|
||
/** BattleCommand_ConfuseTarget: a move's added confusion, silent when
|
||
* it fails. */
|
||
private *confuseTarget(): Gen {
|
||
const target = this.target;
|
||
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) return;
|
||
if (this.effectFailed || this.safeguarded() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||
if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return;
|
||
yield* this.finishConfusingTarget();
|
||
}
|
||
/** BattleCommand_FinishConfusingTarget: confused for 2-5 turns. */
|
||
private *finishConfusingTarget(): Gen {
|
||
const target = this.target;
|
||
target.set(SUB3, C.SUBSTATUS_CONFUSED);
|
||
target.confuseCount = (this.random() & 3) + 2;
|
||
yield* this.say(BATTLE.BecameConfusedText);
|
||
yield* this.useConfusionHealingItem();
|
||
}
|
||
/** BattleCommand_DrainTarget and BattleCommand_EatDream: SapHealth
|
||
* restores half the damage. */
|
||
private *drainTarget(text: number = BATTLE.SuckedHealthText): Gen {
|
||
const mon = this.user.mon;
|
||
const amount = this.curDamage >> 1 || 1;
|
||
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
|
||
if (this.turn === 0) this.updateBattleMonInParty();
|
||
yield this.hud();
|
||
yield* this.say(text);
|
||
}
|
||
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
|
||
private *recoil(): Gen {
|
||
const mon = this.user.mon;
|
||
mon.hp = Math.max(0, mon.hp - (this.curDamage >> 2 || 1));
|
||
if (this.turn === 0) this.updateBattleMonInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.RecoilText);
|
||
}
|
||
private *checkSafeguard(): Gen {
|
||
if (!this.safeguarded()) return;
|
||
this.attackMissed = 1;
|
||
yield* this.say(BATTLE.SafeguardProtectText);
|
||
this.endMoveEffect();
|
||
}
|
||
private *foresight(): Gen {
|
||
const target = this.target;
|
||
if (this.attackMissed || target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)) || target.has(SUB1, C.SUBSTATUS_IDENTIFIED))
|
||
return yield* this.say(target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText);
|
||
target.set(SUB1, C.SUBSTATUS_IDENTIFIED);
|
||
yield* this.say(BATTLE.IdentifiedText);
|
||
}
|
||
/** BattleCommand_TimeBasedHealContinue (Morning Sun, Synthesis, Moonlight). */
|
||
private *timeBasedHeal(time: number): Gen {
|
||
const mon = this.user.mon;
|
||
if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText);
|
||
let index = 2;
|
||
if (this.options.timeOfDay !== time) index--;
|
||
// Twice as much in the sun, half in rain or a sandstorm.
|
||
if (this.weather) index += this.weather === C.WEATHER_SUN ? 1 : -1;
|
||
const quarter = mon.maxHp >> 2 || 1;
|
||
const amount = [quarter >> 1 || 1, quarter, mon.maxHp >> 1 || 1, mon.maxHp][index];
|
||
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
|
||
this.updateUserInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.RegainedHealthText);
|
||
}
|
||
/** UpdateOpponentInParty and UpdateUserInParty. */
|
||
private updateOpponentInParty() {
|
||
if (this.turn === 1) this.updateBattleMonInParty();
|
||
else this.updateEnemyMonInParty();
|
||
}
|
||
private updateUserInParty() {
|
||
if (this.turn === 0) this.updateBattleMonInParty();
|
||
else this.updateEnemyMonInParty();
|
||
}
|
||
/** UpdateEnemyMonInParty: a trainer's Pokémon's level, status and HP go
|
||
* back to wOTPartyMons; wild Pokémon have none. */
|
||
private updateEnemyMonInParty() {
|
||
if (this.battleMode === C.WILD_BATTLE) return;
|
||
const mon = this.options.trainer?.party[this.curOTMon];
|
||
if (!mon) return;
|
||
mon.level = this.enemy.mon.level;
|
||
mon.status = this.enemy.mon.status;
|
||
mon.hp = this.enemy.mon.hp;
|
||
}
|
||
|
||
// ------------------------------------------------------ stat changes
|
||
|
||
/** RaiseStat (BattleCommand_StatUp). */
|
||
private raiseStat(stat: number) {
|
||
this.loweredStat = stat;
|
||
const user = this.user,
|
||
index = stat & 0xf;
|
||
if (this.attackMissed || this.effectFailed) {
|
||
this.failedMessage = 1;
|
||
return;
|
||
}
|
||
let level = user.levels[index] + 1;
|
||
if (level > C.MAX_STAT_LEVEL) {
|
||
this.failedMessage = 2;
|
||
this.attackMissed = 1;
|
||
return;
|
||
}
|
||
if (stat & 0xf0) level = Math.min(C.MAX_STAT_LEVEL, level + 1);
|
||
user.levels[index] = level;
|
||
if (index < C.ACCURACY) {
|
||
if (user.mon.stats[index] === 999) {
|
||
user.levels[index]--;
|
||
this.failedMessage = 2;
|
||
this.attackMissed = 1;
|
||
return;
|
||
}
|
||
this.calcStats(user);
|
||
}
|
||
this.failedMessage = 0;
|
||
}
|
||
/** BattleCommand_StatUp: RaiseStat, then MinimizeDropSub records a
|
||
* successful Minimize for Stomp. */
|
||
private statUp(stat: number) {
|
||
this.raiseStat(stat);
|
||
if (!this.failedMessage && this.user.move.anim === C.MINIMIZE) this.user.minimized = 1;
|
||
}
|
||
/** BattleCommand_StatDown. */
|
||
private statDown(stat: number) {
|
||
this.loweredStat = stat;
|
||
const effect = this.user.move.effect;
|
||
const mistEffect =
|
||
(effect >= C.EFFECT_ATTACK_DOWN && effect <= C.EFFECT_EVASION_DOWN) ||
|
||
(effect >= C.EFFECT_ATTACK_DOWN_2 && effect <= C.EFFECT_EVASION_DOWN_2) ||
|
||
(effect >= C.EFFECT_ATTACK_DOWN_HIT && effect <= C.EFFECT_EVASION_DOWN_HIT);
|
||
const target = this.target;
|
||
if (mistEffect && target.has(SUB4, C.SUBSTATUS_MIST)) {
|
||
this.failedMessage = 2;
|
||
this.attackMissed = 1;
|
||
return;
|
||
}
|
||
const index = stat & 0xf;
|
||
let level = target.levels[index] - 1;
|
||
const cantLower = () => {
|
||
this.failedMessage = 3;
|
||
this.attackMissed = 1;
|
||
};
|
||
if (!level) return cantLower();
|
||
if (stat & 0xf0) {
|
||
level--;
|
||
if (!level) level = 1;
|
||
}
|
||
const failed = () => {
|
||
this.failedMessage = 1;
|
||
this.attackMissed = 1;
|
||
};
|
||
if (this.turn === 1 && effect !== C.EFFECT_ACCURACY_DOWN_HIT && !this.player.has(SUB5, C.SUBSTATUS_LOCK_ON))
|
||
if (this.random() < percent(25) + 1) return failed();
|
||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed || this.effectFailed) return failed();
|
||
if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))) return failed();
|
||
target.levels[index] = level;
|
||
if (index < C.ACCURACY) {
|
||
// TryLowerStat: a stat already at 1 cannot drop further. The level is
|
||
// raised back by one, even after a two-stage drop.
|
||
if (target.mon.stats[index] === 1) {
|
||
target.levels[index] = level + 1;
|
||
return cantLower();
|
||
}
|
||
this.calcStats(target);
|
||
}
|
||
this.failedMessage = 0;
|
||
}
|
||
/** CalcPlayerStats / CalcEnemyStats: stages, the player's badge
|
||
* boosts, 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);
|
||
}
|
||
if (side === this.player) this.badgeStatBoosts();
|
||
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
|
||
|
||
/** HandleBerserkGene, before the menu and AI. Confusion keeps its old
|
||
* count; zero therefore wraps to 255 on the first confused turn. */
|
||
private *handleBerserkGene(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
if (side.mon.item !== C.BERSERK_GENE) continue;
|
||
this.turn = turn;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item);
|
||
this.removeHeldItem(side);
|
||
const confused = side.has(SUB3, C.SUBSTATUS_CONFUSED);
|
||
side.set(SUB3, C.SUBSTATUS_CONFUSED);
|
||
const anim = side.move.anim;
|
||
side.move.anim = 0;
|
||
this.attackMissed = this.effectFailed = 0;
|
||
this.statUp(C.ATTACK | 0x10);
|
||
side.move.anim = anim;
|
||
yield* this.say(BATTLE.BattleText_UsersStringBuffer1Activated);
|
||
yield* this.statUpMessage();
|
||
if (!confused) {
|
||
this.turn ^= 1;
|
||
yield* this.say(BATTLE.BecameConfusedText);
|
||
}
|
||
}
|
||
}
|
||
/** Item removal used by routines which consume unconditionally. */
|
||
private removeHeldItem(side: Side) {
|
||
side.mon.item = 0;
|
||
if (side === this.player) this.partyMon.item = 0;
|
||
else if (this.options.trainer) this.options.trainer.party[this.curOTMon].item = 0;
|
||
}
|
||
/** HandleMysteryberry checks the saved move list and PP before the
|
||
* battle copy, so Transform and Mimic do not replenish copied moves. */
|
||
private *handleMysteryBerry(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
if (this.data.heldEffect(side.mon.item).effect !== C.HELD_RESTORE_PP) continue;
|
||
this.turn = turn;
|
||
const stored = turn === 0 ? this.partyMon : this.options.trainer?.party[this.curOTMon];
|
||
const moves = stored?.moves ?? this.wildMoves, pp = stored?.pp ?? this.wildPP;
|
||
for (let slot = 0; slot < C.NUM_MOVES && moves[slot]; slot++) {
|
||
if (pp[slot] & C.PP_MASK) continue;
|
||
const amount = moves[slot] === C.SKETCH ? 1 : 5;
|
||
pp[slot] = (pp[slot] + amount) & 255;
|
||
if (moves[slot] === side.mon.moves[slot] && !side.has(SUB5, C.SUBSTATUS_TRANSFORMED))
|
||
side.mon.pp[slot] = (side.mon.pp[slot] + amount) & 255;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item);
|
||
this.removeHeldItem(side);
|
||
yield* this.say(BATTLE.BattleText_UserRecoveredPPUsing);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
/** HandleStatBoostingHeldItems. No item in the pinned ROM uses these
|
||
* effects; retain their failure checks and their place between turns. */
|
||
private *handleStatBoostingHeldItems(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
const effect = this.data.heldEffect(side.mon.item).effect;
|
||
if (effect < C.HELD_ATTACK_UP || effect > C.HELD_EVASION_UP) continue;
|
||
this.turn = turn;
|
||
this.statUp(effect - C.HELD_ATTACK_UP);
|
||
if (this.failedMessage) continue;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item);
|
||
this.removeHeldItem(side);
|
||
yield* this.say(BATTLE.BattleText_UsersStringBuffer1Activated);
|
||
yield* this.statUpMessage();
|
||
}
|
||
}
|
||
|
||
/** ResidualDamage for the side whose turn it is: poison and burn (Toxic
|
||
* a growing number of sixteenths), Leech Seed, Nightmare and Curse.
|
||
* Returns false if it fainted. */
|
||
private *residualDamage(): Gen<boolean> {
|
||
const user = this.user,
|
||
mon = user.mon,
|
||
quarter = mon.maxHp >> 2 || 1;
|
||
if (!mon.hp) return false;
|
||
if (mon.status & ((1 << C.PSN) | (1 << C.BRN))) {
|
||
yield* this.say(mon.status & (1 << C.BRN) ? BATTLE.HurtByBurnText : BATTLE.HurtByPoisonText);
|
||
let damage = quarter >> 1 || 1;
|
||
if (user.has(SUB5, C.SUBSTATUS_TOXIC)) {
|
||
user.toxicCount = (user.toxicCount + 1) & 0xff;
|
||
damage = ((quarter >> 2 || 1) * (user.toxicCount || 0x100)) & 0xffff;
|
||
}
|
||
this.subtractHP(user, damage);
|
||
yield this.hud();
|
||
}
|
||
if (!mon.hp) return false;
|
||
if (user.has(SUB4, C.SUBSTATUS_LEECH_SEED)) {
|
||
// The seeder regains the HP actually drained.
|
||
const drained = Math.min(mon.hp, quarter >> 1 || 1);
|
||
this.subtractHP(user, drained);
|
||
const seeder = this.target;
|
||
seeder.mon.hp = Math.min(seeder.mon.maxHp, seeder.mon.hp + drained);
|
||
if (seeder === this.player) this.updateBattleMonInParty();
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.LeechSeedSapsText);
|
||
}
|
||
if (!mon.hp) return false;
|
||
if (user.has(SUB1, C.SUBSTATUS_NIGHTMARE)) {
|
||
this.subtractHP(user, quarter);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.HasANightmareText);
|
||
}
|
||
if (!mon.hp) return false;
|
||
if (user.has(SUB1, C.SUBSTATUS_CURSE)) {
|
||
this.subtractHP(user, quarter);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.HurtByCurseText);
|
||
}
|
||
return mon.hp > 0;
|
||
}
|
||
private subtractHP(side: Side, amount: number) {
|
||
side.mon.hp = Math.max(0, side.mon.hp - amount);
|
||
if (side === this.player) this.updateBattleMonInParty();
|
||
}
|
||
/** HandleBetweenTurnEffects in non-Link order. */
|
||
private *handleBetweenTurnEffects(): Gen {
|
||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||
yield* this.handleFutureSight();
|
||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||
yield* this.handleWeather();
|
||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||
yield* this.handleWrap();
|
||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||
yield* this.handlePerishSong();
|
||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||
// 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);
|
||
}
|
||
yield* this.handleMysteryBerry();
|
||
// HandleDefrost: a 10% thaw for a side that did not just freeze.
|
||
for (const side of [this.player, this.enemy]) {
|
||
if (!(side.mon.status & (1 << C.FRZ)) || side.justGotFrozen) continue;
|
||
if (this.random() >= percent(10)) continue;
|
||
side.mon.status = 0;
|
||
if (side === this.player) this.updateBattleMonInParty();
|
||
this.turn = side === this.player ? 1 : 0;
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.DefrostedOpponentText);
|
||
}
|
||
yield* this.handleSafeguard();
|
||
yield* this.handleScreens();
|
||
yield* this.handleStatBoostingHeldItems();
|
||
// HandleHealingItems: turn 0 handles the enemy's items, then turn 1.
|
||
for (const turn of [0, 1]) {
|
||
this.turn = turn;
|
||
yield* this.handleHPHealingItem();
|
||
yield* this.useHeldStatusHealingItem();
|
||
yield* this.useConfusionHealingItem();
|
||
}
|
||
this.updateBattleMonInParty();
|
||
yield* this.handleEncore();
|
||
}
|
||
/** HandleFutureSight: three turns after its use, the stored attack hits
|
||
* through DoMove, without CheckTurn. */
|
||
private *handleFutureSight(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
this.turn = turn;
|
||
if (!side.futureSightCount) continue;
|
||
side.futureSightCount--;
|
||
if (side.futureSightCount !== 1) continue;
|
||
yield* this.say(BATTLE.BattleText_TargetWasHitByFutureSight);
|
||
const move = side.curMove;
|
||
side.curMove = C.FUTURE_SIGHT;
|
||
this.updateMoveData();
|
||
this.attackMissed = 0;
|
||
this.alreadyDisobeyed = 0;
|
||
this.typeModifier = C.EFFECTIVE;
|
||
yield* this.doMove();
|
||
this.curDamage = 0;
|
||
side.curMove = move;
|
||
this.updateBattleMonInParty();
|
||
this.updateEnemyMonInParty();
|
||
}
|
||
}
|
||
/** HandlePerishSong: each count goes down; at 0 the Pokémon faints. */
|
||
private *handlePerishSong(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
this.turn = turn;
|
||
if (!side.has(SUB1, C.SUBSTATUS_PERISH)) continue;
|
||
side.perishCount = (side.perishCount - 1) & 0xff;
|
||
this.numbers[RAM.textDecimalByte] = side.perishCount;
|
||
yield* this.say(BATTLE.PerishCountText);
|
||
if (side.perishCount) continue;
|
||
side.reset(SUB1, C.SUBSTATUS_PERISH);
|
||
side.mon.hp = 0;
|
||
if (turn === 0) this.partyMon.hp = 0;
|
||
else if (this.battleMode !== C.WILD_BATTLE) this.options.trainer!.party[this.curOTMon].hp = 0;
|
||
yield this.hud();
|
||
}
|
||
}
|
||
/** HandleSafeguard: five turns, the use included. */
|
||
private *handleSafeguard(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
if (!(side.screens & (1 << C.SCREENS_SAFEGUARD))) continue;
|
||
side.safeguardCount = (side.safeguardCount - 1) & 0xff;
|
||
if (side.safeguardCount) continue;
|
||
side.screens &= ~(1 << C.SCREENS_SAFEGUARD);
|
||
this.turn = turn;
|
||
yield* this.say(BATTLE.BattleText_SafeguardFaded);
|
||
}
|
||
}
|
||
/** HandleEncore: an encore ends after its turns, or when the move in
|
||
* the chosen slot runs out of PP. */
|
||
private *handleEncore(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
if (!side.has(SUB5, C.SUBSTATUS_ENCORED)) continue;
|
||
side.encoreCount = (side.encoreCount - 1) & 0xff;
|
||
if (side.encoreCount && side.mon.pp[side.moveNum] & C.PP_MASK) continue;
|
||
side.reset(SUB5, C.SUBSTATUS_ENCORED);
|
||
this.turn = turn ^ 1;
|
||
yield* this.say(BATTLE.BattleText_TargetsEncoreEnded);
|
||
}
|
||
}
|
||
/** SpikesDamage: a Pokémon sent out onto Spikes loses an eighth of its
|
||
* maximum HP, unless it is a Flying type. */
|
||
private *spikesDamage(side: Side): Gen {
|
||
if (!(side.screens & (1 << C.SCREENS_SPIKES)) || this.isType(side, C.FLYING)) return;
|
||
yield* this.say(BATTLE.BattleText_UserHurtBySpikes);
|
||
this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 1 || 1);
|
||
yield this.hud();
|
||
}
|
||
/** HandleWeather: the weather's message, or its end after five turns;
|
||
* a sandstorm hurts all but Rock, Ground, Steel and digging Pokémon. */
|
||
private *handleWeather(): Gen {
|
||
if (!this.weather) return;
|
||
const index = this.weather - 1;
|
||
if (!--this.weatherCount) {
|
||
yield* this.say([BATTLE.BattleText_TheRainStopped, BATTLE.BattleText_TheSunlightFaded, BATTLE.BattleText_TheSandstormSubsided][index]);
|
||
this.weather = C.WEATHER_NONE;
|
||
return;
|
||
}
|
||
yield* this.say([BATTLE.BattleText_RainContinuesToFall, BATTLE.BattleText_TheSunlightIsStrong, BATTLE.BattleText_TheSandstormRages][index]);
|
||
if (this.weather !== C.WEATHER_SANDSTORM) return;
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
this.turn = turn;
|
||
if (side.has(SUB3, C.SUBSTATUS_UNDERGROUND)) continue;
|
||
if (([C.ROCK, C.GROUND, C.STEEL] as number[]).some((type) => this.isType(side, type))) continue;
|
||
this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 1 || 1);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.SandstormHitsText);
|
||
}
|
||
}
|
||
/** HandleWrap: a trapped side loses 1/16 of its maximum HP, or is
|
||
* released on its last turn. A substitute shields it and stops the
|
||
* count. */
|
||
private *handleWrap(): Gen {
|
||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||
this.turn = turn;
|
||
if (!side.wrapCount || side.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) continue;
|
||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(side.trappingMove);
|
||
side.wrapCount--;
|
||
if (!side.wrapCount) {
|
||
yield* this.say(BATTLE.BattleText_UserWasReleasedFromStringBuffer1);
|
||
continue;
|
||
}
|
||
// GetSixteenthMaxHP
|
||
this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 2 || 1);
|
||
yield this.hud();
|
||
yield* this.say(BATTLE.BattleText_UsersHurtByStringBuffer1);
|
||
}
|
||
}
|
||
private *checkFaintPlayerThenEnemy(): Gen<boolean> {
|
||
if (this.hasFainted(this.player)) {
|
||
yield* this.handlePlayerMonFaint();
|
||
if (this.battleEnded) return true;
|
||
}
|
||
if (this.hasFainted(this.enemy)) {
|
||
yield* this.handleEnemyMonFaint();
|
||
if (this.battleEnded) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private *handleEnemyMonFaint(): Gen {
|
||
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
|
||
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||
if (!this.player.mon.hp) yield* this.faintYourPokemon();
|
||
if (!this.options.trainer) this.winSounds();
|
||
// PlayVictoryMusic: a wild battle's, once someone gets the experience
|
||
// or a Pay Day coin.
|
||
if (
|
||
!this.options.trainer &&
|
||
this.options.party.some((mon) => mon.hp > 0) &&
|
||
(this.player.mon.hp ||
|
||
this.payDayMoney ||
|
||
this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE))
|
||
)
|
||
this.playSound({ music: "victory" });
|
||
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
|
||
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
|
||
yield this.hud();
|
||
// A wild battle ends; so does a trainer's with no Pokémon left (WinTrainerBattle).
|
||
if (!this.options.trainer || this.enemyTrainerDefeated()) {
|
||
this.battleEnded = true;
|
||
return;
|
||
}
|
||
if (!this.player.mon.hp) {
|
||
// Trainer battles never ask, so both sides send out a new Pokémon.
|
||
yield* this.askUseNextPokemon();
|
||
yield* this.forcePlayerMonChoice();
|
||
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
|
||
return yield* this.doubleSwitch();
|
||
}
|
||
// HandleEnemySwitch: the player's Pokémon stays in.
|
||
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
|
||
yield* this.enemyPartyMonEntrance(false);
|
||
this.battlePlayerAction = 0;
|
||
}
|
||
/** CheckEnemyTrainerDefeated: every party HP is zero. */
|
||
private enemyTrainerDefeated() {
|
||
return this.options.trainer!.party.every((mon) => mon.hp === 0);
|
||
}
|
||
/** EnemyPartyMonEntrance. `setMode` skips Shift mode's offer, as after a
|
||
* double faint. */
|
||
private *enemyPartyMonEntrance(setMode: boolean): Gen {
|
||
this.enemySwitchMonIndex = 0;
|
||
this.newEnemyMonStatus();
|
||
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels
|
||
this.breakAttraction();
|
||
if (setMode) yield* this.enemySwitchSetMode();
|
||
else yield* this.enemySwitch();
|
||
this.resetBattleParticipants();
|
||
this.turn = 1;
|
||
yield* this.spikesDamage(this.enemy);
|
||
this.enemy.move.anim = 0;
|
||
this.battlePlayerAction = 0;
|
||
}
|
||
/** EnemySwitch: in Shift mode the player may switch too (OfferSwitch). */
|
||
private *enemySwitch(): Gen {
|
||
if (!this.checkWhetherToAskSwitch()) return yield* this.enemySwitchSetMode();
|
||
this.loadEnemyMonToSwitchTo();
|
||
const switching = yield* this.offerSwitch();
|
||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||
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);
|
||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||
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.egg ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
|
||
return false;
|
||
}
|
||
/** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */
|
||
private *pickSwitchMon(): Gen<number> {
|
||
for (;;) {
|
||
const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, true);
|
||
if (slot < 0) return -1;
|
||
if (!(yield* this.checkFitToFight(slot))) continue;
|
||
if (slot === this.curBattleMon) {
|
||
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
|
||
continue;
|
||
}
|
||
return slot;
|
||
}
|
||
}
|
||
/** BattleMenu_PKMN with BattleMonMenu's SWITCH, then TryPlayerSwitch.
|
||
* Returns true if the player switched, which takes the turn. STATS is
|
||
* a presentation screen, and CANCEL returns to the party menu. */
|
||
private *battleMenuPKMN(): Gen<boolean> {
|
||
for (;;) {
|
||
const slot = yield* this.partyMenu(BATTLE.ChooseAMonString, true, true);
|
||
if (slot < 0) return false;
|
||
if (slot === this.curBattleMon) {
|
||
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
|
||
continue;
|
||
}
|
||
if (this.player.wrapCount || this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN)) {
|
||
yield* this.say(BATTLE.BattleText_MonCantBeRecalled);
|
||
continue;
|
||
}
|
||
if (!(yield* this.checkFitToFight(slot))) continue;
|
||
this.lastPlayerMon = this.curBattleMon;
|
||
this.battlePlayerAction = C.BATTLEPLAYERACTION_SWITCH;
|
||
this.curBattleMon = slot;
|
||
yield* this.playerSwitch();
|
||
return true;
|
||
}
|
||
}
|
||
/** PlayerSwitch: the enemy chooses its move, then BattleMonEntrance. */
|
||
private *playerSwitch(): Gen {
|
||
this.playerIsSwitching = 1;
|
||
this.parseEnemyAction();
|
||
yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]);
|
||
this.player.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
this.turn = 1;
|
||
yield* this.pursuitSwitch();
|
||
this.curPartyMon = this.curBattleMon;
|
||
yield* this.sendOutPlayerMon();
|
||
}
|
||
/** PursuitSwitch: an opponent that chose Pursuit uses it on the Pokémon
|
||
* being withdrawn, then cannot move this turn. A withdrawn Pokémon that
|
||
* faints skips the usual faint handling and keeps its status (a kept
|
||
* bug). Returns true if it fainted. */
|
||
private *pursuitSwitch(): Gen<boolean> {
|
||
const user = this.user,
|
||
turn = this.turn;
|
||
// GetMoveEffect, which reads past the table for no move.
|
||
if (this.reader.byte(SYMBOLS.moves + ((user.curMove - 1) & 0xff) * 7 + 1) !== C.EFFECT_PURSUIT)
|
||
return false;
|
||
const current = this.curBattleMon;
|
||
if (turn === 1) this.curBattleMon = this.lastPlayerMon;
|
||
// DoPlayerTurn does nothing after an item or a switch.
|
||
if (turn === 1 || !this.battlePlayerAction) yield* this.doTurn();
|
||
this.turn = turn;
|
||
user.curMove = 0xff; // CANNOT_MOVE
|
||
this.curBattleMon = current;
|
||
if (turn === 0) {
|
||
if (this.enemy.mon.hp) return false;
|
||
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
|
||
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||
return true;
|
||
}
|
||
// UpdateBattleMon: the withdrawn Pokémon's level, status and HP.
|
||
const outgoing = this.options.party[this.lastPlayerMon];
|
||
outgoing.level = this.player.mon.level;
|
||
outgoing.status = this.player.mon.status;
|
||
outgoing.hp = this.player.mon.hp;
|
||
if (this.player.mon.hp) return false;
|
||
this.participants &= ~(1 << this.lastPlayerMon);
|
||
this.playSound({ cry: this.player.mon.species, side: "player" });
|
||
yield* this.say(BATTLE.BattleText_MonFainted);
|
||
return true;
|
||
}
|
||
/** PlayerPartyMonEntrance: send out wCurPartyMon. */
|
||
private *playerPartyMonEntrance(): Gen {
|
||
this.lastPlayerMon = this.curBattleMon;
|
||
this.curBattleMon = this.curPartyMon;
|
||
yield* this.sendOutPlayerMon();
|
||
}
|
||
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
|
||
* PlayerPartyMonEntrance. */
|
||
private *sendOutPlayerMon(): Gen {
|
||
this.addBattleParticipant();
|
||
this.initBattleMon();
|
||
this.checkAmuletCoin();
|
||
yield* this.sendOutMonText();
|
||
this.newStatus(this.player); // with ResetPlayerStatLevels
|
||
this.breakAttraction();
|
||
yield this.hud();
|
||
this.turn = 0;
|
||
yield* this.spikesDamage(this.player);
|
||
}
|
||
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
|
||
private *sendOutMonText(): Gen {
|
||
const { hp, maxHp } = this.enemy.mon;
|
||
if (hp) this.enemyHPAtSwitch = hp;
|
||
const text = sendOutText(hp, maxHp);
|
||
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||
yield* this.say(BATTLE[text]);
|
||
const mon = this.player.mon;
|
||
if (mon.hp && !(mon.status & (C.SLP_MASK | (1 << C.FRZ))))
|
||
this.playSound({ cry: mon.species, side: "player" });
|
||
}
|
||
/** 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's HUD redraw counter is presentation-only. */
|
||
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, either switching or using an item. Whenever the
|
||
* switch decision falls through to DontSwitch (no switch style, no
|
||
* candidate tier, or a failed roll), AI_TryItem gets a turn instead; a
|
||
* decided switch that AI_TrySwitch then refuses (too few Pokémon left)
|
||
* is a `jp`, not a `call`, so it never reaches DontSwitch either. */
|
||
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 yield* this.aiTryItem();
|
||
const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex(
|
||
(bit) => trainer.switchFlags & bit,
|
||
);
|
||
if (style < 0) return yield* this.aiTryItem();
|
||
const param = this.checkAbleToSwitch(),
|
||
tier = param & 0xf0;
|
||
if (!tier) return yield* this.aiTryItem();
|
||
const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1];
|
||
if (this.random() < threshold !== below) return yield* this.aiTryItem();
|
||
this.enemySwitchMonIndex = (param & 0x0f) + 1;
|
||
// AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts.
|
||
if (trainer.party.filter((mon) => mon.hp).length < 2) return false;
|
||
yield* this.aiSwitch();
|
||
return true;
|
||
}
|
||
/** AI_Switch: withdraw the enemy Pokémon and send out the chosen one. */
|
||
private *aiSwitch(): Gen {
|
||
const trainer = this.options.trainer!;
|
||
this.enemyIsSwitching = 1;
|
||
this.enemyGoesFirst = 1;
|
||
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
this.turn = 0;
|
||
const fainted = yield* this.pursuitSwitch();
|
||
// Status and HP go back to the party (MON_STATUS through MON_HP).
|
||
const outgoing = trainer.party[this.curOTMon];
|
||
outgoing.status = this.enemy.mon.status;
|
||
outgoing.hp = this.enemy.mon.hp;
|
||
if (!fainted) yield* this.say(BATTLE.EnemyWithdrewText);
|
||
this.battleHasJustStarted = 1;
|
||
this.newEnemyMonStatus();
|
||
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
|
||
yield* this.enemySwitch();
|
||
this.resetBattleParticipants();
|
||
this.battleHasJustStarted = 0;
|
||
}
|
||
|
||
// ------------------------------------------------------- trainer items
|
||
|
||
/** AI_Items (engine/battle/ai/items.asm): the trainer's items, tried in
|
||
* this fixed order against whichever of its two slots AI_TryItem's scan
|
||
* currently points at. */
|
||
private aiItemHandlers(): { item: number; use: () => Gen<boolean> }[] {
|
||
return [
|
||
{ item: C.FULL_RESTORE, use: () => this.aiFullRestore() },
|
||
{ item: C.MAX_POTION, use: () => this.aiMaxPotion() },
|
||
{ item: C.HYPER_POTION, use: () => this.aiPotion(C.HYPER_POTION, 200) },
|
||
{ item: C.SUPER_POTION, use: () => this.aiPotion(C.SUPER_POTION, 50) },
|
||
{ item: C.POTION, use: () => this.aiPotion(C.POTION, 20) },
|
||
{ item: C.X_ACCURACY, use: () => this.aiXSubstatus(C.X_ACCURACY, C.SUBSTATUS_X_ACCURACY) },
|
||
{ item: C.FULL_HEAL, use: () => this.aiFullHeal() },
|
||
{ item: C.GUARD_SPEC, use: () => this.aiXSubstatus(C.GUARD_SPEC, C.SUBSTATUS_MIST) },
|
||
{ item: C.DIRE_HIT, use: () => this.aiXSubstatus(C.DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY) },
|
||
{ item: C.X_ATTACK, use: () => this.aiXStat(C.X_ATTACK, C.ATTACK) },
|
||
{ item: C.X_DEFEND, use: () => this.aiXStat(C.X_DEFEND, C.DEFENSE) },
|
||
{ item: C.X_SPEED, use: () => this.aiXStat(C.X_SPEED, C.SPEED) },
|
||
{ item: C.X_SPECIAL, use: () => this.aiXStat(C.X_SPECIAL, C.SP_ATTACK) },
|
||
];
|
||
}
|
||
/** AI_TryItem: items are refused in the Battle Tower (not reachable in
|
||
* this port, so skipped), with none held, or when the Pokémon out is not
|
||
* the trainer's highest level. Otherwise AI_Items is scanned in order for
|
||
* whichever slot (`slot`, wEnemyTrainerItem1/2 via `de`) currently holds a
|
||
* listed item; its handler decides whether to use it.
|
||
* BUG: `de` only starts at wEnemyTrainerItem1 once, before the scan, not
|
||
* at each entry. Once the second slot matches and its handler declines,
|
||
* the first slot is unreachable for the rest of THIS scan (it is tried
|
||
* again, fresh, on the trainer's next turn); the pointer can drift once
|
||
* more into wEnemyTrainerBaseReward (`slot` 2), which could then be
|
||
* misread as a third item if it happened to alias one of AI_Items' ids
|
||
* (docs/bugs_and_glitches.md "AI might use its base reward value as an
|
||
* item"). No real two-item trainer's base reward matches an item in this
|
||
* ROM; further drift into AI flag bytes is not modeled. */
|
||
private *aiTryItem(): Gen<boolean> {
|
||
const trainer = this.options.trainer!;
|
||
if (!trainer.items[0] && !trainer.items[1]) return false;
|
||
if (!this.aiIsHighestLevel()) return false;
|
||
const slotValue = (index: number) => (index < 2 ? trainer.items[index] : trainer.baseReward);
|
||
let slot = 0;
|
||
for (const { item, use } of this.aiItemHandlers()) {
|
||
let matched = -1;
|
||
if (slotValue(slot) === item) matched = slot;
|
||
else if (slot < 2 && slotValue(slot + 1) === item) matched = slot + 1;
|
||
if (matched < 0) continue;
|
||
slot = matched;
|
||
if (!(yield* use())) continue;
|
||
if (slot < 2) trainer.items[slot] = 0;
|
||
else trainer.baseReward = 0;
|
||
// "used item": the enemy spent its turn, and several substatuses
|
||
// that only a real move would otherwise reset are cleared here too.
|
||
this.enemyGoesFirst = 1;
|
||
this.enemy.reset(SUB3, C.SUBSTATUS_BIDE);
|
||
this.enemy.furyCutterCount = 0;
|
||
this.enemy.protectCount = 0;
|
||
this.enemy.rageCounter = 0;
|
||
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||
this.enemy.lastCounterMove = 0;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
/** AI_TryItem's .IsHighestLevel: the Pokémon out must be at least as
|
||
* high level as every other party member (ties count). */
|
||
private aiIsHighestLevel(): boolean {
|
||
const party = this.options.trainer!.party;
|
||
const highest = party.reduce((level, mon) => Math.max(level, mon.level), 0);
|
||
return party[this.curOTMon].level >= highest;
|
||
}
|
||
/** AICheckEnemyHalfHP: true (the asm's carry) when the enemy's HP is
|
||
* strictly above half its maximum. */
|
||
private aiAboveHalfHP(): boolean {
|
||
return this.enemy.mon.hp * 2 > this.enemy.mon.maxHp;
|
||
}
|
||
/** AICheckEnemyQuarterHP: strictly above a quarter. */
|
||
private aiAboveQuarterHP(): boolean {
|
||
return this.enemy.mon.hp * 4 > this.enemy.mon.maxHp;
|
||
}
|
||
/** AI_Items' .HealItem, shared by Full Restore, Max/Hyper/Super Potion
|
||
* and Potion: whether this turn should heal, gated by HP and a few
|
||
* random rolls that CONTEXT_USE and UNKNOWN_USE change. */
|
||
private aiHealItem(): boolean {
|
||
const flags = this.options.trainer!.switchFlags;
|
||
if (flags & CONTEXT_USE) {
|
||
if (this.aiAboveHalfHP()) return false;
|
||
if (!this.aiAboveQuarterHP()) return true;
|
||
return this.random() < percent(20) - 1;
|
||
}
|
||
if (this.aiAboveHalfHP()) return false;
|
||
if (flags & UNKNOWN_USE) {
|
||
if (this.aiAboveQuarterHP()) return false;
|
||
return this.random() >= percent(20) - 1;
|
||
}
|
||
if (!this.aiAboveQuarterHP()) return true;
|
||
return this.random() < percent(50) + 1;
|
||
}
|
||
/** AI_Items' .Status, shared by Full Heal and Full Restore's
|
||
* CONTEXT_USE path: whether the enemy's status is worth curing. */
|
||
private aiStatus(): boolean {
|
||
if (!this.enemy.mon.status) return false;
|
||
const flags = this.options.trainer!.switchFlags;
|
||
if (flags & CONTEXT_USE) {
|
||
if (
|
||
this.enemy.has(SUB5, C.SUBSTATUS_TOXIC) &&
|
||
this.enemy.toxicCount >= 4 &&
|
||
this.random() < percent(50) + 1
|
||
)
|
||
return true;
|
||
return Boolean(this.enemy.mon.status & ((1 << C.FRZ) | C.SLP_MASK));
|
||
}
|
||
if (flags & ALWAYS_USE) return true;
|
||
return this.random() < percent(20) - 1;
|
||
}
|
||
/** AI_Items' .XItem, shared by the X items, X Accuracy, Guard Spec and
|
||
* Dire Hit: more readily used on the enemy's first turn out. */
|
||
private aiXItem(): boolean {
|
||
const flags = this.options.trainer!.switchFlags;
|
||
if (!this.enemy.turnsTaken) {
|
||
if (flags & ALWAYS_USE) return true;
|
||
if (this.random() < percent(50) + 1) return false;
|
||
if (flags & CONTEXT_USE) return true;
|
||
return !(this.random() < percent(50) + 1);
|
||
}
|
||
if (!(flags & ALWAYS_USE)) return false;
|
||
return this.random() < percent(20) - 1;
|
||
}
|
||
/** AI_HealStatus: the party struct's and the enemy's status clear, and
|
||
* Toxic stops. Nightmare, confusion and the stat drops from burn or
|
||
* paralysis are kept bugs (docs/bugs_and_glitches.md); CalcEnemyStats is
|
||
* never called here. */
|
||
private aiHealStatus() {
|
||
this.options.trainer!.party[this.curOTMon].status = 0;
|
||
this.enemy.mon.status = 0;
|
||
this.enemy.reset(SUB5, C.SUBSTATUS_TOXIC);
|
||
}
|
||
/** PrintText_UsedItemOn then AIUpdateHUD: "<ENEMY> used ITEM on mon!",
|
||
* the party struct following the enemy's new state, and the HUD. */
|
||
private *enemyUsedOn(item: number): Gen {
|
||
this.strings[RAM.monOrItemNameBuffer] = this.options.data.itemName(item);
|
||
yield* this.say(BATTLE.EnemyUsedOnText);
|
||
this.updateEnemyMonInParty();
|
||
yield this.hud();
|
||
}
|
||
/** .FullHeal: EnemyUsedFullHeal if .Status allows it. */
|
||
private *aiFullHeal(): Gen<boolean> {
|
||
if (!this.aiStatus()) return false;
|
||
this.aiHealStatus();
|
||
yield* this.enemyUsedOn(C.FULL_HEAL);
|
||
return true;
|
||
}
|
||
/** .FullRestore: .HealItem's HP gate, or (with CONTEXT_USE) .Status's
|
||
* verdict instead; AI_HealStatus, then confusion also clears (unlike
|
||
* Full Heal, a second kept bug), and the enemy's HP fills. */
|
||
private *aiFullRestore(): Gen<boolean> {
|
||
const flags = this.options.trainer!.switchFlags;
|
||
if (!this.aiHealItem() && (!(flags & CONTEXT_USE) || !this.aiStatus())) return false;
|
||
this.aiHealStatus();
|
||
this.enemy.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||
this.enemy.confuseCount = 0;
|
||
this.enemy.mon.hp = this.enemy.mon.maxHp;
|
||
yield* this.enemyUsedOn(C.FULL_RESTORE);
|
||
return true;
|
||
}
|
||
/** .MaxPotion: .HealItem's HP gate, then a full heal without curing
|
||
* status. */
|
||
private *aiMaxPotion(): Gen<boolean> {
|
||
if (!this.aiHealItem()) return false;
|
||
this.enemy.mon.hp = this.enemy.mon.maxHp;
|
||
yield* this.enemyUsedOn(C.MAX_POTION);
|
||
return true;
|
||
}
|
||
/** .HyperPotion/.SuperPotion/.Potion: .HealItem's HP gate, then
|
||
* RestoreHealth (EnemyPotionContinue's own inlined equivalent of the
|
||
* same clamped add). */
|
||
private *aiPotion(item: number, amount: number): Gen<boolean> {
|
||
if (!this.aiHealItem()) return false;
|
||
this.enemy.mon.hp = restoreHealth(this.enemy.mon.hp, this.enemy.mon.maxHp, amount);
|
||
yield* this.enemyUsedOn(item);
|
||
return true;
|
||
}
|
||
/** EnemyUsedXAccuracy/GuardSpec/DireHit: .XItem's gate, then the
|
||
* SubStatus4 flag (set, never toggled off, so a repeat has no further
|
||
* effect but still spends the item and the turn). */
|
||
private *aiXSubstatus(item: number, bit: number): Gen<boolean> {
|
||
if (!this.aiXItem()) return false;
|
||
this.enemy.set(SUB4, bit);
|
||
yield* this.enemyUsedOn(item);
|
||
return true;
|
||
}
|
||
/** EnemyUsedXAttack/XDefend/XSpeed/XSpecial: .XItem's gate, the message,
|
||
* then RaiseStat on the enemy even if the stat cannot rise. Unlike the
|
||
* player's own X items (XItemEffect), this never clears wAttackMissed or
|
||
* wEffectFailed first, so a stale miss or failed effect left over from
|
||
* the last move either side used can make RaiseStat silently no-op; no
|
||
* stat-rose or failed text is shown either way. */
|
||
private *aiXStat(item: number, stat: number): Gen<boolean> {
|
||
if (!this.aiXItem()) return false;
|
||
this.strings[RAM.monOrItemNameBuffer] = this.options.data.itemName(item);
|
||
yield* this.say(BATTLE.EnemyUsedOnText);
|
||
this.raiseStat(stat);
|
||
this.updateEnemyMonInParty();
|
||
yield this.hud();
|
||
return true;
|
||
}
|
||
|
||
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
|
||
* a chance tier in the high nibble and a party index in the low one.
|
||
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its
|
||
* callers load, so with hBattleTurn on the enemy every matchup here uses
|
||
* the enemy's chosen move type (docs/bugs_and_glitches.md). */
|
||
private checkAbleToSwitch(): number {
|
||
const party = this.options.trainer!.party,
|
||
data = this.options.data;
|
||
const matchup = (types: readonly number[]) =>
|
||
this.typeMatchup(this.enemy.move.type, types, this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED));
|
||
const bit = (index: number) => 0x20 >> index;
|
||
const damaging = (move: number) => data.move(move).power !== 0;
|
||
const baseTypes = (index: number) => data.baseData(party[index].species).types;
|
||
// FindAliveEnemyMons: the others still standing.
|
||
const aliveOthers = () => {
|
||
let mask = 0;
|
||
if (party.length >= 2)
|
||
party.forEach((mon, index) => {
|
||
if (index !== this.curOTMon && mon.hp) mask |= bit(index);
|
||
});
|
||
return mask;
|
||
};
|
||
// FindEnemyMonsWithAtLeastQuarterMaxHP. BUG: srl c / rl b rotate the low
|
||
// HP byte into the high byte instead of multiplying the HP by four.
|
||
const quarterHP = (mask: number) => {
|
||
let chosen = 0;
|
||
party.forEach((mon, index) => {
|
||
const high = mon.hp >> 8,
|
||
low = mon.hp & 0xff;
|
||
const rotated = ((((high << 2) | ((low & 1) << 1) | ((low >> 1) & 1)) & 0xff) << 8) | (low >> 2);
|
||
if (rotated > mon.stats[0]) chosen |= bit(index);
|
||
});
|
||
return mask & chosen;
|
||
};
|
||
// FindEnemyMonsThatResistPlayer: every branch reduces to one matchup.
|
||
const resisting = (mask: number) => {
|
||
let chosen = 0;
|
||
party.forEach((_, index) => {
|
||
if (matchup(baseTypes(index)) <= C.EFFECTIVE) chosen |= bit(index);
|
||
});
|
||
return mask & chosen;
|
||
};
|
||
// FindEnemyMonsWithASuperEffectiveMove. `e` is not reset between
|
||
// Pokémon, so one neutral move encourages every later candidate.
|
||
const superEffective = (mask: number) => {
|
||
let e = 0,
|
||
encouraged = 0;
|
||
for (let index = 0; index < 6; index++) {
|
||
if (!(mask & bit(index))) continue;
|
||
for (const move of party[index].moves) {
|
||
if (!move) break;
|
||
if (!damaging(move)) continue;
|
||
const value = matchup(this.player.mon.types);
|
||
if (value < C.EFFECTIVE) continue;
|
||
e = 1;
|
||
if (value === C.EFFECTIVE) continue;
|
||
e = 2;
|
||
break;
|
||
}
|
||
if (e === 2) return { e, score: index };
|
||
if (e === 1) encouraged |= bit(index);
|
||
}
|
||
for (let index = 0; index < 6; index++) if (encouraged & bit(index)) return { e, score: index };
|
||
return { e, score: 0xff };
|
||
};
|
||
// FindAliveEnemyMonsWithASuperEffectiveMove: standing, including the
|
||
// current Pokémon.
|
||
const standingSuperEffective = (mask: number) => {
|
||
let standing = 0;
|
||
party.forEach((mon, index) => {
|
||
if (mon.hp) standing |= bit(index);
|
||
});
|
||
return superEffective(mask & standing);
|
||
};
|
||
|
||
if (!aliveOthers()) return 0;
|
||
if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH) && this.enemy.perishCount === 1) {
|
||
// A last Perish count switches at the maximum chance, to the best
|
||
// candidate or else the first one standing.
|
||
const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers())));
|
||
if (e === 2) return (score + 0x30) & 0xff;
|
||
const alive = aliveOthers();
|
||
for (let index = 0; index < 6; index++) if (alive & bit(index)) return index + 0x30;
|
||
}
|
||
if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0;
|
||
const lastCounter = this.player.lastCounterMove;
|
||
if (lastCounter) {
|
||
// FindEnemyMonsImmuneToLastCounterMove.
|
||
let immune = 0;
|
||
party.forEach((mon, index) => {
|
||
if (index === this.curOTMon || !mon.hp || !damaging(lastCounter)) return;
|
||
if (matchup(baseTypes(index)) === 0) immune |= bit(index);
|
||
});
|
||
if (immune) {
|
||
const { e, score } = superEffective(immune);
|
||
if (score === 0xff) return 0;
|
||
if (e !== 2) {
|
||
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
|
||
return score + 0x10;
|
||
}
|
||
return score + (this.checkPlayerMoveTypeMatchups() < C.EFFECTIVE ? 0x20 : 0x10);
|
||
}
|
||
}
|
||
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
|
||
const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers())));
|
||
return e === 2 ? score + 0x10 : 0;
|
||
}
|
||
/** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE
|
||
* (10); lower means the enemy is worse off. CheckTypeMatchup reads the move
|
||
* type and Foresight flag through hBattleTurn; switching runs on the
|
||
* enemy's turn (see checkAbleToSwitch), Smart AI on either. */
|
||
private checkPlayerMoveTypeMatchups(
|
||
type = this.enemy.move.type,
|
||
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
|
||
): number {
|
||
const data = this.options.data;
|
||
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;
|
||
}
|
||
/** FaintYourPokemon: StopDangerSound, then the cry. */
|
||
private *faintYourPokemon(): Gen {
|
||
this.playSound({ stopDanger: true });
|
||
this.playSound({ cry: this.player.mon.species, side: "player" });
|
||
yield* this.say(BATTLE.BattleText_MonFainted);
|
||
}
|
||
private *handlePlayerMonFaint(): Gen {
|
||
yield* this.faintYourPokemon();
|
||
if (!this.enemy.mon.hp) {
|
||
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
|
||
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.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
|
||
const holders = () => this.options.party.reduce(
|
||
(bits, mon, index) => bits | (mon.hp > 0 && mon.item === C.EXP_SHARE ? 1 << index : 0), 0,
|
||
);
|
||
// Halve the bytes first, then divide each pool among its recipients.
|
||
const shared = Boolean(holders());
|
||
yield* this.giveExperience(shared);
|
||
const recipients = holders();
|
||
if (recipients) {
|
||
const participants = this.participants;
|
||
this.participants = recipients;
|
||
yield* this.giveExperience(shared);
|
||
this.participants = participants;
|
||
}
|
||
}
|
||
private *giveExperience(shared = false): Gen {
|
||
// GiveExperiencePoints: none in the Battle Tower.
|
||
if (this.options.battleTower) return;
|
||
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 (shared) {
|
||
base.stats = base.stats.map((value) => value >> 1);
|
||
base.catchRate >>= 1;
|
||
base.exp >>= 1;
|
||
}
|
||
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;
|
||
// A transformed Pokémon keeps its copied stats.
|
||
if (!side.has(SUB5, C.SUBSTATUS_TRANSFORMED)) side.stats = mon.stats.slice(1);
|
||
// ApplyStatLevelMultiplierOnAllStats, ApplyStatusEffectOnPlayerStats,
|
||
// then BadgeStatBoosts.
|
||
this.applyStatLevelMultipliers(side);
|
||
this.applyStatusEffects(side);
|
||
this.badgeStatBoosts();
|
||
}
|
||
// 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;
|
||
}
|
||
}
|
||
}
|