import { DAILY_BOSS_LEVEL } from "#app/constants"; import type { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { settings } from "#app/global-settings-manager"; import { isDailyFinalBoss } from "#data/daily-seed-utils"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleType } from "#enums/battle-type"; import { BattlerIndex } from "#enums/battler-index"; import { BiomeId } from "#enums/biome-id"; import type { Command } from "#enums/command"; import { MoveId } from "#enums/move-id"; import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import type { PokeballType } from "#enums/pokeball"; import { SpeciesFormKey } from "#enums/species-form-key"; import { SpeciesId } from "#enums/species-id"; import { TrainerType } from "#enums/trainer-type"; import { TrainerVariant } from "#enums/trainer-variant"; import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; import { Trainer } from "#field/trainer"; import { MoneyMultiplierModifier, type PokemonHeldItemModifier } from "#modifiers/modifier"; import type { CustomModifierSettings } from "#modifiers/modifier-type"; import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; import { trainerConfigs } from "#trainers/trainer-config"; import type { NewBattleResolvedProps } from "#types/new-battle-props"; import type { TurnMove } from "#types/turn-move"; import { isBetween, NumberHolder, randInt, randomString, randSeedFloat, randSeedInt, randSeedItem, shiftCharCodes, } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { randSeedUniqueItem } from "#utils/random"; import i18next from "i18next"; import Phaser from "phaser"; export interface TurnCommand { command: Command; cursor?: number | undefined; move?: TurnMove; targets?: BattlerIndex[]; skip?: boolean; args?: any[]; } export interface FaintLogEntry { pokemon: Pokemon; turn: number; } interface TurnCommands { [key: number]: TurnCommand | null; } export class Battle { protected gameMode: GameMode; public waveIndex: number; public battleType: BattleType; public trainer: Trainer | null; public enemyLevels: number[] | undefined; public enemyParty: EnemyPokemon[] = []; public seenEnemyPartyMemberIds: Set = new Set(); public double: boolean; public started = false; public enemySwitchCounter = 0; public turn = 0; public preTurnCommands: TurnCommands; public turnCommands: TurnCommands; public playerParticipantIds: Set = new Set(); public battleScore = 0; public postBattleLoot: PokemonHeldItemModifier[] = []; public escapeAttempts = 0; /** * A tracker of the last {@linkcode MoveId} successfully used this battle. */ public lastMove: MoveId = MoveId.NONE; public battleSeed: string = randomString(16, true); private battleSeedState: string | null = null; public moneyScattered = 0; // TODO: These trackers are only used for Sticky Web + Mirror Armor edge cases // and are abhorrently janky. /** Primarily for double battles, keeps track of last enemy and player pokemon that triggered its ability or used a move */ public lastEnemyInvolved: number; public lastPlayerInvolved: number; public lastUsedPokeball: PokeballType | null = null; /** * Saves the number of times a Pokemon on the enemy's side has fainted during this battle. * This is saved here since we encounter a new enemy every wave. * {@linkcode globalScene.arena.playerFaints} is the corresponding faint counter for the player and needs to be save across waves (reset every arena encounter). */ public enemyFaints = 0; public playerFaintsHistory: FaintLogEntry[] = []; public enemyFaintsHistory: FaintLogEntry[] = []; public mysteryEncounterType?: MysteryEncounterType | undefined; /** If the current battle is a Mystery Encounter, this will always be defined */ public mysteryEncounter?: MysteryEncounter | undefined; /** * Tracker for whether the last run attempt failed. * @defaultValue `false` */ public failedRunAway = false; public successfulRun = false; constructor( gameMode: GameMode, { waveIndex, battleType, trainer, mysteryEncounterType, double = false }: NewBattleResolvedProps, ) { this.gameMode = gameMode; this.waveIndex = waveIndex; this.battleType = battleType; this.trainer = trainer ?? null; this.mysteryEncounterType = mysteryEncounterType; this.double = double; this.enemyLevels = battleType === BattleType.TRAINER ? trainer?.getPartyLevels(this.waveIndex) : // TODO: Remove array.fill.map new Array(double ? 2 : 1).fill(null).map(() => this.getLevelForWave()); } public get isClassicFinalBoss(): boolean { return this.gameMode.isClassic && this.gameMode.isWaveFinal(this.waveIndex); } public getLevelForWave(): number { if (isDailyFinalBoss(this.waveIndex)) { return DAILY_BOSS_LEVEL; } const levelWaveIndex = this.gameMode.getWaveForDifficulty(this.waveIndex); const baseLevel = 1 + levelWaveIndex / 2 + Math.pow(levelWaveIndex / 25, 2); const bossMultiplier = 1.2; if (this.gameMode.isBoss(this.waveIndex)) { const ret = Math.floor(baseLevel * bossMultiplier); if (this.isClassicFinalBoss || !(this.waveIndex % 250)) { return Math.ceil(ret / 25) * 25; } let levelOffset = 0; if (!this.gameMode.isWaveFinal(this.waveIndex)) { levelOffset = Math.round(Phaser.Math.RND.realInRange(-1, 1) * Math.floor(levelWaveIndex / 10)); } return ret + levelOffset; } let levelOffset = 0; const deviation = 10 / levelWaveIndex; levelOffset = Math.abs(this.randSeedGaussForLevel(deviation)); return Math.max(Math.round(baseLevel + levelOffset), 1); } randSeedGaussForLevel(value: number): number { let rand = 0; for (let i = value; i > 0; i--) { rand += randSeedFloat(); } return rand / value; } getBattlerCount(): number { return this.double ? 2 : 1; } incrementTurn(): void { this.turn++; this.turnCommands = Object.fromEntries(getEnumValues(BattlerIndex).map(bt => [bt, null])); this.preTurnCommands = Object.fromEntries(getEnumValues(BattlerIndex).map(bt => [bt, null])); this.battleSeedState = null; } addParticipant(playerPokemon: PlayerPokemon): void { this.playerParticipantIds.add(playerPokemon.id); } removeFaintedParticipant(playerPokemon: PlayerPokemon): void { this.playerParticipantIds.delete(playerPokemon.id); } addPostBattleLoot(enemyPokemon: EnemyPokemon): void { this.postBattleLoot.push( ...(globalScene.findModifiers( m => m.is("PokemonHeldItemModifier") && m.pokemonId === enemyPokemon.id && m.isTransferable, false, ) as PokemonHeldItemModifier[]), ); } pickUpScatteredMoney(): void { const moneyAmount = new NumberHolder(globalScene.currentBattle.moneyScattered); globalScene.applyModifiers(MoneyMultiplierModifier, true, moneyAmount); if (globalScene.arena.getTag(ArenaTagType.HAPPY_HOUR)) { moneyAmount.value *= 2; } globalScene.addMoney(moneyAmount.value); const userLocale = navigator.language || "en-US"; const formattedMoneyAmount = moneyAmount.value.toLocaleString(userLocale); const message = i18next.t("battle:moneyPickedUp", { moneyAmount: formattedMoneyAmount, }); globalScene.phaseManager.queueMessage(message, undefined, true); globalScene.currentBattle.moneyScattered = 0; } addBattleScore(): void { let partyMemberTurnMultiplier = globalScene.getEnemyParty().length / 2 + 0.5; if (this.double) { partyMemberTurnMultiplier /= 1.5; } for (const p of globalScene.getEnemyParty()) { if (p.isBoss()) { partyMemberTurnMultiplier *= p.bossSegments / 1.5 / globalScene.getEnemyParty().length; } } const turnMultiplier = Phaser.Tweens.Builders.GetEaseFunction("Sine.easeIn")( 1 - Math.min(this.turn - 2, 10 * partyMemberTurnMultiplier) / (10 * partyMemberTurnMultiplier), ); const finalBattleScore = Math.ceil(this.battleScore * turnMultiplier); globalScene.score += finalBattleScore; console.log( `Battle Score: ${finalBattleScore} (${this.turn - 1} Turns x${Math.floor(turnMultiplier * 100) / 100})`, ); console.log(`Total Score: ${globalScene.score}`); globalScene.updateScoreText(); } getBgmOverride(): string | null { if (this.isBattleMysteryEncounter() && this.mysteryEncounter?.encounterMode === MysteryEncounterMode.DEFAULT) { // Music is overridden for MEs during ME onInit() // Should not use any BGM overrides before swapping from DEFAULT mode return null; } if ( this.battleType === BattleType.TRAINER || this.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE ) { if (!this.started && this.trainer?.config.encounterBgm && this.trainer.getEncounterMessages().length > 0) { return `encounter_${this.trainer.getEncounterBgm()}`; } if (!settings.musicPreferenceAllGens) { return this.trainer?.getBattleBgm() ?? null; } return this.trainer?.getMixedBattleBgm() ?? null; } if (this.gameMode.isClassic) { if (isBetween(this.waveIndex, 191, 194)) { return "end"; } if (isBetween(this.waveIndex, 196, 199)) { return "end_summit"; } } const wildOpponents = globalScene.getEnemyParty(); for (const pokemon of wildOpponents) { if (this.isClassicFinalBoss) { if (pokemon.species.getFormSpriteKey(pokemon.formIndex) === SpeciesFormKey.ETERNAMAX) { return "battle_final"; } return "battle_final_encounter"; } if ( pokemon.species.legendary || pokemon.species.subLegendary || pokemon.species.mythical || (pokemon.species.category.startsWith("Paradox") && globalScene.arena.biomeId !== BiomeId.END) ) { if (!settings.musicPreferenceAllGens) { switch (pokemon.species.speciesId) { case SpeciesId.ARTICUNO: case SpeciesId.ZAPDOS: case SpeciesId.MOLTRES: case SpeciesId.MEWTWO: case SpeciesId.MEW: return "battle_legendary_mew"; case SpeciesId.REGIROCK: case SpeciesId.REGICE: case SpeciesId.REGISTEEL: case SpeciesId.REGIGIGAS: case SpeciesId.REGIDRAGO: case SpeciesId.REGIELEKI: return "battle_legendary_regis_g5"; case SpeciesId.KYUREM: return "battle_legendary_kyurem"; default: if (pokemon.species.legendary) { return "battle_legendary_res_zek"; } return "battle_legendary_unova"; } } if (settings.musicPreferenceAllGens) { switch (pokemon.species.speciesId) { case SpeciesId.ARTICUNO: case SpeciesId.ZAPDOS: case SpeciesId.MOLTRES: case SpeciesId.MEWTWO: case SpeciesId.MEW: return "battle_legendary_kanto"; case SpeciesId.RAIKOU: return "battle_legendary_raikou"; case SpeciesId.ENTEI: return "battle_legendary_entei"; case SpeciesId.SUICUNE: return "battle_legendary_suicune"; case SpeciesId.LUGIA: return "battle_legendary_lugia"; case SpeciesId.HO_OH: return "battle_legendary_ho_oh"; case SpeciesId.REGIROCK: case SpeciesId.REGICE: case SpeciesId.REGISTEEL: case SpeciesId.REGIGIGAS: case SpeciesId.REGIDRAGO: case SpeciesId.REGIELEKI: return "battle_legendary_regis_g6"; case SpeciesId.GROUDON: case SpeciesId.KYOGRE: if (pokemon.getFormKey() === SpeciesFormKey.PRIMAL) { return "battle_legendary_gro_kyo"; } return "battle_legendary_rayquaza"; case SpeciesId.RAYQUAZA: return "battle_legendary_rayquaza"; case SpeciesId.DEOXYS: return "battle_legendary_deoxys"; case SpeciesId.UXIE: case SpeciesId.MESPRIT: case SpeciesId.AZELF: return "battle_legendary_lake_trio"; case SpeciesId.HEATRAN: case SpeciesId.CRESSELIA: case SpeciesId.DARKRAI: case SpeciesId.SHAYMIN: return "battle_legendary_sinnoh"; case SpeciesId.DIALGA: case SpeciesId.PALKIA: if (pokemon.getFormKey() === SpeciesFormKey.ORIGIN) { return "battle_legendary_origin_forme"; } return "battle_legendary_dia_pal"; case SpeciesId.GIRATINA: return "battle_legendary_giratina"; case SpeciesId.ARCEUS: return "battle_legendary_arceus"; case SpeciesId.COBALION: case SpeciesId.TERRAKION: case SpeciesId.VIRIZION: case SpeciesId.KELDEO: case SpeciesId.TORNADUS: case SpeciesId.LANDORUS: case SpeciesId.THUNDURUS: case SpeciesId.MELOETTA: case SpeciesId.GENESECT: return "battle_legendary_unova"; case SpeciesId.KYUREM: return "battle_legendary_kyurem"; case SpeciesId.XERNEAS: case SpeciesId.YVELTAL: case SpeciesId.ZYGARDE: return "battle_legendary_xern_yvel"; case SpeciesId.TAPU_KOKO: case SpeciesId.TAPU_LELE: case SpeciesId.TAPU_BULU: case SpeciesId.TAPU_FINI: return "battle_legendary_tapu"; case SpeciesId.COSMOG: case SpeciesId.COSMOEM: case SpeciesId.SOLGALEO: case SpeciesId.LUNALA: return "battle_legendary_sol_lun"; case SpeciesId.NECROZMA: switch (pokemon.getFormKey()) { case "dusk-mane": case "dawn-wings": return "battle_legendary_dusk_dawn"; case "ultra": return "battle_legendary_ultra_nec"; default: return "battle_legendary_sol_lun"; } case SpeciesId.NIHILEGO: case SpeciesId.PHEROMOSA: case SpeciesId.BUZZWOLE: case SpeciesId.XURKITREE: case SpeciesId.CELESTEELA: case SpeciesId.KARTANA: case SpeciesId.GUZZLORD: case SpeciesId.POIPOLE: case SpeciesId.NAGANADEL: case SpeciesId.STAKATAKA: case SpeciesId.BLACEPHALON: return "battle_legendary_ub"; case SpeciesId.ZACIAN: case SpeciesId.ZAMAZENTA: return "battle_legendary_zac_zam"; case SpeciesId.ETERNATUS: if (pokemon.getFormKey() === "eternamax") { return "battle_legendary_eternatus_p2"; } return "battle_legendary_eternatus_p1"; case SpeciesId.GLASTRIER: case SpeciesId.SPECTRIER: return "battle_legendary_glas_spec"; case SpeciesId.CALYREX: if (pokemon.getFormKey() === "ice" || pokemon.getFormKey() === "shadow") { return "battle_legendary_riders"; } return "battle_legendary_calyrex"; case SpeciesId.GALAR_ARTICUNO: case SpeciesId.GALAR_ZAPDOS: case SpeciesId.GALAR_MOLTRES: return "battle_legendary_birds_galar"; case SpeciesId.WO_CHIEN: case SpeciesId.CHIEN_PAO: case SpeciesId.TING_LU: case SpeciesId.CHI_YU: return "battle_legendary_ruinous"; case SpeciesId.GREAT_TUSK: case SpeciesId.SCREAM_TAIL: case SpeciesId.BRUTE_BONNET: case SpeciesId.FLUTTER_MANE: case SpeciesId.SLITHER_WING: case SpeciesId.SANDY_SHOCKS: case SpeciesId.IRON_TREADS: case SpeciesId.IRON_BUNDLE: case SpeciesId.IRON_HANDS: case SpeciesId.IRON_JUGULIS: case SpeciesId.IRON_MOTH: case SpeciesId.IRON_THORNS: case SpeciesId.ROARING_MOON: case SpeciesId.IRON_VALIANT: case SpeciesId.WALKING_WAKE: case SpeciesId.IRON_LEAVES: case SpeciesId.GOUGING_FIRE: case SpeciesId.RAGING_BOLT: case SpeciesId.IRON_BOULDER: case SpeciesId.IRON_CROWN: case SpeciesId.KORAIDON: case SpeciesId.MIRAIDON: return "battle_legendary_kor_mir"; case SpeciesId.OKIDOGI: case SpeciesId.MUNKIDORI: case SpeciesId.FEZANDIPITI: return "battle_legendary_loyal_three"; case SpeciesId.OGERPON: return "battle_legendary_ogerpon"; case SpeciesId.TERAPAGOS: return "battle_legendary_terapagos"; case SpeciesId.PECHARUNT: return "battle_legendary_pecharunt"; default: if (pokemon.species.legendary) { return "battle_legendary_res_zek"; } return "battle_legendary_unova"; } } } } if (globalScene.gameMode.isClassic && this.waveIndex <= 4) { return "battle_wild"; } return null; } /** * Generates a random number using the current battle's seed. Calls {@linkcode randSeedInt} * @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min} * @param min The minimum integer to pick, default `0` * @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1) */ randSeedInt(range: number, min = 0): number { if (range <= 1) { return min; } const tempSeedOverride = globalScene.rngSeedOverride; const state = Phaser.Math.RND.state(); if (this.battleSeedState) { Phaser.Math.RND.state(this.battleSeedState); } else { Phaser.Math.RND.sow([shiftCharCodes(this.battleSeed, this.turn << 6)]); console.log("Battle Seed:", this.battleSeed); } globalScene.rngSeedOverride = this.battleSeed; const ret = randSeedInt(range, min); this.battleSeedState = Phaser.Math.RND.state(); Phaser.Math.RND.state(state); globalScene.rngSeedOverride = tempSeedOverride; return ret; } /** * Returns if the battle is of type {@linkcode BattleType.MYSTERY_ENCOUNTER} */ isBattleMysteryEncounter(): boolean { return this.battleType === BattleType.MYSTERY_ENCOUNTER; } } export class FixedBattle extends Battle { constructor(waveIndex: number, config: FixedBattleConfig) { super(globalScene.gameMode, { waveIndex, battleType: config.battleType, trainer: config.battleType === BattleType.TRAINER ? config.getTrainer() : undefined, double: config.double, }); if (config.getEnemyParty) { this.enemyParty = config.getEnemyParty(); } } } type GetTrainerFunc = () => Trainer; type GetEnemyPartyFunc = () => EnemyPokemon[]; export class FixedBattleConfig { // TODO: All fixed battles are currently trainer battles public battleType: Exclude; public double: boolean; public getTrainer: GetTrainerFunc; public getEnemyParty: GetEnemyPartyFunc; public seedOffsetWaveIndex: number; public customModifierRewardSettings?: CustomModifierSettings; setBattleType(battleType: Exclude): FixedBattleConfig { this.battleType = battleType; return this; } setDouble(double: boolean): FixedBattleConfig { this.double = double; return this; } setGetTrainerFunc(getTrainerFunc: GetTrainerFunc): FixedBattleConfig { this.getTrainer = getTrainerFunc; return this; } setGetEnemyPartyFunc(getEnemyPartyFunc: GetEnemyPartyFunc): FixedBattleConfig { this.getEnemyParty = getEnemyPartyFunc; return this; } setSeedOffsetWave(seedOffsetWaveIndex: number): FixedBattleConfig { this.seedOffsetWaveIndex = seedOffsetWaveIndex; return this; } setCustomModifierRewards(customModifierRewardSettings: CustomModifierSettings) { this.customModifierRewardSettings = customModifierRewardSettings; return this; } } /** * Helper function to generate a random trainer for evil team trainers and the elite 4/champion * @param trainerPool - The TrainerType or list of TrainerTypes that can possibly be generated * @param randomGender - (default `false`); Whether or not to randomly (50%) generate a female trainer (for use with evil team grunts) * @param seedOffset - (default `0`); A seed offset indicating the invocation count of the function to attempt to choose a random, but unique, trainer from the pool * @returns A function to generate a random trainer */ export function getRandomTrainerFunc( trainerPool: readonly (TrainerType | readonly TrainerType[])[], randomGender = false, seedOffset = 0, ): GetTrainerFunc { return () => { /** The chosen entry in the pool */ let choice = randSeedItem(trainerPool); if (typeof choice !== "number") { choice = seedOffset === 0 ? randSeedItem(choice) : randSeedUniqueItem(choice, seedOffset); } let trainerGender = TrainerVariant.DEFAULT; if (randomGender) { trainerGender = randInt(2) === 0 ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT; } /* 1/3 chance for evil team grunts to be double battles */ const evilTeamGrunts = [ TrainerType.ROCKET_GRUNT, TrainerType.MAGMA_GRUNT, TrainerType.AQUA_GRUNT, TrainerType.GALACTIC_GRUNT, TrainerType.PLASMA_GRUNT, TrainerType.FLARE_GRUNT, TrainerType.AETHER_GRUNT, TrainerType.SKULL_GRUNT, TrainerType.MACRO_GRUNT, TrainerType.STAR_GRUNT, ]; const isEvilTeamGrunt = evilTeamGrunts.includes(choice); if (trainerConfigs[choice].hasDouble && isEvilTeamGrunt) { return new Trainer(choice, randInt(3) === 0 ? TrainerVariant.DOUBLE : trainerGender); } return new Trainer(choice, trainerGender); }; }