import Phaser from "phaser";
import UI from "./ui/ui";
import Pokemon, { PlayerPokemon, EnemyPokemon } from "./field/pokemon";
import PokemonSpecies, { PokemonSpeciesFilter, allSpecies, getPokemonSpecies } from "./data/pokemon-species";
import { Constructor, isNullOrUndefined } from "#app/utils";
import * as Utils from "./utils";
import { Modifier, ModifierBar, ConsumablePokemonModifier, ConsumableModifier, PokemonHpRestoreModifier, TurnHeldItemTransferModifier, HealingBoosterModifier, PersistentModifier, PokemonHeldItemModifier, ModifierPredicate, DoubleBattleChanceBoosterModifier, FusePokemonModifier, PokemonFormChangeItemModifier, TerastallizeModifier, overrideModifiers, overrideHeldItems, PokemonIncrementingStatModifier, ExpShareModifier, ExpBalanceModifier, MultipleParticipantExpBonusModifier, PokemonExpBoosterModifier } from "./modifier/modifier";
import { PokeballType } from "./data/pokeball";
import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets, populateAnims } from "./data/battle-anims";
import { Phase } from "./phase";
import { initGameSpeed } from "./system/game-speed";
import { Arena, ArenaBase } from "./field/arena";
import { GameData } from "./system/game-data";
import { addTextObject, getTextColor, TextStyle } from "./ui/text";
import { allMoves } from "./data/move";
import {
  ModifierPoolType,
  getDefaultModifierTypeForTier,
  getEnemyModifierTypesForWave,
  getLuckString,
  getLuckTextTint,
  getModifierPoolForType,
  getModifierType,
  getPartyLuckValue,
  modifierTypes, PokemonHeldItemModifierType
} from "./modifier/modifier-type";
import AbilityBar from "./ui/ability-bar";
import { BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, ChangeMovePriorityAbAttr, PostBattleInitAbAttr, applyAbAttrs, applyPostBattleInitAbAttrs } from "./data/ability";
import { allAbilities } from "./data/ability";
import Battle, { BattleType, FixedBattleConfig } from "./battle";
import { GameMode, GameModes, getGameMode } from "./game-mode";
import FieldSpritePipeline from "./pipelines/field-sprite";
import SpritePipeline from "./pipelines/sprite";
import PartyExpBar from "./ui/party-exp-bar";
import { trainerConfigs, TrainerSlot } from "./data/trainer-config";
import Trainer, { TrainerVariant } from "./field/trainer";
import TrainerData from "./system/trainer-data";
import SoundFade from "phaser3-rex-plugins/plugins/soundfade";
import { pokemonPrevolutions } from "./data/pokemon-evolutions";
import PokeballTray from "./ui/pokeball-tray";
import InvertPostFX from "./pipelines/invert";
import { Achv, achvs, ModifierAchv, MoneyAchv } from "./system/achv";
import { Voucher, vouchers } from "./system/voucher";
import { Gender } from "./data/gender";
import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-plugin";
import { addUiThemeOverrides } from "./ui/ui-theme";
import PokemonData from "./system/pokemon-data";
import { Nature } from "./data/nature";
import { SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger, SpeciesFormChangeTrigger, pokemonFormChanges, FormChangeItem, SpeciesFormChange } from "./data/pokemon-forms";
import { FormChangePhase } from "./phases/form-change-phase";
import { getTypeRgb } from "./data/type";
import PokemonSpriteSparkleHandler from "./field/pokemon-sprite-sparkle-handler";
import CharSprite from "./ui/char-sprite";
import DamageNumberHandler from "./field/damage-number-handler";
import PokemonInfoContainer from "./ui/pokemon-info-container";
import { biomeDepths, getBiomeName } from "./data/biomes";
import { SceneBase } from "./scene-base";
import CandyBar from "./ui/candy-bar";
import { Variant, variantData } from "./data/variant";
import { Localizable } from "#app/interfaces/locales";
import Overrides from "#app/overrides";
import { InputsController } from "./inputs-controller";
import { UiInputs } from "./ui-inputs";
import { NewArenaEvent } from "./events/battle-scene";
import { ArenaFlyout } from "./ui/arena-flyout";
import { EaseType } from "#enums/ease-type";
import { BattleSpec } from "#enums/battle-spec";
import { BattleStyle } from "#enums/battle-style";
import { Biome } from "#enums/biome";
import { ExpNotification } from "#enums/exp-notification";
import { MoneyFormat } from "#enums/money-format";
import { Moves } from "#enums/moves";
import { PlayerGender } from "#enums/player-gender";
import { Species } from "#enums/species";
import { UiTheme } from "#enums/ui-theme";
import { TimedEventManager } from "#app/timed-event-manager";
import { PokemonAnimType } from "#enums/pokemon-anim-type";
import i18next from "i18next";
import { TrainerType } from "#enums/trainer-type";
import { battleSpecDialogue } from "./data/dialogue";
import { LoadingScene } from "./loading-scene";
import { LevelCapPhase } from "./phases/level-cap-phase";
import { LoginPhase } from "./phases/login-phase";
import { MessagePhase } from "./phases/message-phase";
import { MovePhase } from "./phases/move-phase";
import { NewBiomeEncounterPhase } from "./phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "./phases/next-encounter-phase";
import { PokemonAnimPhase } from "./phases/pokemon-anim-phase";
import { QuietFormChangePhase } from "./phases/quiet-form-change-phase";
import { ReturnPhase } from "./phases/return-phase";
import { SelectBiomePhase } from "./phases/select-biome-phase";
import { ShowTrainerPhase } from "./phases/show-trainer-phase";
import { SummonPhase } from "./phases/summon-phase";
import { SwitchPhase } from "./phases/switch-phase";
import { TitlePhase } from "./phases/title-phase";
import { ToggleDoublePositionPhase } from "./phases/toggle-double-position-phase";
import { TurnInitPhase } from "./phases/turn-init-phase";
import { ShopCursorTarget } from "./enums/shop-cursor-target";
import MysteryEncounter from "./data/mystery-encounters/mystery-encounter";
import { allMysteryEncounters, ANTI_VARIANCE_WEIGHT_MODIFIER, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, mysteryEncountersByBiome, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "./data/mystery-encounters/mystery-encounters";
import { MysteryEncounterSaveData } from "#app/data/mystery-encounters/mystery-encounter-save-data";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import HeldModifierConfig from "#app/interfaces/held-modifier-config";
import { ExpPhase } from "#app/phases/exp-phase";
import { ShowPartyExpBarPhase } from "#app/phases/show-party-exp-bar-phase";
import { MysteryEncounterMode } from "#enums/mystery-encounter-mode";
import { ExpGainsSpeed } from "./enums/exp-gains-speed";

export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1";

const DEBUG_RNG = false;

const OPP_IVS_OVERRIDE_VALIDATED : integer[] = (
  Array.isArray(Overrides.OPP_IVS_OVERRIDE) ?
    Overrides.OPP_IVS_OVERRIDE :
    new Array(6).fill(Overrides.OPP_IVS_OVERRIDE)
).map(iv => isNaN(iv) || iv === null || iv > 31 ? -1 : iv);

export const startingWave = Overrides.STARTING_WAVE_OVERRIDE || 1;

const expSpriteKeys: string[] = [];

export let starterColors: StarterColors;
interface StarterColors {
    [key: string]: [string, string]
}

export interface PokeballCounts {
    [pb: string]: integer;
}

export type AnySound = Phaser.Sound.WebAudioSound | Phaser.Sound.HTML5AudioSound | Phaser.Sound.NoAudioSound;

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

export default class BattleScene extends SceneBase {
  public rexUI: UIPlugin;
  public inputController: InputsController;
  public uiInputs: UiInputs;

  public sessionPlayTime: integer | null = null;
  public lastSavePlayTime: integer | null = null;
  public masterVolume: number = 0.5;
  public bgmVolume: number = 1;
  public fieldVolume: number = 1;
  public seVolume: number = 1;
  public uiVolume: number = 1;
  public gameSpeed: integer = 1;
  public damageNumbersMode: integer = 0;
  public reroll: boolean = false;
  public shopCursorTarget: number = ShopCursorTarget.REWARDS;
  public showMovesetFlyout: boolean = true;
  public showArenaFlyout: boolean = true;
  public showTimeOfDayWidget: boolean = true;
  public timeOfDayAnimation: EaseType = EaseType.NONE;
  public showLevelUpStats: boolean = true;
  public enableTutorials: boolean = import.meta.env.VITE_BYPASS_TUTORIAL === "1";
  public enableMoveInfo: boolean = true;
  public enableRetries: boolean = false;
  public hideIvs: boolean = false;
  /**
   * Determines the condition for a notification should be shown for Candy Upgrades
   * - 0 = 'Off'
   * - 1 = 'Passives Only'
   * - 2 = 'On'
   */
  public candyUpgradeNotification: integer = 0;
  /**
   * Determines what type of notification is used for Candy Upgrades
   * - 0 = 'Icon'
   * - 1 = 'Animation'
   */
  public candyUpgradeDisplay: integer = 0;
  public moneyFormat: MoneyFormat = MoneyFormat.NORMAL;
  public uiTheme: UiTheme = UiTheme.DEFAULT;
  public windowType: integer = 0;
  public experimentalSprites: boolean = false;
  public musicPreference: integer = 0;
  public moveAnimations: boolean = true;
  public expGainsSpeed: ExpGainsSpeed = ExpGainsSpeed.DEFAULT;
  public skipSeenDialogues: boolean = false;
  /**
   * Determines if the egg hatching animation should be skipped
   * - 0 = Never (never skip animation)
   * - 1 = Ask (ask to skip animation when hatching 2 or more eggs)
   * - 2 = Always (automatically skip animation when hatching 2 or more eggs)
   */
  public eggSkipPreference: number = 0;

  /**
     * Defines the experience gain display mode.
     *
     * @remarks
     * The `expParty` can have several modes:
     * - `0` - Default: The normal experience gain display, nothing changed.
     * - `1` - Level Up Notification: Displays the level up in the small frame instead of a message.
     * - `2` - Skip: No level up frame nor message.
     *
     * Modes `1` and `2` are still compatible with stats display, level up, new move, etc.
     * @default 0 - Uses the default normal experience gain display.
     */
  public expParty: ExpNotification = 0;
  public hpBarSpeed: integer = 0;
  public fusionPaletteSwaps: boolean = true;
  public enableTouchControls: boolean = false;
  public enableVibration: boolean = false;
  public showBgmBar: boolean = true;

  /**
   * Determines the selected battle style.
   * - 0 = 'Switch'
   * - 1 = 'Set' - The option to switch the active pokemon at the start of a battle will not display.
   */
  public battleStyle: integer = BattleStyle.SWITCH;

  /**
  * Defines whether or not to show type effectiveness hints
  * - true: No hints
  * - false: Show hints for moves
   */
  public typeHints: boolean = false;

  public disableMenu: boolean = false;

  public gameData: GameData;
  public sessionSlotId: integer;

  /** PhaseQueue: dequeue/remove the first element to get the next phase */
  public phaseQueue: Phase[];
  public conditionalQueue: Array<[() => boolean, Phase]>;
  /** PhaseQueuePrepend: is a temp storage of what will be added to PhaseQueue */
  private phaseQueuePrepend: Phase[];

  /** overrides default of inserting phases to end of phaseQueuePrepend array, useful or inserting Phases "out of order" */
  private phaseQueuePrependSpliceIndex: integer;
  private nextCommandPhaseQueue: Phase[];

  private currentPhase: Phase | null;
  private standbyPhase: Phase | null;
  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;
  public arena: Arena;
  public gameMode: GameMode;
  public score: integer;
  public lockModifierTiers: boolean;
  public trainer: Phaser.GameObjects.Sprite;
  public lastEnemyTrainer: Trainer | null;
  public currentBattle: Battle;
  public pokeballCounts: PokeballCounts;
  public money: integer;
  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;
  /** 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: boolean = false;
  private shopOverlayOpacity: number = .8;

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

  public seed: string;
  public waveSeed: string;
  public waveCycleOffset: integer;
  public offsetGym: boolean;

  public damageNumberHandler: DamageNumberHandler;
  private spriteSparkleHandler: PokemonSpriteSparkleHandler;

  public fieldSpritePipeline: FieldSpritePipeline;
  public spritePipeline: SpritePipeline;

  private bgm: AnySound;
  private bgmResumeTimer: Phaser.Time.TimerEvent | null;
  private bgmCache: Set<string> = new Set();
  private playTimeTimer: Phaser.Time.TimerEvent;

  public rngCounter: integer = 0;
  public rngSeedOverride: string = "";
  public rngOffset: integer = 0;

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

  public eventManager: TimedEventManager;

  /**
   * 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();

  constructor() {
    super("battle");
    this.phaseQueue = [];
    this.phaseQueuePrepend = [];
    this.conditionalQueue = [];
    this.phaseQueuePrependSpliceIndex = -1;
    this.nextCommandPhaseQueue = [];
    this.updateGameInfo();
  }

  loadPokemonAtlas(key: string, atlasPath: string, experimental?: boolean) {
    if (experimental === undefined) {
      experimental = this.experimentalSprites;
    }
    const variant = atlasPath.includes("variant/") || /_[0-3]$/.test(atlasPath);
    if (experimental) {
      experimental = this.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`);
  }

  async preload() {
    if (DEBUG_RNG) {
      const scene = this;
      const originalRealInRange = Phaser.Math.RND.realInRange;
      Phaser.Math.RND.realInRange = function (min: number, max: number): number {
        const ret = originalRealInRange.apply(this, [ min, max ]);
        const args = [ "RNG", ++scene.rngCounter, ret / (max - min), `min: ${min} / max: ${max}` ];
        args.push(`seed: ${scene.rngSeedOverride || scene.waveSeed || scene.seed}`);
        if (scene.rngOffset) {
          args.push(`offset: ${scene.rngOffset}`);
        }
        console.log(...args);
        return ret;
      };
    }

    populateAnims();

    await this.initVariantData();
  }

  create() {
    this.scene.remove(LoadingScene.KEY);
    initGameSpeed.apply(this);
    this.inputController = new InputsController(this);
    this.uiInputs = new UiInputs(this, this.inputController);

    this.gameData = new GameData(this);

    addUiThemeOverrides(this);

    this.load.setBaseURL();

    this.spritePipeline = new SpritePipeline(this.game);
    (this.renderer as Phaser.Renderer.WebGL.WebGLRenderer).pipelines.add("Sprite", this.spritePipeline);

    this.fieldSpritePipeline = new FieldSpritePipeline(this.game);
    (this.renderer as Phaser.Renderer.WebGL.WebGLRenderer).pipelines.add("FieldSprite", this.fieldSpritePipeline);
    this.eventManager = new TimedEventManager();

    this.launchBattle();
  }

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

  launchBattle() {
    this.arenaBg = this.add.sprite(0, 0, "plains_bg");
    this.arenaBg.setName("sprite-arena-bg");
    this.arenaBgTransition = this.add.sprite(0, 0, "plains_bg");
    this.arenaBgTransition.setName("sprite-arena-bg-transition");

    [ this.arenaBgTransition, this.arenaBg ].forEach(a => {
      a.setPipeline(this.fieldSpritePipeline);
      a.setScale(6);
      a.setOrigin(0);
      a.setSize(320, 240);
    });

    const field = this.add.container(0, 0);
    field.setName("field");
    field.setScale(6);

    this.field = field;

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

    this.fieldUI = fieldUI;

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

    //@ts-ignore (the defined types in the package are incromplete...)
    transition.transit({
      mode: "blinds",
      ease: "Cubic.easeInOut",
      duration: 1250,
    });
    transition.once("complete", () => {
      transition.destroy();
    });

    this.add.existing(transition);

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

    this.uiContainer = uiContainer;

    const overlayWidth = this.game.canvas.width / 6;
    const overlayHeight = (this.game.canvas.height / 6) - 48;
    this.fieldOverlay = this.add.rectangle(0, overlayHeight * -1 - 48, overlayWidth, overlayHeight, 0x424242);
    this.fieldOverlay.setName("rect-field-overlay");
    this.fieldOverlay.setOrigin(0, 0);
    this.fieldOverlay.setAlpha(0);
    this.fieldUI.add(this.fieldOverlay);

    this.shopOverlay = this.add.rectangle(0, overlayHeight * -1 - 48, overlayWidth, overlayHeight, 0x070707);
    this.shopOverlay.setName("rect-shop-overlay");
    this.shopOverlay.setOrigin(0, 0);
    this.shopOverlay.setAlpha(0);
    this.fieldUI.add(this.shopOverlay);

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

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

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

    this.charSprite = new CharSprite(this);
    this.charSprite.setName("sprite-char");
    this.charSprite.setup();

    this.fieldUI.add(this.charSprite);

    this.pbTray = new PokeballTray(this, true);
    this.pbTray.setName("pb-tray");
    this.pbTray.setup();

    this.pbTrayEnemy = new PokeballTray(this, false);
    this.pbTrayEnemy.setName("enemy-pb-tray");
    this.pbTrayEnemy.setup();

    this.fieldUI.add(this.pbTray);
    this.fieldUI.add(this.pbTrayEnemy);

    this.abilityBar = new AbilityBar(this);
    this.abilityBar.setName("ability-bar");
    this.abilityBar.setup();
    this.fieldUI.add(this.abilityBar);

    this.partyExpBar = new PartyExpBar(this);
    this.partyExpBar.setName("party-exp-bar");
    this.partyExpBar.setup();
    this.fieldUI.add(this.partyExpBar);

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

    this.biomeWaveText = addTextObject(this, (this.game.canvas.width / 6) - 2, 0, startingWave.toString(), TextStyle.BATTLE_INFO);
    this.biomeWaveText.setName("text-biome-wave");
    this.biomeWaveText.setOrigin(1, 0.5);
    this.fieldUI.add(this.biomeWaveText);

    this.moneyText = addTextObject(this, (this.game.canvas.width / 6) - 2, 0, "", TextStyle.MONEY);
    this.moneyText.setName("text-money");
    this.moneyText.setOrigin(1, 0.5);
    this.fieldUI.add(this.moneyText);

    this.scoreText = addTextObject(this, (this.game.canvas.width / 6) - 2, 0, "", TextStyle.PARTY, { fontSize: "54px" });
    this.scoreText.setName("text-score");
    this.scoreText.setOrigin(1, 0.5);
    this.fieldUI.add(this.scoreText);

    this.luckText = addTextObject(this, (this.game.canvas.width / 6) - 2, 0, "", TextStyle.PARTY, { fontSize: "54px" });
    this.luckText.setName("text-luck");
    this.luckText.setOrigin(1, 0.5);
    this.luckText.setVisible(false);
    this.fieldUI.add(this.luckText);

    this.luckLabelText = addTextObject(this, (this.game.canvas.width / 6) - 2, 0, i18next.t("common:luckIndicator"), TextStyle.PARTY, { fontSize: "54px" });
    this.luckLabelText.setName("text-luck-label");
    this.luckLabelText.setOrigin(1, 0.5);
    this.luckLabelText.setVisible(false);
    this.fieldUI.add(this.luckLabelText);

    this.arenaFlyout = new ArenaFlyout(this);
    this.fieldUI.add(this.arenaFlyout);
    this.fieldUI.moveBelow<Phaser.GameObjects.GameObject>(this.arenaFlyout, this.fieldOverlay);

    this.updateUIPositions();

    this.damageNumberHandler = new DamageNumberHandler();

    this.spriteSparkleHandler = new PokemonSpriteSparkleHandler();
    this.spriteSparkleHandler.setup(this);

    this.pokemonInfoContainer = new PokemonInfoContainer(this, (this.game.canvas.width / 6) + 52, -(this.game.canvas.height / 6) + 66);
    this.pokemonInfoContainer.setup();

    this.fieldUI.add(this.pokemonInfoContainer);

    this.party = [];

    const loadPokemonAssets = [];

    this.arenaPlayer = new ArenaBase(this, true);
    this.arenaPlayer.setName("arena-player");
    this.arenaPlayerTransition = new ArenaBase(this, true);
    this.arenaPlayerTransition.setName("arena-player-transition");
    this.arenaEnemy = new ArenaBase(this, false);
    this.arenaEnemy.setName("arena-enemy");
    this.arenaNextEnemy = new ArenaBase(this, false);
    this.arenaNextEnemy.setName("arena-next-enemy");

    this.arenaBgTransition.setVisible(false);
    this.arenaPlayerTransition.setVisible(false);
    this.arenaNextEnemy.setVisible(false);

    [ this.arenaPlayer, this.arenaPlayerTransition, this.arenaEnemy, this.arenaNextEnemy ].forEach(a => {
      if (a instanceof Phaser.GameObjects.Sprite) {
        a.setOrigin(0, 0);
      }
      field.add(a);
    });

    const trainer = this.addFieldSprite(0, 0, `trainer_${this.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back`);
    trainer.setOrigin(0.5, 1);
    trainer.setName("sprite-trainer");

    field.add(trainer);

    this.trainer = 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);

    const ui = new UI(this);
    this.uiContainer.add(ui);

    this.ui = ui;

    ui.setup();

    const defaultMoves = [ Moves.TACKLE, Moves.TAIL_WHIP, Moves.FOCUS_ENERGY, Moves.STRUGGLE ];

    Promise.all([
      Promise.all(loadPokemonAssets),
      initCommonAnims(this).then(() => loadCommonAnimAssets(this, true)),
      Promise.all([ Moves.TACKLE, Moves.TAIL_WHIP, Moves.FOCUS_ENERGY, Moves.STRUGGLE ].map(m => initMoveAnim(this, m))).then(() => loadMoveAnimAssets(this, defaultMoves, true)),
      this.initStarterColors()
    ]).then(() => {
      this.pushPhase(new LoginPhase(this));
      this.pushPhase(new TitlePhase(this));

      this.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: Utils.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.length) {
      return;
    }
    this.cachedFetch("./exp-sprites.json").then(res => res.json()).then(keys => {
      if (Array.isArray(keys)) {
        expSpriteKeys.push(...keys);
      }
      Promise.resolve();
    });
  }

  async initVariantData(): Promise<void> {
    Object.keys(variantData).forEach(key => delete variantData[key]);
    await this.cachedFetch("./images/pokemon/variant/_masterlist.json").then(res => res.json())
      .then(v => {
        Object.keys(v).forEach(k => variantData[k] = v[k]);
        if (this.experimentalSprites) {
          const expVariantData = variantData["exp"];
          const traverseVariantData = (keys: string[]) => {
            let variantTree = variantData;
            let expTree = expVariantData;
            keys.map((k: string, i: integer) => {
              if (i < keys.length - 1) {
                variantTree = variantTree[k];
                expTree = expTree[k];
              } else if (variantTree.hasOwnProperty(k) && expTree.hasOwnProperty(k)) {
                if ([ "back", "female" ].includes(k)) {
                  traverseVariantData(keys.concat(k));
                } else {
                  variantTree[k] = expTree[k];
                }
              }
            });
          };
          Object.keys(expVariantData).forEach(ek => traverseVariantData([ ek ]));
        }
        Promise.resolve();
      });
  }

  cachedFetch(url: string, init?: RequestInit): Promise<Response> {
    const manifest = this.game["manifest"];
    if (manifest) {
      const timestamp = manifest[`/${url.replace("./", "")}`];
      if (timestamp) {
        url += `?t=${timestamp}`;
      }
    }
    return fetch(url, init);
  }

  initStarterColors(): Promise<void> {
    return new Promise(resolve => {
      if (starterColors) {
        return resolve();
      }

      this.cachedFetch("./starter-colors.json").then(res => res.json()).then(sc => {
        starterColors = {};
        Object.keys(sc).forEach(key => {
          starterColors[key] = sc[key];
        });

        /*const loadPokemonAssets: Promise<void>[] = [];

                for (let s of Object.keys(speciesStarters)) {
                    const species = getPokemonSpecies(parseInt(s));
                    loadPokemonAssets.push(species.loadAssets(this, false, 0, false));
                }

                Promise.all(loadPokemonAssets).then(() => {
                    const starterCandyColors = {};
                    const rgbaToHexFunc = (r, g, b) => [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');

                    for (let s of Object.keys(speciesStarters)) {
                        const species = getPokemonSpecies(parseInt(s));

                        starterCandyColors[species.speciesId] = species.generateCandyColors(this).map(c => rgbaToHexFunc(c[0], c[1], c[2]));
                    }

                    console.log(JSON.stringify(starterCandyColors));

                    resolve();
                });*/

        resolve();
      });
    });
  }

  hasExpSprite(key: string): boolean {
    const keyMatch = /^pkmn__?(back__)?(shiny__)?(female__)?(\d+)(\-.*?)?(?:_[1-3])?$/g.exec(key);
    if (!keyMatch) {
      return false;
    }

    let k = keyMatch[4]!;
    if (keyMatch[2]) {
      k += "s";
    }
    if (keyMatch[1]) {
      k += "b";
    }
    if (keyMatch[3]) {
      k += "f";
    }
    if (keyMatch[5]) {
      k += keyMatch[5];
    }
    if (!expSpriteKeys.includes(k)) {
      return false;
    }
    return true;
  }

  getParty(): PlayerPokemon[] {
    return this.party;
  }

  getPlayerPokemon(): PlayerPokemon | undefined {
    return this.getPlayerField().find(p => p.isActive());
  }

  /**
   * Returns an array of PlayerPokemon of length 1 or 2 depending on if double battles or not
   * @returns array of {@linkcode PlayerPokemon}
   */
  getPlayerField(): PlayerPokemon[] {
    const party = this.getParty();
    return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
  }

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

  getEnemyPokemon(): EnemyPokemon | undefined {
    return this.getEnemyField().find(p => p.isActive());
  }

  /**
   * Returns an array of EnemyPokemon of length 1 or 2 depending on if double battles or not
   * @returns array of {@linkcode EnemyPokemon}
   */
  getEnemyField(): EnemyPokemon[] {
    const party = this.getEnemyParty();
    return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
  }

  getField(activeOnly: boolean = false): Pokemon[] {
    const ret = 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;
  }

  /**
   * Used in doubles battles to redirect moves from one pokemon to another when one faints or is removed from the field
   * @param removedPokemon {@linkcode Pokemon} the pokemon that is being removed from the field (flee, faint), moves to be redirected FROM
   * @param allyPokemon {@linkcode Pokemon} the pokemon that will have the moves be redirected TO
   */
  redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
    // failsafe: if not a double battle just return
    if (this.currentBattle.double === false) {
      return;
    }
    if (allyPokemon?.isActive(true)) {
      let targetingMovePhase: MovePhase;
      do {
        targetingMovePhase = this.findPhase(mp => mp instanceof MovePhase && mp.targets.length === 1 && mp.targets[0] === removedPokemon.getBattlerIndex() && mp.pokemon.isPlayer() !== allyPokemon.isPlayer()) as MovePhase;
        if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) {
          targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex();
        }
      } while (targetingMovePhase);
    }
  }

  /**
   * Returns the ModifierBar of this scene, which is declared private and therefore not accessible elsewhere
   * @param isEnemy Whether to return the enemy's modifier bar
   * @returns {ModifierBar}
   */
  getModifierBar(isEnemy?: boolean): ModifierBar {
    return isEnemy ? this.enemyModifierBar : this.modifierBar;
  }

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

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

  getPokemonById(pokemonId: integer): Pokemon | null {
    const findInParty = (party: Pokemon[]) => party.find(p => p.id === pokemonId);
    return (findInParty(this.getParty()) || findInParty(this.getEnemyParty())) ?? null;
  }

  addPlayerPokemon(species: PokemonSpecies, level: integer, abilityIndex?: integer, formIndex?: integer, gender?: Gender, shiny?: boolean, variant?: Variant, ivs?: integer[], nature?: Nature, dataSource?: Pokemon | PokemonData, postProcess?: (playerPokemon: PlayerPokemon) => void): PlayerPokemon {
    const pokemon = new PlayerPokemon(this, species, level, abilityIndex, formIndex, gender, shiny, variant, ivs, nature, dataSource);
    if (postProcess) {
      postProcess(pokemon);
    }
    pokemon.init();
    return pokemon;
  }

  addEnemyPokemon(species: PokemonSpecies, level: integer, trainerSlot: TrainerSlot, boss: boolean = false, dataSource?: PokemonData, postProcess?: (enemyPokemon: EnemyPokemon) => void): EnemyPokemon {
    if (Overrides.OPP_LEVEL_OVERRIDE > 0) {
      level = Overrides.OPP_LEVEL_OVERRIDE;
    }
    if (Overrides.OPP_SPECIES_OVERRIDE) {
      species = getPokemonSpecies(Overrides.OPP_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(this, species, level, trainerSlot, boss, dataSource);

    overrideModifiers(this, false);
    overrideHeldItems(this, pokemon, false);
    if (boss && !dataSource) {
      const secondaryIvs = Utils.getIvsFromId(Utils.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);
    }

    for (let i = 0; i < pokemon.ivs.length; i++) {
      if (OPP_IVS_OVERRIDE_VALIDATED[i] > -1) {
        pokemon.ivs[i] = OPP_IVS_OVERRIDE_VALIDATED[i];
      }
    }

    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: boolean = 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: number = 0.5, originY: number = 0.5, ignoreOverride: boolean = false): Phaser.GameObjects.Container {
    const container = this.add.container(x, y);
    container.setName(`${pokemon.name}-icon`);

    const icon = this.add.sprite(0, 0, pokemon.getIconAtlasKey(ignoreOverride));
    icon.setName(`sprite-${pokemon.name}-icon`);
    icon.setFrame(pokemon.getIconId(true));
    // Temporary fix to show pokemon's default icon if variant icon doesn't exist
    if (icon.frame.name !== pokemon.getIconId(true)) {
      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));
      icon.setFrame(pokemon.getIconId(true));
      pokemon.shiny = temp;
    }
    icon.setOrigin(0.5, 0);

    container.add(icon);

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

      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 = 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.rngCounter = 0;
    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}(`scene`, {@linkcode range}, {@linkcode min}) in `src/battle.ts`
   * which calls {@linkcode Utils.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: integer, min: integer = 0): integer {
    return this.currentBattle?.randSeedInt(this, range, min);
  }

  reset(clearScene: boolean = false, clearData: boolean = false, reloadI18n: boolean = false): void {
    if (clearData) {
      this.gameData = new GameData(this);
    }

    this.gameMode = getGameMode(GameModes.CLASSIC);

    this.setSeed(Overrides.SEED_OVERRIDE || Utils.randomString(24));
    console.log("Seed:", this.seed);
    this.resetSeed(); // Properly resets RNG after saving and quitting a session

    this.disableMenu = false;

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

    this.lockModifierTiers = false;

    this.pokeballCounts = Object.fromEntries(Utils.getEnumValues(PokeballType).filter(p => p <= PokeballType.MASTER_BALL).map(t => [ t, 0 ]));
    this.pokeballCounts[PokeballType.POKEBALL] += 5;
    if (Overrides.POKEBALL_OVERRIDE.active) {
      this.pokeballCounts = Overrides.POKEBALL_OVERRIDE.pokeballs;
    }

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

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

    //@ts-ignore  - allowing `null` for currentBattle causes a lot of trouble
    this.currentBattle = null; // TODO: resolve ts-ignore

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

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

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

    [ this.luckLabelText, this.luckText ].map(t => t.setVisible(false));

    this.newArena(Overrides.STARTING_BIOME_OVERRIDE || Biome.TOWN);

    this.field.setVisible(true);

    this.arenaBgTransition.setPosition(0, 0);
    this.arenaPlayer.setPosition(300, 0);
    this.arenaPlayerTransition.setPosition(0, 0);
    [ this.arenaEnemy, this.arenaNextEnemy ].forEach(a => a.setPosition(-280, 0));
    this.arenaNextEnemy.setVisible(false);

    this.arena.init();

    this.trainer.setTexture(`trainer_${this.gameData.gender === PlayerGender.FEMALE ? "f" : "m"}_back`);
    this.trainer.setPosition(406, 186);
    this.trainer.setVisible(true);

    this.updateGameInfo();

    if (reloadI18n) {
      const localizable: Localizable[] = [
        ...allSpecies,
        ...allMoves,
        ...allAbilities,
        ...Utils.getEnumValues(ModifierPoolType).map(mpt => getModifierPoolForType(mpt)).map(mp => Object.values(mp).flat().map(mt => mt.modifierType).filter(mt => "localize" in mt).map(lpb => lpb as unknown as Localizable)).flat()
      ];
      for (const item of localizable) {
        item.localize();
      }
    }

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

      this.fadeOutBgm(250, false);
      this.tweens.add({
        targets: [ this.uiContainer ],
        alpha: 0,
        duration: 250,
        ease: "Sine.easeInOut",
        onComplete: () => {
          this.clearPhaseQueue();

          this.children.removeAll(true);
          this.game.domContainer.innerHTML = "";
          this.launchBattle();
        }
      });
    }
  }

  newBattle(waveIndex?: integer, battleType?: BattleType, trainerData?: TrainerData, double?: boolean, mysteryEncounterType?: MysteryEncounterType): Battle | null {
    const _startingWave = Overrides.STARTING_WAVE_OVERRIDE || startingWave;
    const newWaveIndex = waveIndex || ((this.currentBattle?.waveIndex || (_startingWave - 1)) + 1);
    let newDouble: boolean | undefined;
    let newBattleType: BattleType;
    let newTrainer: Trainer | undefined;

    let battleConfig: FixedBattleConfig | null = null;

    this.resetSeed(newWaveIndex);

    const playerField = this.getPlayerField();

    if (this.gameMode.isFixedBattle(newWaveIndex) && trainerData === undefined) {
      battleConfig = this.gameMode.getFixedBattle(newWaveIndex);
      newDouble = battleConfig.double;
      newBattleType = battleConfig.battleType;
      this.executeWithSeedOffset(() => newTrainer = battleConfig?.getTrainer(this), (battleConfig.seedOffsetWaveIndex || newWaveIndex) << 8);
      if (newTrainer) {
        this.field.add(newTrainer);
      }
    } else {
      if (!this.gameMode.hasTrainers) {
        newBattleType = BattleType.WILD;
      } else if (battleType === undefined) {
        newBattleType = this.gameMode.isWaveTrainer(newWaveIndex, this.arena) ? BattleType.TRAINER : BattleType.WILD;
      } else {
        newBattleType = battleType;
      }

      if (newBattleType === BattleType.TRAINER) {
        const trainerType = this.arena.randomTrainerType(newWaveIndex);
        let doubleTrainer = false;
        if (trainerConfigs[trainerType].doubleOnly) {
          doubleTrainer = true;
        } else if (trainerConfigs[trainerType].hasDouble) {
          const doubleChance = new Utils.IntegerHolder(newWaveIndex % 10 === 0 ? 32 : 8);
          this.applyModifiers(DoubleBattleChanceBoosterModifier, true, doubleChance);
          playerField.forEach(p => applyAbAttrs(DoubleBattleChanceAbAttr, p, null, false, doubleChance));
          doubleTrainer = !Utils.randSeedInt(doubleChance.value);
          // Add a check that special trainers can't be double except for tate and liza - they should use the normal double chance
          if (trainerConfigs[trainerType].trainerTypeDouble && ![ TrainerType.TATE, TrainerType.LIZA ].includes(trainerType)) {
            doubleTrainer = false;
          }
        }
        const variant = doubleTrainer ? TrainerVariant.DOUBLE : (Utils.randSeedInt(2) ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT);
        newTrainer = trainerData !== undefined ? trainerData.toTrainer(this) : new Trainer(this, trainerType, variant);
        this.field.add(newTrainer);
      }

      // Check for mystery encounter
      // Can only occur in place of a standard (non-boss) wild battle, waves 10-180
      const [lowestMysteryEncounterWave, highestMysteryEncounterWave] = this.gameMode.getMysteryEncounterLegalWaves();
      if (this.gameMode.hasMysteryEncounters && newBattleType === BattleType.WILD && !this.gameMode.isBoss(newWaveIndex) && newWaveIndex < highestMysteryEncounterWave && newWaveIndex > lowestMysteryEncounterWave) {
        const roll = Utils.randSeedInt(MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT);

        // 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;

        // 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
        const expectedEncountersByFloor = AVERAGE_ENCOUNTERS_PER_RUN_TARGET / (highestMysteryEncounterWave - lowestMysteryEncounterWave) * (newWaveIndex - lowestMysteryEncounterWave);
        const currentRunDiffFromAvg = expectedEncountersByFloor - encounteredEvents.length;
        const favoredEncounterRate = sessionEncounterRate + currentRunDiffFromAvg * ANTI_VARIANCE_WEIGHT_MODIFIER;

        const successRate = isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE) ? favoredEncounterRate : Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE!;

        // If the most recent ME was 3 or fewer waves ago, can never spawn a ME
        const canSpawn = encounteredEvents.length === 0 || (newWaveIndex - encounteredEvents[encounteredEvents.length - 1].waveIndex) > 3 || !isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE);

        if (canSpawn && roll < successRate) {
          newBattleType = BattleType.MYSTERY_ENCOUNTER;
          // Reset base spawn weight
          this.mysteryEncounterSaveData.encounterSpawnChance = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT;
        } else {
          this.mysteryEncounterSaveData.encounterSpawnChance = sessionEncounterRate + WEIGHT_INCREMENT_ON_SPAWN_MISS;
        }
      }
    }

    if (double === undefined && newWaveIndex > 1) {
      if (newBattleType === BattleType.WILD && !this.gameMode.isWaveFinal(newWaveIndex)) {
        const doubleChance = new Utils.IntegerHolder(newWaveIndex % 10 === 0 ? 32 : 8);
        this.applyModifiers(DoubleBattleChanceBoosterModifier, true, doubleChance);
        playerField.forEach(p => applyAbAttrs(DoubleBattleChanceAbAttr, p, null, false, doubleChance));
        newDouble = !Utils.randSeedInt(doubleChance.value);
      } else if (newBattleType === BattleType.TRAINER) {
        newDouble = newTrainer?.variant === TrainerVariant.DOUBLE;
      }
    } else if (!battleConfig) {
      newDouble = !!double;
    }

    if (Overrides.BATTLE_TYPE_OVERRIDE === "double") {
      newDouble = true;
    }
    /* Override battles into single only if not fighting with trainers */
    if (newBattleType !== BattleType.TRAINER && Overrides.BATTLE_TYPE_OVERRIDE === "single") {
      newDouble = false;
    }

    const lastBattle = this.currentBattle;

    if (lastBattle?.double && !newDouble) {
      this.tryRemovePhase(p => p instanceof SwitchPhase);
    }

    const maxExpLevel = this.getMaxExpLevel();

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

    this.executeWithSeedOffset(() => {
      this.currentBattle = new Battle(this.gameMode, newWaveIndex, newBattleType, newTrainer, newDouble);
    }, newWaveIndex << 3, this.waveSeed);
    this.currentBattle.incrementTurn(this);

    if (newBattleType === BattleType.MYSTERY_ENCOUNTER) {
      // Disable double battle on mystery encounters (it may be re-enabled as part of encounter)
      this.currentBattle.double = false;
      this.executeWithSeedOffset(() => {
        this.currentBattle.mysteryEncounter = this.getMysteryEncounter(mysteryEncounterType);
      }, this.currentBattle.waveIndex << 4);
    }

    //this.pushPhase(new TrainerMessageTestPhase(this, TrainerType.RIVAL, TrainerType.RIVAL_2, TrainerType.RIVAL_3, TrainerType.RIVAL_4, TrainerType.RIVAL_5, TrainerType.RIVAL_6));

    if (!waveIndex && lastBattle) {
      const isWaveIndexMultipleOfTen = !(lastBattle.waveIndex % 10);
      const isEndlessOrDaily = this.gameMode.hasShortBiomes || this.gameMode.isDaily;
      const isEndlessFifthWave = this.gameMode.hasShortBiomes && (lastBattle.waveIndex % 5) === 0;
      const isWaveIndexMultipleOfFiftyMinusOne = (lastBattle.waveIndex % 50) === 49;
      const isNewBiome = isWaveIndexMultipleOfTen || isEndlessFifthWave || (isEndlessOrDaily && isWaveIndexMultipleOfFiftyMinusOne);
      const resetArenaState = isNewBiome || [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(this.currentBattle.battleType) || this.currentBattle.battleSpec === BattleSpec.FINAL_BOSS;
      this.getEnemyParty().forEach(enemyPokemon => enemyPokemon.destroy());
      this.trySpreadPokerus();
      if (!isNewBiome && (newWaveIndex % 10) === 5) {
        this.arena.updatePoolsForTimeOfDay();
      }
      if (resetArenaState) {
        this.arena.resetArenaEffects();

        playerField.forEach((pokemon, p) => {
          if (pokemon.isOnField()) {
            this.pushPhase(new ReturnPhase(this, p));
          }
        });

        for (const pokemon of this.getParty()) {
          pokemon.resetBattleData();
          applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon);
        }

        if (!this.trainer.visible) {
          this.pushPhase(new ShowTrainerPhase(this));
        }
      }

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

      if (!this.gameMode.hasRandomBiomes && !isNewBiome) {
        this.pushPhase(new NextEncounterPhase(this));
      } else {
        this.pushPhase(new SelectBiomePhase(this));
        this.pushPhase(new NewBiomeEncounterPhase(this));

        const newMaxExpLevel = this.getMaxExpLevel();
        if (newMaxExpLevel > maxExpLevel) {
          this.pushPhase(new LevelCapPhase(this));
        }
      }
    }

    return this.currentBattle;
  }

  newArena(biome: Biome): Arena {
    this.arena = new Arena(this, biome, Biome[biome].toLowerCase());
    this.eventTarget.dispatchEvent(new NewArenaEvent());

    this.arenaBg.pipelineData = { terrainColorRatio: this.arena.getBgTerrainColorRatioForBiome() };

    return this.arena;
  }

  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: boolean = 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: scale,
        x: (defaultWidth - scaledWidth) / 2,
        y: defaultHeight - scaledHeight,
        duration: !instant ? Utils.fixedInt(Math.abs(this.field.scale - scale) * 200) : 0,
        ease: "Sine.easeInOut",
        onComplete: () => resolve()
      });
    });
  }

  getSpeciesFormIndex(species: PokemonSpecies, gender?: Gender, nature?: Nature, ignoreArena?: boolean): integer {
    if (!species.forms?.length) {
      return 0;
    }

    switch (species.speciesId) {
    case Species.UNOWN:
    case Species.SHELLOS:
    case Species.GASTRODON:
    case Species.BASCULIN:
    case Species.DEERLING:
    case Species.SAWSBUCK:
    case Species.FROAKIE:
    case Species.FROGADIER:
    case Species.SCATTERBUG:
    case Species.SPEWPA:
    case Species.VIVILLON:
    case Species.FLABEBE:
    case Species.FLOETTE:
    case Species.FLORGES:
    case Species.FURFROU:
    case Species.PUMPKABOO:
    case Species.GOURGEIST:
    case Species.ORICORIO:
    case Species.MAGEARNA:
    case Species.ZARUDE:
    case Species.SQUAWKABILLY:
    case Species.TATSUGIRI:
    case Species.PALDEA_TAUROS:
      return Utils.randSeedInt(species.forms.length);
    case Species.PIKACHU:
      return Utils.randSeedInt(8);
    case Species.EEVEE:
      return Utils.randSeedInt(2);
    case Species.GRENINJA:
      return Utils.randSeedInt(2);
    case Species.ZYGARDE:
      return Utils.randSeedInt(3);
    case Species.MINIOR:
      return Utils.randSeedInt(6);
    case Species.ALCREMIE:
      return Utils.randSeedInt(9);
    case Species.MEOWSTIC:
    case Species.INDEEDEE:
    case Species.BASCULEGION:
    case Species.OINKOLOGNE:
      return gender === Gender.FEMALE ? 1 : 0;
    case Species.TOXTRICITY:
      const lowkeyNatures = [ 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.indexOf(nature) > -1) {
        return 1;
      }
      return 0;
    case Species.GIMMIGHOUL:
      // Chest form can only be found in Mysterious Chest Encounter, if this is a game mode with MEs
      if (this.gameMode.hasMysteryEncounters) {
        return 1; // Wandering form
      } else {
        return Utils.randSeedInt(species.forms.length);
      }
    }

    if (ignoreArena) {
      switch (species.speciesId) {
      case Species.BURMY:
      case Species.WORMADAM:
      case Species.ROTOM:
      case Species.LYCANROC:
        return Utils.randSeedInt(species.forms.length);
      }
      return 0;
    }

    return this.arena.getSpeciesFormIndex(species);
  }

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

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

  getEncounterBossSegments(waveIndex: integer, level: integer, species?: PokemonSpecies, forceBoss: boolean = false): integer {
    if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1) {
      return Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE;
    } else if (Overrides.OPP_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)) {
      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 && Utils.randSeedInt(100) < Math.min(Math.max(Math.ceil((waveIndex - 250) / 50), 0) * 2, 30));
      }, waveIndex << 2);
    }
    if (!isBoss) {
      return 0;
    }

    let ret: integer = 2;

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

    return ret;
  }

  trySpreadPokerus(): void {
    const party = this.getParty();
    const infectedIndexes: integer[] = [];
    const spread = (index: number, spreadTo: number) => {
      const partyMember = party[index + spreadTo];
      if (!partyMember.pokerus && !Utils.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));
    });
  }

  resetSeed(waveIndex?: integer): void {
    const wave = waveIndex || this.currentBattle?.waveIndex || 0;
    this.waveSeed = Utils.shiftCharCodes(this.seed, wave);
    Phaser.Math.RND.sow([ this.waveSeed ]);
    console.log("Wave Seed:", this.waveSeed, wave);
    this.rngCounter = 0;
  }

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

  addFieldSprite(x: number, y: number, texture: string | Phaser.Textures.Texture, frame?: string | number, terrainColorRatio: number = 0): Phaser.GameObjects.Sprite {
    const ret = this.add.sprite(x, y, texture, frame);
    ret.setPipeline(this.fieldSpritePipeline);
    if (terrainColorRatio) {
      ret.pipelineData["terrainColorRatio"] = terrainColorRatio;
    }

    return ret;
  }

  addPokemonSprite(pokemon: Pokemon, x: number, y: number, texture: string | Phaser.Textures.Texture, frame?: string | number, hasShadow: boolean = false, ignoreOverride: boolean = 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: boolean = false, ignoreOverride: boolean = false): Phaser.GameObjects.Sprite {
    sprite.setPipeline(this.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], hasShadow: hasShadow, ignoreOverride: ignoreOverride, teraColor: pokemon ? getTypeRgb(pokemon.getTeraType()) : undefined });
    this.spriteSparkleHandler.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);
  }

  showFieldOverlay(duration: integer): Promise<void> {
    return new Promise(resolve => {
      this.tweens.add({
        targets: this.fieldOverlay,
        alpha: 0.5,
        ease: "Sine.easeOut",
        duration: duration,
        onComplete: () => resolve()
      });
    });
  }

  hideFieldOverlay(duration: integer): Promise<void> {
    return new Promise(resolve => {
      this.tweens.add({
        targets: this.fieldOverlay,
        alpha: 0,
        duration: duration,
        ease: "Cubic.easeIn",
        onComplete: () => resolve()
      });
    });
  }

  updateShopOverlayOpacity(value: number): void {
    this.shopOverlayOpacity = value;

    if (this.shopOverlayShown) {
      this.shopOverlay.setAlpha(this.shopOverlayOpacity);
    }
  }

  showShopOverlay(duration: integer): Promise<void> {
    this.shopOverlayShown = true;
    return new Promise(resolve => {
      this.tweens.add({
        targets: this.shopOverlay,
        alpha: this.shopOverlayOpacity,
        ease: "Sine.easeOut",
        duration,
        onComplete: () => resolve()
      });
    });
  }

  hideShopOverlay(duration: integer): Promise<void> {
    this.shopOverlayShown = false;
    return new Promise(resolve => {
      this.tweens.add({
        targets: this.shopOverlay,
        alpha: 0,
        duration: duration,
        ease: "Cubic.easeIn",
        onComplete: () => resolve()
      });
    });
  }

  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.biomeType);
    this.fieldUI.moveAbove(this.biomeWaveText, this.luckText);
    this.biomeWaveText.setText( biomeString + " - " + this.currentBattle.waveIndex.toString());
    this.biomeWaveText.setColor(!isBoss ? "#ffffff" : "#f89890");
    this.biomeWaveText.setShadowColor(!isBoss ? "#636363" : "#984038");
    this.biomeWaveText.setVisible(true);
  }

  updateMoneyText(forceVisible: boolean = true): void {
    if (this.money === undefined) {
      return;
    }
    const formattedMoney = Utils.formatMoney(this.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 ? 1 : -1);
    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 {
    this.scoreText.setText(`Score: ${this.score.toString()}`);
    this.scoreText.setVisible(this.gameMode.isDaily);
  }

  updateAndShowText(duration: integer): void {
    const labels = [ this.luckLabelText, this.luckText ];
    labels.forEach(t => t.setAlpha(0));
    const luckValue = getPartyLuckValue(this.getParty());
    this.luckText.setText(getLuckString(luckValue));
    if (luckValue < 14) {
      this.luckText.setTint(getLuckTextTint(luckValue));
    } else {
      this.luckText.setTint(0xffef5c, 0x47ff69, 0x6b6bff, 0xff6969);
    }
    this.luckLabelText.setX((this.game.canvas.width / 6) - 2 - (this.luckText.displayWidth + 2));
    this.tweens.add({
      targets: labels,
      duration: duration,
      alpha: 1,
      onComplete: () => {
        labels.forEach(t => t.setVisible(true));
      }
    });
  }

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

  updateUIPositions(): void {
    const enemyModifierCount = this.enemyModifiers.filter(m => m.isIconVisible(this)).length;
    const biomeWaveTextHeight = this.biomeWaveText.getBottomLeft().y - this.biomeWaveText.getTopLeft().y;
    this.biomeWaveText.setY(
      -(this.game.canvas.height / 6) + (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.game.canvas.height / 6 + 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: integer, total: integer) => 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?: boolean): integer {
    if (ignoreLevelCap) {
      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: integer, level: integer, fromArenaPool?: boolean, speciesFilter?: PokemonSpeciesFilter, filterAllEvolutions?: boolean): PokemonSpecies {
    if (fromArenaPool) {
      return this.arena.randomSpecies(waveIndex, level, undefined, getPartyLuckValue(this.party));
    }
    const filteredSpecies = speciesFilter ? [...new Set(allSpecies.filter(s => s.isCatchable()).filter(speciesFilter).map(s => {
      if (!filterAllEvolutions) {
        while (pokemonPrevolutions.hasOwnProperty(s.speciesId)) {
          s = getPokemonSpecies(pokemonPrevolutions[s.speciesId]);
        }
      }
      return s;
    }))] : allSpecies.filter(s => s.isCatchable());
    return filteredSpecies[Utils.randSeedInt(filteredSpecies.length)];
  }

  generateRandomBiome(waveIndex: integer): Biome {
    const relWave = waveIndex % 250;
    const biomes = Utils.getEnumValues(Biome).slice(1, Utils.getEnumValues(Biome).filter(b => b >= 40).length * -1);
    const maxDepth = biomeDepths[Biome.END][0] - 2;
    const depthWeights = new Array(maxDepth + 1).fill(null)
      .map((_, i: integer) => ((1 - Math.min(Math.abs((i / (maxDepth - 1)) - (relWave / 250)) + 0.25, 1)) / 0.75) * 250);
    const biomeThresholds: integer[] = [];
    let totalWeight = 0;
    for (const biome of biomes) {
      totalWeight += Math.ceil(depthWeights[biomeDepths[biome][0] - 1] / biomeDepths[biome][1]);
      biomeThresholds.push(totalWeight);
    }

    const randInt = Utils.randSeedInt(totalWeight);

    for (const biome of biomes) {
      if (randInt < biomeThresholds[biome]) {
        return biome;
      }
    }

    return biomes[Utils.randSeedInt(biomes.length)];
  }

  isBgmPlaying(): boolean {
    return this.bgm && this.bgm.isPlaying;
  }

  playBgm(bgmName?: string, fadeOut?: boolean): void {
    if (bgmName === undefined) {
      bgmName = this.currentBattle?.getBgmOverride(this) || this.arena?.bgm;
    }
    if (this.bgm && bgmName === this.bgm.key) {
      if (!this.bgm.isPlaying) {
        this.bgm.play({
          volume: this.masterVolume * this.bgmVolume
        });
      }
      return;
    }
    if (fadeOut && !this.bgm) {
      fadeOut = false;
    }
    this.bgmCache.add(bgmName);
    this.loadBgm(bgmName);
    let loopPoint = 0;
    loopPoint = bgmName === this.arena.bgm
      ? this.arena.getBgmLoopPoint()
      : this.getBgmLoopPoint(bgmName);
    let loaded = false;
    const playNewBgm = () => {
      this.ui.bgmBar.setBgmToBgmBar(bgmName);
      if (bgmName === null && this.bgm && !this.bgm.pendingRemove) {
        this.bgm.play({
          volume: this.masterVolume * this.bgmVolume
        });
        return;
      }
      if (this.bgm && !this.bgm.pendingRemove && this.bgm.isPlaying) {
        this.bgm.stop();
      }
      this.bgm = this.sound.add(bgmName, { loop: true });
      this.bgm.play({
        volume: this.masterVolume * this.bgmVolume
      });
      if (loopPoint) {
        this.bgm.on("looped", () => this.bgm.play({ seek: loopPoint }));
      }
    };
    this.load.once(Phaser.Loader.Events.COMPLETE, () => {
      loaded = true;
      if (!fadeOut || !this.bgm.isPlaying) {
        playNewBgm();
      }
    });
    if (fadeOut) {
      const onBgmFaded = () => {
        if (loaded && (!this.bgm.isPlaying || this.bgm.pendingRemove)) {
          playNewBgm();
        }
      };
      this.time.delayedCall(this.fadeOutBgm(500, true) ? 750 : 250, onBgmFaded);
    }
    if (!this.load.isLoading()) {
      this.load.start();
    }
  }

  pauseBgm(): boolean {
    if (this.bgm && !this.bgm.pendingRemove && this.bgm.isPlaying) {
      this.bgm.pause();
      return true;
    }
    return false;
  }

  resumeBgm(): boolean {
    if (this.bgm && !this.bgm.pendingRemove && this.bgm.isPaused) {
      this.bgm.resume();
      return true;
    }
    return false;
  }

  updateSoundVolume(): void {
    if (this.sound) {
      for (const sound of this.sound.getAllPlaying() as AnySound[]) {
        if (this.bgmCache.has(sound.key)) {
          sound.setVolume(this.masterVolume * this.bgmVolume);
        } else {
          const soundDetails = sound.key.split("/");
          switch (soundDetails[0]) {

          case "battle_anims":
          case "cry":
            if (soundDetails[1].startsWith("PRSFX- ")) {
              sound.setVolume(this.masterVolume*this.fieldVolume*0.5);
            } else {
              sound.setVolume(this.masterVolume*this.fieldVolume);
            }
            break;
          case "se":
          case "ui":
            sound.setVolume(this.masterVolume*this.seVolume);
          }
        }
      }
    }
  }

  fadeOutBgm(duration: integer = 500, destroy: boolean = true): boolean {
    if (!this.bgm) {
      return false;
    }
    const bgm = this.sound.getAllPlaying().find(bgm => bgm.key === this.bgm.key);
    if (bgm) {
      SoundFade.fadeOut(this, this.bgm, duration, destroy);
      return true;
    }

    return false;
  }

  playSound(sound: string | AnySound, config?: object): AnySound {
    const key = typeof sound === "string" ? sound : sound.key;
    config = config ?? {};
    try {
      const keyDetails = key.split("/");
      config["volume"] = config["volume"] ?? 1;
      switch (keyDetails[0]) {
      case "level_up_fanfare":
      case "item_fanfare":
      case "minor_fanfare":
      case "heal":
      case "evolution":
      case "evolution_fanfare":
        // These sounds are loaded in as BGM, but played as sound effects
        // When these sounds are updated in updateVolume(), they are treated as BGM however because they are placed in the BGM Cache through being called by playSoundWithoutBGM()
        config["volume"] *= (this.masterVolume * this.bgmVolume);
        break;
      case "battle_anims":
      case "cry":
        config["volume"] *= (this.masterVolume * this.fieldVolume);
        //PRSFX sound files are unusually loud
        if (keyDetails[1].startsWith("PRSFX- ")) {
          config["volume"] *= 0.5;
        }
        break;
      case "ui":
        //As of, right now this applies to the "select", "menu_open", "error" sound effects
        config["volume"] *= (this.masterVolume * this.uiVolume);
        break;
      case "se":
        config["volume"] *= (this.masterVolume * this.seVolume);
        break;
      }
      this.sound.play(key, config);
      return this.sound.get(key) as AnySound;
    } catch {
      console.log(`${key} not found`);
      return sound as AnySound;
    }
  }

  playSoundWithoutBgm(soundName: string, pauseDuration?: integer): AnySound {
    this.bgmCache.add(soundName);
    const resumeBgm = this.pauseBgm();
    this.playSound(soundName);
    const sound = this.sound.get(soundName) as AnySound;
    if (this.bgmResumeTimer) {
      this.bgmResumeTimer.destroy();
    }
    if (resumeBgm) {
      this.bgmResumeTimer = this.time.delayedCall((pauseDuration || Utils.fixedInt(sound.totalDuration * 1000)), () => {
        this.resumeBgm();
        this.bgmResumeTimer = null;
      });
    }
    return sound;
  }

  getBgmLoopPoint(bgmName: string): number {
    switch (bgmName) {
    case "battle_kanto_champion": //B2W2 Kanto Champion Battle
      return 13.950;
    case "battle_johto_champion": //B2W2 Johto Champion Battle
      return 23.498;
    case "battle_hoenn_champion_g5": //B2W2 Hoenn Champion Battle
      return 11.328;
    case "battle_hoenn_champion_g6": //ORAS Hoenn Champion Battle
      return 11.762;
    case "battle_sinnoh_champion": //B2W2 Sinnoh Champion Battle
      return 12.235;
    case "battle_champion_alder": //BW Unova Champion Battle
      return 27.653;
    case "battle_champion_iris": //B2W2 Unova Champion Battle
      return 10.145;
    case "battle_kalos_champion": //XY Kalos Champion Battle
      return 10.380;
    case "battle_alola_champion": //USUM Alola Champion Battle
      return 13.025;
    case "battle_galar_champion": //SWSH Galar Champion Battle
      return 61.635;
    case "battle_champion_geeta": //SV Champion Geeta Battle
      return 37.447;
    case "battle_champion_nemona": //SV Champion Nemona Battle
      return 14.914;
    case "battle_champion_kieran": //SV Champion Kieran Battle
      return 7.206;
    case "battle_hoenn_elite": //ORAS Elite Four Battle
      return 11.350;
    case "battle_unova_elite": //BW Elite Four Battle
      return 17.730;
    case "battle_kalos_elite": //XY Elite Four Battle
      return 12.340;
    case "battle_alola_elite": //SM Elite Four Battle
      return 19.212;
    case "battle_galar_elite": //SWSH League Tournament Battle
      return 164.069;
    case "battle_paldea_elite": //SV Elite Four Battle
      return 12.770;
    case "battle_bb_elite": //SV BB League Elite Four Battle
      return 19.434;
    case "battle_final_encounter": //PMD RTDX Rayquaza's Domain
      return 19.159;
    case "battle_final": //BW Ghetsis Battle
      return 16.453;
    case "battle_kanto_gym": //B2W2 Kanto Gym Battle
      return 13.857;
    case "battle_johto_gym": //B2W2 Johto Gym Battle
      return 12.911;
    case "battle_hoenn_gym": //B2W2 Hoenn Gym Battle
      return 12.379;
    case "battle_sinnoh_gym": //B2W2 Sinnoh Gym Battle
      return 13.122;
    case "battle_unova_gym": //BW Unova Gym Battle
      return 19.145;
    case "battle_kalos_gym": //XY Kalos Gym Battle
      return 44.810;
    case "battle_galar_gym": //SWSH Galar Gym Battle
      return 171.262;
    case "battle_paldea_gym": //SV Paldea Gym Battle
      return 127.489;
    case "battle_legendary_kanto": //XY Kanto Legendary Battle
      return 32.966;
    case "battle_legendary_raikou": //HGSS Raikou Battle
      return 12.632;
    case "battle_legendary_entei": //HGSS Entei Battle
      return 2.905;
    case "battle_legendary_suicune": //HGSS Suicune Battle
      return 12.636;
    case "battle_legendary_lugia": //HGSS Lugia Battle
      return 19.770;
    case "battle_legendary_ho_oh": //HGSS Ho-oh Battle
      return 17.668;
    case "battle_legendary_regis_g5": //B2W2 Legendary Titan Battle
      return 49.500;
    case "battle_legendary_regis_g6": //ORAS Legendary Titan Battle
      return 21.130;
    case "battle_legendary_gro_kyo": //ORAS Groudon & Kyogre Battle
      return 10.547;
    case "battle_legendary_rayquaza": //ORAS Rayquaza Battle
      return 10.495;
    case "battle_legendary_deoxys": //ORAS Deoxys Battle
      return 13.333;
    case "battle_legendary_lake_trio": //ORAS Lake Guardians Battle
      return 16.887;
    case "battle_legendary_sinnoh": //ORAS Sinnoh Legendary Battle
      return 22.770;
    case "battle_legendary_dia_pal": //ORAS Dialga & Palkia Battle
      return 16.009;
    case "battle_legendary_origin_forme": //LA Origin Dialga & Palkia Battle
      return 18.961;
    case "battle_legendary_giratina": //ORAS Giratina Battle
      return 10.451;
    case "battle_legendary_arceus": //HGSS Arceus Battle
      return 9.595;
    case "battle_legendary_unova": //BW Unova Legendary Battle
      return 13.855;
    case "battle_legendary_kyurem": //BW Kyurem Battle
      return 18.314;
    case "battle_legendary_res_zek": //BW Reshiram & Zekrom Battle
      return 18.329;
    case "battle_legendary_xern_yvel": //XY Xerneas & Yveltal Battle
      return 26.468;
    case "battle_legendary_tapu": //SM Tapu Battle
      return 0.000;
    case "battle_legendary_sol_lun": //SM Solgaleo & Lunala Battle
      return 6.525;
    case "battle_legendary_ub": //SM Ultra Beast Battle
      return 9.818;
    case "battle_legendary_dusk_dawn": //USUM Dusk Mane & Dawn Wings Necrozma Battle
      return 5.211;
    case "battle_legendary_ultra_nec": //USUM Ultra Necrozma Battle
      return 10.344;
    case "battle_legendary_zac_zam": //SWSH Zacian & Zamazenta Battle
      return 11.424;
    case "battle_legendary_glas_spec": //SWSH Glastrier & Spectrier Battle
      return 12.503;
    case "battle_legendary_calyrex": //SWSH Calyrex Battle
      return 50.641;
    case "battle_legendary_riders": //SWSH Ice & Shadow Rider Calyrex Battle
      return 18.155;
    case "battle_legendary_birds_galar": //SWSH Galarian Legendary Birds Battle
      return 0.175;
    case "battle_legendary_ruinous": //SV Treasures of Ruin Battle
      return 6.333;
    case "battle_legendary_kor_mir": //SV Depths of Area Zero Battle
      return 6.442;
    case "battle_legendary_loyal_three": //SV Loyal Three Battle
      return 6.500;
    case "battle_legendary_ogerpon": //SV Ogerpon Battle
      return 14.335;
    case "battle_legendary_terapagos": //SV Terapagos Battle
      return 24.377;
    case "battle_legendary_pecharunt": //SV Pecharunt Battle
      return 6.508;
    case "battle_rival": //BW Rival Battle
      return 14.110;
    case "battle_rival_2": //BW N Battle
      return 17.714;
    case "battle_rival_3": //BW Final N Battle
      return 17.586;
    case "battle_trainer": //BW Trainer Battle
      return 13.686;
    case "battle_wild": //BW Wild Battle
      return 12.703;
    case "battle_wild_strong": //BW Strong Wild Battle
      return 13.940;
    case "end_summit": //PMD RTDX Sky Tower Summit
      return 30.025;
    case "battle_rocket_grunt": //HGSS Team Rocket Battle
      return 12.707;
    case "battle_aqua_magma_grunt": //ORAS Team Aqua & Magma Battle
      return 12.062;
    case "battle_galactic_grunt": //BDSP Team Galactic Battle
      return 13.043;
    case "battle_plasma_grunt": //BW Team Plasma Battle
      return 12.974;
    case "battle_flare_grunt": //XY Team Flare Battle
      return 4.228;
    case "battle_aether_grunt": // SM Aether Foundation Battle
      return 16.00;
    case "battle_skull_grunt": // SM Team Skull Battle
      return 20.87;
    case "battle_macro_grunt": // SWSH Trainer Battle
      return 11.56;
    case "battle_galactic_admin": //BDSP Team Galactic Admin Battle
      return 11.997;
    case "battle_skull_admin": //SM Team Skull Admin Battle
      return 15.463;
    case "battle_oleana": //SWSH Oleana Battle
      return 14.110;
    case "battle_rocket_boss": //USUM Giovanni Battle
      return 9.115;
    case "battle_aqua_magma_boss": //ORAS Archie & Maxie Battle
      return 14.847;
    case "battle_galactic_boss": //BDSP Cyrus Battle
      return 106.962;
    case "battle_plasma_boss": //B2W2 Ghetsis Battle
      return 25.624;
    case "battle_flare_boss": //XY Lysandre Battle
      return 8.085;
    case "battle_aether_boss": //SM Lusamine Battle
      return 11.33;
    case "battle_skull_boss": //SM Guzma Battle
      return 13.13;
    case "battle_macro_boss": //SWSH Rose Battle
      return 11.42;
    }

    return 0;
  }

  toggleInvert(invert: boolean): void {
    if (invert) {
      this.cameras.main.setPostPipeline(InvertPostFX);
    } else {
      this.cameras.main.removePostPipeline("InvertPostFX");
    }
  }

  /* Phase Functions */
  getCurrentPhase(): Phase | null {
    return this.currentPhase;
  }

  getStandbyPhase(): Phase | null {
    return this.standbyPhase;
  }


  /**
   * Adds a phase to the conditional queue and ensures it is executed only when the specified condition is met.
   *
   * This method allows deferring the execution of a phase until certain conditions are met, which is useful for handling
   * situations like abilities and entry hazards that depend on specific game states.
   *
   * @param {Phase} phase - The phase to be added to the conditional queue.
   * @param {() => boolean} condition - A function that returns a boolean indicating whether the phase should be executed.
   *
   */
  pushConditionalPhase(phase: Phase, condition: () => boolean): void {
    this.conditionalQueue.push([condition, phase]);
  }

  /**
   * Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false
   * @param phase {@linkcode Phase} the phase to add
   * @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue
   */
  pushPhase(phase: Phase, defer: boolean = false): void {
    (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase);
  }

  /**
   * Adds Phase to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
   * @param phase {@linkcode Phase} the phase to add
   */
  unshiftPhase(phase: Phase): void {
    if (this.phaseQueuePrependSpliceIndex === -1) {
      this.phaseQueuePrepend.push(phase);
    } else {
      this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, phase);
    }
  }

  /**
   * Clears the phaseQueue
   */
  clearPhaseQueue(): void {
    this.phaseQueue.splice(0, this.phaseQueue.length);
  }

  /**
   * Used by function unshiftPhase(), sets index to start inserting at current length instead of the end of the array, useful if phaseQueuePrepend gets longer with Phases
   */
  setPhaseQueueSplice(): void {
    this.phaseQueuePrependSpliceIndex = this.phaseQueuePrepend.length;
  }

  /**
   * Resets phaseQueuePrependSpliceIndex to -1, implies that calls to unshiftPhase will insert at end of phaseQueuePrepend
   */
  clearPhaseQueueSplice(): void {
    this.phaseQueuePrependSpliceIndex = -1;
  }

  /**
   * Is called by each Phase implementations "end()" by default
   * We dump everything from phaseQueuePrepend to the start of of phaseQueue
   * then removes first Phase and starts it
   */
  shiftPhase(): void {
    if (this.standbyPhase) {
      this.currentPhase = this.standbyPhase;
      this.standbyPhase = null;
      return;
    }

    if (this.phaseQueuePrependSpliceIndex > -1) {
      this.clearPhaseQueueSplice();
    }
    if (this.phaseQueuePrepend.length) {
      while (this.phaseQueuePrepend.length) {
        const poppedPhase = this.phaseQueuePrepend.pop();
        if (poppedPhase) {
          this.phaseQueue.unshift(poppedPhase);
        }
      }
    }
    if (!this.phaseQueue.length) {
      this.populatePhaseQueue();
      // Clear the conditionalQueue if there are no phases left in the phaseQueue
      this.conditionalQueue = [];
    }

    this.currentPhase = this.phaseQueue.shift() ?? null;

    // Check if there are any conditional phases queued
    if (this.conditionalQueue?.length) {
      // Retrieve the first conditional phase from the queue
      const conditionalPhase = this.conditionalQueue.shift();
      // Evaluate the condition associated with the phase
      if (conditionalPhase?.[0]()) {
        // If the condition is met, add the phase to the phase queue
        this.pushPhase(conditionalPhase[1]);
      } else if (conditionalPhase) {
        // If the condition is not met, re-add the phase back to the front of the conditional queue
        this.conditionalQueue.unshift(conditionalPhase);
      } else {
        console.warn("condition phase is undefined/null!", conditionalPhase);
      }
    }

    this.currentPhase?.start();
  }

  overridePhase(phase: Phase): boolean {
    if (this.standbyPhase) {
      return false;
    }

    this.standbyPhase = this.currentPhase;
    this.currentPhase = phase;
    phase.start();

    return true;
  }

  /**
   * Find a specific {@linkcode Phase} in the phase queue.
   *
   * @param phaseFilter filter function to use to find the wanted phase
   * @returns the found phase or undefined if none found
   */
  findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
    return this.phaseQueue.find(phaseFilter) as P;
  }

  tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean {
    const phaseIndex = this.phaseQueue.findIndex(phaseFilter);
    if (phaseIndex > -1) {
      this.phaseQueue[phaseIndex] = phase;
      return true;
    }
    return false;
  }

  tryRemovePhase(phaseFilter: (phase: Phase) => boolean): boolean {
    const phaseIndex = this.phaseQueue.findIndex(phaseFilter);
    if (phaseIndex > -1) {
      this.phaseQueue.splice(phaseIndex, 1);
      return true;
    }
    return false;
  }

  pushMovePhase(movePhase: MovePhase, priorityOverride?: integer): void {
    const movePriority = new Utils.IntegerHolder(priorityOverride !== undefined ? priorityOverride : movePhase.move.getMove().priority);
    applyAbAttrs(ChangeMovePriorityAbAttr, movePhase.pokemon, null, false, movePhase.move.getMove(), movePriority);
    const lowerPriorityPhase = this.phaseQueue.find(p => p instanceof MovePhase && p.move.getMove().priority < movePriority.value);
    if (lowerPriorityPhase) {
      this.phaseQueue.splice(this.phaseQueue.indexOf(lowerPriorityPhase), 0, movePhase);
    } else {
      this.pushPhase(movePhase);
    }
  }

  /**
   * Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase()
   * @param phase {@linkcode Phase} the phase to be added
   * @param targetPhase {@linkcode Phase} the type of phase to search for in phaseQueue
   * @returns boolean if a targetPhase was found and added
   */
  prependToPhase(phase: Phase, targetPhase: Constructor<Phase>): boolean {
    const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase);

    if (targetIndex !== -1) {
      this.phaseQueue.splice(targetIndex, 0, phase);
      return true;
    } else {
      this.unshiftPhase(phase);
      return false;
    }
  }

  /**
   * Adds a MessagePhase, either to PhaseQueuePrepend or nextCommandPhaseQueue
   * @param message string for MessagePhase
   * @param callbackDelay optional param for MessagePhase constructor
   * @param prompt optional param for MessagePhase constructor
   * @param promptDelay optional param for MessagePhase constructor
   * @param defer boolean for which queue to add it to, false -> add to PhaseQueuePrepend, true -> nextCommandPhaseQueue
   */
  queueMessage(message: string, callbackDelay?: integer | null, prompt?: boolean | null, promptDelay?: integer | null, defer?: boolean | null) {
    const phase = new MessagePhase(this, message, callbackDelay, prompt, promptDelay);
    if (!defer) {
      // adds to the end of PhaseQueuePrepend
      this.unshiftPhase(phase);
    } else {
      //remember that pushPhase adds it to nextCommandPhaseQueue
      this.pushPhase(phase);
    }
  }

  /**
   * Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order)
   */
  populatePhaseQueue(): void {
    if (this.nextCommandPhaseQueue.length) {
      this.phaseQueue.push(...this.nextCommandPhaseQueue);
      this.nextCommandPhaseQueue.splice(0, this.nextCommandPhaseQueue.length);
    }
    this.phaseQueue.push(new TurnInitPhase(this));
  }

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

  getWaveMoneyAmount(moneyMultiplier: number): integer {
    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): Promise<boolean> {
    if (!modifier) {
      return Promise.resolve(false);
    }
    return new Promise(resolve => {
      let success = false;
      const soundName = modifier.type.soundName;
      this.validateAchvs(ModifierAchv, modifier);
      const modifiersToRemove: PersistentModifier[] = [];
      const modifierPromises: Promise<boolean>[] = [];
      if (modifier instanceof PersistentModifier) {
        if (modifier instanceof TerastallizeModifier) {
          modifiersToRemove.push(...(this.findModifiers(m => m instanceof TerastallizeModifier && m.pokemonId === modifier.pokemonId)));
        }
        if ((modifier as PersistentModifier).add(this.modifiers, !!virtual, this)) {
          if (modifier instanceof PokemonFormChangeItemModifier || modifier instanceof TerastallizeModifier) {
            success = modifier.apply([ this.getPokemonById(modifier.pokemonId), true ]);
          }
          if (playSound && !this.sound.get(soundName)) {
            this.playSound(soundName);
          }
        } else if (!virtual) {
          const defaultModifierType = getDefaultModifierTypeForTier(modifier.type.tier);
          this.queueMessage(i18next.t("battle:itemStackFull", { fullItemName: modifier.type.name, itemName: defaultModifierType.name }), undefined, true);
          return this.addModifier(defaultModifierType.newModifier(), ignoreUpdate, playSound, false, instant).then(success => resolve(success));
        }

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

        if (!ignoreUpdate && !virtual) {
          return this.updateModifiers(true, instant).then(() => resolve(success));
        }
      } else if (modifier instanceof ConsumableModifier) {
        if (playSound && !this.sound.get(soundName)) {
          this.playSound(soundName);
        }

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

            const args: any[] = [ pokemon ];
            if (modifier instanceof PokemonHpRestoreModifier) {
              if (!(modifier as PokemonHpRestoreModifier).fainted) {
                const hpRestoreMultiplier = new Utils.IntegerHolder(1);
                this.applyModifiers(HealingBoosterModifier, true, hpRestoreMultiplier);
                args.push(hpRestoreMultiplier.value);
              } else {
                args.push(1);
              }
            } else if (modifier instanceof FusePokemonModifier) {
              args.push(this.getPokemonById(modifier.fusePokemonId) as PlayerPokemon);
            }

            if (modifier.shouldApply(args)) {
              const result = modifier.apply(args);
              if (result instanceof Promise) {
                modifierPromises.push(result.then(s => success ||= s));
              } else {
                success ||= result;
              }
            }
          }

          return Promise.allSettled([this.party.map(p => p.updateInfo(instant)), ...modifierPromises]).then(() => resolve(success));
        } else {
          const args = [ this ];
          if (modifier.shouldApply(args)) {
            const result = modifier.apply(args);
            if (result instanceof Promise) {
              return result.then(success => resolve(success));
            } else {
              success ||= result;
            }
          }
        }
      }

      resolve(success);
    });
  }

  addEnemyModifier(modifier: PersistentModifier, ignoreUpdate?: boolean, instant?: boolean): Promise<void> {
    return new Promise(resolve => {
      const modifiersToRemove: PersistentModifier[] = [];
      if (modifier instanceof TerastallizeModifier) {
        modifiersToRemove.push(...(this.findModifiers(m => m instanceof TerastallizeModifier && m.pokemonId === modifier.pokemonId, false)));
      }
      if ((modifier as PersistentModifier).add(this.enemyModifiers, false, this)) {
        if (modifier instanceof PokemonFormChangeItemModifier || modifier instanceof TerastallizeModifier) {
          modifier.apply([ this.getPokemonById(modifier.pokemonId), true ]);
        }
        for (const rm of modifiersToRemove) {
          this.removeModifier(rm, true);
        }
      }
      if (!ignoreUpdate) {
        this.updateModifiers(false, instant).then(() => resolve());
      } else {
        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} pokemon recepient in this transfer
   * @param playSound {boolean}
   * @param transferQuantity {@linkcode integer} how many items of the stack to transfer. Optional, defaults to 1
   * @param instant {boolean}
   * @param ignoreUpdate {boolean}
   * @returns true if the transfer was successful
   */
  tryTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, playSound: boolean, transferQuantity: integer = 1, instant?: boolean, ignoreUpdate?: boolean): Promise<boolean> {
    return new Promise(resolve => {
      const source = itemModifier.pokemonId ? itemModifier.getPokemon(target.scene) : null;
      const cancelled = new Utils.BooleanHolder(false);
      Utils.executeIf(!!source && source.isPlayer() !== target.isPlayer(), () => applyAbAttrs(BlockItemTheftAbAttr, source! /* checked in condition*/, cancelled)).then(() => {
        if (cancelled.value) {
          return resolve(false);
        }
        const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier;
        newItemModifier.pokemonId = target.id;
        const matchingModifier = target.scene.findModifier(m => m instanceof PokemonHeldItemModifier
                    && (m as PokemonHeldItemModifier).matchType(itemModifier) && m.pokemonId === target.id, target.isPlayer()) as PokemonHeldItemModifier;
        let removeOld = true;
        if (matchingModifier) {
          const maxStackCount = matchingModifier.getMaxStackCount(target.scene);
          if (matchingModifier.stackCount >= maxStackCount) {
            return resolve(false);
          }
          const countTaken = Math.min(transferQuantity, itemModifier.stackCount, maxStackCount - matchingModifier.stackCount);
          itemModifier.stackCount -= countTaken;
          newItemModifier.stackCount = matchingModifier.stackCount + countTaken;
          removeOld = !itemModifier.stackCount;
        } else {
          const countTaken = Math.min(transferQuantity, itemModifier.stackCount);
          itemModifier.stackCount -= countTaken;
          newItemModifier.stackCount = countTaken;
        }
        removeOld = !itemModifier.stackCount;
        if (!removeOld || !source || this.removeModifier(itemModifier, !source.isPlayer())) {
          const addModifier = () => {
            if (!matchingModifier || this.removeModifier(matchingModifier, !target.isPlayer())) {
              if (target.isPlayer()) {
                this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant).then(() => resolve(true));
              } else {
                this.addEnemyModifier(newItemModifier, ignoreUpdate, instant).then(() => resolve(true));
              }
            } else {
              resolve(false);
            }
          };
          if (source && source.isPlayer() !== target.isPlayer() && !ignoreUpdate) {
            this.updateModifiers(source.isPlayer(), instant).then(() => addModifier());
          } else {
            addModifier();
          }
          return;
        }
        resolve(false);
      });
    });
  }

  removePartyMemberModifiers(partyMemberIndex: integer): Promise<void> {
    return new Promise(resolve => {
      const pokemonId = this.getParty()[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().then(() => resolve());
    });
  }

  generateEnemyModifiers(heldModifiersConfigs?: HeldModifierConfig[][]): Promise<void> {
    return new Promise(resolve => {
      if (this.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) {
        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: integer) => {
        if (heldModifiersConfigs && i < heldModifiersConfigs.length && heldModifiersConfigs[i] && heldModifiersConfigs[i].length > 0) {
          heldModifiersConfigs[i].forEach(mt => {
            let modifier: PokemonHeldItemModifier;
            if (mt.modifier instanceof PokemonHeldItemModifierType) {
              modifier = mt.modifier.newModifier(enemyPokemon);
            } else {
              modifier = mt.modifier as PokemonHeldItemModifier;
              modifier.pokemonId = enemyPokemon.id;
            }
            const stackCount = mt.stackCount ?? 1;
            modifier.stackCount = stackCount;
            // TODO: set isTransferable
            // modifier.isTransferrable = mt.isTransferable ?? true;
            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;
          }
          const modifierChance = this.gameMode.getEnemyModifierChance(isBoss);
          let pokemonModifierChance = modifierChance;
          if (this.currentBattle.battleType === BattleType.TRAINER && this.currentBattle.trainer)
            pokemonModifierChance = Math.ceil(pokemonModifierChance * this.currentBattle.trainer.getPartyMemberModifierChanceMultiplier(i)); // eslint-disable-line
          let count = 0;
          for (let c = 0; c < chances; c++) {
            if (!Utils.randSeedInt(modifierChance)) {
              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, this));
        }
        return true;
      });
      this.updateModifiers(false).then(() => resolve());
    });
  }

  /**
    * Removes all modifiers from enemy of 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).then(() => this.updateUIPositions());
  }

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

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

  updateModifiers(player?: boolean, instant?: boolean): Promise<void> {
    if (player === undefined) {
      player = true;
    }
    return new Promise(resolve => {
      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);
        }
      }
      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.getParty() : this.getEnemyParty(), instant).then(() => {
        (player ? this.modifierBar : this.enemyModifierBar).updateModifiers(modifiers);
        if (!player) {
          this.updateUIPositions();
        }
        resolve();
      });
    });
  }

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

  removeModifier(modifier: PersistentModifier, enemy?: boolean): boolean {
    const modifiers = !enemy ? this.modifiers : this.enemyModifiers;
    const modifierIndex = modifiers.indexOf(modifier);
    if (modifierIndex > -1) {
      modifiers.splice(modifierIndex, 1);
      if (modifier instanceof PokemonFormChangeItemModifier || modifier instanceof TerastallizeModifier) {
        modifier.apply([ this.getPokemonById(modifier.pokemonId), 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: boolean = true): T[] {
    return (player ? this.modifiers : this.enemyModifiers).filter((m): m is T => m instanceof modifierType);
  }

  findModifiers(modifierFilter: ModifierPredicate, player: boolean = true): PersistentModifier[] {
    return (player ? this.modifiers : this.enemyModifiers).filter(m => (modifierFilter as ModifierPredicate)(m));
  }

  findModifier(modifierFilter: ModifierPredicate, player: boolean = true): PersistentModifier | undefined {
    return (player ? this.modifiers : this.enemyModifiers).find(m => (modifierFilter as ModifierPredicate)(m));
  }

  applyShuffledModifiers(scene: BattleScene, modifierType: Constructor<Modifier>, player: boolean = true, ...args: any[]): PersistentModifier[] {
    let modifiers = (player ? this.modifiers : this.enemyModifiers).filter(m => m instanceof modifierType && m.shouldApply(args));
    scene.executeWithSeedOffset(() => {
      const shuffleModifiers = mods => {
        if (mods.length < 1) {
          return mods;
        }
        const rand = Utils.randSeedInt(mods.length);
        return [mods[rand], ...shuffleModifiers(mods.filter((_, i) => i !== rand))];
      };
      modifiers = shuffleModifiers(modifiers);
    }, scene.currentBattle.turn << 4, scene.waveSeed);
    return this.applyModifiersInternal(modifiers, player, args);
  }

  applyModifiers(modifierType: Constructor<Modifier>, player: boolean = true, ...args: any[]): PersistentModifier[] {
    const modifiers = (player ? this.modifiers : this.enemyModifiers).filter(m => m instanceof modifierType && m.shouldApply(args));
    return this.applyModifiersInternal(modifiers, player, args);
  }

  applyModifiersInternal(modifiers: PersistentModifier[], player: boolean, args: any[]): PersistentModifier[] {
    const appliedModifiers: PersistentModifier[] = [];
    for (const modifier of modifiers) {
      if (modifier.apply(args)) {
        console.log("Applied", modifier.type.name, !player ? "(enemy)" : "");
        appliedModifiers.push(modifier);
      }
    }

    return appliedModifiers;
  }

  applyModifier(modifierType: Constructor<Modifier>, player: boolean = true, ...args: any[]): PersistentModifier | null {
    const modifiers = (player ? this.modifiers : this.enemyModifiers).filter(m => 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: boolean = false, modal: boolean = false): boolean {
    if (pokemonFormChanges.hasOwnProperty(pokemon.species.speciesId)) {

      // in case this is NECROZMA, determine which forms this
      const matchingFormChangeOpts = pokemonFormChanges[pokemon.species.speciesId].filter(fc => fc.findTrigger(formChangeTriggerType) && fc.canChange(pokemon));
      let matchingFormChange: SpeciesFormChange | null;
      if (pokemon.species.speciesId === Species.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 instanceof PlayerPokemon && !matchingFormChange.quiet) {
          phase = new FormChangePhase(this, pokemon, matchingFormChange, modal);
        } else {
          phase = new QuietFormChangePhase(this, pokemon, matchingFormChange);
        }
        if (pokemon instanceof PlayerPokemon && !matchingFormChange.quiet && modal) {
          this.overridePhase(phase);
        } else if (delayed) {
          this.pushPhase(phase);
        } else {
          this.unshiftPhase(phase);
        }
        return true;
      }
    }

    return false;
  }

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

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

  validateAchv(achv: Achv, args?: any[]): boolean {
    if (!this.gameData.achvUnlocks.hasOwnProperty(achv.id) && achv.validate(this, args)) {
      this.gameData.achvUnlocks[achv.id] = new Date().getTime();
      this.ui.achvBar.showAchv(achv);
      if (vouchers.hasOwnProperty(achv.id)) {
        this.validateVoucher(vouchers[achv.id]);
      }
      return true;
    }

    return false;
  }

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

    return false;
  }

  updateGameInfo(): void {
    const gameInfo = {
      playTime: this.sessionPlayTime ? this.sessionPlayTime : 0,
      gameMode: this.currentBattle ? this.gameMode.getName() : "Title",
      biome: this.currentBattle ? getBiomeName(this.arena.biomeType) : "",
      wave: this.currentBattle?.waveIndex || 0,
      party: this.party ? this.party.map(p => {
        return { name: p.name, level: p.level };
      }) : [],
      modeChain: this.ui?.getModeChain() ?? [],
    };
    (window as any).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[] = [];
    const playerParty = this.getParty();
    playerParty.forEach(p => {
      keys.push(p.getSpriteKey(true));
      keys.push(p.getBattleSpriteKey(true, true));
      keys.push("cry/" + p.species.getCryKey(p.formIndex));
      if (p.fusionSpecies) {
        keys.push("cry/"+p.fusionSpecies.getCryKey(p.fusionFormIndex));
      }
    });
    // enemyParty has to be operated on separately from playerParty because playerPokemon =/= enemyPokemon
    const enemyParty = this.getEnemyParty();
    enemyParty.forEach(p => {
      keys.push(p.getSpriteKey(true));
      keys.push("cry/" + p.species.getCryKey(p.formIndex));
      if (p.fusionSpecies) {
        keys.push("cry/"+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 instanceof EnemyPokemon && pokemon.isBoss() && !pokemon.formIndex && pokemon.bossSegmentIndex < 1) {
      this.fadeOutBgm(Utils.fixedInt(2000), false);
      this.ui.showDialogue(battleSpecDialogue[BattleSpec.FINAL_BOSS].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(1);
        this.setFieldScale(0.75);
        this.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger, false);
        this.currentBattle.double = true;
        const availablePartyMembers = this.getParty().filter((p) => p.isAllowedInBattle());
        if (availablePartyMembers.length > 1) {
          this.pushPhase(new ToggleDoublePositionPhase(this, true));
          if (!availablePartyMembers[1].isOnField()) {
            this.pushPhase(new SummonPhase(this, 1));
          }
        }

        this.shiftPhase();
      });
      return;
    }

    this.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 pokemonParticipantIds Participants. If none are defined, no exp will be given. To spread evenly among the party, should pass all ids of party members.
   */
  applyPartyExp(expValue: number, pokemonDefeated: boolean, useWaveIndexMultiplier?: boolean, pokemonParticipantIds?: Set<number>): void {
    const participantIds = pokemonParticipantIds ?? this.currentBattle.playerParticipantIds;
    const party = this.getParty();
    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.battleType === BattleType.MYSTERY_ENCOUNTER && 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(2);
          const machoBraceModifier = partyMember.getHeldItems().find(m => m instanceof PokemonIncrementingStatModifier);
          if (machoBraceModifier && machoBraceModifier.stackCount < machoBraceModifier.getMaxStackCount(this)) {
            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 (Overrides.XP_MULTIPLIER_OVERRIDE !== null) {
          expMultiplier = Overrides.XP_MULTIPLIER_OVERRIDE;
        }
        const pokemonExp = new Utils.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.unshiftPhase(expPartyMembers[pm].isOnField() ? new ExpPhase(this, partyMemberIndex, exp) : new ShowPartyExpBarPhase(this, partyMemberIndex, exp));
        }
      }
    }
  }

  /**
   * Loads or generates a mystery encounter
   * @param encounterType used to load session encounter when restarting game, etc.
   * @returns
   */
  getMysteryEncounter(encounterType?: MysteryEncounterType): MysteryEncounter {
    // Loading override or session encounter
    let encounter: MysteryEncounter | null;
    if (!isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_OVERRIDE) && allMysteryEncounters.hasOwnProperty(Overrides.MYSTERY_ENCOUNTER_OVERRIDE!)) {
      encounter = allMysteryEncounters[Overrides.MYSTERY_ENCOUNTER_OVERRIDE!];
    } else {
      encounter = !isNullOrUndefined(encounterType) ? allMysteryEncounters[encounterType!] : null;
    }

    // Check for queued encounters first
    if (!encounter && this.mysteryEncounterSaveData?.queuedEncounters && this.mysteryEncounterSaveData.queuedEncounters.length > 0) {
      let i = 0;
      while (i < this.mysteryEncounterSaveData.queuedEncounters.length && !!encounter) {
        const candidate = this.mysteryEncounterSaveData.queuedEncounters[i];
        const forcedChance = candidate.spawnPercent;
        if (Utils.randSeedInt(100) < forcedChance) {
          encounter = allMysteryEncounters[candidate.type];
        }

        i++;
      }
    }

    if (encounter) {
      encounter = new MysteryEncounter(encounter);
      encounter.populateDialogueTokensFromRequirements(this);
      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
    this.mysteryEncounterSaveData.encounteredEvents.forEach(seenEncounterData => {
      if (seenEncounterData.tier === MysteryEncounterTier.COMMON) {
        tierWeights[0] = tierWeights[0] - 6;
      } else if (seenEncounterData.tier === MysteryEncounterTier.GREAT) {
        tierWeights[1] = tierWeights[1] - 4;
      }
    });

    const totalWeight = tierWeights.reduce((a, b) => a + b);
    const tierValue = Utils.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 (!isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE)) {
      tier = Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE!;
    }

    let availableEncounters: MysteryEncounter[] = [];
    // New encounter should never be the same as the most recent encounter
    const previousEncounter = this.mysteryEncounterSaveData.encounteredEvents.length > 0 ? this.mysteryEncounterSaveData.encounteredEvents[this.mysteryEncounterSaveData.encounteredEvents.length - 1].type : null;
    const biomeMysteryEncounters = mysteryEncountersByBiome.get(this.arena.biomeType) ?? [];
    // 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 (encounterCandidate.encounterTier !== tier) { // Encounter is in tier
            return false;
          }
          const disabledModes = encounterCandidate.disabledGameModes;
          if (disabledModes && disabledModes.length > 0
            && disabledModes.includes(this.gameMode.modeId)) { // Encounter is enabled for game mode
            return false;
          }
          if (!encounterCandidate.meetsRequirements(this)) { // Meets encounter requirements
            return false;
          }
          if (previousEncounter !== null && encounterType === previousEncounter) { // Previous encounter was not this one
            return false;
          }
          if (this.mysteryEncounterSaveData.encounteredEvents.length > 0 && // Encounter has not exceeded max allowed encounters
            (encounterCandidate.maxAllowedEncounters && encounterCandidate.maxAllowedEncounters > 0)
            && this.mysteryEncounterSaveData.encounteredEvents.filter(e => e.type === encounterType).length >= encounterCandidate.maxAllowedEncounters) {
            return false;
          }
          return true;
        })
        .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];
    }
    encounter = availableEncounters[Utils.randSeedInt(availableEncounters.length)];
    // New encounter object to not dirty flags
    encounter = new MysteryEncounter(encounter);
    encounter.populateDialogueTokensFromRequirements(this);
    return encounter;
  }
}