Ctrl K
ComunidadGuías…

src/battle-scene.ts

Rama beta · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { Animation } from "#app/animations";
import { Battle } from "#app/battle";
import {
  ANTI_VARIANCE_WEIGHT_MODIFIER,
  AVERAGE_ENCOUNTERS_PER_RUN_TARGET,
  BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT,
  MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT,
} from "#app/constants";
import { eventBus } from "#app/event-bus";
import type { GameMode } from "#app/game-mode";
import { getGameMode } from "#app/game-mode";
import { audioManager } from "#app/global-audio-manager";
import { timedEventManager } from "#app/global-event-manager";
import { globalScene, initGlobalScene } from "#app/global-scene";
import { settings } from "#app/global-settings-manager";
import { speciesDataRegistry } from "#app/global-species-data-registry";
import { InputsController } from "#app/inputs-controller";
import { LoadingScene } from "#app/loading-scene";
import { activeOverrides } from "#app/overrides";
import type { Phase } from "#app/phase";
import { PhaseManager } from "#app/phase-manager";
import { FieldSpriteBatchHandler, FieldSpriteSubmitter } from "#app/render-nodes/field-sprite";
import { SpriteBatchHandler, SpriteSubmitter } from "#app/render-nodes/sprite";
import { SceneBase } from "#app/scene-base";
import { TurnCommandManager } from "#app/turn-command-manager";
import { UiInputs } from "#app/ui-inputs";
import { STARTING_WAVE } from "#balance/misc";
import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#balance/starters";
import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets } from "#data/battle-anims";
import { getDailyMysteryEncounter } from "#data/daily-run";
import { allMoves, biomeDepths, modifierTypes } from "#data/data-lists";
import { classicFinalBossDialogue } from "#data/dialogue";
import type { SpeciesFormChangeTrigger } from "#data/form-change-triggers";
import { SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger } from "#data/form-change-triggers";
import { Gender } from "#data/gender";
import type { SpeciesFormChange } from "#data/pokemon-forms";
import type { PokemonSpecies, PokemonSpeciesFilter } from "#data/pokemon-species";
import { getTypeRgb } from "#data/type";
import { BattleType } from "#enums/battle-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { BiomeId } from "#enums/biome-id";
import { FormChangeItem } from "#enums/form-change-item";
import { GameModes } from "#enums/game-modes";
import { ModifierPoolType } from "#enums/modifier-pool-type";
import { MoveId } from "#enums/move-id";
import { MysteryEncounterMode } from "#enums/mystery-encounter-mode";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { Nature } from "#enums/nature";
import { PlayerGender } from "#enums/player-gender";
import { PokeballType } from "#enums/pokeball";
import type { PokemonAnimType } from "#enums/pokemon-anim-type";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { TextStyle } from "#enums/text-style";
import { TimeOfDay } from "#enums/time-of-day";
import type { TrainerSlot } from "#enums/trainer-slot";
import { TrainerType } from "#enums/trainer-type";
import { TrainerVariant } from "#enums/trainer-variant";
import type { UiWindowStyle } from "#enums/ui-window-style";
import { VolumeSetting } from "#enums/volume-setting";
import { NewArenaEvent } from "#events/battle-scene";
import { Arena, getBiomeHasProps, getBiomeKey } from "#field/arena";
import { ArenaBase } from "#field/arena-base";
import { DamageNumberHandler } from "#field/damage-number-handler";
import type { Pokemon } from "#field/pokemon";
import { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { PokemonSpriteTeraSparkleHandler } from "#field/pokemon-sprite-tera-sparkle-handler";
import { Trainer } from "#field/trainer";
import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "#modifiers/modifier";
import {
  ConsumableModifier,
  ConsumablePokemonModifier,
  DoubleBattleChanceBoosterModifier,
  ExpBalanceModifier,
  ExpShareModifier,
  FusePokemonModifier,
  HealingBoosterModifier,
  ModifierBar,
  MultipleParticipantExpBonusModifier,
  PersistentModifier,
  PokemonExpBoosterModifier,
  PokemonFormChangeItemModifier,
  PokemonHeldItemModifier,
  PokemonHpRestoreModifier,
  PokemonIncrementingStatModifier,
  RememberMoveModifier,
} from "#modifiers/modifier";
import {
  getDefaultModifierTypeForTier,
  getEnemyModifierTypesForWave,
  getLuckString,
  getLuckTextTint,
  getPartyLuckValue,
  type ModifierType,
  PokemonHeldItemModifierType,
} from "#modifiers/modifier-type";
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { allMysteryEncounters, mysteryEncountersByBiome } from "#mystery-encounters/mystery-encounter-biomes";
import { MysteryEncounterSaveData } from "#mystery-encounters/mystery-encounter-save-data";
import { expSpriteKeys } from "#sprites/sprite-keys";
import { hasExpSprite } from "#sprites/sprite-utils";
import type { Variant } from "#sprites/variant";
import { clearVariantData, variantData } from "#sprites/variant";
import type { Achv } from "#system/achv";
import { achvs, ModifierAchv, MoneyAchv } from "#system/achv";
import { GameData } from "#system/game-data";
import { initGameSpeed } from "#system/game-speed";
import type { PokemonData } from "#system/pokemon-data";
import type { Voucher } from "#system/voucher";
import { vouchers } from "#system/voucher";
import { trainerConfigs } from "#trainers/trainer-config";
import type { Constructor } from "#types/common";
import type { SettingsUpdateEventArgs } from "#types/event-bus-types";
import type { HeldModifierConfig } from "#types/held-modifier-config";
import type { Localizable } from "#types/locales";
import type {
  NewBattleConstructedProps,
  NewBattleInitialProps,
  NewBattleResolvedProps,
  NewBattleSavedProps,
} from "#types/new-battle-props";
import type { SessionSaveData } from "#types/save-data";
import type { VolumeSettingsKey } from "#types/settings";
import { AbilityBar } from "#ui/ability-bar";
import { ArenaFlyout } from "#ui/arena-flyout";
import { CandyBar } from "#ui/candy-bar";
import { CharSprite } from "#ui/char-sprite";
import { PartyExpBar } from "#ui/party-exp-bar";
import { PokeballTray } from "#ui/pokeball-tray";
import { PokemonInfoContainer } from "#ui/pokemon-info-container";
import { addTextObject, getTextColor, RAINBOW_TINT } from "#ui/text";
import { UI } from "#ui/ui";
import { addUiThemeOverrides, updateWindowType } from "#ui/ui-theme";
import { playTween } from "#utils/anim-utils";
import {
  BooleanHolder,
  fixedInt,
  formatMoney,
  getBiomeName,
  getIvsFromId,
  isBetween,
  NumberHolder,
  randomString,
  randSeedInt,
  randSeedItem,
  shiftCharCodes,
} from "#utils/common";
import { deepMergeSpriteData } from "#utils/data";
import { getEnumValues } from "#utils/enums";
import { cachedFetch } from "#utils/fetch-utils";
import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils";
import { decodeNickname } from "#utils/pokemon-utils";
import { capitalizeFirstLetterOnly } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";

export type PokeballCounts = Record<Exclude<PokeballType, PokeballType.LUXURY_BALL>, number>;

export interface InfoToggle {
  toggleInfo(force?: boolean): void;
  isActive(): boolean;
}

/**
 * The `BattleScene` is the primary scene for the game.
 *
 * Despite its name, it handles _everything_ other than initial asset loading,
 * up to and including title menuing and settings handling.
 */
// TODO: Breakup into multiple scenes if possible/practical
export class BattleScene extends SceneBase {
  public inputController: InputsController;
  public uiInputs: UiInputs;

  public sessionPlayTime: number | null = null;
  public lastSavePlayTime: number | null = null;

  public reroll = false;

  public disableMenu = false;

  public gameData: GameData;
  /** The numeric slot number of the current save slot being played. */
  public sessionSlotId: number;

  /** Manager for the phases active in the battle scene */
  public readonly phaseManager: PhaseManager = new PhaseManager();

  /**
   * Global state variable indicating AI moveset generation is in progress
   *
   * @remarks
   * It is intended that this is set to `true` while movesets are being generated.
   * Its purpose is to skip certain checks and effects that are not relevant during
   * the moveset generation process, such as ability suppression checks.
   *
   * @defaultValue `false`
   */
  public movesetGenInProgress = false;

  /** A manager for the commands and moves used in the current battle. */
  public readonly turnCommandManager: TurnCommandManager = new TurnCommandManager();

  public field: Phaser.GameObjects.Container;
  public fieldUI: Phaser.GameObjects.Container;
  public charSprite: CharSprite;
  public pbTray: PokeballTray;
  public pbTrayEnemy: PokeballTray;
  public abilityBar: AbilityBar;
  public partyExpBar: PartyExpBar;
  public candyBar: CandyBar;
  public arenaBg: Phaser.GameObjects.Sprite;
  public arenaBgTransition: Phaser.GameObjects.Sprite;
  public arenaPlayer: ArenaBase;
  public arenaPlayerTransition: ArenaBase;
  public arenaEnemy: ArenaBase;
  public arenaNextEnemy: ArenaBase;

  private invertFilter: Phaser.Filters.ColorMatrix | null = null;

  public arena: Arena;
  public gameMode: GameMode;
  public score: number;
  public lockModifierTiers: boolean;
  public trainer: Phaser.GameObjects.Sprite;
  public lastEnemyTrainer: Trainer | null;
  public currentBattle: Battle;
  public pokeballCounts: PokeballCounts;
  public money: number;
  public pokemonInfoContainer: PokemonInfoContainer;
  private party: PlayerPokemon[];
  /** Session save data that pertains to Mystery Encounters */
  public mysteryEncounterSaveData: MysteryEncounterSaveData = new MysteryEncounterSaveData();
  /** If the previous wave was a MysteryEncounter, tracks the object with this variable. Mostly used for visual object cleanup */
  public lastMysteryEncounter?: MysteryEncounter | undefined;

  /** Combined Biome and Wave count text */
  private biomeWaveText: Phaser.GameObjects.Text;
  private moneyText: Phaser.GameObjects.Text;
  private scoreText: Phaser.GameObjects.Text;
  private luckLabelText: Phaser.GameObjects.Text;
  private luckText: Phaser.GameObjects.Text;
  private modifierBar: ModifierBar;
  private enemyModifierBar: ModifierBar;
  public arenaFlyout: ArenaFlyout;

  private fieldOverlay: Phaser.GameObjects.Rectangle;
  private shopOverlay: Phaser.GameObjects.Rectangle;
  private shopOverlayShown = false;

  public modifiers: PersistentModifier[];
  private enemyModifiers: PersistentModifier[];
  public uiContainer: Phaser.GameObjects.Container;
  public ui: UI;

  public seed: string;
  public waveSeed: string;
  public waveCycleOffset: number;
  /**
   * Whether to offset Gym Leader waves by 10 (30, 60, 90 instead of 20, 50, 80). \
   * Determined at the start of the run, and is unused for non-Classic game modes.
   */
  public offsetGym = false;

  public damageNumberHandler: DamageNumberHandler;
  private spriteTeraSparkleHandler: PokemonSpriteTeraSparkleHandler;

  public fieldSpriteSubmitter: FieldSpriteSubmitter;
  public fieldSpriteBatchHandler: FieldSpriteBatchHandler;
  public spriteSubmitter: SpriteSubmitter;
  public spriteBatchHandler: SpriteBatchHandler;

  private playTimeTimer: Phaser.Time.TimerEvent;

  public rngSeedOverride = "";
  public rngOffset = 0;

  public inputMethod: string;
  private readonly infoToggles: InfoToggle[] = [];

  /**
   * Allows subscribers to listen for events
   *
   * Current Events:
   * - {@linkcode BattleSceneEventType.MOVE_USED} {@linkcode MoveUsedEvent}
   * - {@linkcode BattleSceneEventType.TURN_INIT} {@linkcode TurnInitEvent}
   * - {@linkcode BattleSceneEventType.TURN_END} {@linkcode TurnEndEvent}
   * - {@linkcode BattleSceneEventType.NEW_ARENA} {@linkcode NewArenaEvent}
   */
  public readonly eventTarget: EventTarget = new EventTarget();

  /** A helper class containing several animation-related functions. */
  public readonly animations: Animation = new Animation();
  declare renderer: Phaser.Renderer.WebGL.WebGLRenderer;

  constructor() {
    super("battle");

    this.updateGameInfo();
    initGlobalScene(this);
    this.initSettingsEventListeners();
  }

  private initSettingsEventListeners(): void {
    const updateSoundKeys = ["bgmVolume", "fieldVolume", "masterVolume", "soundEffectsVolume", "uiVolume"] as const;

    // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: necessary
    eventBus.on("settings/update/success", ({ key, value }: SettingsUpdateEventArgs) => {
      if (updateSoundKeys.includes(key as VolumeSettingsKey) && typeof value === "number") {
        const keyMap = {
          bgmVolume: VolumeSetting.BGM,
          fieldVolume: VolumeSetting.FIELD,
          masterVolume: VolumeSetting.MAIN,
          soundEffectsVolume: VolumeSetting.SE,
          uiVolume: VolumeSetting.UI,
        } as const satisfies Record<VolumeSettingsKey, VolumeSetting>;
        audioManager.setVolume(keyMap[key], value);
        return;
      }

      if (key === "enableTouchControls") {
        const touchControls = document.getElementById("touchControls");
        if (touchControls && typeof value === "boolean") {
          touchControls.classList.toggle("visible", value);
        }
        return;
      }

      if (key === "uiWindowStyle" && typeof value === "number") {
        updateWindowType(value as UiWindowStyle);
        return;
      }

      if (key === "playerGender" && typeof value === "number") {
        const female = value === PlayerGender.FEMALE;
        this.trainer.setTexture(this.trainer.texture.key.replace(female ? "m" : "f", female ? "f" : "m"));
        return;
      }

      if (key === "moneyFormat" && typeof value === "number") {
        this.updateMoneyText(false);
        return;
      }

      if (key === "shopOverlayOpacity" && typeof value === "number") {
        this.updateShopOverlayOpacity(value);
        return;
      }
    });
  }

  public loadPokemonAtlas(key: string, atlasPath: string, experimental = settings.expSpritesEnabled): void {
    const variant = atlasPath.includes("variant/") || /_[0-3]$/.test(atlasPath);
    if (experimental) {
      experimental = hasExpSprite(key);
    }
    if (variant) {
      atlasPath = atlasPath.replace("variant/", "");
    }
    this.load.atlas(
      key,
      `images/pokemon/${variant ? "variant/" : ""}${experimental ? "exp/" : ""}${atlasPath}.png`,
      `images/pokemon/${variant ? "variant/" : ""}${experimental ? "exp/" : ""}${atlasPath}.json`,
    );
  }

  /**
   * Load game assets necessary for the scene to run.
   * Called by Phaser on new game start.
   */
  public async preload(): Promise<void> {
    /**
     * These moves serve as fallback animations for other moves without loaded animations,
     * and must be loaded prior to game start.
     */
    const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE];

    await Promise.all([
      this.initExpSprites(),
      this.initVariantData(),
      initCommonAnims().then(() => loadCommonAnimAssets(true)),
      Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)),
    ]).catch(reason => {
      throw new Error(`Unexpected error during BattleScene preLoad!\nReason: ${reason}`);
    });
  }

  /**
   * Create game objects with loaded assets.
   * Called by Phaser on new game start.
   */
  public create(): void {
    this.scene.remove(LoadingScene.KEY);
    initGameSpeed(this);
    this.inputController = new InputsController();
    this.uiInputs = new UiInputs(this.inputController);

    this.gameData = new GameData();

    addUiThemeOverrides();

    this.load.setBaseURL();

    this.invertFilter = this.cameras.main.filters.external.addColorMatrix().setActive(false);
    this.invertFilter.colorMatrix.negative();

    // Condition check needed for when running headless mode during tests
    if (this.renderer instanceof Phaser.Renderer.WebGL.WebGLRenderer) {
      this.spriteSubmitter = new SpriteSubmitter(this.renderer.renderNodes);
      this.renderer.renderNodes.addNode(SpriteSubmitter.NAME, this.spriteSubmitter);
      this.spriteBatchHandler = new SpriteBatchHandler(this.renderer.renderNodes);
      this.renderer.renderNodes.addNode(SpriteBatchHandler.NAME, this.spriteBatchHandler);
      this.fieldSpriteSubmitter = new FieldSpriteSubmitter(this.renderer.renderNodes);
      this.renderer.renderNodes.addNode(FieldSpriteSubmitter.NAME, this.fieldSpriteSubmitter);
      this.fieldSpriteBatchHandler = new FieldSpriteBatchHandler(this.renderer.renderNodes);
      this.renderer.renderNodes.addNode(FieldSpriteBatchHandler.NAME, this.fieldSpriteBatchHandler);
    }

    this.launchBattle();
  }

  update() {
    this.ui?.update();
  }

  // TODO: Split this up into multiple sub-methods
  launchBattle() {
    const biome = activeOverrides.STARTING_BIOME_OVERRIDE || BiomeId.PLAINS;
    const biomeKey = getBiomeKey(biome);

    this.arenaBg = this.add
      .sprite(0, 0, `${biomeKey}_bg`)
      .setName("sprite-arena-bg")
      .setRenderNodeRole("Submitter", this.fieldSpriteSubmitter)
      .setRenderNodeRole("BatchHandler", this.fieldSpriteBatchHandler)
      .setScale(6)
      .setOrigin(0)
      .setSize(320, 240);
    this.arenaBgTransition = this.add
      .sprite(0, 0, `${biomeKey}_bg`)
      .setName("sprite-arena-bg-transition")
      .setRenderNodeRole("Submitter", this.fieldSpriteSubmitter)
      .setRenderNodeRole("BatchHandler", this.fieldSpriteBatchHandler)
      .setScale(6)
      .setOrigin(0)
      .setSize(320, 240)
      .setVisible(false);

    this.field = this.add //
      .container(0, 0)
      .setName("field")
      .setScale(6);
    this.fieldUI = this.add //
      .container(0, this.game.canvas.height)
      .setName("field-ui")
      .setDepth(1)
      .setScale(6);

    const transition = this.make.rexTransitionImagePack(
      {
        x: 0,
        y: 0,
        scale: 6,
        key: "loading_bg",
        origin: { x: 0, y: 0 },
      },
      true,
    );

    // TODO: fix the typing in a `.d.ts` file so the `ts-ignore` is no longer necessary
    /* biome-ignore lint/suspicious/noTsIgnore: ts-ignore is necessary because `tsc` and `tsgo` require the directive to be on different lines,
     *   meaning `@ts-expect-error` is guaranteed to emit a diagnostic on one of the lines depending on which one is used
     */
    // @ts-ignore
    transition.transit({
      mode: "blinds",
      /* biome-ignore lint/suspicious/noTsIgnore: ts-ignore is necessary because `tsc` and `tsgo` require the directive to be on different lines,
       *   meaning `@ts-expect-error` is guaranteed to emit a diagnostic on one of the lines depending on which one is used
       */
      // @ts-ignore
      ease: "Cubic.easeInOut",
      duration: 1250,
    });
    transition.once("complete", () => {
      transition.destroy();
    });
    this.add.existing(transition);

    this.uiContainer = this.add //
      .container(0, 0)
      .setName("ui")
      .setDepth(2)
      .setScale(6);

    const overlayWidth = this.scaledCanvas.width;
    const overlayHeight = this.scaledCanvas.height - 48;
    this.fieldOverlay = this.add
      .rectangle(0, overlayHeight * -1 - 48, overlayWidth, overlayHeight, 0x424242)
      .setName("rect-field-overlay")
      .setOrigin(0)
      .setAlpha(0);
    this.shopOverlay = this.add
      .rectangle(0, overlayHeight * -1 - 48, overlayWidth, overlayHeight, 0x070707)
      .setName("rect-shop-overlay")
      .setOrigin(0)
      .setAlpha(0);

    this.modifiers = [];
    this.enemyModifiers = [];

    this.modifierBar = new ModifierBar() //
      .setName("modifier-bar");
    this.add.existing(this.modifierBar);

    this.enemyModifierBar = new ModifierBar(true) //
      .setName("enemy-modifier-bar");
    this.add.existing(this.enemyModifierBar);

    this.charSprite = new CharSprite() //
      .setName("sprite-char")
      .setup();
    this.pbTray = new PokeballTray(true) //
      .setName("pb-tray")
      .setup();
    this.pbTrayEnemy = new PokeballTray(false) //
      .setName("enemy-pb-tray")
      .setup();
    this.abilityBar = new AbilityBar() //
      .setName("ability-bar")
      .setup();
    this.partyExpBar = new PartyExpBar() //
      .setName("party-exp-bar")
      .setup();

    this.candyBar = new CandyBar() //
      .setName("candy-bar")
      .setup();

    this.biomeWaveText = addTextObject(this.scaledCanvas.width - 2, 0, STARTING_WAVE.toString(), TextStyle.BATTLE_INFO)
      .setName("text-biome-wave")
      .setOrigin(1, 0.5);
    this.moneyText = addTextObject(this.scaledCanvas.width - 2, 0, "", TextStyle.MONEY)
      .setName("text-money")
      .setOrigin(1, 0.5);

    this.scoreText = addTextObject(this.scaledCanvas.width - 2, 0, "", TextStyle.PARTY, { fontSize: "36px" })
      .setName("text-score")
      .setOrigin(1, 0.5);

    this.luckText = addTextObject(this.scaledCanvas.width - 2, 0, "", TextStyle.PARTY, { fontSize: "36px" })
      .setName("text-luck")
      .setOrigin(1, 0.5)
      .setVisible(false);

    this.luckLabelText = addTextObject(
      this.scaledCanvas.width - 2,
      0,
      i18next.t("common:luckIndicator"),
      TextStyle.PARTY,
      { fontSize: "36px" },
    )
      .setName("text-luck-label")
      .setOrigin(1, 0.5)
      .setVisible(false);

    this.arenaFlyout = new ArenaFlyout();
    this.pokemonInfoContainer = new PokemonInfoContainer(this.scaledCanvas.width + 52, -this.scaledCanvas.height + 66) //
      .setup();
    this.updateUIPositions();

    this.damageNumberHandler = new DamageNumberHandler();
    this.spriteTeraSparkleHandler = new PokemonSpriteTeraSparkleHandler() //
      .setup();

    this.fieldUI
      .add([
        this.fieldOverlay,
        this.shopOverlay,
        this.charSprite,
        this.pbTray,
        this.pbTrayEnemy,
        this.abilityBar,
        this.partyExpBar,
        this.candyBar,
        this.biomeWaveText,
        this.moneyText,
        this.scoreText,
        this.luckText,
        this.luckLabelText,
        this.arenaFlyout,
        this.pokemonInfoContainer,
      ])
      .moveBelow<Phaser.GameObjects.GameObject>(this.arenaFlyout, this.fieldOverlay);

    this.uiContainer.add([this.modifierBar, this.enemyModifierBar]);

    this.party = [];

    this.arenaPlayer = new ArenaBase(true) //
      .setName("arena-player");
    this.arenaPlayerTransition = new ArenaBase(true) //
      .setName("arena-player-transition")
      .setVisible(false);
    this.arenaEnemy = new ArenaBase(false) //
      .setName("arena-enemy");
    this.arenaNextEnemy = new ArenaBase(false) //
      .setName("arena-next-enemy")
      .setVisible(false);
    this.field.add([this.arenaPlayer, this.arenaPlayerTransition, this.arenaEnemy, this.arenaNextEnemy]);

    this.trainer = this.addFieldSprite(0, 0, `trainer_${settings.isPlayerFemale ? "f" : "m"}_back`)
      .setOrigin(0.5, 1)
      .setName("sprite-trainer");
    this.field.add(this.trainer);

    this.anims.create({
      key: "prompt",
      frames: this.anims.generateFrameNumbers("prompt", { start: 1, end: 4 }),
      frameRate: 6,
      repeat: -1,
      showOnStart: true,
    });
    this.anims.create({
      key: "tera_sparkle",
      frames: this.anims.generateFrameNumbers("tera_sparkle", {
        start: 0,
        end: 12,
      }),
      frameRate: 18,
      repeat: 0,
      showOnStart: true,
      hideOnComplete: true,
    });

    this.reset(false, false, true);

    // Initialize UI-related aspects and then start the login phase.
    this.ui = new UI();
    this.uiContainer.add(this.ui);
    this.ui.setup();

    this.phaseManager.toTitleScreen(true);
    this.phaseManager.shiftPhase();
  }

  initSession(): void {
    if (this.sessionPlayTime === null) {
      this.sessionPlayTime = 0;
    }
    if (this.lastSavePlayTime === null) {
      this.lastSavePlayTime = 0;
    }

    if (this.playTimeTimer) {
      this.playTimeTimer.destroy();
    }

    this.playTimeTimer = this.time.addEvent({
      delay: fixedInt(1000),
      repeat: -1,
      callback: () => {
        if (this.gameData) {
          this.gameData.gameStats.playTime++;
        }
        if (this.sessionPlayTime !== null) {
          this.sessionPlayTime++;
        }
        if (this.lastSavePlayTime !== null) {
          this.lastSavePlayTime++;
        }
      },
    });

    this.updateBiomeWaveText();
    this.updateMoneyText();
    this.updateScoreText();
  }

  async initExpSprites(): Promise<void> {
    if (expSpriteKeys.size > 0) {
      return;
    }
    const res = await cachedFetch("./exp-sprites.json");
    const keys = await res.json();
    if (!Array.isArray(keys)) {
      throw new Error("EXP Sprites were not array when fetched!");
    }

    // TODO: Optimize this
    for (const k of keys) {
      expSpriteKeys.add(k);
    }
  }

  /**
   * Initialize the variant data.
   * If experimental sprites are enabled, their entries are replaced via this method.
   */
  async initVariantData(): Promise<void> {
    clearVariantData();
    const otherVariantData = await cachedFetch("./images/pokemon/variant/_masterlist.json").then(r => r.json());
    for (const k of Object.keys(otherVariantData)) {
      variantData[k] = otherVariantData[k];
    }
    if (!settings.expSpritesEnabled) {
      return;
    }
    const expVariantData = await cachedFetch("./images/pokemon/variant/_exp_masterlist.json").then(r => r.json());
    deepMergeSpriteData(variantData, expVariantData);
  }

  // TODO: Add a `getPartyOnSide` function for getting the party of a pokemon
  public getPlayerParty(): PlayerPokemon[] {
    return this.party;
  }

  /**
   * @returns An array of {@linkcode PlayerPokemon} filtered from the player's party
   * that are {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
   */
  public getPokemonAllowedInBattle(): PlayerPokemon[] {
    return this.getPlayerParty().filter(p => p.isAllowedInBattle());
  }

  /**
   * @returns The first {@linkcode PlayerPokemon} that is {@linkcode getPlayerField on the field}
   * and {@linkcode PlayerPokemon.isActive is active}
   * (aka {@linkcode PlayerPokemon.isAllowedInBattle is allowed in battle}),
   * or `undefined` if there are no valid pokemon
   * @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
   */
  public getPlayerPokemon(includeSwitching = true): PlayerPokemon | undefined {
    return this.getPlayerField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false));
  }

  // TODO: Add `undefined` to return type
  /**
   * Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not.
   * @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon
   * @returns array of {@linkcode PlayerPokemon}
   */
  public getPlayerField(active = false): PlayerPokemon[] {
    const party = this.getPlayerParty();
    return party
      .slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1))
      .filter(p => !active || p.isActive());
  }

  public getEnemyParty(): EnemyPokemon[] {
    return this.currentBattle?.enemyParty ?? [];
  }

  /**
   * @returns The first {@linkcode EnemyPokemon} that is {@linkcode getEnemyField | on the field}
   * and {@linkcode EnemyPokemon.isActive | is active}
   * (aka {@linkcode EnemyPokemon.isAllowedInBattle | is allowed in battle}),
   * or `undefined` if there are no valid pokemon
   * @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
   */
  public getEnemyPokemon(includeSwitching = true): EnemyPokemon | undefined {
    return this.getEnemyField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false));
  }

  /**
   * Returns an array of EnemyPokemon of length 1 or 2 depending on if in a double battle or not.
   * @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon
   * @returns array of {@linkcode EnemyPokemon}
   */
  public getEnemyField(active = false): EnemyPokemon[] {
    const party = this.getEnemyParty();
    return party
      .slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1))
      .filter(p => !active || p.isActive());
  }

  /**
   * Returns an array of Pokemon on both sides of the battle - player first, then enemy.
   * Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type.
   * @param activeOnly - Whether to consider only active pokemon (as described by {@linkcode Pokemon#isActive()}); default `false`.
   * If `true`, will also remove all `null` values from the array.
   * @returns An array of {@linkcode Pokemon}, as described above.
   *
   * @remarks
   * This should *only* be used in instances where speed order is not relevant.
   * If speed order matters, use {@linkcode inSpeedOrder}.
   */
  public getField(activeOnly = false): Pokemon[] {
    const ret: Pokemon[] = new Array(4).fill(null);
    const playerField = this.getPlayerField();
    const enemyField = this.getEnemyField();
    ret.splice(0, playerField.length, ...playerField);
    ret.splice(2, enemyField.length, ...enemyField);
    return activeOnly ? ret.filter(p => p?.isActive()) : ret;
  }

  /**
   * Attempt to redirect a move in double battles from a fainted/removed Pokemon to its ally.
   * @param removedPokemon - The {@linkcode Pokemon} having been removed from the field.
   * @param allyPokemon - The {@linkcode Pokemon} allied with the removed Pokemon; will have moves redirected to it
   */
  redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
    this.phaseManager.redirectMoves(removedPokemon, allyPokemon);
  }

  /**
   * Returns the ModifierBar of this scene, which is declared private and therefore not accessible elsewhere
   * @param isEnemy - Whether to return the enemy modifier bar instead of the player bar; default `false`
   * @returns The {@linkcode ModifierBar} for the given side of the field
   */
  getModifierBar(isEnemy = false): ModifierBar {
    return isEnemy ? this.enemyModifierBar : this.modifierBar;
  }

  // store info toggles to be accessible by the ui
  addInfoToggle(...infoToggles: InfoToggle[]): void {
    this.infoToggles.push(...infoToggles);
  }

  // return the stored info toggles; used by ui-inputs
  getInfoToggles(activeOnly = false): InfoToggle[] {
    return activeOnly ? this.infoToggles.filter(t => t?.isActive()) : this.infoToggles;
  }

  /**
   * Return the {@linkcode Pokemon} associated with the given ID.
   * @param pokemonId - The PID whose Pokemon will be retrieved
   * @returns The `Pokemon` associated with the given ID,
   * or `undefined` if none is found in either team's party.
   * @see {@linkcode Pokemon.id}
   */
  // TODO: Remove `undefined` from signature
  public getPokemonById(pokemonId: number | undefined): Pokemon | undefined {
    if (pokemonId == null) {
      // biome-ignore lint/complexity/noUselessUndefined: More explicit
      return undefined;
    }

    const party = (this.getPlayerParty() as Pokemon[]).concat(this.getEnemyParty());
    return party.find(p => p.id === pokemonId);
  }

  // biome-ignore lint/complexity/useMaxParams: will be fixed later
  addPlayerPokemon(
    species: PokemonSpecies,
    level: number,
    abilityIndex?: number,
    formIndex?: number,
    gender?: Gender,
    shiny?: boolean,
    variant?: Variant,
    ivs?: number[],
    nature?: Nature,
    dataSource?: Pokemon | PokemonData,
    postProcess?: (playerPokemon: PlayerPokemon) => void,
  ): PlayerPokemon {
    const pokemon = new PlayerPokemon(
      species,
      level,
      abilityIndex,
      formIndex,
      gender,
      shiny,
      variant,
      ivs,
      nature,
      dataSource,
    );

    if (postProcess) {
      postProcess(pokemon);
    }

    if (activeOverrides.IVS_OVERRIDE === null) {
      // do nothing
    } else if (Array.isArray(activeOverrides.IVS_OVERRIDE)) {
      if (activeOverrides.IVS_OVERRIDE.length !== 6) {
        throw new Error("The Player IVs override must be an array of length 6 or a number!");
      }
      if (activeOverrides.IVS_OVERRIDE.some(value => !isBetween(value, 0, 31))) {
        throw new Error("All IVs in the player IV override must be between 0 and 31!");
      }
      pokemon.ivs = activeOverrides.IVS_OVERRIDE;
    } else {
      if (!isBetween(activeOverrides.IVS_OVERRIDE, 0, 31)) {
        throw new Error("The Player IV override must be a value between 0 and 31!");
      }
      pokemon.ivs = new Array(6).fill(activeOverrides.IVS_OVERRIDE);
    }

    if (activeOverrides.NATURE_OVERRIDE !== null) {
      pokemon.nature = activeOverrides.NATURE_OVERRIDE;
    }

    pokemon.init();
    return pokemon;
  }

  // biome-ignore lint/complexity/useMaxParams: will be fixed later
  addEnemyPokemon(
    species: PokemonSpecies,
    level: number,
    trainerSlot: TrainerSlot,
    boss = false,
    shinyLock = false,
    dataSource?: PokemonData,
    postProcess?: (enemyPokemon: EnemyPokemon) => void,
    forRival = false,
  ): EnemyPokemon {
    if (activeOverrides.ENEMY_LEVEL_OVERRIDE > 0) {
      level = activeOverrides.ENEMY_LEVEL_OVERRIDE;
    }
    if (activeOverrides.ENEMY_SPECIES_OVERRIDE) {
      species = speciesDataRegistry.getSpecies(activeOverrides.ENEMY_SPECIES_OVERRIDE);
      // The fact that a Pokemon is a boss or not can change based on its Species and level
      boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1;
    }

    const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource, forRival);
    if (activeOverrides.ENEMY_FUSION_OVERRIDE) {
      pokemon.generateFusionSpecies();
    }

    if (boss && !dataSource && !this.gameMode.dailyConfig?.boss?.ivs) {
      const secondaryIvs = getIvsFromId(randSeedInt(4294967296));

      for (let s = 0; s < pokemon.ivs.length; s++) {
        pokemon.ivs[s] = Math.round(
          Phaser.Math.Linear(
            Math.min(pokemon.ivs[s], secondaryIvs[s]),
            Math.max(pokemon.ivs[s], secondaryIvs[s]),
            0.75,
          ),
        );
      }
    }
    if (postProcess) {
      postProcess(pokemon);
    }

    if (activeOverrides.ENEMY_IVS_OVERRIDE === null) {
      // do nothing
    } else if (Array.isArray(activeOverrides.ENEMY_IVS_OVERRIDE)) {
      if (activeOverrides.ENEMY_IVS_OVERRIDE.length !== 6) {
        throw new Error("The Enemy IVs override must be an array of length 6 or a number!");
      }
      if (activeOverrides.ENEMY_IVS_OVERRIDE.some(value => !isBetween(value, 0, 31))) {
        throw new Error("All IVs in the enemy IV override must be between 0 and 31!");
      }
      pokemon.ivs = activeOverrides.ENEMY_IVS_OVERRIDE;
    } else {
      if (!isBetween(activeOverrides.ENEMY_IVS_OVERRIDE, 0, 31)) {
        throw new Error("The Enemy IV override must be a value between 0 and 31!");
      }
      pokemon.ivs = new Array(6).fill(activeOverrides.ENEMY_IVS_OVERRIDE);
    }

    if (activeOverrides.ENEMY_NATURE_OVERRIDE !== null) {
      pokemon.nature = activeOverrides.ENEMY_NATURE_OVERRIDE;
    }

    pokemon.init();
    return pokemon;
  }

  /**
   * Removes a {@linkcode PlayerPokemon} from the party, and clears modifiers for that Pokemon's id
   * Useful for MEs/Challenges that remove Pokemon from the player party temporarily or permanently
   * @param pokemon
   * @param destroy Default true. If true, will destroy the {@linkcode PlayerPokemon} after removing
   */
  removePokemonFromPlayerParty(pokemon: PlayerPokemon, destroy = true) {
    if (!pokemon) {
      return;
    }

    const partyIndex = this.party.indexOf(pokemon);
    this.party.splice(partyIndex, 1);
    if (destroy) {
      this.field.remove(pokemon, true);
      pokemon.destroy();
    }
    this.updateModifiers(true);
  }

  addPokemonIcon(
    pokemon: Pokemon,
    x: number,
    y: number,
    originX = 0.5,
    originY = 0.5,
    ignoreOverride = true,
    useIllusion = false,
  ): Phaser.GameObjects.Container {
    const container = this.add //
      .container(x, y)
      .setName(`${pokemon.name}-icon`);

    const icon = this.add
      .sprite(0, 0, pokemon.getIconAtlasKey(ignoreOverride, useIllusion))
      .setName(`sprite-${pokemon.name}-icon`)
      .setFrame(pokemon.getIconId(ignoreOverride, useIllusion))
      .setOrigin(0.5, 0);
    // Temporary fix to show pokemon's default icon if variant icon doesn't exist
    if (icon.frame.name !== pokemon.getIconId(ignoreOverride, useIllusion)) {
      console.log(`${pokemon.name}'s variant icon does not exist. Replacing with default.`);
      const temp = pokemon.shiny;
      pokemon.shiny = false;
      icon.setTexture(pokemon.getIconAtlasKey(ignoreOverride, useIllusion));
      icon.setFrame(pokemon.getIconId(true, useIllusion));
      pokemon.shiny = temp;
    }
    icon.setOrigin(0.5, 0);

    container.add(icon);

    if (pokemon.isFusion(useIllusion)) {
      const fusionIcon = this.add
        .sprite(0, 0, pokemon.getFusionIconAtlasKey(ignoreOverride, useIllusion))
        .setName("sprite-fusion-icon")
        .setOrigin(0.5, 0)
        .setFrame(pokemon.getFusionIconId(ignoreOverride, useIllusion));

      const originalWidth = icon.width;
      const originalHeight = icon.height;
      const originalFrame = icon.frame;

      const iconHeight = (icon.frame.cutHeight <= fusionIcon.frame.cutHeight ? Math.ceil : Math.floor)(
        (icon.frame.cutHeight + fusionIcon.frame.cutHeight) / 4,
      );

      // Inefficient, but for some reason didn't work with only the unique properties as part of the name
      const iconFrameId = `${icon.frame.name}f${fusionIcon.frame.name}`;

      if (!icon.frame.texture.has(iconFrameId)) {
        icon.frame.texture.add(
          iconFrameId,
          icon.frame.sourceIndex,
          icon.frame.cutX,
          icon.frame.cutY,
          icon.frame.cutWidth,
          iconHeight,
        );
      }

      icon.setFrame(iconFrameId);

      fusionIcon.y = icon.frame.cutHeight;

      const originalFusionFrame = fusionIcon.frame;

      const fusionIconY = fusionIcon.frame.cutY + icon.frame.cutHeight;
      const fusionIconHeight = fusionIcon.frame.cutHeight - icon.frame.cutHeight;

      // Inefficient, but for some reason didn't work with only the unique properties as part of the name
      const fusionIconFrameId = `${fusionIcon.frame.name}f${icon.frame.name}`;

      if (!fusionIcon.frame.texture.has(fusionIconFrameId)) {
        fusionIcon.frame.texture.add(
          fusionIconFrameId,
          fusionIcon.frame.sourceIndex,
          fusionIcon.frame.cutX,
          fusionIconY,
          fusionIcon.frame.cutWidth,
          fusionIconHeight,
        );
      }
      fusionIcon.setFrame(fusionIconFrameId);

      const frameY = (originalFrame.y + originalFusionFrame.y) / 2;
      icon.frame.y = frameY;
      fusionIcon.frame.y = frameY;

      container.add(fusionIcon);

      if (originX !== 0.5) {
        container.x -= originalWidth * (originX - 0.5);
      }
      if (originY !== 0) {
        container.y -= originalHeight * originY;
      }
    } else {
      if (originX !== 0.5) {
        container.x -= icon.width * (originX - 0.5);
      }
      if (originY !== 0) {
        container.y -= icon.height * originY;
      }
    }

    return container;
  }

  setSeed(seed: string): void {
    this.seed = seed;
    this.waveCycleOffset = this.getGeneratedWaveCycleOffset();
    this.offsetGym = this.gameMode.isClassic && this.getGeneratedOffsetGym();
  }

  /**
   * Generates a random number using the current battle's seed
   *
   * This calls {@linkcode Battle.randSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle.ts`
   * which calls {@linkcode randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`
   *
   * @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)
   */
  randBattleSeedInt(range: number, min = 0): number {
    return this.currentBattle?.randSeedInt(range, min);
  }

  // TODO: Break up function - this does far too much in 1 sitting
  reset(clearScene = false, clearData = false, reloadI18n = false): void {
    if (clearData) {
      this.gameData = new GameData();
    }

    this.turnCommandManager.resetTurnOrder();
    this.gameMode = getGameMode(GameModes.CLASSIC);

    this.disableMenu = false;

    this.score = 0;
    this.money = 0;

    this.lockModifierTiers = false;

    if (activeOverrides.POKEBALL_OVERRIDE.active) {
      this.pokeballCounts = activeOverrides.POKEBALL_OVERRIDE.pokeballs;
    } else {
      // TODO: Remove unused luxury balls and remove the `filter`
      this.pokeballCounts = Object.fromEntries(
        getEnumValues(PokeballType)
          .filter(pt => pt !== PokeballType.LUXURY_BALL)
          .map(t => [t, 0]),
      );
      this.pokeballCounts[PokeballType.POKEBALL] = 5;
    }

    this.modifiers = [];
    this.enemyModifiers = [];
    this.modifierBar.removeAll(true);
    this.enemyModifierBar.removeAll(true);

    for (const p of this.getPlayerParty()) {
      p.destroy();
    }
    this.party = [];
    for (const p of this.getEnemyParty()) {
      p.destroy();
    }

    // If this is a ME, clear any residual visual sprites before reloading
    if (this.currentBattle?.mysteryEncounter?.introVisuals) {
      this.field.remove(this.currentBattle.mysteryEncounter?.introVisuals, true);
    }

    // Same for trainers, which would otherwise stay on the field into the next run
    this.currentBattle?.trainer?.destroy();
    this.lastEnemyTrainer?.destroy();
    this.lastEnemyTrainer = null;

    this.currentBattle = null!; // TODO: this should never be `null`, probably needs multiple scenes

    // Reset RNG after end of game or save & quit.
    // This needs to happen after clearing this.currentBattle or the seed will be affected by the last wave played
    this.setSeed(activeOverrides.SEED_OVERRIDE || randomString(24));
    console.log("Seed:", this.seed);
    this.resetSeed();

    this.biomeWaveText.setText(STARTING_WAVE.toString());
    this.biomeWaveText.setVisible(false);

    this.updateMoneyText();
    this.moneyText.setVisible(false);

    this.updateScoreText();
    this.scoreText.setVisible(false);

    [this.luckLabelText, this.luckText].forEach(t => {
      t.setVisible(false);
    });

    this.newArena(activeOverrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN);

    this.field.setVisible(true);

    this.arenaBgTransition.setPosition(0, 0);
    this.arenaPlayer.setPosition(300, 0);
    this.arenaPlayerTransition.setPosition(0, 0);
    this.arenaEnemy.setPosition(-280, 0);
    this.arenaNextEnemy.setPosition(-280, 0);
    this.arenaNextEnemy.setVisible(false);

    this.arena.init();

    this.trainer.setTexture(`trainer_${settings.isPlayerFemale ? "f" : "m"}_back`);
    this.trainer.setPosition(406, 186);
    this.trainer.setVisible(true);

    this.mysteryEncounterSaveData = new MysteryEncounterSaveData();

    this.updateGameInfo();

    if (reloadI18n) {
      const localizable: Localizable[] = [
        ...speciesDataRegistry.getAllSpecies(),
        ...allMoves,
        ...getEnumValues(ModifierPoolType)
          .map(mpt => getModifierPoolForType(mpt))
          .flatMap(mp =>
            Object.values(mp)
              .flat()
              .map(mt => mt.modifierType)
              .filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"),
          ),
      ];
      for (const item of localizable) {
        item.localize();
      }
    }

    if (clearScene) {
      // Reload variant data in case sprite set has changed
      this.initVariantData();

      audioManager.fadeOutBgm(250);
      this.tweens.add({
        targets: [this.uiContainer],
        alpha: 0,
        duration: 250,
        ease: "Sine.easeInOut",
        onComplete: () => {
          this.ui.freeUIData();
          this.uiContainer.remove(this.ui, true);
          this.uiContainer.destroy();
          this.children.removeAll(true);
          // TODO: Do we even need this?
          this.game.domContainer.innerHTML = "";
          // TODO: `launchBattle` calls `reset(false, false, true)`
          this.launchBattle();
        },
      });
    }
  }

  // TODO: Invert the chances for this
  private getDoubleBattleChance(newWaveIndex: number): number {
    const doubleChance = new NumberHolder(newWaveIndex % 10 === 0 ? 32 : 8);
    this.applyModifiers(DoubleBattleChanceBoosterModifier, true, doubleChance);
    for (const p of this.getPlayerField()) {
      // TODO: This passes `null` to `applyAbAttrs`
      applyAbAttrs("DoubleBattleChanceAbAttr", { pokemon: p, chance: doubleChance });
    }
    return Math.max(doubleChance.value, 1);
  }

  isNewBiome(currentBattle = this.currentBattle) {
    const isWaveIndexMultipleOfTen = !(currentBattle.waveIndex % 10);
    const isEndlessOrDaily = this.gameMode.hasShortBiomes || this.gameMode.isDaily;
    const isEndlessFifthWave = this.gameMode.hasShortBiomes && currentBattle.waveIndex % 5 === 0;
    const isWaveIndexMultipleOfFiftyMinusOne = currentBattle.waveIndex % 50 === 49;
    return isWaveIndexMultipleOfTen || isEndlessFifthWave || (isEndlessOrDaily && isWaveIndexMultipleOfFiftyMinusOne);
  }

  /**
   * Create and initialize a new battle.
   * @param fromSession - The {@linkcode SessionSaveData} being used to seed the battle. \
   *   Should be omitted if not loading an existing save file.
   * @returns The newly created `Battle` instance.
   */
  public newBattle(fromSession?: SessionSaveData): Battle {
    const props = this.getNewBattleProps(fromSession);
    const { waveIndex, mysteryEncounterType } = props;
    const resolved: NewBattleInitialProps = { waveIndex, mysteryEncounterType };

    // TODO: This _should_ be safe to move elsewhere but idk
    this.resetSeed(waveIndex);

    // Set attributes of the `resolved` object based on the type of battle being created.
    if (fromSession) {
      this.handleSavedBattle(resolved, props);
    } else if (this.gameMode.isFixedBattle(waveIndex)) {
      this.handleFixedBattle(resolved);
    } else {
      this.handleNonFixedBattle(resolved);
    }

    if (resolved.battleType == null) {
      throw new Error(
        "BattleScene.newBattle lacked battle type information inside new battle config!\nData:\n"
          + JSON.stringify(resolved),
      );
    }
    resolved.double = this.checkIsDouble(resolved as NewBattleConstructedProps);

    const lastBattle: Battle | null = this.currentBattle;
    const maxExpLevel = this.getMaxExpLevel();

    this.lastEnemyTrainer = lastBattle?.trainer ?? null;
    this.lastMysteryEncounter = lastBattle?.mysteryEncounter;

    // TODO: Is this even needed?
    if (lastBattle?.double && !resolved.double) {
      this.phaseManager.tryRemovePhase("SwitchPhase");
      // TODO: We already do this later in the function
      for (const p of this.getPlayerField()) {
        p.lapseTag(BattlerTagType.COMMANDED);
      }
    }

    this.executeWithSeedOffset(
      () => {
        // NB: Type assertion is fine as resolved should always be populated at this point
        this.currentBattle = new Battle(this.gameMode, resolved as NewBattleResolvedProps);
      },
      waveIndex << 3, // TODO: Why use this specific bitshift?
      this.waveSeed,
    );
    this.currentBattle.incrementTurn();

    if (!fromSession?.waveIndex && lastBattle) {
      this.doPostBattleCleanup(lastBattle, maxExpLevel);
    }
    return this.currentBattle;
  }

  /**
   * Helper function to {@linkcode BattleScene.newBattle | newBattle} to initialize variables
   * with defaults if no session data is provided.
   * @param fromSession - The session data being used to initialize the battle
   * @returns The new battle props
   */
  // TODO: If or when the `resetSeed` call is (re)moved from `newBattle`, move this inline into `handleSavedBattle`
  private getNewBattleProps(fromSession?: SessionSaveData): NewBattleSavedProps {
    if (fromSession == null) {
      return {
        battleType: BattleType.WILD,
        // Don't increment wave index when computing starting wave
        waveIndex:
          this.currentBattle == null
            ? (activeOverrides.STARTING_WAVE_OVERRIDE ?? STARTING_WAVE)
            : this.currentBattle.waveIndex + 1,
      };
    }

    const { waveIndex, battleType, trainer: trainerData, mysteryEncounterType: sessionMEType } = fromSession;
    // TODO: Remove fallback once we stop using `-1` as a default value for session data fields (which wastes space)
    const mysteryEncounterType = sessionMEType === -1 ? undefined : sessionMEType;

    let fixedDouble: boolean;
    // make sure illegal battle types don't occur due to save data corruption (e.g. from enum shifting)
    if (
      trainerData?.variant === TrainerVariant.DOUBLE
      && !trainerConfigs[trainerData.trainerType].hasDouble
      && !trainerConfigs[trainerData.trainerType].doubleOnly
    ) {
      trainerData.variant = TrainerVariant.DEFAULT;
      fixedDouble = false;
    } else if (
      trainerData
      && trainerData.variant !== TrainerVariant.DOUBLE
      && trainerConfigs[trainerData.trainerType].doubleOnly
    ) {
      trainerData.variant = TrainerVariant.DOUBLE;
      fixedDouble = true;
    }

    switch (battleType) {
      case BattleType.WILD:
        fixedDouble = fromSession.enemyParty.length > 1;
        break;
      case BattleType.TRAINER: {
        const config = trainerConfigs[trainerData.trainerType];
        fixedDouble = config.doubleOnly || (config.hasDouble && trainerData.variant === TrainerVariant.DOUBLE);
        break;
      }
      case BattleType.MYSTERY_ENCOUNTER:
        fixedDouble = false;
        break;
    }

    return {
      battleType,
      mysteryEncounterType,
      waveIndex,
      trainerData,
      double: fixedDouble,
    } satisfies NewBattleSavedProps;
  }

  /**
   * Sub-method of {@linkcode newBattle} that handles fixed trainer battles.
   * @param resolved - The object to modify
   */
  private handleFixedBattle(resolved: NewBattleInitialProps): void {
    const { waveIndex } = resolved;
    // Bang is justified as this code is only called when `isFixedBattle` is true
    const battleConfig = this.gameMode.getFixedBattle(waveIndex)!;
    resolved.double = battleConfig.double;
    resolved.battleType = battleConfig.battleType;

    // `!` tells TS this will always be defined; necessary due to block scoping from using `executeWithSeedOffset`
    let trainer!: Trainer;
    this.executeWithSeedOffset(
      () => {
        trainer = battleConfig.getTrainer();
      },
      // TODO: This is only used to ensure evil team leaders get pre-generated as the same team, which is EXTREMELY susceptible to internal RNG changes
      // Instead, the save data can store the evil team/gym leader preset used upon run creation
      (battleConfig.seedOffsetWaveIndex || waveIndex) << 8,
    );
    this.field.add(trainer);
    resolved.trainer = trainer;
  }

  /**
   * Sub-method of {@linkcode newBattle} that handles loading existing saved battles.
   * @param resolved - The object to modify
   * @param props - The {@linkcode NewBattleSavedProps} created from the save data
   */
  private handleSavedBattle(resolved: NewBattleInitialProps, props: NewBattleSavedProps): void {
    resolved.battleType = props.battleType;
    resolved.double = props.double;
    resolved.trainer = props.trainerData?.toTrainer();
    if (resolved.trainer) {
      this.field.add(resolved.trainer);
    }
  }

  /**
   * Sub-method of {@linkcode newBattle} that handles generating a new battle from scratch.
   * @param resolved - The object to modify properties of
   */
  private handleNonFixedBattle(resolved: NewBattleInitialProps): void {
    const { waveIndex } = resolved;
    resolved.battleType =
      !this.gameMode.hasTrainers || activeOverrides.DISABLE_STANDARD_TRAINERS_OVERRIDE
        ? BattleType.WILD
        : (activeOverrides.BATTLE_TYPE_OVERRIDE
          ?? (this.gameMode.isWaveTrainer(waveIndex) ? BattleType.TRAINER : BattleType.WILD));

    // Check for mystery encounter
    // Can only occur in place of a standard (non-boss) wild battle, waves 10-180
    // NB: battle type checks are offloaded to `isWaveMysteryEncounter`
    // TODO: This means MEs can generate when the override is set to `BattleType.WILD`
    if (!activeOverrides.BATTLE_TYPE_OVERRIDE && this.isWaveMysteryEncounter(resolved.battleType, waveIndex)) {
      resolved.battleType = BattleType.MYSTERY_ENCOUNTER;
      // Reset to base spawn weight
      this.mysteryEncounterSaveData.encounterSpawnChance = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT;
      return;
    }

    if (resolved.battleType !== BattleType.TRAINER) {
      return;
    }

    const trainer = this.generateNewBattleTrainer(waveIndex);
    this.field.add(trainer);
    resolved.trainer = trainer;
  }

  /**
   * Helper function to randomly determine the attributes of a newly generated trainer.
   * @param waveIndex - The wave number being generated
   * @returns The generated trainer.
   */
  private generateNewBattleTrainer(waveIndex: number): Trainer {
    const trainerType = activeOverrides.RANDOM_TRAINER_OVERRIDE?.trainerType ?? this.arena.randomTrainerType(waveIndex);
    const config = trainerConfigs[trainerType];

    let doubleTrainer: boolean;
    if (config.doubleOnly) {
      doubleTrainer = true;
    } else if (
      // Add a check that special trainers can't be double except for tate and liza - they should use the normal double chance
      // TODO: Review this
      !config.hasDouble
      || (config.trainerTypeDouble && ![TrainerType.TATE, TrainerType.LIZA].includes(trainerType))
    ) {
      doubleTrainer = false;
    } else {
      doubleTrainer = randSeedInt(this.getDoubleBattleChance(waveIndex)) === 0;
    }

    const overrideVariant = doubleTrainer
      ? TrainerVariant.DOUBLE
      : activeOverrides.RANDOM_TRAINER_OVERRIDE?.trainerVariant;
    const variant = overrideVariant ?? (randSeedInt(2) ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT);

    return new Trainer(trainerType, variant);
  }

  /**
   * Sub-method of {@linkcode newBattle} that returns whether the new battle is a double battle.
   * @param __namedParameters - Needed for typedoc to function
   * @returns Whether the battle should be a double battle.
   */
  private checkIsDouble({ double: forcedDouble, battleType, waveIndex, trainer }: NewBattleConstructedProps): boolean {
    // TODO: enforce using the proper override depending on whether it's a trainer or a wild battle
    const doubleBattleOverride = this.doCheckDoubleOverride(waveIndex);
    if (doubleBattleOverride != null) {
      return doubleBattleOverride;
    }

    // Edge cases
    if (
      this.gameMode.isWaveFinal(waveIndex) // Endless bosses and classic mode finales are never double battles
      || this.gameMode.isEndlessBoss(waveIndex)
      || battleType === BattleType.MYSTERY_ENCOUNTER // MEs are never double battles
    ) {
      return false;
    }

    if (forcedDouble != null) {
      return forcedDouble;
    }

    // Standard wild battle chance
    // TODO: Rework the calcs here - this is weird
    if (battleType === BattleType.WILD) {
      return randSeedInt(this.getDoubleBattleChance(waveIndex)) === 0;
    }
    return trainer?.variant === TrainerVariant.DOUBLE;
  }

  /**
   * Check the double battle override for the current wave.
   * @param waveIndex - The wave number of the newly generated wave
   * @returns Whether the wave should be forced into being a double battle.
   * Returns `undefined` if the override is `null`.
   */
  private doCheckDoubleOverride(waveIndex: number): boolean | undefined {
    switch (activeOverrides.FIELD_SIZE_OVERRIDE) {
      case "double":
        return true;
      case "single":
        return false;
      case "even-doubles":
        return waveIndex % 2 === 0;
      case "odd-doubles":
        return waveIndex % 2 === 1;
      default:
        activeOverrides.FIELD_SIZE_OVERRIDE satisfies null;
        return;
    }
  }

  // TODO: Split this up and move it to a "post battle phase"
  private doPostBattleCleanup(lastBattle: Battle, maxExpLevel: number): void {
    const isNewBiome = this.isNewBiome(lastBattle);
    /** Whether to reset and recall pokemon */
    const resetArenaState =
      isNewBiome
      || [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.currentBattle.battleType)
      || this.currentBattle.isClassicFinalBoss;

    for (const enemyPokemon of this.getEnemyParty()) {
      enemyPokemon.destroy();
    }

    this.trySpreadPokerus();
    if (!isNewBiome && this.currentBattle.waveIndex % 10 === 5) {
      this.arena.updatePoolsForTimeOfDay();
    }

    // use the old value of `double` to ensure both combatants are handled properly when going from double to single battles
    const playerField = this.getPlayerParty().slice(0, 1 + Number(lastBattle.double));
    if (resetArenaState) {
      // Remove the tag so that Tatsugiri is ejected, and will be recalled correctly
      for (const pokemon of playerField) {
        pokemon.lapseTag(BattlerTagType.COMMANDED);
      }
    }

    // Recalls, repositions and sends out the player's Pokemon as needed for the new battle
    this.phaseManager.unshiftNew("PartyReorderSwitchPhase", resetArenaState);

    if (resetArenaState) {
      this.arena.resetArenaEffects();

      for (const pokemon of this.getPlayerParty()) {
        pokemon.resetBattleAndWaveData();
        pokemon.resetTera();
        applyAbAttrs("PostBattleInitAbAttr", { pokemon });
        // Terapagos resets tera on each fight
        if (pokemon.hasSpecies(SpeciesId.TERAPAGOS)) {
          this.arena.playerTerasUsed = 0;
        }
      }

      if (!this.trainer.visible) {
        this.phaseManager.pushNew("ShowTrainerPhase");
      }
    }

    for (const pokemon of this.getPlayerParty()) {
      this.triggerPokemonFormChange(pokemon, SpeciesFormChangeTimeOfDayTrigger);
    }

    if (!this.gameMode.hasRandomBiomes && !isNewBiome) {
      this.phaseManager.pushNew("NextEncounterPhase");
    } else {
      this.phaseManager.pushNew("NewBiomeEncounterPhase");

      const newMaxExpLevel = this.getMaxExpLevel();
      if (newMaxExpLevel > maxExpLevel) {
        this.phaseManager.pushNew("LevelCapPhase");
      }
    }
  }

  newArena(biome: BiomeId, playerFaints = 0): Arena {
    this.arena = new Arena(biome, playerFaints);
    this.eventTarget.dispatchEvent(new NewArenaEvent());

    this.arenaBg.renderNodeData[globalScene.fieldSpriteSubmitter.name] = {
      terrainColorRatio: this.arena.bgTerrainColorRatioForBiome,
    };

    return this.arena;
  }

  /**
   * Loads the visual assets for a given biome, including background, arena layers, and props. \
   * If the assets are already in the texture cache, it resolves immediately.
   * @param biome - The {@linkcode BiomeId} of the biome to load assets for
   * @returns A promise that resolves when the assets have finished loading
   */
  public async loadBiomeAssets(biome: BiomeId): Promise<void> {
    const { promise, resolve } = Promise.withResolvers<void>();
    const btKey = getBiomeKey(biome);

    // Already in texture cache — nothing to load
    if (this.textures.exists(`${btKey}_bg`)) {
      resolve();
      return promise;
    }

    const isBaseAnimated = btKey === "end";
    const baseAKey = `${btKey}_a`;
    const baseBKey = `${btKey}_b`;

    this.loadImage(`${btKey}_bg`, "arenas");

    if (isBaseAnimated) {
      this.loadAtlas(baseAKey, "arenas") //
        .loadAtlas(baseBKey, "arenas");
    } else {
      this.loadImage(baseAKey, "arenas") //
        .loadImage(baseBKey, "arenas");
    }

    if (getBiomeHasProps(biome)) {
      for (let p = 1; p <= 3; p++) {
        const isPropAnimated = p === 3 && ["power_plant", "end"].includes(btKey);
        const propKey = `${btKey}_b_${p}`;
        if (isPropAnimated) {
          this.loadAtlas(propKey, "arenas");
        } else {
          this.loadImage(propKey, "arenas");
        }
      }
    }

    this.load.once(Phaser.Loader.Events.COMPLETE, resolve);
    this.load.start();

    return promise;
  }

  /**
   * Clears the visual assets for a given biome from the texture cache to free up memory. \
   * The "TOWN" biome is exempt from clearing as it is the base biome.
   * @param biome - The {@linkcode BiomeId} of the biome to clear assets for
   */
  public clearBiomeAssets(biome: BiomeId): void {
    const btKey = getBiomeKey(biome);

    // Don't clear TOWN — it's the starting biome
    if (btKey === "town") {
      return;
    }

    const keysToClear = [`${btKey}_bg`, `${btKey}_a`, `${btKey}_b`];

    if (getBiomeHasProps(biome)) {
      for (let p = 1; p <= 3; p++) {
        keysToClear.push(`${btKey}_b_${p}`);
      }
    }

    for (const key of keysToClear) {
      if (this.anims.exists(key)) {
        console.log(`Removing animation for key ${key}..`);
        this.anims.remove(key);
      }

      if (this.textures.exists(key)) {
        this.textures.remove(key);
      }
    }
  }

  updateFieldScale(): Promise<void> {
    return new Promise(resolve => {
      const fieldScale =
        Math.floor(
          Math.pow(
            1
              / this.getField(true)
                .map(p => p.getSpriteScale())
                .reduce((highestScale: number, scale: number) => (highestScale = Math.max(scale, highestScale)), 0),
            0.7,
          ) * 40,
        ) / 40;
      this.setFieldScale(fieldScale).then(() => resolve());
    });
  }

  setFieldScale(scale: number, instant = false): Promise<void> {
    return new Promise(resolve => {
      scale *= 6;
      if (this.field.scale === scale) {
        return resolve();
      }

      const defaultWidth = this.arenaBg.width * 6;
      const defaultHeight = 132 * 6;
      const scaledWidth = this.arenaBg.width * scale;
      const scaledHeight = 132 * scale;

      this.tweens.add({
        targets: this.field,
        scale,
        x: (defaultWidth - scaledWidth) / 2,
        y: defaultHeight - scaledHeight,
        duration: instant ? 0 : fixedInt(Math.abs(this.field.scale - scale) * 200),
        ease: "Sine.easeInOut",
        onComplete: () => resolve(),
      });
    });
  }

  // TODO: break this up
  public getSpeciesFormIndex(species: PokemonSpecies, gender?: Gender, nature?: Nature, ignoreArena = false): number {
    if (species.forms == null) {
      console.warn(`Form data missing for ${species.name}!\n`, species);
      return 0;
    }
    if (species.forms.length === 0) {
      return 0;
    }

    const isEggPhase =
      this.phaseManager.getCurrentPhase().is("EggLapsePhase")
      || this.phaseManager.getCurrentPhase().is("EggHatchPhase");

    const isTrainerBattle = this.currentBattle?.battleType === BattleType.TRAINER;

    // Give trainers with specialty types an appropriately-typed form for Wormadam, Rotom, Arceus, Oricorio, Silvally, or Paldean Tauros.
    if (!isEggPhase && isTrainerBattle && this.currentBattle.trainer?.config.hasSpecialtyType()) {
      if (species.speciesId === SpeciesId.WORMADAM) {
        switch (this.currentBattle.trainer.config.specialtyType) {
          case PokemonType.GROUND:
            return 1; // Sandy Cloak
          case PokemonType.STEEL:
            return 2; // Trash Cloak
          case PokemonType.GRASS:
            return 0; // Plant Cloak
        }
      }
      if (species.speciesId === SpeciesId.ROTOM) {
        switch (this.currentBattle.trainer.config.specialtyType) {
          case PokemonType.FLYING:
            return 4; // Fan Rotom
          case PokemonType.GHOST:
            return 0; // Lightbulb Rotom
          case PokemonType.FIRE:
            return 1; // Heat Rotom
          case PokemonType.WATER:
            return 2; // Wash Rotom
          case PokemonType.GRASS:
            return 5; // Mow Rotom
          case PokemonType.ICE:
            return 3; // Frost Rotom
        }
      }
      if (species.speciesId === SpeciesId.ORICORIO) {
        switch (this.currentBattle.trainer.config.specialtyType) {
          case PokemonType.GHOST:
            return 3; // Sensu Style
          case PokemonType.FIRE:
            return 0; // Baile Style
          case PokemonType.ELECTRIC:
            return 1; // Pom-Pom Style
          case PokemonType.PSYCHIC:
            return 2; // Pa'u Style
        }
      }
      if (species.speciesId === SpeciesId.PALDEA_TAUROS) {
        switch (this.currentBattle.trainer.config.specialtyType) {
          case PokemonType.FIRE:
            return 1; // Blaze Breed
          case PokemonType.WATER:
            return 2; // Aqua Breed
        }
      }
      if (species.speciesId === SpeciesId.SILVALLY || species.speciesId === SpeciesId.ARCEUS) {
        // Would probably never happen, but might as well
        return this.currentBattle.trainer.config.specialtyType;
      }
    }

    switch (species.speciesId) {
      case SpeciesId.UNOWN:
      case SpeciesId.SHELLOS:
      case SpeciesId.GASTRODON:
      case SpeciesId.ROTOM:
      case SpeciesId.BASCULIN:
      case SpeciesId.DEERLING:
      case SpeciesId.SAWSBUCK:
      case SpeciesId.SCATTERBUG:
      case SpeciesId.SPEWPA:
      case SpeciesId.VIVILLON:
      case SpeciesId.FLABEBE:
      case SpeciesId.FLOETTE:
      case SpeciesId.FLORGES:
      case SpeciesId.FURFROU:
      case SpeciesId.PUMPKABOO:
      case SpeciesId.GOURGEIST:
      case SpeciesId.ORICORIO:
      case SpeciesId.ZARUDE:
      case SpeciesId.SQUAWKABILLY:
      case SpeciesId.PALDEA_TAUROS:
        return randSeedInt(species.forms.length);
      case SpeciesId.SINISTEA:
      case SpeciesId.POLTEAGEIST:
      case SpeciesId.MAUSHOLD:
      case SpeciesId.DUDUNSPARCE:
      case SpeciesId.POLTCHAGEIST:
      case SpeciesId.SINISTCHA:
        return randSeedInt(16) ? 0 : 1;
      case SpeciesId.PICHU:
        return randSeedInt(8) ? 0 : 1;
      case SpeciesId.PIKACHU:
        if (isTrainerBattle && this.currentBattle?.waveIndex < 30) {
          return 0; // Ban Cosplay and Partner Pika from Trainers before wave 30
        }
        return randSeedInt(8);
      case SpeciesId.EEVEE:
        if (isTrainerBattle && this.currentBattle?.waveIndex < 30 && !isEggPhase) {
          return 0; // No Partner Eevee for Wave 12 Preschoolers
        }
        return randSeedInt(2);
      case SpeciesId.MAGEARNA:
      case SpeciesId.URSHIFU:
        return randSeedInt(2);
      case SpeciesId.TATSUGIRI:
        return randSeedInt(3);
      case SpeciesId.ZYGARDE:
        return randSeedInt(4);
      case SpeciesId.MINIOR:
        return randSeedInt(7);
      case SpeciesId.ALCREMIE:
        return randSeedInt(9);
      case SpeciesId.MEOWSTIC:
      case SpeciesId.INDEEDEE:
      case SpeciesId.BASCULEGION:
      case SpeciesId.OINKOLOGNE:
        return gender === Gender.FEMALE ? 1 : 0;
      case SpeciesId.TOXTRICITY: {
        const lowkeyNatures: readonly Nature[] = [
          Nature.LONELY,
          Nature.BOLD,
          Nature.RELAXED,
          Nature.TIMID,
          Nature.SERIOUS,
          Nature.MODEST,
          Nature.MILD,
          Nature.QUIET,
          Nature.BASHFUL,
          Nature.CALM,
          Nature.GENTLE,
          Nature.CAREFUL,
        ];
        if (nature !== undefined && lowkeyNatures.includes(nature)) {
          return 1;
        }
        return 0;
      }
      case SpeciesId.GIMMIGHOUL:
        // In game modes with MEs (currently Classic only),
        // chest form Gimmighoul is only allowed to appear in the Mysterious Chest Encounter
        if (this.gameMode.hasMysteryEncounters && !isEggPhase) {
          return 1; // Wandering form
        }
        return randSeedInt(species.forms.length);
      case SpeciesId.BURMY:
      case SpeciesId.WORMADAM:
        if (ignoreArena) {
          return randSeedInt(species.forms.length);
        }
        switch (this.arena.biomeId) {
          case BiomeId.BEACH:
            return 1;
          case BiomeId.SLUM:
            return 2;
        }
        return 0;
      case SpeciesId.LYCANROC:
        if (ignoreArena) {
          return randSeedInt(species.forms.length);
        }
        switch (this.arena.getTimeOfDay()) {
          case TimeOfDay.DAWN:
          case TimeOfDay.DAY:
            return 0;
          case TimeOfDay.DUSK:
            return 2;
          case TimeOfDay.NIGHT:
            return 1;
        }
        return 0;
    }

    return 0;
  }

  private getGeneratedOffsetGym(): boolean {
    let ret = false;
    this.executeWithSeedOffset(
      () => {
        ret = !randSeedInt(2);
      },
      0,
      this.seed.toString(),
    );
    return ret;
  }

  private getGeneratedWaveCycleOffset(): number {
    let ret = 0;
    this.executeWithSeedOffset(
      () => {
        ret = randSeedInt(8) * 5;
      },
      0,
      this.seed.toString(),
    );
    return ret;
  }

  getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number {
    if (activeOverrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1) {
      return activeOverrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE;
    }
    if (activeOverrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE === 1) {
      // The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss
      return 0;
    }

    if (this.gameMode.isDaily && this.gameMode.isWaveFinal(waveIndex)) {
      if (this.gameMode.dailyConfig?.boss?.segments != null) {
        return this.gameMode.dailyConfig.boss.segments;
      }
      return 5;
    }

    let isBoss: boolean | undefined;
    if (forceBoss || (species && (species.subLegendary || species.legendary || species.mythical))) {
      isBoss = true;
    } else {
      this.executeWithSeedOffset(() => {
        isBoss =
          waveIndex % 10 === 0
          || (this.gameMode.hasRandomBosses
            && randSeedInt(100) < Math.min(Math.max(Math.ceil((waveIndex - 250) / 50), 0) * 2, 30));
      }, waveIndex << 2);
    }
    if (!isBoss) {
      return 0;
    }

    let ret = 2;

    if (level >= 100) {
      ret++;
    }
    if (species && species.baseTotal >= 670) {
      ret++;
    }
    ret += Math.floor(waveIndex / 250);

    return ret;
  }

  trySpreadPokerus(): void {
    const party = this.getPlayerParty();
    const infectedIndexes: number[] = [];
    const spread = (index: number, spreadTo: number) => {
      const partyMember = party[index + spreadTo];
      if (!partyMember.pokerus && !randSeedInt(10)) {
        partyMember.pokerus = true;
        infectedIndexes.push(index + spreadTo);
      }
    };
    party.forEach((pokemon, p) => {
      if (!pokemon.pokerus || infectedIndexes.indexOf(p) > -1) {
        return;
      }

      this.executeWithSeedOffset(
        () => {
          if (p) {
            spread(p, -1);
          }
          if (p < party.length - 1) {
            spread(p, 1);
          }
        },
        this.currentBattle.waveIndex + (p << 8),
      );
    });
  }

  // TODO: Refactor this and other RNG functions - these dearly need help
  resetSeed(waveIndex?: number): void {
    const wave = waveIndex ?? this.currentBattle?.waveIndex ?? 0;
    this.waveSeed = shiftCharCodes(this.seed, wave);
    Phaser.Math.RND.sow([this.waveSeed]);
    console.log("Wave Seed:", this.waveSeed, wave);
  }

  executeWithSeedOffset(func: () => void, offset: number, seedOverride?: string): void {
    if (!func) {
      return;
    }
    const tempRngOffset = this.rngOffset;
    const tempRngSeedOverride = this.rngSeedOverride;
    const state = Phaser.Math.RND.state();
    Phaser.Math.RND.sow([shiftCharCodes(seedOverride || this.seed, offset)]);
    this.rngOffset = offset;
    this.rngSeedOverride = seedOverride || "";
    func();
    Phaser.Math.RND.state(state);
    this.rngOffset = tempRngOffset;
    this.rngSeedOverride = tempRngSeedOverride;
  }

  addFieldSprite(
    x: number,
    y: number,
    texture: string | Phaser.Textures.Texture,
    frame?: string | number,
    terrainColorRatio = 0,
  ): Phaser.GameObjects.Sprite {
    return this.add
      .sprite(x, y, texture, frame)
      .setRenderNodeRole("Submitter", this.fieldSpriteSubmitter, terrainColorRatio ? { terrainColorRatio } : undefined)
      .setRenderNodeRole("BatchHandler", this.fieldSpriteBatchHandler);
  }

  addPokemonSprite(
    pokemon: Pokemon,
    x: number,
    y: number,
    texture: string | Phaser.Textures.Texture,
    frame?: string | number,
    hasShadow = false,
    ignoreOverride = false,
  ): Phaser.GameObjects.Sprite {
    const ret = this.addFieldSprite(x, y, texture, frame);
    this.initPokemonSprite(ret, pokemon, hasShadow, ignoreOverride);
    return ret;
  }

  initPokemonSprite(
    sprite: Phaser.GameObjects.Sprite,
    pokemon?: Pokemon,
    hasShadow = false,
    ignoreOverride = false,
  ): Phaser.GameObjects.Sprite {
    sprite
      .setRenderNodeRole("Submitter", this.spriteSubmitter, {
        hasShadow,
        ignoreOverride,
        teraColor: pokemon ? getTypeRgb(pokemon.getTeraType()) : undefined,
        isTerastallized: pokemon ? pokemon.isTerastallized : false,
      })
      .setRenderNodeRole("BatchHandler", this.spriteBatchHandler);

    this.spriteTeraSparkleHandler.add(sprite);
    return sprite;
  }

  moveBelowOverlay<T extends Phaser.GameObjects.GameObject>(gameObject: T) {
    this.fieldUI.moveBelow<any>(gameObject, this.fieldOverlay);
  }
  processInfoButton(pressed: boolean): void {
    this.arenaFlyout.toggleFlyout(pressed);
  }

  public async showFieldOverlay(duration: number): Promise<void> {
    await playTween(
      {
        targets: this.fieldOverlay,
        alpha: 0.5,
        ease: "Sine.easeOut",
        duration,
      },
      this,
    );
  }

  public async hideFieldOverlay(duration: number): Promise<void> {
    await playTween(
      {
        targets: this.fieldOverlay,
        alpha: 0,
        duration,
        ease: "Cubic.easeIn",
      },
      this,
    );
  }

  public updateShopOverlayOpacity(value: number): void {
    if (this.shopOverlayShown) {
      this.shopOverlay.setAlpha(value);
    }
  }

  public async showShopOverlay(duration: number): Promise<void> {
    this.shopOverlayShown = true;

    await playTween({
      targets: this.shopOverlay,
      alpha: settings.display.shopOverlayOpacity,
      ease: "Sine.easeOut",
      duration,
    });
  }

  public async hideShopOverlay(duration: number): Promise<void> {
    this.shopOverlayShown = false;

    await playTween({ targets: this.shopOverlay, alpha: 0, duration, ease: "Cubic.easeIn" });
  }

  showEnemyModifierBar(): void {
    this.enemyModifierBar.setVisible(true);
  }

  hideEnemyModifierBar(): void {
    this.enemyModifierBar.setVisible(false);
  }

  updateBiomeWaveText(): void {
    const isBoss = !(this.currentBattle.waveIndex % 10);
    const biomeString: string = getBiomeName(this.arena.biomeId);
    this.fieldUI.moveAbove(this.biomeWaveText, this.luckText);
    this.biomeWaveText
      .setText(biomeString + " - " + this.currentBattle.waveIndex.toString())
      .setColor(isBoss ? "#f89890" : "#ffffff")
      .setShadowColor(isBoss ? "#984038" : "#636363")
      .setVisible(true);
  }

  updateMoneyText(forceVisible = true): void {
    if (this.money === undefined) {
      return;
    }
    const formattedMoney = formatMoney(settings.display.moneyFormat, this.money);
    this.moneyText.setText(i18next.t("battleScene:moneyOwned", { formattedMoney }));
    this.fieldUI.moveAbove(this.moneyText, this.luckText);
    if (forceVisible) {
      this.moneyText.setVisible(true);
    }
  }

  animateMoneyChanged(positiveChange: boolean): void {
    if (this.tweens.getTweensOf(this.moneyText).length > 0) {
      return;
    }
    const deltaScale = this.moneyText.scale * 0.14 * +positiveChange;
    this.moneyText.setShadowColor(positiveChange ? "#008000" : "#FF0000");
    this.tweens.add({
      targets: this.moneyText,
      duration: 250,
      scale: this.moneyText.scale + deltaScale,
      loop: 0,
      yoyo: true,
      onComplete: _ => this.moneyText.setShadowColor(getTextColor(TextStyle.MONEY, true)),
    });
  }

  updateScoreText(): void {
    const { score } = this;
    this.scoreText // formatting
      .setText(i18next.t("battleScene:score", { score }))
      .setVisible(this.gameMode.isDaily);
  }

  /**
   * Displays the current luck value.
   * @param duration The time for this label to fade in, if it is not already visible.
   */
  updateAndShowText(duration: number): void {
    const labels = [this.luckLabelText, this.luckText];
    for (const label of labels) {
      label.setAlpha(0);
    }
    const luckValue = getPartyLuckValue(this.getPlayerParty());
    this.luckText.setText(getLuckString(luckValue));
    if (luckValue < 14) {
      this.luckText.setTint(getLuckTextTint(luckValue));
    } else {
      this.luckText.setTint(...RAINBOW_TINT);
    }
    this.luckLabelText.setX(this.scaledCanvas.width - 2 - (this.luckText.displayWidth + 2));
    this.tweens.add({
      targets: labels,
      duration,
      alpha: 1,
      onComplete: () => {
        for (const label of labels) {
          label.setVisible(true);
        }
      },
    });
  }

  hideLuckText(duration: number): void {
    if (this.reroll) {
      return;
    }
    const labels = [this.luckLabelText, this.luckText];
    this.tweens.add({
      targets: labels,
      duration,
      alpha: 0,
      onComplete: () => {
        for (const label of labels) {
          label.setVisible(false);
        }
      },
    });
  }

  updateUIPositions(): void {
    const enemyModifierCount = this.enemyModifiers.filter(m => m.isIconVisible()).length;
    const biomeWaveTextHeight = this.biomeWaveText.getBottomLeft().y - this.biomeWaveText.getTopLeft().y;
    this.biomeWaveText.setY(
      -this.scaledCanvas.height
        + (enemyModifierCount ? (enemyModifierCount <= 12 ? 15 : 24) : 0)
        + biomeWaveTextHeight / 2,
    );
    this.moneyText.setY(this.biomeWaveText.y + 10);
    this.scoreText.setY(this.moneyText.y + 10);
    [this.luckLabelText, this.luckText].map(l =>
      l.setY((this.scoreText.visible ? this.scoreText : this.moneyText).y + 10),
    );
    const offsetY = (this.scoreText.visible ? this.scoreText : this.moneyText).y + 15;
    this.partyExpBar.setY(offsetY);
    this.candyBar.setY(offsetY + 15);
    this.ui?.achvBar.setY(this.scaledCanvas.height + offsetY);
  }

  /**
   * Pushes all {@linkcode Phaser.GameObjects.Text} objects in the top right to the bottom of the canvas
   */
  sendTextToBack(): void {
    this.fieldUI.sendToBack(this.biomeWaveText);
    this.fieldUI.sendToBack(this.moneyText);
    this.fieldUI.sendToBack(this.scoreText);
  }

  addFaintedEnemyScore(enemy: EnemyPokemon): void {
    let scoreIncrease =
      enemy.getSpeciesForm().getBaseExp()
      * (enemy.level / this.getMaxExpLevel())
      * ((enemy.ivs.reduce((iv: number, total: number) => (total += iv), 0) / 93) * 0.2 + 0.8);
    this.findModifiers(m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemy.id, false).map(
      m => (scoreIncrease *= (m as PokemonHeldItemModifier).getScoreMultiplier()),
    );
    if (enemy.isBoss()) {
      scoreIncrease *= Math.sqrt(enemy.bossSegments);
    }
    this.currentBattle.battleScore += Math.ceil(scoreIncrease);
  }

  getMaxExpLevel(ignoreLevelCap = false): number {
    const capOverride = activeOverrides.LEVEL_CAP_OVERRIDE ?? 0;
    if (capOverride > 0) {
      return capOverride;
    }

    if (ignoreLevelCap || capOverride < 0) {
      return Number.MAX_SAFE_INTEGER;
    }

    const waveIndex = Math.ceil((this.currentBattle?.waveIndex || 1) / 10) * 10;
    const difficultyWaveIndex = this.gameMode.getWaveForDifficulty(waveIndex);
    const baseLevel = (1 + difficultyWaveIndex / 2 + Math.pow(difficultyWaveIndex / 25, 2)) * 1.2;
    return Math.ceil(baseLevel / 2) * 2 + 2;
  }

  randomSpecies(
    waveIndex: number,
    level: number,
    fromArenaPool = false,
    speciesFilter?: PokemonSpeciesFilter,
    filterAllEvolutions = false,
  ): PokemonSpecies {
    if (fromArenaPool) {
      return this.arena.randomSpecies(waveIndex, level, 0, getPartyLuckValue(this.party));
    }

    // TODO: simplify this?
    const filteredSpecies = speciesFilter
      ? [
          ...new Set(
            speciesDataRegistry
              .getAllSpecies()
              .filter(s => s.isCatchable() && speciesFilter(s))
              .map(s => {
                if (!filterAllEvolutions) {
                  while (speciesDataRegistry.hasPrevolution(s.speciesId)) {
                    s = speciesDataRegistry.getSpecies(speciesDataRegistry.getPrevolution(s.speciesId)!);
                  }
                }
                return s;
              }),
          ),
        ]
      : // TODO: Why is `filterAllEvolutions` only checked if there is a speciesFilter?
        speciesDataRegistry.getAllSpecies().filter(s => s.isCatchable());
    return randSeedItem(filteredSpecies);
  }

  generateRandomBiome(waveIndex: number): BiomeId {
    const relWave = waveIndex % 250;
    const biomes = Object.values(BiomeId).filter(b => b !== BiomeId.TOWN && b !== BiomeId.END);
    const maxDepth = biomeDepths[BiomeId.END][0] - 2;
    const depthWeights = new Array(maxDepth + 1)
      .fill(null)
      .map((_, i: number) => ((1 - Math.min(Math.abs(i / (maxDepth - 1) - relWave / 250) + 0.25, 1)) / 0.75) * 250);
    const biomeThresholds: number[] = [];
    let totalWeight = 0;
    for (const biome of biomes) {
      totalWeight += Math.ceil(depthWeights[biomeDepths[biome][0] - 1] / biomeDepths[biome][1]);
      biomeThresholds.push(totalWeight);
    }

    const randInt = randSeedInt(totalWeight);

    for (let i = 0; i < biomes.length; i++) {
      if (randInt < biomeThresholds[i]) {
        return biomes[i];
      }
    }

    return randSeedItem(biomes);
  }

  toggleInvert(invert: boolean): void {
    this.invertFilter?.setActive(invert);
  }

  addMoney(amount: number): void {
    this.money = Math.min(this.money + amount, Number.MAX_SAFE_INTEGER);
    this.updateMoneyText();
    this.animateMoneyChanged(true);
    this.validateAchvs(MoneyAchv);
  }

  getWaveMoneyAmount(moneyMultiplier: number): number {
    const waveIndex = this.currentBattle.waveIndex;
    const waveSetIndex = Math.ceil(waveIndex / 10) - 1;
    const moneyValue =
      Math.pow((waveSetIndex + 1 + (0.75 + (((waveIndex - 1) % 10) + 1) / 10)) * 100, 1 + 0.005 * waveSetIndex)
      * moneyMultiplier;
    return Math.floor(moneyValue / 10) * 10;
  }

  addModifier(
    modifier: Modifier | null,
    ignoreUpdate?: boolean,
    playSound?: boolean,
    virtual?: boolean,
    instant?: boolean,
    cost?: number,
  ): boolean {
    // We check against modifier.type to stop a bug related to loading in a pokemon that has a form change item, which prior to some patch
    // that changed form change modifiers worked, had previously set the `type` field to null.
    // TODO: This is not the right place to check for this; it should ideally go in a session migrator.
    if (!modifier || !modifier.type) {
      return false;
    }
    let success = false;
    const soundName = modifier.type.soundName;
    this.validateAchvs(ModifierAchv, modifier);
    const modifiersToRemove: PersistentModifier[] = [];
    if (modifier instanceof PersistentModifier) {
      if ((modifier as PersistentModifier).add(this.modifiers, !!virtual)) {
        if (modifier instanceof PokemonFormChangeItemModifier) {
          const pokemon = this.getPokemonById(modifier.pokemonId);
          if (pokemon) {
            success = modifier.apply(pokemon, true);
          }
        }
        if (playSound && !this.sound.get(soundName)) {
          audioManager.playSound(soundName);
        }
      } else if (!virtual) {
        const defaultModifierType = getDefaultModifierTypeForTier(modifier.type.tier);
        this.phaseManager.queueMessage(
          i18next.t("battle:itemStackFull", {
            fullItemName: modifier.type.name,
            itemName: defaultModifierType.name,
          }),
          undefined,
          false,
          3000,
        );
        return this.addModifier(defaultModifierType.newModifier(), ignoreUpdate, playSound, false, instant);
      }

      for (const rm of modifiersToRemove) {
        this.removeModifier(rm);
      }

      if (!ignoreUpdate && !virtual) {
        this.updateModifiers(true, instant);
      }
    } else if (modifier instanceof ConsumableModifier) {
      if (playSound && !this.sound.get(soundName)) {
        audioManager.playSound(soundName);
      }

      if (modifier instanceof ConsumablePokemonModifier) {
        for (const p in this.party) {
          const pokemon = this.party[p];

          const args: unknown[] = [];
          if (modifier instanceof PokemonHpRestoreModifier) {
            if ((modifier as PokemonHpRestoreModifier).fainted) {
              args.push(1);
            } else {
              const hpRestoreMultiplier = new NumberHolder(1);
              this.applyModifiers(HealingBoosterModifier, true, hpRestoreMultiplier);
              args.push(hpRestoreMultiplier.value);
            }
          } else if (modifier instanceof FusePokemonModifier) {
            args.push(this.getPokemonById(modifier.fusePokemonId) as PlayerPokemon);
          } else if (modifier instanceof RememberMoveModifier && cost != null) {
            args.push(cost);
          }

          if (modifier.shouldApply(pokemon, ...args)) {
            const result = modifier.apply(pokemon, ...args);
            success ||= result;
          }
        }

        this.party.forEach(p => {
          p.updateInfo(instant);
        });
      } else {
        const args = [this];
        if (modifier.shouldApply(...args)) {
          const result = modifier.apply(...args);
          success ||= result;
        }
      }
    }
    return success;
  }

  addEnemyModifier(modifier: PersistentModifier, ignoreUpdate?: boolean, instant?: boolean): Promise<void> {
    return new Promise(resolve => {
      const modifiersToRemove: PersistentModifier[] = [];
      if ((modifier as PersistentModifier).add(this.enemyModifiers, false)) {
        if (modifier instanceof PokemonFormChangeItemModifier) {
          const pokemon = this.getPokemonById(modifier.pokemonId);
          if (pokemon) {
            modifier.apply(pokemon, true);
          }
        }
        for (const rm of modifiersToRemove) {
          this.removeModifier(rm, true);
        }
      }
      if (!ignoreUpdate) {
        this.updateModifiers(false, instant);
      }
      resolve();
    });
  }

  /**
   * Try to transfer a held item to another pokemon.
   * If the recepient already has the maximum amount allowed for this item, the transfer is cancelled.
   * The quantity to transfer is automatically capped at how much the recepient can take before reaching the maximum stack size for the item.
   * A transfer that moves a quantity smaller than what is specified in the transferQuantity parameter is still considered successful.
   * @param itemModifier {@linkcode PokemonHeldItemModifier} item to transfer (represents the whole stack)
   * @param target {@linkcode Pokemon} recepient in this transfer
   * @param playSound `true` to play a sound when transferring the item
   * @param transferQuantity How many items of the stack to transfer. Optional, defaults to `1`
   * @param instant ??? (Optional)
   * @param ignoreUpdate ??? (Optional)
   * @param itemLost If `true`, treat the item's current holder as losing the item (for now, this simply enables Unburden). Default is `true`.
   * @returns `true` if the transfer was successful
   */
  tryTransferHeldItemModifier(
    itemModifier: PokemonHeldItemModifier,
    target: Pokemon,
    playSound: boolean,
    transferQuantity = 1,
    instant?: boolean,
    ignoreUpdate?: boolean,
    itemLost = true,
  ): boolean {
    const source = itemModifier.pokemonId ? itemModifier.getPokemon() : null;
    const cancelled = new BooleanHolder(false);

    if (source && source.isPlayer() !== target.isPlayer()) {
      applyAbAttrs("BlockItemTheftAbAttr", { pokemon: source, cancelled });
    }

    if (cancelled.value) {
      return false;
    }

    const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier;
    newItemModifier.pokemonId = target.id;
    const matchingModifier = this.findModifier(
      m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id,
      target.isPlayer(),
    ) as PokemonHeldItemModifier;

    if (matchingModifier) {
      const maxStackCount = matchingModifier.getMaxStackCount();
      if (matchingModifier.stackCount >= maxStackCount) {
        return false;
      }
      const countTaken = Math.min(
        transferQuantity,
        itemModifier.stackCount,
        maxStackCount - matchingModifier.stackCount,
      );
      itemModifier.stackCount -= countTaken;
      newItemModifier.stackCount = matchingModifier.stackCount + countTaken;
    } else {
      const countTaken = Math.min(transferQuantity, itemModifier.stackCount);
      itemModifier.stackCount -= countTaken;
      newItemModifier.stackCount = countTaken;
    }

    const removeOld = itemModifier.stackCount === 0;

    if (!removeOld || !source || this.removeModifier(itemModifier, source.isEnemy())) {
      const addModifier = () => {
        if (!matchingModifier || this.removeModifier(matchingModifier, target.isEnemy())) {
          if (target.isPlayer()) {
            this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant);
            if (source && itemLost) {
              applyAbAttrs("PostItemLostAbAttr", { pokemon: source });
            }
            return true;
          }
          this.addEnemyModifier(newItemModifier, ignoreUpdate, instant);
          if (source && itemLost) {
            applyAbAttrs("PostItemLostAbAttr", { pokemon: source });
          }
          return true;
        }
        return false;
      };
      if (source && source.isPlayer() !== target.isPlayer() && !ignoreUpdate) {
        this.updateModifiers(source.isPlayer(), instant);
        addModifier();
      } else {
        addModifier();
      }
      return true;
    }
    return false;
  }
  /**
   * Attempt to discard one or more copies of a held item.
   * @param itemModifier - The {@linkcode PokemonHeldItemModifier} being discarded
   * @param discardQuantity - The number of copies to remove (up to the amount currently held); default `1`
   * @returns Whether the item was successfully discarded.
   * Removing fewer items than requested is still considered a success.
   */
  tryDiscardHeldItemModifier(itemModifier: PokemonHeldItemModifier, discardQuantity = 1): boolean {
    const countTaken = Math.min(discardQuantity, itemModifier.stackCount);
    itemModifier.stackCount -= countTaken;

    if (itemModifier.stackCount > 0) {
      return true;
    }

    return this.removeModifier(itemModifier);
  }

  canTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, transferQuantity = 1): boolean {
    const mod = itemModifier.clone() as PokemonHeldItemModifier;
    const source = mod.pokemonId ? mod.getPokemon() : null;
    const cancelled = new BooleanHolder(false);

    if (source && source.isPlayer() !== target.isPlayer()) {
      applyAbAttrs("BlockItemTheftAbAttr", { pokemon: source, cancelled });
    }

    if (cancelled.value) {
      return false;
    }

    const matchingModifier = this.findModifier(
      m => m instanceof PokemonHeldItemModifier && m.matchType(mod) && m.pokemonId === target.id,
      target.isPlayer(),
    ) as PokemonHeldItemModifier;

    if (matchingModifier) {
      const maxStackCount = matchingModifier.getMaxStackCount();
      if (matchingModifier.stackCount >= maxStackCount) {
        return false;
      }
      const countTaken = Math.min(transferQuantity, mod.stackCount, maxStackCount - matchingModifier.stackCount);
      mod.stackCount -= countTaken;
    } else {
      const countTaken = Math.min(transferQuantity, mod.stackCount);
      mod.stackCount -= countTaken;
    }

    const removeOld = mod.stackCount === 0;

    return !removeOld || !source || this.hasModifier(itemModifier, !source.isPlayer());
  }

  removePartyMemberModifiers(partyMemberIndex: number): Promise<void> {
    return new Promise(resolve => {
      const pokemonId = this.getPlayerParty()[partyMemberIndex].id;
      const modifiersToRemove = this.modifiers.filter(
        m => m instanceof PokemonHeldItemModifier && (m as PokemonHeldItemModifier).pokemonId === pokemonId,
      );
      for (const m of modifiersToRemove) {
        this.modifiers.splice(this.modifiers.indexOf(m), 1);
      }
      this.updateModifiers();
      resolve();
    });
  }

  generateEnemyModifiers(heldModifiersConfigs?: HeldModifierConfig[][]): Promise<void> {
    return new Promise(resolve => {
      if (this.currentBattle.isClassicFinalBoss) {
        return resolve();
      }
      const difficultyWaveIndex = this.gameMode.getWaveForDifficulty(this.currentBattle.waveIndex);
      const isFinalBoss = this.gameMode.isWaveFinal(this.currentBattle.waveIndex);
      let chances = Math.ceil(difficultyWaveIndex / 10);
      if (isFinalBoss) {
        chances = Math.ceil(chances * 2.5);
      }

      const party = this.getEnemyParty();

      if (this.currentBattle.trainer) {
        const modifiers = this.currentBattle.trainer.genModifiers(party);
        for (const modifier of modifiers) {
          this.addEnemyModifier(modifier, true, true);
        }
      }

      party.forEach((enemyPokemon: EnemyPokemon, i: number) => {
        if (heldModifiersConfigs && i < heldModifiersConfigs.length && heldModifiersConfigs[i]) {
          for (const mt of heldModifiersConfigs[i]) {
            let modifier: PokemonHeldItemModifier;
            if (mt.modifier instanceof PokemonHeldItemModifierType) {
              modifier = mt.modifier.newModifier(enemyPokemon);
            } else {
              modifier = mt.modifier as PokemonHeldItemModifier;
              modifier.pokemonId = enemyPokemon.id;
            }
            modifier.stackCount = mt.stackCount ?? 1;
            modifier.isTransferable = mt.isTransferable ?? modifier.isTransferable;
            this.addEnemyModifier(modifier, true);
          }
        } else {
          const isBoss =
            enemyPokemon.isBoss()
            || (this.currentBattle.battleType === BattleType.TRAINER && !!this.currentBattle.trainer?.config.isBoss);
          let upgradeChance = 32;
          if (isBoss) {
            upgradeChance /= 2;
          }
          if (isFinalBoss) {
            upgradeChance /= 8;
          }
          let count = 0;
          for (let c = 0; c < chances; c++) {
            if (!randSeedInt(this.gameMode.getEnemyModifierChance(isBoss))) {
              count++;
            }
          }
          if (isBoss) {
            count = Math.max(count, Math.floor(chances / 2));
          }
          getEnemyModifierTypesForWave(
            difficultyWaveIndex,
            count,
            [enemyPokemon],
            this.currentBattle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD,
            upgradeChance,
          ).map(mt => mt.newModifier(enemyPokemon).add(this.enemyModifiers, false));
        }
        return true;
      });
      this.updateModifiers(false);
      resolve();
    });
  }

  /**
   * Removes all modifiers from enemy pokemon of {@linkcode PersistentModifier} type
   */
  clearEnemyModifiers(): void {
    const modifiersToRemove = this.enemyModifiers.filter(m => m instanceof PersistentModifier);
    for (const m of modifiersToRemove) {
      this.enemyModifiers.splice(this.enemyModifiers.indexOf(m), 1);
    }
    this.updateModifiers(false);
    this.updateUIPositions();
  }

  /**
   * Removes all modifiers from enemy pokemon of {@linkcode PokemonHeldItemModifier} type
   * @param pokemon - If specified, only removes held items from that {@linkcode Pokemon}
   */
  clearEnemyHeldItemModifiers(pokemon?: Pokemon): void {
    const modifiersToRemove = this.enemyModifiers.filter(
      m => m instanceof PokemonHeldItemModifier && (!pokemon || m.getPokemon() === pokemon),
    );
    for (const m of modifiersToRemove) {
      this.enemyModifiers.splice(this.enemyModifiers.indexOf(m), 1);
    }
    this.updateModifiers(false);
    this.updateUIPositions();
  }

  setModifiersVisible(visible: boolean) {
    [this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible));
  }

  // TODO: Document this
  updateModifiers(player = true, instant?: boolean): void {
    const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]);
    for (let m = 0; m < modifiers.length; m++) {
      const modifier = modifiers[m];
      if (
        modifier instanceof PokemonHeldItemModifier
        && !this.getPokemonById((modifier as PokemonHeldItemModifier).pokemonId)
      ) {
        modifiers.splice(m--, 1);
      }
      if (
        modifier instanceof PokemonHeldItemModifier
        && modifier.getSpecies() != null
        && !this.getPokemonById(modifier.pokemonId)?.hasSpecies(modifier.getSpecies()!)
      ) {
        modifiers.splice(m--, 1);
      }
    }
    for (const modifier of modifiers) {
      if (modifier instanceof PersistentModifier) {
        (modifier as PersistentModifier).virtualStackCount = 0;
      }
    }

    const modifiersClone = modifiers.slice(0);
    for (const modifier of modifiersClone) {
      if (!modifier.getStackCount()) {
        modifiers.splice(modifiers.indexOf(modifier), 1);
      }
    }

    this.updatePartyForModifiers(player ? this.getPlayerParty() : this.getEnemyParty(), instant);
    (player ? this.modifierBar : this.enemyModifierBar).updateModifiers(modifiers);
    if (!player) {
      this.updateUIPositions();
    }
  }

  updatePartyForModifiers(party: Pokemon[], instant?: boolean): Promise<void> {
    return new Promise(resolve => {
      Promise.allSettled(
        party.map(p => {
          p.calculateStats();
          return p.updateInfo(instant);
        }),
      ).then(() => resolve());
    });
  }

  hasModifier(modifier: PersistentModifier, enemy = false): boolean {
    const modifiers = enemy ? this.enemyModifiers : this.modifiers;
    return modifiers.indexOf(modifier) > -1;
  }

  /**
   * Removes a currently owned item. If the item is stacked, the entire item stack
   * gets removed. This function does NOT apply in-battle effects, such as Unburden.
   * If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead.
   * @param modifier The item to be removed.
   * @param enemy `true` to remove an item owned by the enemy rather than the player; default `false`.
   * @returns `true` if the item exists and was successfully removed, `false` otherwise
   */
  removeModifier(modifier: PersistentModifier, enemy = false): boolean {
    const modifiers = enemy ? this.enemyModifiers : this.modifiers;
    const modifierIndex = modifiers.indexOf(modifier);
    if (modifierIndex > -1) {
      modifiers.splice(modifierIndex, 1);
      if (modifier instanceof PokemonFormChangeItemModifier) {
        const pokemon = this.getPokemonById(modifier.pokemonId);
        if (pokemon) {
          modifier.apply(pokemon, false);
        }
      }
      return true;
    }

    return false;
  }

  /**
   * Get all of the modifiers that match `modifierType`
   * @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
   * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
   * @returns the list of all modifiers that matched `modifierType`.
   */
  getModifiers<T extends PersistentModifier>(modifierType: Constructor<T>, player = true): T[] {
    return (player ? this.modifiers : this.enemyModifiers).filter((m): m is T => m instanceof modifierType);
  }

  /**
   * Get all of the modifiers that pass the `modifierFilter` function
   * @param modifierFilter The function used to filter a target's modifiers
   * @param isPlayer Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
   * @returns the list of all modifiers that passed the `modifierFilter` function
   */
  findModifiers(modifierFilter: ModifierPredicate, isPlayer = true): PersistentModifier[] {
    return (isPlayer ? this.modifiers : this.enemyModifiers).filter(modifierFilter);
  }

  /**
   * Find the first modifier that pass the `modifierFilter` function
   * @param modifierFilter The function used to filter a target's modifiers
   * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
   * @returns the first modifier that passed the `modifierFilter` function; `undefined` if none passed
   */
  findModifier(modifierFilter: ModifierPredicate, player = true): PersistentModifier | undefined {
    return (player ? this.modifiers : this.enemyModifiers).find(modifierFilter);
  }

  /**
   * Apply all modifiers that match `modifierType` in a random order
   * @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
   * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
   * @param args The list of arguments needed to invoke `modifierType.apply`
   * @returns the list of all modifiers that matched `modifierType` and were applied.
   */
  applyShuffledModifiers<T extends PersistentModifier>(
    modifierType: Constructor<T>,
    player = true,
    ...args: Parameters<T["apply"]>
  ): T[] {
    let modifiers = (player ? this.modifiers : this.enemyModifiers).filter(
      (m): m is T => m instanceof modifierType && m.shouldApply(...args),
    );
    this.executeWithSeedOffset(
      () => {
        const shuffleModifiers = mods => {
          if (mods.length === 0) {
            return mods;
          }
          const rand = randSeedInt(mods.length);
          return [mods[rand], ...shuffleModifiers(mods.filter((_, i) => i !== rand))];
        };
        modifiers = shuffleModifiers(modifiers);
      },
      this.currentBattle.turn << 4,
      this.waveSeed,
    );
    return this.applyModifiersInternal(modifiers, player, args);
  }

  /**
   * Apply all modifiers that match `modifierType`
   * @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
   * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
   * @param args The list of arguments needed to invoke `modifierType.apply`
   * @returns the list of all modifiers that matched `modifierType` and were applied.
   */
  applyModifiers<T extends PersistentModifier>(
    modifierType: Constructor<T>,
    player = true,
    ...args: Parameters<T["apply"]>
  ): T[] {
    const modifiers = (player ? this.modifiers : this.enemyModifiers).filter(
      (m): m is T => m instanceof modifierType && m.shouldApply(...args),
    );
    return this.applyModifiersInternal(modifiers, player, args);
  }

  /** Helper function to apply all passed modifiers */
  applyModifiersInternal<T extends PersistentModifier>(
    modifiers: T[],
    player: boolean,
    args: Parameters<T["apply"]>,
  ): T[] {
    const appliedModifiers: T[] = [];
    for (const modifier of modifiers) {
      if (modifier.apply(...args)) {
        console.log("Applied", modifier.type.name, player ? "" : "(enemy)");
        appliedModifiers.push(modifier);
      }
    }

    return appliedModifiers;
  }

  /**
   * Apply the first modifier that matches `modifierType`
   * @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier}
   * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true`
   * @param args The list of arguments needed to invoke `modifierType.apply`
   * @returns the first modifier that matches `modifierType` and was applied; return `null` if none matched
   */
  applyModifier<T extends PersistentModifier>(
    modifierType: Constructor<T>,
    player = true,
    ...args: Parameters<T["apply"]>
  ): T | null {
    const modifiers = (player ? this.modifiers : this.enemyModifiers).filter(
      (m): m is T => m instanceof modifierType && m.shouldApply(...args),
    );
    for (const modifier of modifiers) {
      if (modifier.apply(...args)) {
        console.log("Applied", modifier.type.name, player ? "" : "(enemy)");
        return modifier;
      }
    }

    return null;
  }

  triggerPokemonFormChange(
    pokemon: Pokemon,
    formChangeTriggerType: Constructor<SpeciesFormChangeTrigger>,
    delayed = false,
    modal = false,
  ): boolean {
    if (speciesDataRegistry.hasFormChanges(pokemon.species.speciesId)) {
      // in case this is NECROZMA, determine which forms this
      const matchingFormChangeOpts = speciesDataRegistry
        .getFormChanges(pokemon.species.speciesId)
        .filter(fc => fc.findTrigger(formChangeTriggerType) && fc.canChange(pokemon));
      let matchingFormChange: SpeciesFormChange | null;
      if (pokemon.species.speciesId === SpeciesId.NECROZMA && matchingFormChangeOpts.length > 1) {
        // Ultra Necrozma is changing its form back, so we need to figure out into which form it devolves.
        const formChangeItemModifiers = (
          this.findModifiers(
            m => m instanceof PokemonFormChangeItemModifier && m.pokemonId === pokemon.id,
          ) as PokemonFormChangeItemModifier[]
        )
          .filter(m => m.active)
          .map(m => m.formChangeItem);

        matchingFormChange = formChangeItemModifiers.includes(FormChangeItem.N_LUNARIZER)
          ? matchingFormChangeOpts[0]
          : formChangeItemModifiers.includes(FormChangeItem.N_SOLARIZER)
            ? matchingFormChangeOpts[1]
            : null;
      } else {
        matchingFormChange = matchingFormChangeOpts[0];
      }
      if (matchingFormChange) {
        let phase: Phase;
        if (pokemon.isPlayer() && !matchingFormChange.quiet) {
          phase = this.phaseManager.create("FormChangePhase", pokemon, matchingFormChange, modal);
        } else {
          phase = this.phaseManager.create("QuietFormChangePhase", pokemon, matchingFormChange);
        }
        if (pokemon.isPlayer() && !matchingFormChange.quiet && modal) {
          this.phaseManager.overridePhase(phase);
        } else if (delayed) {
          this.phaseManager.pushPhase(phase);
        } else {
          this.phaseManager.unshiftPhase(phase);
        }
        return true;
      }
    }

    return false;
  }

  triggerPokemonBattleAnim(
    pokemon: Pokemon,
    battleAnimType: PokemonAnimType,
    fieldAssets?: Phaser.GameObjects.Sprite[],
    delayed = false,
  ): boolean {
    const phaseManager = this.phaseManager;
    const phase: Phase = phaseManager.create("PokemonAnimPhase", battleAnimType, pokemon, fieldAssets);
    if (delayed) {
      phaseManager.pushPhase(phase);
    } else {
      phaseManager.unshiftPhase(phase);
    }
    return true;
  }

  validateAchvs<T extends Achv>(achvType: Constructor<T>, ...args: NonNullable<Parameters<T["validate"]>[0]>): void {
    const filteredAchvs = Object.values(achvs).filter(a => a instanceof achvType);
    for (const achv of filteredAchvs) {
      this.validateAchv(achv, args);
    }
  }

  validateAchv<T extends Achv>(achv: T, args?: Parameters<T["validate"]>[0]): boolean {
    if (
      (!Object.hasOwn(this.gameData.achvUnlocks, achv.id) || activeOverrides.ACHIEVEMENTS_REUNLOCK_OVERRIDE)
      && achv.validate(args)
    ) {
      this.gameData.achvUnlocks[achv.id] = Date.now();
      this.ui.achvBar.showAchv(achv);
      if (Object.hasOwn(vouchers, achv.id)) {
        this.validateVoucher(vouchers[achv.id]);
      }
      return true;
    }

    return false;
  }

  validateVoucher(voucher: Voucher, args?: unknown[]): boolean {
    if (!Object.hasOwn(this.gameData.voucherUnlocks, voucher.id) && voucher.validate(args)) {
      this.gameData.voucherUnlocks[voucher.id] = Date.now();
      this.ui.achvBar.showAchv(voucher);
      this.gameData.voucherCounts[voucher.voucherType]++;
      return true;
    }

    return false;
  }

  public updateGameInfo(): void {
    type GameInfo = {
      /** @since 2.0.0 */
      gameInfoVersion: string;
      /** @since 1.1.0 */
      playTime: number;
      /** @since 1.0.0 */
      gameMode: string;
      /** @since 1.0.0 */
      biome: string;
      /** @since 1.0.0 */
      wave: number;
      /** @since 2.1.0 */
      luck: number;
      /** @since 1.0.0 */
      party: PartyInfo[];
    };
    type PartyInfo = {
      /** @since 1.0.0 */
      name: string;
      /** @since 2.0.0 */
      nickname: string;
      /** @since 2.0.0 */
      gender: string;
      /** @since 1.2.0 */
      form: string;
      /** @since 1.2.0 */
      types: string[];
      /** @since 2.0.0 */
      tempTypes: string[];
      /** @since 1.2.0 */
      teraType: string;
      /** @since 1.3.0 */
      isTerastallized: boolean;
      /** @since 1.0.0 */
      level: number;
      /** @since 1.2.0 */
      currentHP: number;
      /** @since 1.2.0 */
      maxHP: number;
      /** @since 1.2.0 */
      status: string;
      /** @since 2.0.0 */
      moveset: string[];
      /** @since 2.0.0 */
      tempMoveset: string[];
      /** @since 2.0.0 */
      ability: string;
      /** @since 2.0.0 */
      tempAbility: string;
      /** @since 2.0.0 */
      passiveAbility: string;
      /** @since 2.0.0 */
      isPassiveEnabled: boolean;
      /** @since 2.0.0 */
      nature: string;
      /** @since 2.0.0 */
      baseStats: {
        atk: number;
        def: number;
        spAtk: number;
        spDef: number;
        speed: number;
      };
      /** @since 2.0.0 */
      tempStats:
        | {
            atk: number;
            def: number;
            spAtk: number;
            spDef: number;
            speed: number;
          }
        | {
            atk?: never;
            def?: never;
            spAtk?: never;
            spDef?: never;
            speed?: never;
          };
      /** @since 2.0.0 */
      statStages: {
        atk: number;
        def: number;
        spAtk: number;
        spDef: number;
        speed: number;
        acc: number;
        eva: number;
      };
      /** @since 2.0.0 */
      shiny: boolean;
      /** @since 2.0.0 */
      variant: string;
      /** @since 2.0.0 */
      isFusion: boolean;
    };
    const variantMap = {
      [0]: "Normal",
      [1]: "Rare",
      [2]: "Epic",
    };
    const gameInfo: GameInfo = {
      //! Make sure to update this in accordance with semver when the output is changed
      // cf https://semver.org/
      gameInfoVersion: "2.1.0",
      playTime: this.sessionPlayTime ?? 0,
      gameMode: this.currentBattle ? this.gameMode.getName() : "Title",
      biome: this.currentBattle ? getBiomeName(this.arena.biomeId) : "",
      wave: this.currentBattle?.waveIndex ?? 0,
      luck: this.currentBattle ? getPartyLuckValue(this.party) : -1,
      party:
        this.party?.map(
          p =>
            ({
              name: p.name,
              nickname: p.nickname ? decodeNickname(p.nickname, p.name) : "",
              gender: capitalizeFirstLetterOnly(Gender[p.gender]),
              form: p.getFormKey(),
              // Does not include temporary changes, such as those from Transform, Forest's Curse, etc
              // Ignores Tera type
              types: p
                .getTypes({ includeTeraType: false, bypassSummonData: true, ignoreThirdType: true })
                .map(pType => capitalizeFirstLetterOnly(PokemonType[pType])),
              // Includes temporary changes, such as those from Transform, Forest's Curse, etc
              // Ignores Tera type
              tempTypes:
                p.summonData.types.length > 0 || p.summonData.addedType
                  ? p.getTypes({ includeTeraType: false }).map(pType => capitalizeFirstLetterOnly(PokemonType[pType]))
                  : [],
              teraType: capitalizeFirstLetterOnly(PokemonType[p.getTeraType()]),
              isTerastallized: p.isTerastallized,
              level: p.level,
              currentHP: p.hp,
              maxHP: p.getMaxHp(),
              status: p.status?.effect ? capitalizeFirstLetterOnly(StatusEffect[p.status.effect]) : "",
              // the pokemon's actual moveset
              moveset: p.getMoveset(true).map(move => move.getName()),
              // the pokemon's temporary moveset, e.g. from Transform
              // biome-ignore lint/style/useExplicitLengthCheck: doubles as a null check
              tempMoveset: p.summonData.moveset?.length ? p.getMoveset().map(move => move.getName()) : [],
              // the pokemon's actual ability
              ability: p.getAbility(true).name,
              // the pokemon's temporary ability, e.g. from Transform or Skill Swap
              tempAbility: p.summonData.ability ? p.getAbility().name : "",
              passiveAbility: p.getPassiveAbility().name,
              isPassiveEnabled: p.hasPassive(),
              nature: capitalizeFirstLetterOnly(Nature[p.getNature()]),
              baseStats: {
                atk: p.getStat(Stat.ATK),
                def: p.getStat(Stat.DEF),
                spAtk: p.getStat(Stat.SPATK),
                spDef: p.getStat(Stat.SPDEF),
                speed: p.getStat(Stat.SPD),
              },
              // e.g. from Transform
              tempStats: p.summonData.stats.some(v => v > 0)
                ? {
                    atk: p.getStat(Stat.ATK, false),
                    def: p.getStat(Stat.DEF, false),
                    spAtk: p.getStat(Stat.SPATK, false),
                    spDef: p.getStat(Stat.SPDEF, false),
                    speed: p.getStat(Stat.SPD, false),
                  }
                : {},
              statStages: {
                atk: p.getStatStage(Stat.ATK),
                def: p.getStatStage(Stat.DEF),
                spAtk: p.getStatStage(Stat.SPATK),
                spDef: p.getStatStage(Stat.SPDEF),
                speed: p.getStatStage(Stat.SPD),
                acc: p.getStatStage(Stat.ACC),
                eva: p.getStatStage(Stat.EVA),
              },
              shiny: p.isShiny(),
              variant: p.isShiny() ? variantMap[p.getVariant()] : "N/A",
              isFusion: p.isFusion(),
            }) as PartyInfo satisfies PartyInfo,
        ) ?? [],
    };
    // TODO: Don't store it here
    window["gameInfo"] = gameInfo;
  }

  /**
   * This function retrieves the sprite and audio keys for active Pokemon.
   * Active Pokemon include both enemy and player Pokemon of the current wave.
   * Note: Questions on garbage collection go to `@frutescens`
   * @returns a string array of active sprite and audio keys that should not be deleted
   */
  getActiveKeys(): string[] {
    const keys: string[] = [];
    let activePokemon: (PlayerPokemon | EnemyPokemon)[] = this.getPlayerParty();
    activePokemon = activePokemon.concat(this.getEnemyParty());
    for (const p of activePokemon) {
      keys.push(p.getSpriteKey(true));
      if (p.isPlayer()) {
        keys.push(p.getBattleSpriteKey(true, true));
      }
      keys.push(p.species.getCryKey(p.formIndex));
      if (p.fusionSpecies) {
        keys.push(p.fusionSpecies.getCryKey(p.fusionFormIndex));
      }
    }
    return keys;
  }

  /**
   * Initialized the 2nd phase of the final boss (e.g. form-change for Eternatus)
   * @param pokemon The (enemy) pokemon
   */
  initFinalBossPhaseTwo(pokemon: Pokemon): void {
    if (!pokemon.isEnemy() || !pokemon.isBoss() || pokemon.formIndex > 0 || pokemon.bossSegmentIndex >= 1) {
      this.phaseManager.shiftPhase();
      return;
    }

    audioManager.fadeOutBgm(2000, true);
    this.ui.showDialogue(classicFinalBossDialogue.firstStageWin, pokemon.species.name, undefined, () => {
      const finalBossMBH = getModifierType(modifierTypes.MINI_BLACK_HOLE).newModifier(
        pokemon,
      ) as TurnHeldItemTransferModifier;
      finalBossMBH.setTransferrableFalse();
      this.addEnemyModifier(finalBossMBH, false, true);
      pokemon.generateAndPopulateMoveset(false, 1);
      this.setFieldScale(0.75);
      this.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger, false);
      this.currentBattle.double = true;
      const availablePartyMembers = this.getPlayerParty().filter(p => p.isAllowedInBattle());
      if (availablePartyMembers.length > 1) {
        this.phaseManager.pushNew("ToggleDoublePositionPhase", true);
        if (!availablePartyMembers[1].isOnField()) {
          this.phaseManager.pushNew("SummonPhase", 1);
          this.phaseManager.pushNew("PostSummonPhase", 1);
        }
      }

      this.phaseManager.shiftPhase();
    });
  }

  /**
   * Updates Exp and level values for Player's party, adding new level up phases as required
   * @param expValue raw value of exp to split among participants, OR the base multiplier to use with waveIndex
   * @param pokemonDefeated If true, will increment Macho Brace stacks and give the party Pokemon friendship increases
   * @param useWaveIndexMultiplier Default `false`. If true, will multiply expValue by a scaling waveIndex multiplier. Not needed if expValue is already scaled by level/wave
   * @param participantIds - A `Set` containing the IDs of all Pokemon that will share the EXP earned.
   * If omitted, will default to the current battle's participants.
   */
  applyPartyExp(
    expValue: number,
    pokemonDefeated: boolean,
    useWaveIndexMultiplier = false,
    participantIds = this.currentBattle.playerParticipantIds,
  ): void {
    // TODO: make this code actually not insane
    const party = this.getPlayerParty();
    const expShareModifier = this.findModifier(m => m instanceof ExpShareModifier) as ExpShareModifier;
    const expBalanceModifier = this.findModifier(m => m instanceof ExpBalanceModifier) as ExpBalanceModifier;
    const multipleParticipantExpBonusModifier = this.findModifier(
      m => m instanceof MultipleParticipantExpBonusModifier,
    ) as MultipleParticipantExpBonusModifier;
    const nonFaintedPartyMembers = party.filter(p => p.hp);
    const expPartyMembers = nonFaintedPartyMembers.filter(p => p.level < this.getMaxExpLevel());
    const partyMemberExp: number[] = [];
    // EXP value calculation is based off Pokemon.getExpValue
    if (useWaveIndexMultiplier) {
      expValue = Math.floor((expValue * this.currentBattle.waveIndex) / 5 + 1);
    }

    if (participantIds.size > 0) {
      if (
        this.currentBattle.battleType === BattleType.TRAINER
        || this.currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE
      ) {
        expValue = Math.floor(expValue * 1.5);
      } else if (this.currentBattle.isBattleMysteryEncounter() && this.currentBattle.mysteryEncounter) {
        expValue = Math.floor(expValue * this.currentBattle.mysteryEncounter.expMultiplier);
      }
      for (const partyMember of nonFaintedPartyMembers) {
        const pId = partyMember.id;
        const participated = participantIds.has(pId);
        if (participated && pokemonDefeated) {
          partyMember.addFriendship(FRIENDSHIP_GAIN_FROM_BATTLE);
          const machoBraceModifier = partyMember.getHeldItems().find(m => m instanceof PokemonIncrementingStatModifier);
          if (machoBraceModifier && machoBraceModifier.stackCount < machoBraceModifier.getMaxStackCount()) {
            machoBraceModifier.stackCount++;
            this.updateModifiers(true, true);
            partyMember.updateInfo();
          }
        }
        if (!expPartyMembers.includes(partyMember)) {
          continue;
        }
        if (!participated && !expShareModifier) {
          partyMemberExp.push(0);
          continue;
        }
        let expMultiplier = 0;
        if (participated) {
          expMultiplier += 1 / participantIds.size;
          if (participantIds.size > 1 && multipleParticipantExpBonusModifier) {
            expMultiplier += multipleParticipantExpBonusModifier.getStackCount() * 0.2;
          }
        } else if (expShareModifier) {
          expMultiplier += (expShareModifier.getStackCount() * 0.2) / participantIds.size;
        }
        if (partyMember.pokerus) {
          expMultiplier *= 1.5;
        }
        if (activeOverrides.XP_MULTIPLIER_OVERRIDE !== null) {
          expMultiplier = activeOverrides.XP_MULTIPLIER_OVERRIDE;
        }
        const pokemonExp = new NumberHolder(expValue * expMultiplier);
        this.applyModifiers(PokemonExpBoosterModifier, true, partyMember, pokemonExp);
        partyMemberExp.push(Math.floor(pokemonExp.value));
      }

      if (expBalanceModifier) {
        let totalLevel = 0;
        let totalExp = 0;
        expPartyMembers.forEach((expPartyMember, epm) => {
          totalExp += partyMemberExp[epm];
          totalLevel += expPartyMember.level;
        });

        const medianLevel = Math.floor(totalLevel / expPartyMembers.length);

        const recipientExpPartyMemberIndexes: number[] = [];
        expPartyMembers.forEach((expPartyMember, epm) => {
          if (expPartyMember.level <= medianLevel) {
            recipientExpPartyMemberIndexes.push(epm);
          }
        });

        const splitExp = Math.floor(totalExp / recipientExpPartyMemberIndexes.length);

        expPartyMembers.forEach((_partyMember, pm) => {
          partyMemberExp[pm] = Phaser.Math.Linear(
            partyMemberExp[pm],
            recipientExpPartyMemberIndexes.indexOf(pm) > -1 ? splitExp : 0,
            0.2 * expBalanceModifier.getStackCount(),
          );
        });
      }

      for (let pm = 0; pm < expPartyMembers.length; pm++) {
        const exp = partyMemberExp[pm];

        if (exp) {
          const partyMemberIndex = party.indexOf(expPartyMembers[pm]);
          this.phaseManager.unshiftPhase(
            expPartyMembers[pm].isOnField()
              ? this.phaseManager.create("ExpPhase", partyMemberIndex, exp)
              : this.phaseManager.create("ShowPartyExpBarPhase", partyMemberIndex, exp),
          );
        }
      }
    }
  }

  /**
   * Determine whether a wave should randomly generate a {@linkcode MysteryEncounter}.
   * Currently, the only modes that MEs are allowed in are Classic and Challenge.
   * Additionally, MEs cannot spawn outside of waves 10-180 in those modes
   * @param battleType - The {@linkcode BattleType} of the newly created battle
   * @param waveIndex - The wave number of the newly spawned wave
   * @returns Whether a Mystery Encounter should be generated.
   */
  private isWaveMysteryEncounter(battleType: BattleType, waveIndex: number): boolean {
    if (getDailyMysteryEncounter(waveIndex) != null) {
      return true;
    }
    if (!this.isMysteryEncounterValidForWave(battleType, waveIndex)) {
      return false;
    }

    const [lowestMysteryEncounterWave, highestMysteryEncounterWave] = this.gameMode.getMysteryEncounterLegalWaves();
    // Base spawn weight is BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT/256, and increases
    // by WEIGHT_INCREMENT_ON_SPAWN_MISS/256 for each missed attempt at spawning an encounter on a valid floor
    const sessionEncounterRate = this.mysteryEncounterSaveData.encounterSpawnChance;
    const encounteredEvents = this.mysteryEncounterSaveData.encounteredEvents;

    // MEs can only spawn 3 or more waves after the previous ME, barring overrides
    const canSpawn =
      activeOverrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE !== null
      || encounteredEvents.length === 0
      || waveIndex > 3 + encounteredEvents.at(-1)!.waveIndex; // Bang on `at()` is justified due to the check for length === 0
    if (!canSpawn) {
      return false;
    }

    // If total number of encounters is lower than expected for the run, slightly favor a new encounter spawn (reverse as well)
    // Reduces occurrence of runs with total encounters significantly different from AVERAGE_ENCOUNTERS_PER_RUN_TARGET
    // Favored rate changes can never exceed 50%. So if base rate is 15/256 and favored rate would add 200/256, result will be (15 + 128)/256
    const expectedEncountersByFloor =
      (AVERAGE_ENCOUNTERS_PER_RUN_TARGET / (highestMysteryEncounterWave - lowestMysteryEncounterWave))
      * (waveIndex - lowestMysteryEncounterWave);
    const currentRunDiffFromAvg = expectedEncountersByFloor - encounteredEvents.length;
    const favoredEncounterRate =
      sessionEncounterRate
      + Math.min(currentRunDiffFromAvg * ANTI_VARIANCE_WEIGHT_MODIFIER, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT / 2);

    const successRate = activeOverrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE ?? favoredEncounterRate;

    let roll = 0;
    // Always rolls the check on the same offset to ensure no RNG changes from reloading session
    this.executeWithSeedOffset(() => {
      roll = randSeedInt(MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT);
    }, waveIndex * 3000);
    return roll < successRate;
  }

  /**
   * Returns if a wave COULD spawn a {@linkcode MysteryEncounter}.
   * @param battleType - The {@linkcode BattleType} of the newly created battle
   * @param waveIndex - The wave number of the newly spawned wave
   * @returns Whether an ME can legally spawn on the given wave.
   * @see {@linkcode BattleScene.isWaveMysteryEncounter} - Function that rolls for ME creation on new wave start
   */
  public isMysteryEncounterValidForWave(battleType: BattleType, waveIndex: number): boolean {
    const [lowestMysteryEncounterWave, highestMysteryEncounterWave] = this.gameMode.getMysteryEncounterLegalWaves();
    return (
      this.gameMode.hasMysteryEncounters
      && battleType === BattleType.WILD
      && !this.gameMode.isBoss(waveIndex)
      && waveIndex % 10 !== 1
      && isBetween(waveIndex, lowestMysteryEncounterWave, highestMysteryEncounterWave)
    );
  }

  /**
   * Loads or generates a mystery encounter
   * @param encounterType used to load session encounter when restarting game, etc.
   * @param canBypass optional boolean to indicate that the request is coming from a function that needs to access a Mystery Encounter outside of gameplay requirements
   * @returns
   */
  getMysteryEncounter(encounterType?: MysteryEncounterType, canBypass?: boolean): MysteryEncounter {
    // Loading override or session encounter
    let encounter: MysteryEncounter | null;
    if (
      activeOverrides.MYSTERY_ENCOUNTER_OVERRIDE != null
      && Object.hasOwn(allMysteryEncounters, activeOverrides.MYSTERY_ENCOUNTER_OVERRIDE)
    ) {
      encounter = allMysteryEncounters[activeOverrides.MYSTERY_ENCOUNTER_OVERRIDE];
      if (canBypass) {
        return encounter;
      }
    } else if (canBypass) {
      encounter = allMysteryEncounters[encounterType ?? -1];
      return encounter;
    } else if (getDailyMysteryEncounter(this.currentBattle.waveIndex) == null) {
      encounter = encounterType == null ? null : allMysteryEncounters[encounterType];
    } else {
      encounter = allMysteryEncounters[getDailyMysteryEncounter(this.currentBattle.waveIndex)!];
    }

    // Check for queued encounters first
    // TODO: the inner `while` loop will never run, should this code be deleted or fixed?
    const queuedEncounters = this.mysteryEncounterSaveData?.queuedEncounters ?? [];
    if (!encounter && queuedEncounters.length > 0) {
      let i = 0;
      while (i < queuedEncounters.length && encounter) {
        const candidate = queuedEncounters[i];
        const forcedChance = candidate.spawnPercent;
        if (randSeedInt(100) < forcedChance) {
          encounter = allMysteryEncounters[candidate.type];
        }

        i++;
      }
    }

    if (encounter) {
      encounter = new MysteryEncounter(encounter);
      encounter.populateDialogueTokensFromRequirements();
      return encounter;
    }

    // See Enum values for base tier weights
    const tierWeights = [
      MysteryEncounterTier.COMMON,
      MysteryEncounterTier.GREAT,
      MysteryEncounterTier.ULTRA,
      MysteryEncounterTier.ROGUE,
    ];

    // Adjust tier weights by previously encountered events to lower odds of only Common/Great in run
    for (const seenEncounterData of this.mysteryEncounterSaveData.encounteredEvents) {
      if (seenEncounterData.tier === MysteryEncounterTier.COMMON) {
        tierWeights[0] -= 6;
      } else if (seenEncounterData.tier === MysteryEncounterTier.GREAT) {
        tierWeights[1] -= 4;
      }
    }

    const totalWeight = tierWeights.reduce((a, b) => a + b);
    const tierValue = randSeedInt(totalWeight);
    const commonThreshold = totalWeight - tierWeights[0];
    const greatThreshold = totalWeight - tierWeights[0] - tierWeights[1];
    const ultraThreshold = totalWeight - tierWeights[0] - tierWeights[1] - tierWeights[2];
    let tier: MysteryEncounterTier | null =
      tierValue > commonThreshold
        ? MysteryEncounterTier.COMMON
        : tierValue > greatThreshold
          ? MysteryEncounterTier.GREAT
          : tierValue > ultraThreshold
            ? MysteryEncounterTier.ULTRA
            : MysteryEncounterTier.ROGUE;

    if (activeOverrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE != null) {
      tier = activeOverrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE;
    }

    let availableEncounters: MysteryEncounter[] = [];
    const previousEncounter = this.mysteryEncounterSaveData.encounteredEvents.at(-1)?.type ?? null; // TODO: This being `null` is a bit weird
    const disabledEncounters = timedEventManager.getEventMysteryEncountersDisabled();
    const biomeMysteryEncounters =
      mysteryEncountersByBiome.get(this.arena.biomeId)?.filter(enc => !disabledEncounters.includes(enc)) ?? [];
    // If no valid encounters exist at tier, checks next tier down, continuing until there are some encounters available
    while (availableEncounters.length === 0 && tier !== null) {
      availableEncounters = biomeMysteryEncounters
        .filter(encounterType => {
          const encounterCandidate = allMysteryEncounters[encounterType];
          if (!encounterCandidate) {
            return false;
          }
          if (
            timedEventManager.getMysteryEncounterTierForEvent(encounterType, encounterCandidate.encounterTier) !== tier
          ) {
            return false;
          }
          const disallowedGameModes = encounterCandidate.disallowedGameModes;
          if (
            disallowedGameModes
            && disallowedGameModes.length > 0
            && disallowedGameModes.includes(this.gameMode.modeId)
          ) {
            return false;
          }
          if (encounterCandidate.disallowedChallenges?.some(challenge => this.gameMode.hasChallenge(challenge))) {
            return false;
          }
          if (!encounterCandidate.meetsRequirements()) {
            return false;
          }
          if (previousEncounter !== null && encounterType === previousEncounter) {
            return false;
          }
          return !(
            this.mysteryEncounterSaveData.encounteredEvents.length > 0
            && encounterCandidate.maxAllowedEncounters
            && encounterCandidate.maxAllowedEncounters > 0
            && this.mysteryEncounterSaveData.encounteredEvents.filter(e => e.type === encounterType).length
              >= encounterCandidate.maxAllowedEncounters
          );
        })
        .map(m => allMysteryEncounters[m]);
      // Decrement tier
      if (tier === MysteryEncounterTier.ROGUE) {
        tier = MysteryEncounterTier.ULTRA;
      } else if (tier === MysteryEncounterTier.ULTRA) {
        tier = MysteryEncounterTier.GREAT;
      } else if (tier === MysteryEncounterTier.GREAT) {
        tier = MysteryEncounterTier.COMMON;
      } else {
        tier = null; // Ends loop
      }
    }

    // If absolutely no encounters are available, spawn 0th encounter
    if (availableEncounters.length === 0) {
      console.log("No Mystery Encounters found, falling back to Mysterious Challengers.");
      return allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHALLENGERS];
    }
    // TODO: should this use `randSeedItem`?
    encounter = availableEncounters[randSeedInt(availableEncounters.length)];
    // New encounter object to not dirty flags
    encounter = new MysteryEncounter(encounter);
    encounter.populateDialogueTokensFromRequirements();
    return encounter;
  }
}