From deb2035610091eedb884c60ee5f2f4cd14365f50 Mon Sep 17 00:00:00 2001
From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>
Date: Wed, 9 Oct 2024 20:30:28 +0200
Subject: [PATCH 01/15] [Beta][P2] Fix Grip Claw (#4614)

* [Beta][P2] Fix Grip Claw

* Add test for Grip Claw

* [test] improve grip claw's test readability

* PR feedback

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
---
 src/modifier/modifier.ts         |  7 ++-
 src/test/items/grip_claw.test.ts | 96 +++++++++++++++++++++++---------
 2 files changed, 74 insertions(+), 29 deletions(-)

diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts
index b658d3b5277..6c9b5db1bca 100644
--- a/src/modifier/modifier.ts
+++ b/src/modifier/modifier.ts
@@ -3084,11 +3084,12 @@ export abstract class HeldItemTransferModifier extends PokemonHeldItemModifier {
    * Steals an item from a set of target Pokemon.
    * This prioritizes high-tier held items when selecting the item to steal.
    * @param pokemon The {@linkcode Pokemon} holding this item
+   * @param target The {@linkcode Pokemon} to steal from (optional)
    * @param _args N/A
    * @returns `true` if an item was stolen; false otherwise.
    */
-  override apply(pokemon: Pokemon, ..._args: unknown[]): boolean {
-    const opponents = this.getTargets(pokemon);
+  override apply(pokemon: Pokemon, target?: Pokemon, ..._args: unknown[]): boolean {
+    const opponents = this.getTargets(pokemon, target);
 
     if (!opponents.length) {
       return false;
@@ -3187,7 +3188,7 @@ export class TurnHeldItemTransferModifier extends HeldItemTransferModifier {
  * @see {@linkcode HeldItemTransferModifier}
  */
 export class ContactHeldItemTransferChanceModifier extends HeldItemTransferModifier {
-  private chance: number;
+  public readonly chance: number;
 
   constructor(type: ModifierType, pokemonId: number, chancePercent: number, stackCount?: number) {
     super(type, pokemonId, stackCount);
diff --git a/src/test/items/grip_claw.test.ts b/src/test/items/grip_claw.test.ts
index 9d44a9e4672..2909549af87 100644
--- a/src/test/items/grip_claw.test.ts
+++ b/src/test/items/grip_claw.test.ts
@@ -1,16 +1,14 @@
 import { BattlerIndex } from "#app/battle";
-import { allMoves } from "#app/data/move";
-import { Abilities } from "#app/enums/abilities";
-import { BerryType } from "#app/enums/berry-type";
-import { Moves } from "#app/enums/moves";
-import { Species } from "#app/enums/species";
-import { MoveEndPhase } from "#app/phases/move-end-phase";
+import Pokemon from "#app/field/pokemon";
+import { ContactHeldItemTransferChanceModifier } from "#app/modifier/modifier";
+import { Abilities } from "#enums/abilities";
+import { BerryType } from "#enums/berry-type";
+import { Moves } from "#enums/moves";
+import { Species } from "#enums/species";
 import GameManager from "#test/utils/gameManager";
 import Phase from "phaser";
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
-// 20 seconds
-
 describe("Items - Grip Claw", () => {
   let phaserGame: Phaser.Game;
   let game: GameManager;
@@ -30,39 +28,85 @@ describe("Items - Grip Claw", () => {
 
     game.override
       .battleType("double")
-      .moveset([ Moves.POPULATION_BOMB, Moves.SPLASH ])
+      .moveset([ Moves.TACKLE, Moves.SPLASH, Moves.ATTRACT ])
       .startingHeldItems([
-        { name: "GRIP_CLAW", count: 5 }, // TODO: Find a way to mock the steal chance of grip claw
-        { name: "MULTI_LENS", count: 3 },
+        { name: "GRIP_CLAW", count: 1 },
       ])
       .enemySpecies(Species.SNORLAX)
-      .ability(Abilities.KLUTZ)
+      .enemyAbility(Abilities.UNNERVE)
+      .ability(Abilities.UNNERVE)
       .enemyMoveset(Moves.SPLASH)
       .enemyHeldItems([
         { name: "BERRY", type: BerryType.SITRUS, count: 2 },
         { name: "BERRY", type: BerryType.LUM, count: 2 },
       ])
-      .startingLevel(100)
       .enemyLevel(100);
 
-    vi.spyOn(allMoves[Moves.POPULATION_BOMB], "accuracy", "get").mockReturnValue(100);
   });
 
-  it(
-    "should only steal items from the attack target",
-    async () => {
-      await game.startBattle([ Species.PANSEAR, Species.ROWLET ]);
+  it("should steal items on contact and only from the attack target", async () => {
+    await game.classicMode.startBattle([ Species.FEEBAS, Species.MILOTIC ]);
 
-      const enemyPokemon = game.scene.getEnemyField();
+    const [ playerPokemon, ] = game.scene.getPlayerField();
 
-      const enemyHeldItemCt = enemyPokemon.map(p => p.getHeldItems.length);
+    const gripClaw = playerPokemon.getHeldItems()[0] as ContactHeldItemTransferChanceModifier;
+    vi.spyOn(gripClaw, "chance", "get").mockReturnValue(100);
 
-      game.move.select(Moves.POPULATION_BOMB, 0, BattlerIndex.ENEMY);
-      game.move.select(Moves.SPLASH, 1);
+    const enemyPokemon = game.scene.getEnemyField();
 
-      await game.phaseInterceptor.to(MoveEndPhase, false);
+    const playerHeldItemCount = getHeldItemCount(playerPokemon);
+    const enemy1HeldItemCount = getHeldItemCount(enemyPokemon[0]);
+    const enemy2HeldItemCount = getHeldItemCount(enemyPokemon[1]);
+    expect(enemy2HeldItemCount).toBeGreaterThan(0);
 
-      expect(enemyPokemon[1].getHeldItems.length).toBe(enemyHeldItemCt[1]);
-    }
-  );
+    game.move.select(Moves.TACKLE, 0, BattlerIndex.ENEMY_2);
+    game.move.select(Moves.SPLASH, 1);
+
+    await game.phaseInterceptor.to("BerryPhase", false);
+
+    const playerHeldItemCountAfter = getHeldItemCount(playerPokemon);
+    const enemy1HeldItemCountsAfter = getHeldItemCount(enemyPokemon[0]);
+    const enemy2HeldItemCountsAfter = getHeldItemCount(enemyPokemon[1]);
+
+    expect(playerHeldItemCountAfter).toBe(playerHeldItemCount + 1);
+    expect(enemy1HeldItemCountsAfter).toBe(enemy1HeldItemCount);
+    expect(enemy2HeldItemCountsAfter).toBe(enemy2HeldItemCount - 1);
+  });
+
+  it("should not steal items when using a targetted, non attack move", async () => {
+    await game.classicMode.startBattle([ Species.FEEBAS, Species.MILOTIC ]);
+
+    const [ playerPokemon, ] = game.scene.getPlayerField();
+
+    const gripClaw = playerPokemon.getHeldItems()[0] as ContactHeldItemTransferChanceModifier;
+    vi.spyOn(gripClaw, "chance", "get").mockReturnValue(100);
+
+    const enemyPokemon = game.scene.getEnemyField();
+
+    const playerHeldItemCount = getHeldItemCount(playerPokemon);
+    const enemy1HeldItemCount = getHeldItemCount(enemyPokemon[0]);
+    const enemy2HeldItemCount = getHeldItemCount(enemyPokemon[1]);
+    expect(enemy2HeldItemCount).toBeGreaterThan(0);
+
+    game.move.select(Moves.ATTRACT, 0, BattlerIndex.ENEMY_2);
+    game.move.select(Moves.SPLASH, 1);
+
+    await game.phaseInterceptor.to("BerryPhase", false);
+
+    const playerHeldItemCountAfter = getHeldItemCount(playerPokemon);
+    const enemy1HeldItemCountsAfter = getHeldItemCount(enemyPokemon[0]);
+    const enemy2HeldItemCountsAfter = getHeldItemCount(enemyPokemon[1]);
+
+    expect(playerHeldItemCountAfter).toBe(playerHeldItemCount);
+    expect(enemy1HeldItemCountsAfter).toBe(enemy1HeldItemCount);
+    expect(enemy2HeldItemCountsAfter).toBe(enemy2HeldItemCount);
+  });
 });
+
+/*
+ * Gets the total number of items a Pokemon holds
+ */
+function getHeldItemCount(pokemon: Pokemon) {
+  return pokemon.getHeldItems().reduce((currentTotal, item) => currentTotal + item.getStackCount(), 0);
+}
+

From d2c579cf2a2d22639ba1c3998f36e27f4e5d5b3a Mon Sep 17 00:00:00 2001
From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>
Date: Wed, 9 Oct 2024 20:32:20 +0200
Subject: [PATCH 02/15] [P2] Prevent generating Pokemon with duplicate IDs in
 daily runs (#4623)

---
 src/phases/title-phase.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts
index 115e4f640a2..58683cf8ec8 100644
--- a/src/phases/title-phase.ts
+++ b/src/phases/title-phase.ts
@@ -196,7 +196,7 @@ export class TitlePhase extends Phase {
         this.scene.gameMode = getGameMode(GameModes.DAILY);
 
         this.scene.setSeed(seed);
-        this.scene.resetSeed(1);
+        this.scene.resetSeed(0);
 
         this.scene.money = this.scene.gameMode.getStartingMoney();
 

From ffe941d235f6f4923bc5b87e0d29701a609c4bba Mon Sep 17 00:00:00 2001
From: Mumble <171087428+frutescens@users.noreply.github.com>
Date: Wed, 9 Oct 2024 12:04:13 -0700
Subject: [PATCH 03/15] [Feature][UI] Save Preview (#4410)

* Making 3 Option UI real

* idk anymore

* Revert "Making 3 Option UI real"

This reverts commit beaad44c1eb098a09cfd2d04043d878d24f494c1.

* Let's see

* Current issues - scrolling upwards and correct cursor landing

* argh

* Fixed reactive scrolling

* Adding ME handling

* set up descriptions

* Cleaned up UI i think

* stupid alder

* Added double trainer handling + changed enum name

* Apply suggestions from code review

Thank you Moka!

Co-authored-by: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>

* Arrow Visibility now depends on Session Slot hasData

* documentation

* Simplified calls to revertSessionSlot + changed function name per feedback

* Fixed scrollCursor issue.

* added comment

* Update src/ui/save-slot-select-ui-handler.ts

Co-authored-by: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>

* Fixed sound played + added better conditional

* Balance Team....

* ME related changes

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>

* Update src/data/mystery-encounters/mystery-encounter.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/data/mystery-encounters/mystery-encounter.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Sending Doubles-fix

* eslint..

---------

Co-authored-by: frutescens <info@laptop>
Co-authored-by: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
---
 src/battle-scene.ts                           |   6 +-
 .../encounters/a-trainers-test-encounter.ts   |   1 +
 .../encounters/absolute-avarice-encounter.ts  |   1 +
 .../an-offer-you-cant-refuse-encounter.ts     |   1 +
 .../encounters/berries-abound-encounter.ts    |   1 +
 .../encounters/bug-type-superfan-encounter.ts |   1 +
 .../encounters/clowning-around-encounter.ts   |   1 +
 .../encounters/dancing-lessons-encounter.ts   |   1 +
 .../encounters/dark-deal-encounter.ts         |   1 +
 .../encounters/delibirdy-encounter.ts         |   1 +
 .../department-store-sale-encounter.ts        |   1 +
 .../encounters/field-trip-encounter.ts        |   1 +
 .../encounters/fiery-fallout-encounter.ts     |   1 +
 .../encounters/fight-or-flight-encounter.ts   |   1 +
 .../encounters/fun-and-games-encounter.ts     |   1 +
 .../global-trade-system-encounter.ts          |   1 +
 .../encounters/lost-at-sea-encounter.ts       |   1 +
 .../mysterious-challengers-encounter.ts       |   1 +
 .../encounters/mysterious-chest-encounter.ts  |   1 +
 .../encounters/part-timer-encounter.ts        |   1 +
 .../encounters/safari-zone-encounter.ts       |   1 +
 .../shady-vitamin-dealer-encounter.ts         |   1 +
 .../slumbering-snorlax-encounter.ts           |   1 +
 .../teleporting-hijinks-encounter.ts          |   1 +
 .../the-expert-pokemon-breeder-encounter.ts   |   1 +
 .../the-pokemon-salesman-encounter.ts         |   1 +
 .../encounters/the-strong-stuff-encounter.ts  |   1 +
 .../the-winstrate-challenge-encounter.ts      |   1 +
 .../encounters/training-session-encounter.ts  |   1 +
 .../encounters/trash-to-treasure-encounter.ts |   1 +
 .../encounters/uncommon-breed-encounter.ts    |   1 +
 .../encounters/weird-dream-encounter.ts       |   1 +
 .../mystery-encounters/mystery-encounter.ts   |  14 +-
 src/ui/run-history-ui-handler.ts              |   3 +-
 src/ui/run-info-ui-handler.ts                 | 136 +++++++++++++++---
 src/ui/save-slot-select-ui-handler.ts         |  94 +++++++++---
 36 files changed, 246 insertions(+), 38 deletions(-)

diff --git a/src/battle-scene.ts b/src/battle-scene.ts
index cc6934f20d1..a586b565e13 100644
--- a/src/battle-scene.ts
+++ b/src/battle-scene.ts
@@ -3161,13 +3161,17 @@ export default class BattleScene extends SceneBase {
   /**
    * Loads or generates a mystery encounter
    * @param encounterType used to load session encounter when restarting game, etc.
+   * @param canBypass optional boolean to indicate that the request is coming from a function that needs to access a Mystery Encounter outside of gameplay requirements
    * @returns
    */
-  getMysteryEncounter(encounterType?: MysteryEncounterType): MysteryEncounter {
+  getMysteryEncounter(encounterType?: MysteryEncounterType, canBypass?: boolean): 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 if (canBypass) {
+      encounter = allMysteryEncounters[encounterType ?? -1];
+      return encounter;
     } else {
       encounter = !isNullOrUndefined(encounterType) ? allMysteryEncounters[encounterType] : null;
     }
diff --git a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts
index 13e187179d4..f3b886ac0ac 100644
--- a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts
+++ b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts
@@ -128,6 +128,7 @@ export const ATrainersTestEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts b/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts
index c98947a3f93..70b2d50fe99 100644
--- a/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts
+++ b/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts
@@ -166,6 +166,7 @@ export const AbsoluteAvariceEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts b/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts
index e445a8f481d..ab892ae00f2 100644
--- a/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts
+++ b/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts
@@ -64,6 +64,7 @@ export const AnOfferYouCantRefuseEncounter: MysteryEncounter =
         speaker: `${namespace}:speaker`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts
index 3e5d75727b1..095f8a8473b 100644
--- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts
+++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts
@@ -110,6 +110,7 @@ export const BerriesAboundEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts
index 20c0569c725..b5d47cf6912 100644
--- a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts
+++ b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts
@@ -276,6 +276,7 @@ export const BugTypeSuperfanEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts
index 6c028d4619a..be52ab42c9d 100644
--- a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts
+++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts
@@ -148,6 +148,7 @@ export const ClowningAroundEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts
index cb07bf06a81..0f784739777 100644
--- a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts
+++ b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts
@@ -102,6 +102,7 @@ export const DancingLessonsEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts
index fc8c8088d58..5ad6630386f 100644
--- a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts
+++ b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts
@@ -117,6 +117,7 @@ export const DarkDealEncounter: MysteryEncounter =
     .withSceneWaveRangeRequirement(30, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES[1])
     .withScenePartySizeRequirement(2, 6, true) // Must have at least 2 pokemon in party
     .withCatchAllowed(true)
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/delibirdy-encounter.ts b/src/data/mystery-encounters/encounters/delibirdy-encounter.ts
index a11dc8cbe72..5686d0f6ce5 100644
--- a/src/data/mystery-encounters/encounters/delibirdy-encounter.ts
+++ b/src/data/mystery-encounters/encounters/delibirdy-encounter.ts
@@ -84,6 +84,7 @@ export const DelibirdyEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts b/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts
index 1505768f968..10034d19263 100644
--- a/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts
+++ b/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts
@@ -51,6 +51,7 @@ export const DepartmentStoreSaleEncounter: MysteryEncounter =
       },
     ])
     .withAutoHideIntroVisuals(false)
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/field-trip-encounter.ts b/src/data/mystery-encounters/encounters/field-trip-encounter.ts
index a75e5ef6a77..bf5fb28163b 100644
--- a/src/data/mystery-encounters/encounters/field-trip-encounter.ts
+++ b/src/data/mystery-encounters/encounters/field-trip-encounter.ts
@@ -52,6 +52,7 @@ export const FieldTripEncounter: MysteryEncounter =
       },
     ])
     .withAutoHideIntroVisuals(false)
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts
index 9e7652e24ea..d44e7bae596 100644
--- a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts
+++ b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts
@@ -122,6 +122,7 @@ export const FieryFalloutEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts
index a04521839fe..380662ca817 100644
--- a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts
+++ b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts
@@ -120,6 +120,7 @@ export const FightOrFlightEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts
index 2b103e0a293..549faa01fa1 100644
--- a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts
+++ b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts
@@ -76,6 +76,7 @@ export const FunAndGamesEncounter: MysteryEncounter =
         text: `${namespace}:intro_dialogue`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts
index 7b929ea5e7b..bafc1901e5e 100644
--- a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts
+++ b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts
@@ -96,6 +96,7 @@ export const GlobalTradeSystemEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts
index 6ca131543b4..8fd46982dc1 100644
--- a/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts
+++ b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts
@@ -50,6 +50,7 @@ export const LostAtSeaEncounter: MysteryEncounter = MysteryEncounterBuilder.with
 
     return true;
   })
+  .setLocalizationKey(`${namespace}`)
   .withTitle(`${namespace}:title`)
   .withDescription(`${namespace}:description`)
   .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts
index 08536f44245..fb25976ebd8 100644
--- a/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts
+++ b/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts
@@ -125,6 +125,7 @@ export const MysteriousChallengersEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts
index 2b44f6ee33d..1eb1c4cb13e 100644
--- a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts
+++ b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts
@@ -61,6 +61,7 @@ export const MysteriousChestEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/part-timer-encounter.ts b/src/data/mystery-encounters/encounters/part-timer-encounter.ts
index 2f41aa96677..17a3a366569 100644
--- a/src/data/mystery-encounters/encounters/part-timer-encounter.ts
+++ b/src/data/mystery-encounters/encounters/part-timer-encounter.ts
@@ -69,6 +69,7 @@ export const PartTimerEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts
index d029460e617..c6b04b7aca6 100644
--- a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts
+++ b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts
@@ -54,6 +54,7 @@ export const SafariZoneEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts b/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts
index 89cb572962c..c70048ade07 100644
--- a/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts
+++ b/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts
@@ -62,6 +62,7 @@ export const ShadyVitaminDealerEncounter: MysteryEncounter =
         speaker: `${namespace}:speaker`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts b/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts
index d0b4cc13301..3a4bf465a78 100644
--- a/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts
+++ b/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts
@@ -88,6 +88,7 @@ export const SlumberingSnorlaxEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts
index 63ab178c52a..01e241f63d4 100644
--- a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts
+++ b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts
@@ -58,6 +58,7 @@ export const TeleportingHijinksEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts
index aca46f1598b..4515736b30a 100644
--- a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts
+++ b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts
@@ -196,6 +196,7 @@ export const TheExpertPokemonBreederEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts
index 2720653e654..95f359547e4 100644
--- a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts
+++ b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts
@@ -53,6 +53,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter =
         speaker: `${namespace}:speaker`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts b/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts
index d1d5b484129..7ee57d36027 100644
--- a/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts
+++ b/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts
@@ -117,6 +117,7 @@ export const TheStrongStuffEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts
index ad4f2dd8498..c7cb23fe6f8 100644
--- a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts
+++ b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts
@@ -94,6 +94,7 @@ export const TheWinstrateChallengeEncounter: MysteryEncounter =
 
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/training-session-encounter.ts b/src/data/mystery-encounters/encounters/training-session-encounter.ts
index ff993f339cb..10bb956636b 100644
--- a/src/data/mystery-encounters/encounters/training-session-encounter.ts
+++ b/src/data/mystery-encounters/encounters/training-session-encounter.ts
@@ -52,6 +52,7 @@ export const TrainingSessionEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       }
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts
index be2cd796386..c2a0426bceb 100644
--- a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts
+++ b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts
@@ -54,6 +54,7 @@ export const TrashToTreasureEncounter: MysteryEncounter =
         text: `${namespace}:intro`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts
index 51c1d5f963f..13594f273d9 100644
--- a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts
+++ b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts
@@ -125,6 +125,7 @@ export const UncommonBreedEncounter: MysteryEncounter =
       scene.time.delayedCall(500, () => scene.playSound("battle_anims/PRSFX- Spotlight2"));
       return true;
     })
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts
index 17f33c27645..6e2f8352480 100644
--- a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts
+++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts
@@ -125,6 +125,7 @@ export const WeirdDreamEncounter: MysteryEncounter =
         text: `${namespace}:intro_dialogue`,
       },
     ])
+    .setLocalizationKey(`${namespace}`)
     .withTitle(`${namespace}:title`)
     .withDescription(`${namespace}:description`)
     .withQuery(`${namespace}:query`)
diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts
index aabb0a3311a..7e175957e21 100644
--- a/src/data/mystery-encounters/mystery-encounter.ts
+++ b/src/data/mystery-encounters/mystery-encounter.ts
@@ -190,7 +190,7 @@ export default class MysteryEncounter implements IMysteryEncounter {
   secondaryPokemon?: PlayerPokemon[];
 
   // #region Post-construct / Auto-populated params
-
+  localizationKey: string;
   /**
    * Dialogue object containing all the dialogue, messages, tooltips, etc. for an encounter
    */
@@ -264,6 +264,7 @@ export default class MysteryEncounter implements IMysteryEncounter {
       Object.assign(this, encounter);
     }
     this.encounterTier = this.encounterTier ?? MysteryEncounterTier.COMMON;
+    this.localizationKey = this.localizationKey ?? "";
     this.dialogue = this.dialogue ?? {};
     this.spriteConfigs = this.spriteConfigs ? [ ...this.spriteConfigs ] : [];
     // Default max is 1 for ROGUE encounters, 2 for others
@@ -528,6 +529,7 @@ export class MysteryEncounterBuilder implements Partial<IMysteryEncounter> {
   options: [MysteryEncounterOption, MysteryEncounterOption, ...MysteryEncounterOption[]];
   enemyPartyConfigs: EnemyPartyConfig[] = [];
 
+  localizationKey: string = "";
   dialogue: MysteryEncounterDialogue = {};
   requirements: EncounterSceneRequirement[] = [];
   primaryPokemonRequirements: EncounterPokemonRequirement[] = [];
@@ -632,6 +634,16 @@ export class MysteryEncounterBuilder implements Partial<IMysteryEncounter> {
     return this.withIntroSpriteConfigs(spriteConfigs).withIntroDialogue(dialogue);
   }
 
+  /**
+   * Sets the localization key used by the encounter
+   * @param localizationKey the string used as the key
+   * @returns `this`
+   */
+  setLocalizationKey(localizationKey: string): this {
+    this.localizationKey = localizationKey;
+    return this;
+  }
+
   /**
    * OPTIONAL
    */
diff --git a/src/ui/run-history-ui-handler.ts b/src/ui/run-history-ui-handler.ts
index f4de9b21963..20de7fd832c 100644
--- a/src/ui/run-history-ui-handler.ts
+++ b/src/ui/run-history-ui-handler.ts
@@ -12,6 +12,7 @@ import { BattleType } from "../battle";
 import { RunEntry } from "../system/game-data";
 import { PlayerGender } from "#enums/player-gender";
 import { TrainerVariant } from "../field/trainer";
+import { RunDisplayMode } from "#app/ui/run-info-ui-handler";
 
 export type RunSelectCallback = (cursor: number) => void;
 
@@ -104,7 +105,7 @@ export default class RunHistoryUiHandler extends MessageUiHandler {
       if (button === Button.ACTION) {
         const cursor = this.cursor + this.scrollCursor;
         if (this.runs[cursor]) {
-          this.scene.ui.setOverlayMode(Mode.RUN_INFO, this.runs[cursor].entryData, true);
+          this.scene.ui.setOverlayMode(Mode.RUN_INFO, this.runs[cursor].entryData, RunDisplayMode.RUN_HISTORY, true);
         } else {
           return false;
         }
diff --git a/src/ui/run-info-ui-handler.ts b/src/ui/run-info-ui-handler.ts
index d5f04f90e5b..39927f8e071 100644
--- a/src/ui/run-info-ui-handler.ts
+++ b/src/ui/run-info-ui-handler.ts
@@ -5,6 +5,7 @@ import { SessionSaveData } from "../system/game-data";
 import { TextStyle, addTextObject, addBBCodeTextObject, getTextColor } from "./text";
 import { Mode } from "./ui";
 import { addWindow } from "./ui-theme";
+import { getPokeballAtlasKey } from "#app/data/pokeball";
 import * as Utils from "../utils";
 import PokemonData from "../system/pokemon-data";
 import i18next from "i18next";
@@ -22,6 +23,8 @@ import * as Modifier from "../modifier/modifier";
 import { Species } from "#enums/species";
 import { PlayerGender } from "#enums/player-gender";
 import { SettingKeyboard } from "#app/system/settings/settings-keyboard";
+import { getBiomeName } from "#app/data/balance/biomes";
+import { MysteryEncounterType } from "#enums/mystery-encounter-type";
 
 /**
  * RunInfoUiMode indicates possible overlays of RunInfoUiHandler.
@@ -34,6 +37,11 @@ enum RunInfoUiMode {
   ENDING_ART
 }
 
+export enum RunDisplayMode {
+  RUN_HISTORY,
+  SESSION_PREVIEW
+}
+
 /**
  * Some variables are protected because this UI class will most likely be extended in the future to display more information.
  * These variables will most likely be shared across 'classes' aka pages.
@@ -41,6 +49,7 @@ enum RunInfoUiMode {
  * For now, I leave as is.
  */
 export default class RunInfoUiHandler extends UiHandler {
+  protected runDisplayMode: RunDisplayMode;
   protected runInfo: SessionSaveData;
   protected isVictory: boolean;
   protected pageMode: RunInfoUiMode;
@@ -66,6 +75,7 @@ export default class RunInfoUiHandler extends UiHandler {
     // The import of the modifiersModule is loaded here to sidestep async/await issues.
     this.modifiersModule = Modifier;
     this.runContainer.setVisible(false);
+    this.scene.loadImage("encounter_exclaim", "mystery-encounters");
  	}
 
   /**
@@ -87,9 +97,15 @@ export default class RunInfoUiHandler extends UiHandler {
     this.runContainer.add(gameStatsBg);
 
     const run = args[0];
+    this.runDisplayMode = args[1];
+    if (this.runDisplayMode === RunDisplayMode.RUN_HISTORY) {
+      this.runInfo = this.scene.gameData.parseSessionData(JSON.stringify(run.entry));
+      this.isVictory = run.isVictory ?? false;
+    } else if (this.runDisplayMode === RunDisplayMode.SESSION_PREVIEW) {
+      this.runInfo = args[0];
+    }
     // Assigning information necessary for the UI's creation
-    this.runInfo = this.scene.gameData.parseSessionData(JSON.stringify(run.entry));
-    this.isVictory = run.isVictory;
+
     this.pageMode = RunInfoUiMode.MAIN;
 
     // Creates Header and adds to this.runContainer
@@ -102,7 +118,11 @@ export default class RunInfoUiHandler extends UiHandler {
     const runResultWindow = addWindow(this.scene, 0, 0, this.statsBgWidth - 11, 65);
     runResultWindow.setOrigin(0, 0);
     this.runResultContainer.add(runResultWindow);
-    this.parseRunResult();
+    if (this.runDisplayMode === RunDisplayMode.RUN_HISTORY) {
+      this.parseRunResult();
+    } else if (this.runDisplayMode === RunDisplayMode.SESSION_PREVIEW) {
+      this.parseRunStatus();
+    }
 
     // Creates Run Info Container
     this.runInfoContainer = this.scene.add.container(0, 89);
@@ -226,6 +246,66 @@ export default class RunInfoUiHandler extends UiHandler {
     this.runContainer.add(this.runResultContainer);
   }
 
+  /**
+   * This function is used when the Run Info UI is used to preview a Session.
+   * It edits {@linkcode runResultContainer}, but most importantly - does not display the negative results of a Mystery Encounter or any details of a trainer's party.
+   * Trainer Parties are replaced with their sprites, names, and their party size.
+   * Mystery Encounters contain sprites associated with MEs + the title of the specific ME.
+   */
+  private parseRunStatus() {
+    const runStatusText = addTextObject(this.scene, 6, 5, `${i18next.t("saveSlotSelectUiHandler:wave")} ${this.runInfo.waveIndex} - ${getBiomeName(this.runInfo.arena.biome)}`, TextStyle.WINDOW, { fontSize : "65px", lineSpacing: 0.1 });
+
+    const enemyContainer = this.scene.add.container(0, 0);
+    this.runResultContainer.add(enemyContainer);
+    if (this.runInfo.battleType === BattleType.WILD) {
+      if (this.runInfo.enemyParty.length === 1) {
+        this.parseWildSingleDefeat(enemyContainer);
+      } else if (this.runInfo.enemyParty.length === 2) {
+        this.parseWildDoubleDefeat(enemyContainer);
+      }
+    } else if (this.runInfo.battleType === BattleType.TRAINER) {
+      this.showTrainerSprites(enemyContainer);
+      const row_limit = 3;
+      this.runInfo.enemyParty.forEach((p, i) => {
+        const pokeball = this.scene.add.sprite(0, 0, "pb");
+        pokeball.setFrame(getPokeballAtlasKey(p.pokeball));
+        pokeball.setScale(0.5);
+        pokeball.setPosition(52 + ((i % row_limit) * 8), (i <= 2) ? 18 : 25);
+        enemyContainer.add(pokeball);
+      });
+      const trainerObj = this.runInfo.trainer.toTrainer(this.scene);
+      const RIVAL_TRAINER_ID_THRESHOLD = 375;
+      let trainerName = "";
+      if (this.runInfo.trainer.trainerType >= RIVAL_TRAINER_ID_THRESHOLD) {
+        trainerName = (trainerObj.variant === TrainerVariant.FEMALE) ? i18next.t("trainerNames:rival_female") : i18next.t("trainerNames:rival");
+      } else {
+        trainerName = trainerObj.getName(0, true);
+      }
+      const boxString = i18next.t(trainerObj.variant !== TrainerVariant.DOUBLE ? "battle:trainerAppeared" : "battle:trainerAppearedDouble", { trainerName: trainerName }).replace(/\n/g, " ");
+      const descContainer = this.scene.add.container(0, 0);
+      const textBox = addTextObject(this.scene, 0, 0, boxString, TextStyle.WINDOW, { fontSize : "35px", wordWrap: { width: 200 }});
+      descContainer.add(textBox);
+      descContainer.setPosition(52, 29);
+      this.runResultContainer.add(descContainer);
+    } else if (this.runInfo.battleType === BattleType.MYSTERY_ENCOUNTER) {
+      const encounterExclaim = this.scene.add.sprite(0, 0, "encounter_exclaim");
+      encounterExclaim.setPosition(34, 26);
+      encounterExclaim.setScale(0.65);
+      const subSprite = this.scene.add.sprite(56, -106, "pkmn__sub");
+      subSprite.setScale(0.65);
+      subSprite.setPosition(34, 46);
+      const mysteryEncounterTitle = i18next.t(this.scene.getMysteryEncounter(this.runInfo.mysteryEncounterType as MysteryEncounterType, true).localizationKey + ":title");
+      const descContainer = this.scene.add.container(0, 0);
+      const textBox = addTextObject(this.scene, 0, 0, mysteryEncounterTitle, TextStyle.WINDOW, { fontSize : "45px", wordWrap: { width: 160 }});
+      descContainer.add(textBox);
+      descContainer.setPosition(47, 37);
+      this.runResultContainer.add([ encounterExclaim, subSprite, descContainer ]);
+    }
+
+    this.runResultContainer.add(runStatusText);
+    this.runContainer.add(this.runResultContainer);
+  }
+
   /**
    * This function is called to edit an enemyContainer to represent a loss from a defeat by a wild single Pokemon battle.
    * @param enemyContainer - container holding enemy visual and level information
@@ -278,40 +358,58 @@ export default class RunInfoUiHandler extends UiHandler {
   }
 
   /**
-   * This edits a container to represent a loss from a defeat by a trainer battle.
-   * @param enemyContainer - container holding enemy visuals and level information
-   * The trainers are placed to the left of their party.
-   * Depending on the trainer icon, there may be overlap between the edges of the box or their party. (Capes...)
-   *
-   * Party Pokemon have their icons, terastalization status, and level shown.
+   * This loads the enemy sprites, positions, and scales them according to the current display mode of the RunInfo UI and then adds them to the container parameter.
+   * Used by {@linkcode parseRunStatus} and {@linkcode parseTrainerDefeat}
+   * @param enemyContainer a Phaser Container that should hold enemy sprites
    */
-  private parseTrainerDefeat(enemyContainer: Phaser.GameObjects.Container) {
+  private showTrainerSprites(enemyContainer: Phaser.GameObjects.Container) {
     // Creating the trainer sprite and adding it to enemyContainer
     const tObj = this.runInfo.trainer.toTrainer(this.scene);
-
     // Loads trainer assets on demand, as they are not loaded by default in the scene
     tObj.config.loadAssets(this.scene, this.runInfo.trainer.variant).then(() => {
       const tObjSpriteKey = tObj.config.getSpriteKey(this.runInfo.trainer.variant === TrainerVariant.FEMALE, false);
       const tObjSprite = this.scene.add.sprite(0, 5, tObjSpriteKey);
-      if (this.runInfo.trainer.variant === TrainerVariant.DOUBLE) {
+      if (this.runInfo.trainer.variant === TrainerVariant.DOUBLE && !tObj.config.doubleOnly) {
         const doubleContainer = this.scene.add.container(5, 8);
         tObjSprite.setPosition(-3, -3);
         const tObjPartnerSpriteKey = tObj.config.getSpriteKey(true, true);
         const tObjPartnerSprite = this.scene.add.sprite(5, -3, tObjPartnerSpriteKey);
         // Double Trainers have smaller sprites than Single Trainers
-        tObjPartnerSprite.setScale(0.20);
-        tObjSprite.setScale(0.20);
-        doubleContainer.add(tObjSprite);
-        doubleContainer.add(tObjPartnerSprite);
-        doubleContainer.setPosition(12, 38);
+        if (this.runDisplayMode === RunDisplayMode.RUN_HISTORY) {
+          tObjPartnerSprite.setScale(0.20);
+          tObjSprite.setScale(0.20);
+          doubleContainer.add(tObjSprite);
+          doubleContainer.add(tObjPartnerSprite);
+          doubleContainer.setPosition(12, 38);
+        } else {
+          tObjSprite.setScale(0.55);
+          tObjSprite.setPosition(-9, -3);
+          tObjPartnerSprite.setScale(0.55);
+          doubleContainer.add([ tObjSprite, tObjPartnerSprite ]);
+          doubleContainer.setPosition(28, 40);
+        }
         enemyContainer.add(doubleContainer);
       } else {
-        tObjSprite.setScale(0.35, 0.35);
-        tObjSprite.setPosition(12, 28);
+        const scale = (this.runDisplayMode === RunDisplayMode.RUN_HISTORY) ? 0.35 : 0.65;
+        const position = (this.runDisplayMode === RunDisplayMode.RUN_HISTORY) ? [ 12, 28 ] : [ 32, 36 ];
+        tObjSprite.setScale(scale, scale);
+        tObjSprite.setPosition(position[0], position[1]);
         enemyContainer.add(tObjSprite);
       }
     });
+  }
 
+  /**
+   * This edits a container to represent a loss from a defeat by a trainer battle.
+   * The trainers are placed to the left of their party.
+   * Depending on the trainer icon, there may be overlap between the edges of the box or their party. (Capes...)
+   *
+   * Party Pokemon have their icons, terastalization status, and level shown.
+   * @param enemyContainer - container holding enemy visuals and level information
+   */
+  private parseTrainerDefeat(enemyContainer: Phaser.GameObjects.Container) {
+    // Loads and adds trainer sprites to the UI
+    this.showTrainerSprites(enemyContainer);
     // Determining which Terastallize Modifier belongs to which Pokemon
     // Creates a dictionary {PokemonId: TeraShardType}
     const teraPokemon = {};
diff --git a/src/ui/save-slot-select-ui-handler.ts b/src/ui/save-slot-select-ui-handler.ts
index 89b20322a68..bd1a7dd9ac4 100644
--- a/src/ui/save-slot-select-ui-handler.ts
+++ b/src/ui/save-slot-select-ui-handler.ts
@@ -10,6 +10,7 @@ import MessageUiHandler from "./message-ui-handler";
 import { TextStyle, addTextObject } from "./text";
 import { Mode } from "./ui";
 import { addWindow } from "./ui-theme";
+import { RunDisplayMode } from "#app/ui/run-info-ui-handler";
 
 const sessionSlotCount = 5;
 
@@ -33,7 +34,7 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler {
 
   private scrollCursor: integer = 0;
 
-  private cursorObj: Phaser.GameObjects.NineSlice | null;
+  private cursorObj: Phaser.GameObjects.Container | null;
 
   private sessionSlotsContainerInitialY: number;
 
@@ -83,9 +84,11 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler {
     this.saveSlotSelectCallback = args[1] as SaveSlotSelectCallback;
 
     this.saveSlotSelectContainer.setVisible(true);
-    this.populateSessionSlots();
-    this.setScrollCursor(0);
-    this.setCursor(0);
+    this.populateSessionSlots()
+      .then(() => {
+        this.setScrollCursor(0);
+        this.setCursor(0);
+      });
 
     return true;
   }
@@ -147,21 +150,28 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler {
         success = true;
       }
     } else {
+      const cursorPosition = this.cursor + this.scrollCursor;
       switch (button) {
       case Button.UP:
         if (this.cursor) {
-          success = this.setCursor(this.cursor - 1);
+          // Check to prevent cursor from accessing a negative index
+          success = (this.cursor === 0) ? this.setCursor(this.cursor) : this.setCursor(this.cursor - 1, cursorPosition);
         } else if (this.scrollCursor) {
-          success = this.setScrollCursor(this.scrollCursor - 1);
+          success = this.setScrollCursor(this.scrollCursor - 1, cursorPosition);
         }
         break;
       case Button.DOWN:
         if (this.cursor < 2) {
-          success = this.setCursor(this.cursor + 1);
+          success = this.setCursor(this.cursor + 1, this.cursor);
         } else if (this.scrollCursor < sessionSlotCount - 3) {
-          success = this.setScrollCursor(this.scrollCursor + 1);
+          success = this.setScrollCursor(this.scrollCursor + 1, cursorPosition);
         }
         break;
+      case Button.RIGHT:
+        if (this.sessionSlots[cursorPosition].hasData && this.sessionSlots[cursorPosition].saveData) {
+          this.scene.ui.setOverlayMode(Mode.RUN_INFO, this.sessionSlots[cursorPosition].saveData, RunDisplayMode.SESSION_PREVIEW);
+          success = true;
+        }
       }
     }
 
@@ -174,10 +184,10 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler {
     return success || error;
   }
 
-  populateSessionSlots() {
+  async populateSessionSlots() {
     for (let s = 0; s < sessionSlotCount; s++) {
       const sessionSlot = new SessionSlot(this.scene, s);
-      sessionSlot.load();
+      await sessionSlot.load();
       this.scene.add.existing(sessionSlot);
       this.sessionSlotsContainer.add(sessionSlot);
       this.sessionSlots.push(sessionSlot);
@@ -198,25 +208,74 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler {
     this.saveSlotSelectMessageBoxContainer.setVisible(!!text?.length);
   }
 
-  setCursor(cursor: integer): boolean {
+  /**
+   * setCursor takes user navigation as an input and positions the cursor accordingly
+   * @param cursor the index provided to the cursor
+   * @param prevCursor the previous index occupied by the cursor - optional
+   * @returns `true` if the cursor position has changed | `false` if it has not
+   */
+  override setCursor(cursor: integer, prevCursor?: integer): boolean {
     const changed = super.setCursor(cursor);
 
     if (!this.cursorObj) {
-      this.cursorObj = this.scene.add.nineslice(0, 0, "select_cursor_highlight_thick", undefined, 296, 44, 6, 6, 6, 6);
-      this.cursorObj.setOrigin(0, 0);
+      this.cursorObj = this.scene.add.container(0, 0);
+      const cursorBox = this.scene.add.nineslice(0, 0, "select_cursor_highlight_thick", undefined, 296, 44, 6, 6, 6, 6);
+      const rightArrow = this.scene.add.image(0, 0, "cursor");
+      rightArrow.setPosition(160, 0);
+      rightArrow.setName("rightArrow");
+      this.cursorObj.add([ cursorBox, rightArrow ]);
       this.sessionSlotsContainer.add(this.cursorObj);
     }
-    this.cursorObj.setPosition(4, 4 + (cursor + this.scrollCursor) * 56);
+    const cursorPosition = cursor + this.scrollCursor;
+    const cursorIncrement = cursorPosition * 56;
+    if (this.sessionSlots[cursorPosition] && this.cursorObj) {
+      const hasData = this.sessionSlots[cursorPosition].hasData;
+      // If the session slot lacks session data, it does not move from its default, central position.
+      // Only session slots with session data will move leftwards and have a visible arrow.
+      if (!hasData) {
+        this.cursorObj.setPosition(151, 26 + cursorIncrement);
+        this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement);
+      } else {
+        this.cursorObj.setPosition(145, 26 + cursorIncrement);
+        this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement);
+      }
+      this.setArrowVisibility(hasData);
+    }
+    if (!Utils.isNullOrUndefined(prevCursor)) {
+      this.revertSessionSlot(prevCursor);
+    }
 
     return changed;
   }
 
-  setScrollCursor(scrollCursor: integer): boolean {
+  /**
+   * Helper function that resets the session slot position to its default central position
+   * @param prevCursor the previous location of the cursor
+   */
+  revertSessionSlot(prevCursor: integer): void {
+    const sessionSlot = this.sessionSlots[prevCursor];
+    if (sessionSlot) {
+      sessionSlot.setPosition(0, prevCursor * 56);
+    }
+  }
+
+  /**
+   * Helper function that checks if the session slot involved holds data or not
+   * @param hasData `true` if session slot contains data | 'false' if not
+   */
+  setArrowVisibility(hasData: boolean): void {
+    if (this.cursorObj) {
+      const rightArrow = this.cursorObj?.getByName("rightArrow") as Phaser.GameObjects.Image;
+      rightArrow.setVisible(hasData);
+    }
+  }
+
+  setScrollCursor(scrollCursor: integer, priorCursor?: integer): boolean {
     const changed = scrollCursor !== this.scrollCursor;
 
     if (changed) {
       this.scrollCursor = scrollCursor;
-      this.setCursor(this.cursor);
+      this.setCursor(this.cursor, priorCursor);
       this.scene.tweens.add({
         targets: this.sessionSlotsContainer,
         y: this.sessionSlotsContainerInitialY - 56 * scrollCursor,
@@ -254,6 +313,8 @@ class SessionSlot extends Phaser.GameObjects.Container {
   public hasData: boolean;
   private loadingLabel: Phaser.GameObjects.Text;
 
+  public saveData: SessionSaveData;
+
   constructor(scene: BattleScene, slotId: integer) {
     super(scene, 0, slotId * 56);
 
@@ -337,6 +398,7 @@ class SessionSlot extends Phaser.GameObjects.Container {
           return;
         }
         this.hasData = true;
+        this.saveData = sessionData;
         await this.setupWithData(sessionData);
         resolve(true);
       });

From f180b6070e8f61ca06fc50a12665bab961070653 Mon Sep 17 00:00:00 2001
From: flx-sta <50131232+flx-sta@users.noreply.github.com>
Date: Wed, 9 Oct 2024 13:01:49 -0700
Subject: [PATCH 04/15] [Qol] Load i18n en locales during tests (#4553)

* add: i18n backend support

the backend is being supported by using msw which will import the correct file from the local locales folder

* fix: tests to no longer rely on static i18n keys

* Update src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/test/ui/type-hints.test.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts

Co-authored-by: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>

* Fix typos

Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com>

* Fix linting

* update locales submodule

update reference to `56eeb809eb5a2de40cfc5bc6128a78bef14deea9` (from `3ccef8472dd7cc7c362538489954cb8fdad27e5f`)

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com>
Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com>
---
 global.d.ts                                   | 14 +++
 src/test/abilities/ability_timing.test.ts     | 10 +-
 src/test/items/toxic_orb.test.ts              | 15 ++-
 .../a-trainers-test-encounter.test.ts         |  4 +-
 .../absolute-avarice-encounter.test.ts        |  3 +-
 ...an-offer-you-cant-refuse-encounter.test.ts |  5 +-
 .../encounters/field-trip-encounter.test.ts   | 32 +++----
 .../fiery-fallout-encounter.test.ts           |  3 +-
 .../encounters/lost-at-sea-encounter.test.ts  |  5 +-
 .../teleporting-hijinks-encounter.test.ts     | 33 +++----
 .../phases/mystery-encounter-phase.test.ts    |  7 +-
 src/test/system/game_data.test.ts             |  5 +-
 src/test/ui/starter-select.test.ts            | 91 ++++++++++---------
 src/test/ui/type-hints.test.ts                |  7 +-
 src/test/vitest.setup.ts                      | 58 +++++++-----
 15 files changed, 166 insertions(+), 126 deletions(-)
 create mode 100644 global.d.ts

diff --git a/global.d.ts b/global.d.ts
new file mode 100644
index 00000000000..f4dfa7d4cb2
--- /dev/null
+++ b/global.d.ts
@@ -0,0 +1,14 @@
+import type { SetupServerApi } from "msw/node";
+
+export {};
+
+declare global {
+  /**
+   * Only used in testing.
+   * Can technically be undefined/null but for ease of use we are going to assume it is always defined.
+   * Used to load i18n files exclusively.
+   * 
+   * To set up your own server in a test see `game_data.test.ts`
+   */
+  var i18nServer: SetupServerApi;
+}
diff --git a/src/test/abilities/ability_timing.test.ts b/src/test/abilities/ability_timing.test.ts
index 1472f9eb429..e3264c2c1a8 100644
--- a/src/test/abilities/ability_timing.test.ts
+++ b/src/test/abilities/ability_timing.test.ts
@@ -1,13 +1,13 @@
 import { BattleStyle } from "#app/enums/battle-style";
 import { CommandPhase } from "#app/phases/command-phase";
 import { TurnInitPhase } from "#app/phases/turn-init-phase";
-import i18next, { initI18n } from "#app/plugins/i18n";
+import i18next from "#app/plugins/i18n";
 import { Mode } from "#app/ui/ui";
 import { Abilities } from "#enums/abilities";
 import { Species } from "#enums/species";
 import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
-import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
 
 describe("Ability Timing", () => {
@@ -32,11 +32,10 @@ describe("Ability Timing", () => {
       .enemySpecies(Species.MAGIKARP)
       .enemyAbility(Abilities.INTIMIDATE)
       .ability(Abilities.BALL_FETCH);
+    vi.spyOn(i18next, "t");
   });
 
   it("should trigger after switch check", async () => {
-    initI18n();
-    i18next.changeLanguage("en");
     game.settings.battleStyle = BattleStyle.SWITCH;
     await game.classicMode.runToSummon([ Species.EEVEE, Species.FEEBAS ]);
 
@@ -46,7 +45,6 @@ describe("Ability Timing", () => {
     }, () => game.isCurrentPhase(CommandPhase) || game.isCurrentPhase(TurnInitPhase));
 
     await game.phaseInterceptor.to("MessagePhase");
-    const message = game.textInterceptor.getLatestMessage();
-    expect(message).toContain("battle:statFell");
+    expect(i18next.t).toHaveBeenCalledWith("battle:statFell", expect.objectContaining({ count: 1 }));
   }, 5000);
 });
diff --git a/src/test/items/toxic_orb.test.ts b/src/test/items/toxic_orb.test.ts
index 35d6e77b209..a83fd3655e5 100644
--- a/src/test/items/toxic_orb.test.ts
+++ b/src/test/items/toxic_orb.test.ts
@@ -2,13 +2,13 @@ import { StatusEffect } from "#app/data/status-effect";
 import { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
 import { MessagePhase } from "#app/phases/message-phase";
 import { TurnEndPhase } from "#app/phases/turn-end-phase";
-import i18next, { initI18n } from "#app/plugins/i18n";
+import i18next from "#app/plugins/i18n";
 import { Abilities } from "#enums/abilities";
 import { Moves } from "#enums/moves";
 import { Species } from "#enums/species";
 import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
-import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
 
 describe("Items - Toxic orb", () => {
@@ -39,11 +39,11 @@ describe("Items - Toxic orb", () => {
     game.override.startingHeldItems([{
       name: "TOXIC_ORB",
     }]);
+
+    vi.spyOn(i18next, "t");
   });
 
   it("TOXIC ORB", async () => {
-    initI18n();
-    i18next.changeLanguage("en");
     const moveToUse = Moves.GROWTH;
     await game.startBattle([
       Species.MIGHTYENA,
@@ -57,11 +57,10 @@ describe("Items - Toxic orb", () => {
     await game.phaseInterceptor.runFrom(EnemyCommandPhase).to(TurnEndPhase);
     // Toxic orb should trigger here
     await game.phaseInterceptor.run(MessagePhase);
-    const message = game.textInterceptor.getLatestMessage();
-    expect(message).toContain("statusEffect:toxic.obtainSource");
+    expect(i18next.t).toHaveBeenCalledWith("statusEffect:toxic.obtainSource", expect.anything());
+
     await game.phaseInterceptor.run(MessagePhase);
-    const message2 = game.textInterceptor.getLatestMessage();
-    expect(message2).toBe("statusEffect:toxic.activation");
+    expect(i18next.t).toHaveBeenCalledWith("statusEffect:toxic.activation", expect.anything());
     expect(game.scene.getParty()[0].status!.effect).toBe(StatusEffect.TOXIC);
   }, 20000);
 });
diff --git a/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts b/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts
index f24800eaa71..b1aa378d82a 100644
--- a/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts
@@ -16,6 +16,7 @@ import { EggTier } from "#enums/egg-type";
 import { CommandPhase } from "#app/phases/command-phase";
 import { SelectModifierPhase } from "#app/phases/select-modifier-phase";
 import { PartyHealPhase } from "#app/phases/party-heal-phase";
+import i18next from "i18next";
 
 const namespace = "mysteryEncounters/aTrainersTest";
 const defaultParty = [ Species.LAPRAS, Species.GENGAR, Species.ABRA ];
@@ -106,7 +107,8 @@ describe("A Trainer's Test - Mystery Encounter", () => {
       expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name);
       expect(enemyField.length).toBe(1);
       expect(scene.currentBattle.trainer).toBeDefined();
-      expect([ "trainerNames:buck", "trainerNames:cheryl", "trainerNames:marley", "trainerNames:mira", "trainerNames:riley" ].includes(scene.currentBattle.trainer!.config.name)).toBeTruthy();
+      expect([ i18next.t("trainerNames:buck"), i18next.t("trainerNames:cheryl"), i18next.t("trainerNames:marley"), i18next.t("trainerNames:mira"), i18next.t("trainerNames:riley") ]
+        .map(name => name.toLowerCase()).includes(scene.currentBattle.trainer!.config.name)).toBeTruthy();
       expect(enemyField[0]).toBeDefined();
     });
 
diff --git a/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts b/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts
index 99a835cb6ae..a72a9fbb5a3 100644
--- a/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/absolute-avarice-encounter.test.ts
@@ -16,6 +16,7 @@ import { Moves } from "#enums/moves";
 import { CommandPhase } from "#app/phases/command-phase";
 import { MovePhase } from "#app/phases/move-phase";
 import { SelectModifierPhase } from "#app/phases/select-modifier-phase";
+import i18next from "i18next";
 
 const namespace = "mysteryEncounters/absoluteAvarice";
 const defaultParty = [ Species.LAPRAS, Species.GENGAR, Species.ABRA ];
@@ -146,7 +147,7 @@ describe("Absolute Avarice - Mystery Encounter", () => {
         const pokemonId = partyPokemon.id;
         const pokemonItems = scene.findModifiers(m => m instanceof PokemonHeldItemModifier
           && (m as PokemonHeldItemModifier).pokemonId === pokemonId, true) as PokemonHeldItemModifier[];
-        const revSeed = pokemonItems.find(i => i.type.name === "modifierType:ModifierType.REVIVER_SEED.name");
+        const revSeed = pokemonItems.find(i => i.type.name === i18next.t("modifierType:ModifierType.REVIVER_SEED.name"));
         expect(revSeed).toBeDefined;
         expect(revSeed?.stackCount).toBe(1);
       }
diff --git a/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts b/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts
index 77d5a842b47..9883b4332b9 100644
--- a/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/an-offer-you-cant-refuse-encounter.test.ts
@@ -17,6 +17,7 @@ import { getPokemonSpecies } from "#app/data/pokemon-species";
 import { Moves } from "#enums/moves";
 import { ShinyRateBoosterModifier } from "#app/modifier/modifier";
 import { SelectModifierPhase } from "#app/phases/select-modifier-phase";
+import i18next from "i18next";
 
 const namespace = "mysteryEncounters/anOfferYouCantRefuse";
 /** Gyarados for Indimidate */
@@ -93,8 +94,8 @@ describe("An Offer You Can't Refuse - Mystery Encounter", () => {
 
     expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.strongestPokemon).toBeDefined();
     expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.price).toBeDefined();
-    expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.option2PrimaryAbility).toBe("ability:intimidate.name");
-    expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.moveOrAbility).toBe("ability:intimidate.name");
+    expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.option2PrimaryAbility).toBe(i18next.t("ability:intimidate.name"));
+    expect(AnOfferYouCantRefuseEncounter.dialogueTokens?.moveOrAbility).toBe(i18next.t("ability:intimidate.name"));
     expect(AnOfferYouCantRefuseEncounter.misc.pokemon instanceof PlayerPokemon).toBeTruthy();
     expect(AnOfferYouCantRefuseEncounter.misc?.price?.toString()).toBe(AnOfferYouCantRefuseEncounter.dialogueTokens?.price);
     expect(onInitResult).toBe(true);
diff --git a/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts b/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts
index 232bad3c2b8..a6f925274c3 100644
--- a/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/field-trip-encounter.test.ts
@@ -103,11 +103,11 @@ describe("Field Trip - Mystery Encounter", () => {
       expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT);
       const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler;
       expect(modifierSelectHandler.options.length).toEqual(5);
-      expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_attack");
-      expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_defense");
-      expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_speed");
-      expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe("modifierType:ModifierType.DIRE_HIT.name");
-      expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe("modifierType:ModifierType.RARER_CANDY.name");
+      expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_attack"));
+      expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_defense"));
+      expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_speed"));
+      expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.DIRE_HIT.name"));
+      expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.RARER_CANDY.name"));
     });
 
     it("should leave encounter without battle", async () => {
@@ -150,11 +150,11 @@ describe("Field Trip - Mystery Encounter", () => {
       expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT);
       const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler;
       expect(modifierSelectHandler.options.length).toEqual(5);
-      expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_sp_atk");
-      expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_sp_def");
-      expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_speed");
-      expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe("modifierType:ModifierType.DIRE_HIT.name");
-      expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe("modifierType:ModifierType.RARER_CANDY.name");
+      expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_sp_atk"));
+      expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_sp_def"));
+      expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_speed"));
+      expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.DIRE_HIT.name"));
+      expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.RARER_CANDY.name"));
     });
 
     it("should leave encounter without battle", async () => {
@@ -198,12 +198,12 @@ describe("Field Trip - Mystery Encounter", () => {
       expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT);
       const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler;
       expect(modifierSelectHandler.options.length).toEqual(5);
-      expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_accuracy");
-      expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe("modifierType:TempStatStageBoosterItem.x_speed");
-      expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe("modifierType:ModifierType.AddPokeballModifierType.name");
-      expect(i18next.t).toHaveBeenCalledWith("modifierType:ModifierType.AddPokeballModifierType.name", expect.objectContaining({ modifierCount: 5 }));
-      expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe("modifierType:ModifierType.IV_SCANNER.name");
-      expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe("modifierType:ModifierType.RARER_CANDY.name");
+      expect(modifierSelectHandler.options[0].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_accuracy"));
+      expect(modifierSelectHandler.options[1].modifierTypeOption.type.name).toBe(i18next.t("modifierType:TempStatStageBoosterItem.x_speed"));
+      expect(modifierSelectHandler.options[2].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.AddPokeballModifierType.name", { modifierCount: 5, pokeballName: i18next.t("pokeball:greatBall") }));
+      expect(i18next.t).toHaveBeenCalledWith(("modifierType:ModifierType.AddPokeballModifierType.name"), expect.objectContaining({ modifierCount: 5 }));
+      expect(modifierSelectHandler.options[3].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.IV_SCANNER.name"));
+      expect(modifierSelectHandler.options[4].modifierTypeOption.type.name).toBe(i18next.t("modifierType:ModifierType.RARER_CANDY.name"));
     });
 
     it("should leave encounter without battle", async () => {
diff --git a/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts b/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts
index 3d2533c0817..a4f303d121f 100644
--- a/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/fiery-fallout-encounter.test.ts
@@ -22,6 +22,7 @@ import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils";
 import { CommandPhase } from "#app/phases/command-phase";
 import { MovePhase } from "#app/phases/move-phase";
 import { SelectModifierPhase } from "#app/phases/select-modifier-phase";
+import i18next from "i18next";
 
 const namespace = "mysteryEncounters/fieryFallout";
 /** Arcanine and Ninetails for 2 Fire types. Lapras, Gengar, Abra for burnable mon. */
@@ -205,7 +206,7 @@ describe("Fiery Fallout - Mystery Encounter", () => {
 
       const burnablePokemon = party.filter((pkm) => pkm.isAllowedInBattle() && !pkm.getTypes().includes(Type.FIRE));
       const notBurnablePokemon = party.filter((pkm) => !pkm.isAllowedInBattle() || pkm.getTypes().includes(Type.FIRE));
-      expect(scene.currentBattle.mysteryEncounter?.dialogueTokens["burnedPokemon"]).toBe("pokemon:gengar");
+      expect(scene.currentBattle.mysteryEncounter?.dialogueTokens["burnedPokemon"]).toBe(i18next.t("pokemon:gengar"));
       burnablePokemon.forEach((pkm) => {
         expect(pkm.hp, `${pkm.name} should have received 20% damage: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp() - Math.floor(pkm.getMaxHp() * 0.2));
       });
diff --git a/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts b/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts
index 456c18c572d..dec14d46cc8 100644
--- a/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts
@@ -14,6 +14,7 @@ import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils";
 import BattleScene from "#app/battle-scene";
 import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases";
 import { PartyExpPhase } from "#app/phases/party-exp-phase";
+import i18next from "i18next";
 
 
 const namespace = "mysteryEncounters/lostAtSea";
@@ -86,8 +87,8 @@ describe("Lost at Sea - Mystery Encounter", () => {
     const onInitResult = onInit!(scene);
 
     expect(LostAtSeaEncounter.dialogueTokens?.damagePercentage).toBe("25");
-    expect(LostAtSeaEncounter.dialogueTokens?.option1RequiredMove).toBe("move:surf.name");
-    expect(LostAtSeaEncounter.dialogueTokens?.option2RequiredMove).toBe("move:fly.name");
+    expect(LostAtSeaEncounter.dialogueTokens?.option1RequiredMove).toBe(i18next.t("move:surf.name"));
+    expect(LostAtSeaEncounter.dialogueTokens?.option2RequiredMove).toBe(i18next.t("move:fly.name"));
     expect(onInitResult).toBe(true);
   });
 
diff --git a/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts b/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts
index 2411752baa7..02375d83b98 100644
--- a/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/teleporting-hijinks-encounter.test.ts
@@ -1,21 +1,22 @@
-import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters";
-import { Biome } from "#app/enums/biome";
-import { MysteryEncounterType } from "#app/enums/mystery-encounter-type";
-import { Species } from "#app/enums/species";
-import GameManager from "#app/test/utils/gameManager";
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils";
 import BattleScene from "#app/battle-scene";
+import { TeleportingHijinksEncounter } from "#app/data/mystery-encounters/encounters/teleporting-hijinks-encounter";
+import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters";
+import { Abilities } from "#enums/abilities";
+import { Biome } from "#enums/biome";
+import { MysteryEncounterType } from "#enums/mystery-encounter-type";
+import { Species } from "#enums/species";
+import { CommandPhase } from "#app/phases/command-phase";
+import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases";
+import { SelectModifierPhase } from "#app/phases/select-modifier-phase";
+import GameManager from "#test/utils/gameManager";
+import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler";
+import { Mode } from "#app/ui/ui";
 import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode";
 import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
+import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounter-test-utils";
 import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils";
-import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases";
-import { CommandPhase } from "#app/phases/command-phase";
-import { TeleportingHijinksEncounter } from "#app/data/mystery-encounters/encounters/teleporting-hijinks-encounter";
-import { SelectModifierPhase } from "#app/phases/select-modifier-phase";
-import { Mode } from "#app/ui/ui";
-import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler";
-import { Abilities } from "#app/enums/abilities";
+import i18next from "i18next";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
 const namespace = "mysteryEncounters/teleportingHijinks";
 const defaultParty = [ Species.LAPRAS, Species.GENGAR, Species.ABRA ];
@@ -300,8 +301,8 @@ describe("Teleporting Hijinks - Mystery Encounter", () => {
 
       expect(scene.ui.getMode()).to.equal(Mode.MODIFIER_SELECT);
       const modifierSelectHandler = scene.ui.handlers.find(h => h instanceof ModifierSelectUiHandler) as ModifierSelectUiHandler;
-      expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === "modifierType:AttackTypeBoosterItem.metal_coat")).toBe(true);
-      expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === "modifierType:AttackTypeBoosterItem.magnet")).toBe(true);
+      expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === i18next.t("modifierType:AttackTypeBoosterItem.metal_coat"))).toBe(true);
+      expect(modifierSelectHandler.options.some(opt => opt.modifierTypeOption.type.name === i18next.t("modifierType:AttackTypeBoosterItem.magnet"))).toBe(true);
     });
   });
 });
diff --git a/src/test/phases/mystery-encounter-phase.test.ts b/src/test/phases/mystery-encounter-phase.test.ts
index 4468045756b..32e31ce1c94 100644
--- a/src/test/phases/mystery-encounter-phase.test.ts
+++ b/src/test/phases/mystery-encounter-phase.test.ts
@@ -9,6 +9,7 @@ import MysteryEncounterUiHandler from "#app/ui/mystery-encounter-ui-handler";
 import { MysteryEncounterType } from "#enums/mystery-encounter-type";
 import MessageUiHandler from "#app/ui/message-ui-handler";
 import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
+import i18next from "i18next";
 
 describe("Mystery Encounter Phases", () => {
   let phaserGame: Phaser.Game;
@@ -78,9 +79,9 @@ describe("Mystery Encounter Phases", () => {
       expect(ui.getMode()).toBe(Mode.MESSAGE);
       expect(ui.showDialogue).toHaveBeenCalledTimes(1);
       expect(ui.showText).toHaveBeenCalledTimes(2);
-      expect(ui.showDialogue).toHaveBeenCalledWith("battle:mysteryEncounterAppeared", "???", null, expect.any(Function));
-      expect(ui.showText).toHaveBeenCalledWith("mysteryEncounters/mysteriousChallengers:intro", null, expect.any(Function), 750, true);
-      expect(ui.showText).toHaveBeenCalledWith("mysteryEncounters/mysteriousChallengers:option.selected", null, expect.any(Function), 300, true);
+      expect(ui.showDialogue).toHaveBeenCalledWith(i18next.t("battle:mysteryEncounterAppeared"),  "???", null, expect.any(Function));
+      expect(ui.showText).toHaveBeenCalledWith(i18next.t("mysteryEncounters/mysteriousChallengers:intro"), null, expect.any(Function), 750, true);
+      expect(ui.showText).toHaveBeenCalledWith(i18next.t("mysteryEncounters/mysteriousChallengers:option.selected"), null, expect.any(Function), 300, true);
     });
   });
 
diff --git a/src/test/system/game_data.test.ts b/src/test/system/game_data.test.ts
index 5eb4dea3910..fcb7e9067a3 100644
--- a/src/test/system/game_data.test.ts
+++ b/src/test/system/game_data.test.ts
@@ -11,13 +11,15 @@ import * as account from "../../account";
 
 const apiBase = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8001";
 
-export const server = setupServer();
+/** We need a custom server. For some reasons I can't extend the listeners of {@linkcode global.i18nServer} with {@linkcode global.i18nServer.use} */
+const server = setupServer();
 
 describe("System - Game Data", () => {
   let phaserGame: Phaser.Game;
   let game: GameManager;
 
   beforeAll(() => {
+    global.i18nServer.close();
     server.listen();
     phaserGame = new Phaser.Game({
       type: Phaser.HEADLESS,
@@ -26,6 +28,7 @@ describe("System - Game Data", () => {
 
   afterAll(() => {
     server.close();
+    global.i18nServer.listen();
   });
 
   beforeEach(() => {
diff --git a/src/test/ui/starter-select.test.ts b/src/test/ui/starter-select.test.ts
index dd0761be392..94370ca1b74 100644
--- a/src/test/ui/starter-select.test.ts
+++ b/src/test/ui/starter-select.test.ts
@@ -14,6 +14,7 @@ import { Abilities } from "#enums/abilities";
 import { Button } from "#enums/buttons";
 import { Species } from "#enums/species";
 import GameManager from "#test/utils/gameManager";
+import i18next from "i18next";
 import Phaser from "phaser";
 import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
 
@@ -66,11 +67,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -127,11 +128,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -191,11 +192,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -254,11 +255,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -315,11 +316,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -376,11 +377,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -436,11 +437,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     await new Promise<void>((resolve) => {
@@ -496,11 +497,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     let starterSelectUiHandler: StarterSelectUiHandler;
@@ -561,11 +562,11 @@ describe("UI - Starter select", () => {
         resolve();
       });
     });
-    expect(options.some(option => option.label === "starterSelectUiHandler:addToParty")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:toggleIVs")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:manageMoves")).toBe(true);
-    expect(options.some(option => option.label === "starterSelectUiHandler:useCandies")).toBe(true);
-    expect(options.some(option => option.label === "menu:cancel")).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:addToParty"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:toggleIVs"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:manageMoves"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("starterSelectUiHandler:useCandies"))).toBe(true);
+    expect(options.some(option => option.label === i18next.t("menu:cancel"))).toBe(true);
     optionSelectUiHandler?.processInput(Button.ACTION);
 
     let starterSelectUiHandler: StarterSelectUiHandler | undefined;
diff --git a/src/test/ui/type-hints.test.ts b/src/test/ui/type-hints.test.ts
index 450f43f1263..2977262dda7 100644
--- a/src/test/ui/type-hints.test.ts
+++ b/src/test/ui/type-hints.test.ts
@@ -7,7 +7,8 @@ import { Mode } from "#app/ui/ui";
 import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
 import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
-import MockText from "../utils/mocks/mocksContainer/mockText";
+import MockText from "#test/utils/mocks/mocksContainer/mockText";
+import i18next from "i18next";
 
 describe("UI - Type Hints", () => {
   let phaserGame: Phaser.Game;
@@ -53,7 +54,7 @@ describe("UI - Type Hints", () => {
       const movesContainer = ui.getByName<Phaser.GameObjects.Container>(FightUiHandler.MOVES_CONTAINER_NAME);
       const dragonClawText = movesContainer
         .getAll<Phaser.GameObjects.Text>()
-        .find((text) => text.text === "move:dragonClaw.name")! as unknown as MockText;
+        .find((text) => text.text === i18next.t("move:dragonClaw.name"))! as unknown as MockText;
 
       expect.soft(dragonClawText.color).toBe("#929292");
       ui.getHandler().processInput(Button.ACTION);
@@ -78,7 +79,7 @@ describe("UI - Type Hints", () => {
       const movesContainer = ui.getByName<Phaser.GameObjects.Container>(FightUiHandler.MOVES_CONTAINER_NAME);
       const growlText = movesContainer
         .getAll<Phaser.GameObjects.Text>()
-        .find((text) => text.text === "move:growl.name")! as unknown as MockText;
+        .find((text) => text.text === i18next.t("move:growl.name"))! as unknown as MockText;
 
       expect.soft(growlText.color).toBe(undefined);
       ui.getHandler().processInput(Button.ACTION);
diff --git a/src/test/vitest.setup.ts b/src/test/vitest.setup.ts
index 0d67d6787c4..8438f607db2 100644
--- a/src/test/vitest.setup.ts
+++ b/src/test/vitest.setup.ts
@@ -4,16 +4,17 @@ import { initLoggedInUser } from "#app/account";
 import { initAbilities } from "#app/data/ability";
 import { initBiomes } from "#app/data/balance/biomes";
 import { initEggMoves } from "#app/data/balance/egg-moves";
+import { initPokemonPrevolutions } from "#app/data/balance/pokemon-evolutions";
 import { initMoves } from "#app/data/move";
 import { initMysteryEncounters } from "#app/data/mystery-encounters/mystery-encounters";
-import { initPokemonPrevolutions } from "#app/data/balance/pokemon-evolutions";
 import { initPokemonForms } from "#app/data/pokemon-forms";
 import { initSpecies } from "#app/data/pokemon-species";
 import { initAchievements } from "#app/system/achv";
 import { initVouchers } from "#app/system/voucher";
 import { initStatsKeys } from "#app/ui/game-stats-ui-handler";
-import { beforeAll, vi } from "vitest";
+import { afterAll, beforeAll, vi } from "vitest";
 
+/** Set the timezone to UTC for tests. */
 process.env.TZ = "UTC";
 
 /** Mock the override import to always return default values, ignoring any custom overrides. */
@@ -26,26 +27,36 @@ vi.mock("#app/overrides", async (importOriginal) => {
   } satisfies typeof import("#app/overrides");
 });
 
-vi.mock("i18next", () => ({
-  default: {
-    use: () => {},
-    t: (key: string) => key,
-    changeLanguage: () => Promise.resolve(),
-    init: () => Promise.resolve(),
-    resolvedLanguage: "en",
-    exists: () => true,
-    getDataByLanguage:() => ({
-      en: {
-        keys: [ "foo" ]
-      },
-    }),
-    services: {
-      formatter: {
-        add: () => {},
+/**
+ * This is a hacky way to mock the i18n backend requests (with the help of {@link https://mswjs.io/ | msw}).
+ * The reason to put it inside of a mock is to elevate it.
+ * This is necessary because how our code is structured.
+ * Do NOT try to put any of this code into external functions, it won't work as it's elevated during runtime.
+ */
+vi.mock("i18next", async (importOriginal) => {
+  console.log("Mocking i18next");
+  const { setupServer } = await import("msw/node");
+  const { http, HttpResponse } = await import("msw");
+
+  global.i18nServer = setupServer(
+    http.get("/locales/en/*", async (req) => {
+      const filename = req.params[0];
+
+      try {
+        const json = await import(`../../public/locales/en/${req.params[0]}`);
+        console.log("Loaded locale", filename);
+        return HttpResponse.json(json);
+      } catch (err) {
+        console.log(`Failed to load locale ${filename}!`, err);
+        return HttpResponse.json({});
       }
-    },
-  },
-}));
+    })
+  );
+  global.i18nServer.listen({ onUnhandledRequest: "error" });
+  console.log("i18n MSW server listening!");
+
+  return await importOriginal();
+});
 
 initVouchers();
 initAchievements();
@@ -70,3 +81,8 @@ beforeAll(() => {
     },
   });
 });
+
+afterAll(() => {
+  global.i18nServer.close();
+  console.log("Closing i18n MSW server!");
+});

From ca3cc3c9c6a569572942516f72edd8610c76b6c5 Mon Sep 17 00:00:00 2001
From: PigeonBar <56974298+PigeonBar@users.noreply.github.com>
Date: Thu, 10 Oct 2024 11:28:26 -0400
Subject: [PATCH 05/15] [P1 Bug] Fix infinite recursion from abilities disabled
 by Sheer Force (#4631)

---
 src/field/pokemon.ts                   |  4 ++--
 src/test/abilities/sheer_force.test.ts | 27 ++++++++++++++++++++++++++
 2 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts
index 241524df1b9..35f389b58a4 100644
--- a/src/field/pokemon.ts
+++ b/src/field/pokemon.ts
@@ -1417,10 +1417,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
    * @returns {boolean} Whether the ability is present and active
    */
   hasAbility(ability: Abilities, canApply: boolean = true, ignoreOverride?: boolean): boolean {
-    if ((!canApply || this.canApplyAbility()) && this.getAbility(ignoreOverride).id === ability) {
+    if (this.getAbility(ignoreOverride).id === ability && (!canApply || this.canApplyAbility())) {
       return true;
     }
-    if (this.hasPassive() && (!canApply || this.canApplyAbility(true)) && this.getPassiveAbility().id === ability) {
+    if (this.getPassiveAbility().id === ability && this.hasPassive() && (!canApply || this.canApplyAbility(true))) {
       return true;
     }
     return false;
diff --git a/src/test/abilities/sheer_force.test.ts b/src/test/abilities/sheer_force.test.ts
index a3add0a9964..a2600476d6d 100644
--- a/src/test/abilities/sheer_force.test.ts
+++ b/src/test/abilities/sheer_force.test.ts
@@ -9,6 +9,7 @@ import { Species } from "#enums/species";
 import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
 import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { allMoves } from "#app/data/move";
 
 
 describe("Abilities - Sheer Force", () => {
@@ -174,5 +175,31 @@ describe("Abilities - Sheer Force", () => {
 
   }, 20000);
 
+  it("Two Pokemon with abilities disabled by Sheer Force hitting each other should not cause a crash", async () => {
+    const moveToUse = Moves.CRUNCH;
+    game.override.enemyAbility(Abilities.COLOR_CHANGE)
+      .ability(Abilities.COLOR_CHANGE)
+      .moveset(moveToUse)
+      .enemyMoveset(moveToUse);
+
+    await game.classicMode.startBattle([
+      Species.PIDGEOT
+    ]);
+
+    const pidgeot = game.scene.getParty()[0];
+    const onix = game.scene.getEnemyParty()[0];
+
+    pidgeot.stats[Stat.DEF] = 10000;
+    onix.stats[Stat.DEF] = 10000;
+
+    game.move.select(moveToUse);
+    await game.toNextTurn();
+
+    // Check that both Pokemon's Color Change activated
+    const expectedTypes = [ allMoves[moveToUse].type ];
+    expect(pidgeot.getTypes()).toStrictEqual(expectedTypes);
+    expect(onix.getTypes()).toStrictEqual(expectedTypes);
+  });
+
   //TODO King's Rock Interaction Unit Test
 });

From 52257def2fa65aa09018800b29e89864572fc8b0 Mon Sep 17 00:00:00 2001
From: NightKev <34855794+DayKev@users.noreply.github.com>
Date: Thu, 10 Oct 2024 08:30:19 -0700
Subject: [PATCH 06/15] [P3] Fix enemy used PP flyout, fixes #4622 (#4629)

Also add missing function return types
---
 src/phases/move-phase.ts | 41 ++++++++++++++++++++--------------------
 1 file changed, 21 insertions(+), 20 deletions(-)

diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts
index 10cc062ea3b..94093188571 100644
--- a/src/phases/move-phase.ts
+++ b/src/phases/move-phase.ts
@@ -8,10 +8,6 @@ import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms";
 import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect";
 import { Type } from "#app/data/type";
 import { getTerrainBlockMessage } from "#app/data/weather";
-import { Abilities } from "#app/enums/abilities";
-import { BattlerTagType } from "#app/enums/battler-tag-type";
-import { Moves } from "#app/enums/moves";
-import { StatusEffect } from "#app/enums/status-effect";
 import { MoveUsedEvent } from "#app/events/battle-scene";
 import Pokemon, { MoveResult, PokemonMove, TurnMove } from "#app/field/pokemon";
 import { getPokemonNameWithAffix } from "#app/messages";
@@ -20,7 +16,11 @@ import { CommonAnimPhase } from "#app/phases/common-anim-phase";
 import { MoveEffectPhase } from "#app/phases/move-effect-phase";
 import { MoveEndPhase } from "#app/phases/move-end-phase";
 import { ShowAbilityPhase } from "#app/phases/show-ability-phase";
-import * as Utils from "#app/utils";
+import { BooleanHolder, NumberHolder } from "#app/utils";
+import { Abilities } from "#enums/abilities";
+import { BattlerTagType } from "#enums/battler-tag-type";
+import { Moves } from "#enums/moves";
+import { StatusEffect } from "#enums/status-effect";
 import i18next from "i18next";
 
 export class MovePhase extends BattlePhase {
@@ -89,7 +89,7 @@ export class MovePhase extends BattlePhase {
     this.cancelled = true;
   }
 
-  public start() {
+  public start(): void {
     super.start();
 
     console.log(Moves[this.move.moveId]);
@@ -140,7 +140,7 @@ export class MovePhase extends BattlePhase {
   }
 
   /** Check for cancellation edge cases - no targets remaining, or {@linkcode Moves.NONE} is in the queue */
-  protected resolveFinalPreMoveCancellationChecks() {
+  protected resolveFinalPreMoveCancellationChecks(): void {
     const targets = this.getActiveTargetPokemon();
     const moveQueue = this.pokemon.getMoveQueue();
 
@@ -150,14 +150,14 @@ export class MovePhase extends BattlePhase {
     }
   }
 
-  public getActiveTargetPokemon() {
+  public getActiveTargetPokemon(): Pokemon[] {
     return this.scene.getField(true).filter(p => this.targets.includes(p.getBattlerIndex()));
   }
 
   /**
    * Handles {@link StatusEffect.SLEEP Sleep}/{@link StatusEffect.PARALYSIS Paralysis}/{@link StatusEffect.FREEZE Freeze} rolls and side effects.
    */
-  protected resolvePreMoveStatusEffects() {
+  protected resolvePreMoveStatusEffects(): void {
     if (!this.followUp && this.pokemon.status && !this.pokemon.status.isPostTurn()) {
       this.pokemon.status.incrementTurn();
       let activated = false;
@@ -198,7 +198,7 @@ export class MovePhase extends BattlePhase {
    * Lapse {@linkcode BattlerTagLapseType.PRE_MOVE PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed.
    * Also lapse {@linkcode BattlerTagLapseType.MOVE MOVE} tags if the move should be successful.
    */
-  protected lapsePreMoveAndMoveTags() {
+  protected lapsePreMoveAndMoveTags(): void {
     this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
 
     // TODO: does this intentionally happen before the no targets/Moves.NONE on queue cancellation case is checked?
@@ -207,7 +207,7 @@ export class MovePhase extends BattlePhase {
     }
   }
 
-  protected useMove() {
+  protected useMove(): void {
     const targets = this.getActiveTargetPokemon();
     const moveQueue = this.pokemon.getMoveQueue();
 
@@ -217,7 +217,8 @@ export class MovePhase extends BattlePhase {
     this.showMoveText();
 
     // TODO: Clean up implementation of two-turn moves.
-    if (moveQueue.length > 0) { // Using .shift here clears out two turn moves once they've been used
+    if (moveQueue.length > 0) {
+      // Using .shift here clears out two turn moves once they've been used
       this.ignorePp = moveQueue.shift()?.ignorePP ?? false;
     }
 
@@ -226,7 +227,7 @@ export class MovePhase extends BattlePhase {
       const ppUsed = 1 + this.getPpIncreaseFromPressure(targets);
 
       this.move.usePp(ppUsed);
-      this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
+      this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed));
     }
 
     // Update the battle's "last move" pointer, unless we're currently mimicking a move.
@@ -275,7 +276,7 @@ export class MovePhase extends BattlePhase {
       this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual });
 
       let failedText: string | undefined;
-      const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new Utils.BooleanHolder(false));
+      const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false));
 
       if (failureMessage) {
         failedText = failureMessage;
@@ -299,7 +300,7 @@ export class MovePhase extends BattlePhase {
    * Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`,
    * then ends the phase.
    */
-  public end() {
+  public end(): void {
     if (!this.followUp && this.canMove()) {
       this.scene.unshiftPhase(new MoveEndPhase(this.scene, this.pokemon.getBattlerIndex()));
     }
@@ -313,7 +314,7 @@ export class MovePhase extends BattlePhase {
    *
    * TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability.
    */
-  public getPpIncreaseFromPressure(targets: Pokemon[]) {
+  public getPpIncreaseFromPressure(targets: Pokemon[]): number {
     const foesWithPressure = this.pokemon.getOpponents().filter(o => targets.includes(o) && o.isActive(true) && o.hasAbilityWithAttr(IncreasePpAbAttr));
     return foesWithPressure.length;
   }
@@ -323,10 +324,10 @@ export class MovePhase extends BattlePhase {
    * - Move redirection abilities, effects, etc.
    * - Counterattacks, which pass a special value into the `targets` constructor param (`[`{@linkcode BattlerIndex.ATTACKER}`]`).
    */
-  protected resolveRedirectTarget() {
+  protected resolveRedirectTarget(): void {
     if (this.targets.length === 1) {
       const currentTarget = this.targets[0];
-      const redirectTarget = new Utils.NumberHolder(currentTarget);
+      const redirectTarget = new NumberHolder(currentTarget);
 
       // check move redirection abilities of every pokemon *except* the user.
       this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, false, this.move.moveId, redirectTarget));
@@ -372,7 +373,7 @@ export class MovePhase extends BattlePhase {
    * If there is no last attacker, or they are no longer on the field, a message is displayed and the
    * move is marked for failure.
    */
-  protected resolveCounterAttackTarget() {
+  protected resolveCounterAttackTarget(): void {
     if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) {
       if (this.pokemon.turnData.attacksReceived.length) {
         this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex;
@@ -411,7 +412,7 @@ export class MovePhase extends BattlePhase {
    *
    *   TODO: handle charge moves more gracefully
    */
-  protected handlePreMoveFailures() {
+  protected handlePreMoveFailures(): void {
     if (this.cancelled || this.failed) {
       if (this.failed) {
         const ppUsed = this.ignorePp ? 0 : 1;

From e9906ea2293171aa8b32b81ee1d1a0a77254b3f2 Mon Sep 17 00:00:00 2001
From: Mumble <171087428+frutescens@users.noreply.github.com>
Date: Thu, 10 Oct 2024 08:31:10 -0700
Subject: [PATCH 07/15] [P2] Obstruct/Kings Shield/etc no longer reduce stats
 through Clear Body/etc (#4627)

* bug fix

* Add test

---------

Co-authored-by: frutescens <info@laptop>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
---
 src/data/battler-tags.ts        |  2 +-
 src/test/moves/obstruct.test.ts | 26 +++++++++++++++++++-------
 2 files changed, 20 insertions(+), 8 deletions(-)

diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts
index a54a8c5f519..6307b3d28be 100644
--- a/src/data/battler-tags.ts
+++ b/src/data/battler-tags.ts
@@ -1376,7 +1376,7 @@ export class ContactStatStageChangeProtectedTag extends DamageProtectedTag {
       const effectPhase = pokemon.scene.getCurrentPhase();
       if (effectPhase instanceof MoveEffectPhase && effectPhase.move.getMove().hasFlag(MoveFlags.MAKES_CONTACT)) {
         const attacker = effectPhase.getPokemon();
-        pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, attacker.getBattlerIndex(), true, [ this.stat ], this.levels));
+        pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, attacker.getBattlerIndex(), false, [ this.stat ], this.levels));
       }
     }
 
diff --git a/src/test/moves/obstruct.test.ts b/src/test/moves/obstruct.test.ts
index fbb5437b43a..1649c199e32 100644
--- a/src/test/moves/obstruct.test.ts
+++ b/src/test/moves/obstruct.test.ts
@@ -1,6 +1,7 @@
-import { Moves } from "#app/enums/moves";
-import { Stat } from "#app/enums/stat";
 import { Abilities } from "#enums/abilities";
+import { Moves } from "#enums/moves";
+import { Species } from "#enums/species";
+import { Stat } from "#enums/stat";
 import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
 import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@@ -22,13 +23,15 @@ describe("Moves - Obstruct", () => {
     game = new GameManager(phaserGame);
     game.override
       .battleType("single")
+      .enemySpecies(Species.MAGIKARP)
+      .enemyMoveset(Moves.TACKLE)
       .enemyAbility(Abilities.BALL_FETCH)
       .ability(Abilities.BALL_FETCH)
-      .moveset([ Moves.OBSTRUCT ]);
+      .moveset([ Moves.OBSTRUCT ])
+      .starterSpecies(Species.FEEBAS);
   });
 
   it("protects from contact damaging moves and lowers the opponent's defense by 2 stages", async () => {
-    game.override.enemyMoveset(Array(4).fill(Moves.ICE_PUNCH));
     await game.classicMode.startBattle();
 
     game.move.select(Moves.OBSTRUCT);
@@ -42,7 +45,6 @@ describe("Moves - Obstruct", () => {
   });
 
   it("bypasses accuracy checks when applying protection and defense reduction", async () => {
-    game.override.enemyMoveset(Array(4).fill(Moves.ICE_PUNCH));
     await game.classicMode.startBattle();
 
     game.move.select(Moves.OBSTRUCT);
@@ -59,7 +61,7 @@ describe("Moves - Obstruct", () => {
   );
 
   it("protects from non-contact damaging moves and doesn't lower the opponent's defense by 2 stages", async () => {
-    game.override.enemyMoveset(Array(4).fill(Moves.WATER_GUN));
+    game.override.enemyMoveset(Moves.WATER_GUN);
     await game.classicMode.startBattle();
 
     game.move.select(Moves.OBSTRUCT);
@@ -73,7 +75,7 @@ describe("Moves - Obstruct", () => {
   });
 
   it("doesn't protect from status moves", async () => {
-    game.override.enemyMoveset(Array(4).fill(Moves.GROWL));
+    game.override.enemyMoveset(Moves.GROWL);
     await game.classicMode.startBattle();
 
     game.move.select(Moves.OBSTRUCT);
@@ -83,4 +85,14 @@ describe("Moves - Obstruct", () => {
 
     expect(player.getStatStage(Stat.ATK)).toBe(-1);
   });
+
+  it("doesn't reduce the stats of an opponent with Clear Body/etc", async () => {
+    game.override.enemyAbility(Abilities.CLEAR_BODY);
+    await game.classicMode.startBattle();
+
+    game.move.select(Moves.OBSTRUCT);
+    await game.phaseInterceptor.to("BerryPhase");
+
+    expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.DEF)).toBe(0);
+  });
 });

From 51894d46c265116ee391146a10bedb16eccbc056 Mon Sep 17 00:00:00 2001
From: Mumble <171087428+frutescens@users.noreply.github.com>
Date: Thu, 10 Oct 2024 08:38:17 -0700
Subject: [PATCH 08/15] [P2] Pollen Puff ally behavior fixed (#4615)

* pollen puff fix

* bcvbvcbfd

* integerholder to numberholder

* moved it back

---------

Co-authored-by: frutescens <info@laptop>
---
 src/data/move.ts     | 4 ++--
 src/field/pokemon.ts | 4 +++-
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/data/move.ts b/src/data/move.ts
index 08c00829b48..4924341870d 100644
--- a/src/data/move.ts
+++ b/src/data/move.ts
@@ -4107,11 +4107,11 @@ export class StatusCategoryOnAllyAttr extends VariableMoveCategoryAttr {
    * @param user {@linkcode Pokemon} using the move
    * @param target {@linkcode Pokemon} target of the move
    * @param move {@linkcode Move} with this attribute
-   * @param args [0] {@linkcode Utils.IntegerHolder} The category of the move
+   * @param args [0] {@linkcode Utils.NumberHolder} The category of the move
    * @returns true if the function succeeds
    */
   apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
-    const category = (args[0] as Utils.IntegerHolder);
+    const category = (args[0] as Utils.NumberHolder);
 
     if (user.getAlly() === target) {
       category.value = MoveCategory.STATUS;
diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts
index 35f389b58a4..4d85d5b8e1e 100644
--- a/src/field/pokemon.ts
+++ b/src/field/pokemon.ts
@@ -2684,7 +2684,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
    */
   apply(source: Pokemon, move: Move): HitResult {
     const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
-    if (move.category === MoveCategory.STATUS) {
+    const moveCategory = new Utils.NumberHolder(move.category);
+    applyMoveAttrs(VariableMoveCategoryAttr, source, this, move, moveCategory);
+    if (moveCategory.value === MoveCategory.STATUS) {
       const cancelled = new Utils.BooleanHolder(false);
       const typeMultiplier = this.getMoveEffectiveness(source, move, false, false, cancelled);
 

From 64147e44145faa8e8f14730279e764d146797841 Mon Sep 17 00:00:00 2001
From: PigeonBar <56974298+PigeonBar@users.noreply.github.com>
Date: Thu, 10 Oct 2024 11:40:14 -0400
Subject: [PATCH 09/15] [P2] Fix Battle Bond continuing to affect Water
 Shuriken after Greninja returns to base form (#4602)

* [Bug] Fix Battle Bond continuing to buff Water Shuriken after Greninja returns to base form

* Test cleanup

* PR feedback

* Update test to use getMultiHitType()

* PR Feedback
---
 src/data/move.ts                       | 13 +++-
 src/test/abilities/battle_bond.test.ts | 94 +++++++++++++++++---------
 2 files changed, 73 insertions(+), 34 deletions(-)

diff --git a/src/data/move.ts b/src/data/move.ts
index 4924341870d..bae8eea0d8a 100644
--- a/src/data/move.ts
+++ b/src/data/move.ts
@@ -1938,12 +1938,21 @@ export class IncrementMovePriorityAttr extends MoveAttr {
  * @see {@linkcode apply}
  */
 export class MultiHitAttr extends MoveAttr {
+  /** This move's intrinsic multi-hit type. It should never be modified. */
+  private readonly intrinsicMultiHitType: MultiHitType;
+  /** This move's current multi-hit type. It may be temporarily modified by abilities (e.g., Battle Bond). */
   private multiHitType: MultiHitType;
 
   constructor(multiHitType?: MultiHitType) {
     super();
 
-    this.multiHitType = multiHitType !== undefined ? multiHitType : MultiHitType._2_TO_5;
+    this.intrinsicMultiHitType = multiHitType !== undefined ? multiHitType : MultiHitType._2_TO_5;
+    this.multiHitType = this.intrinsicMultiHitType;
+  }
+
+  // Currently used by `battle_bond.test.ts`
+  getMultiHitType(): MultiHitType {
+    return this.multiHitType;
   }
 
   /**
@@ -1957,7 +1966,7 @@ export class MultiHitAttr extends MoveAttr {
    * @returns True
    */
   apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
-    const hitType = new Utils.NumberHolder(this.multiHitType);
+    const hitType = new Utils.NumberHolder(this.intrinsicMultiHitType);
     applyMoveAttrs(ChangeMultiHitTypeAttr, user, target, move, hitType);
     this.multiHitType = hitType.value;
 
diff --git a/src/test/abilities/battle_bond.test.ts b/src/test/abilities/battle_bond.test.ts
index c7dffeb150a..283fb0d0f14 100644
--- a/src/test/abilities/battle_bond.test.ts
+++ b/src/test/abilities/battle_bond.test.ts
@@ -1,17 +1,19 @@
+import { allMoves, MultiHitAttr, MultiHitType } from "#app/data/move";
 import { Status, StatusEffect } from "#app/data/status-effect";
-import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase";
-import { TurnEndPhase } from "#app/phases/turn-end-phase";
 import { Abilities } from "#enums/abilities";
 import { Moves } from "#enums/moves";
 import { Species } from "#enums/species";
 import GameManager from "#test/utils/gameManager";
-import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 
 
 describe("Abilities - BATTLE BOND", () => {
   let phaserGame: Phaser.Game;
   let game: GameManager;
 
+  const baseForm = 1;
+  const ashForm = 2;
+
   beforeAll(() => {
     phaserGame = new Phaser.Game({
       type: Phaser.HEADLESS,
@@ -24,40 +26,68 @@ describe("Abilities - BATTLE BOND", () => {
 
   beforeEach(() => {
     game = new GameManager(phaserGame);
-    const moveToUse = Moves.SPLASH;
-    game.override.battleType("single");
-    game.override.ability(Abilities.BATTLE_BOND);
-    game.override.moveset([ moveToUse ]);
-    game.override.enemyMoveset([ Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE ]);
+    game.override.battleType("single")
+      .startingWave(4) // Leads to arena reset on Wave 5 trainer battle
+      .ability(Abilities.BATTLE_BOND)
+      .starterForms({ [Species.GRENINJA]: ashForm, })
+      .moveset([ Moves.SPLASH, Moves.WATER_SHURIKEN ])
+      .enemySpecies(Species.BULBASAUR)
+      .enemyMoveset(Moves.SPLASH)
+      .startingLevel(100) // Avoid levelling up
+      .enemyLevel(1000); // Avoid opponent dying before `doKillOpponents()`
   });
 
-  test(
-    "check if fainted pokemon switches to base form on arena reset",
-    async () => {
-      const baseForm = 1;
-      const ashForm = 2;
-      game.override.startingWave(4);
-      game.override.starterForms({
-        [Species.GRENINJA]: ashForm,
-      });
+  it("check if fainted pokemon switches to base form on arena reset", async () => {
+    await game.classicMode.startBattle([ Species.MAGIKARP, Species.GRENINJA ]);
 
-      await game.startBattle([ Species.MAGIKARP, Species.GRENINJA ]);
+    const greninja = game.scene.getParty()[1];
+    expect(greninja.formIndex).toBe(ashForm);
 
-      const greninja = game.scene.getParty().find((p) => p.species.speciesId === Species.GRENINJA);
-      expect(greninja).toBeDefined();
-      expect(greninja!.formIndex).toBe(ashForm);
+    greninja.hp = 0;
+    greninja.status = new Status(StatusEffect.FAINT);
+    expect(greninja.isFainted()).toBe(true);
 
-      greninja!.hp = 0;
-      greninja!.status = new Status(StatusEffect.FAINT);
-      expect(greninja!.isFainted()).toBe(true);
+    game.move.select(Moves.SPLASH);
+    await game.doKillOpponents();
+    await game.phaseInterceptor.to("TurnEndPhase");
+    game.doSelectModifier();
+    await game.phaseInterceptor.to("QuietFormChangePhase");
 
-      game.move.select(Moves.SPLASH);
-      await game.doKillOpponents();
-      await game.phaseInterceptor.to(TurnEndPhase);
-      game.doSelectModifier();
-      await game.phaseInterceptor.to(QuietFormChangePhase);
+    expect(greninja.formIndex).toBe(baseForm);
+  });
 
-      expect(greninja!.formIndex).toBe(baseForm);
-    },
-  );
+  it("should not keep buffing Water Shuriken after Greninja switches to base form", async () => {
+    await game.classicMode.startBattle([ Species.GRENINJA ]);
+
+    const waterShuriken = allMoves[Moves.WATER_SHURIKEN];
+    vi.spyOn(waterShuriken, "calculateBattlePower");
+
+    let actualMultiHitType: MultiHitType | null = null;
+    const multiHitAttr = waterShuriken.getAttrs(MultiHitAttr)[0];
+    vi.spyOn(multiHitAttr, "getHitCount").mockImplementation(() => {
+      actualMultiHitType = multiHitAttr.getMultiHitType();
+      return 3;
+    });
+
+    // Wave 4: Use Water Shuriken in Ash form
+    let expectedBattlePower = 20;
+    let expectedMultiHitType = MultiHitType._3;
+
+    game.move.select(Moves.WATER_SHURIKEN);
+    await game.phaseInterceptor.to("BerryPhase", false);
+    expect(waterShuriken.calculateBattlePower).toHaveLastReturnedWith(expectedBattlePower);
+    expect(actualMultiHitType).toBe(expectedMultiHitType);
+
+    await game.doKillOpponents();
+    await game.toNextWave();
+
+    // Wave 5: Use Water Shuriken in base form
+    expectedBattlePower = 15;
+    expectedMultiHitType = MultiHitType._2_TO_5;
+
+    game.move.select(Moves.WATER_SHURIKEN);
+    await game.phaseInterceptor.to("BerryPhase", false);
+    expect(waterShuriken.calculateBattlePower).toHaveLastReturnedWith(expectedBattlePower);
+    expect(actualMultiHitType).toBe(expectedMultiHitType);
+  });
 });

From a778537ccadcfe23a39766abcf8afee7b287ca09 Mon Sep 17 00:00:00 2001
From: Mumble <171087428+frutescens@users.noreply.github.com>
Date: Thu, 10 Oct 2024 08:43:50 -0700
Subject: [PATCH 10/15] [P2] Sketch Failure Bug involving multiple Sketch-s in
 a moveset (#4618)

* Sketch bug fix

* Added test

---------

Co-authored-by: frutescens <info@laptop>
---
 src/phases/turn-start-phase.ts     |  2 +-
 src/test/moves/sketch.test.ts      | 53 ++++++++++++++++++++++++++++++
 src/test/utils/gameManagerUtils.ts |  2 +-
 3 files changed, 55 insertions(+), 2 deletions(-)
 create mode 100644 src/test/moves/sketch.test.ts

diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts
index 95d55986185..53623f933f2 100644
--- a/src/phases/turn-start-phase.ts
+++ b/src/phases/turn-start-phase.ts
@@ -158,7 +158,7 @@ export class TurnStartPhase extends FieldPhase {
         if (!queuedMove) {
           continue;
         }
-        const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move) || new PokemonMove(queuedMove.move);
+        const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move && m?.ppUsed < m?.getMovePp()) || new PokemonMove(queuedMove.move);
         if (move.getMove().hasAttr(MoveHeaderAttr)) {
           this.scene.unshiftPhase(new MoveHeaderPhase(this.scene, pokemon, move));
         }
diff --git a/src/test/moves/sketch.test.ts b/src/test/moves/sketch.test.ts
new file mode 100644
index 00000000000..2e3eb97a76c
--- /dev/null
+++ b/src/test/moves/sketch.test.ts
@@ -0,0 +1,53 @@
+import { Abilities } from "#enums/abilities";
+import { Moves } from "#enums/moves";
+import { Species } from "#enums/species";
+import { MoveResult } from "#app/field/pokemon";
+import GameManager from "#test/utils/gameManager";
+import Phaser from "phaser";
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+
+describe("Moves - Sketch", () => {
+  let phaserGame: Phaser.Game;
+  let game: GameManager;
+
+  beforeAll(() => {
+    phaserGame = new Phaser.Game({
+      type: Phaser.HEADLESS,
+    });
+  });
+
+  afterEach(() => {
+    game.phaseInterceptor.restoreOg();
+  });
+
+  beforeEach(() => {
+    game = new GameManager(phaserGame);
+    game.override
+      .ability(Abilities.BALL_FETCH)
+      .battleType("single")
+      .disableCrits()
+      .enemySpecies(Species.SHUCKLE)
+      .enemyAbility(Abilities.BALL_FETCH)
+      .enemyMoveset(Moves.SPLASH);
+  });
+
+  it("Sketch should not fail even if a previous Sketch failed to retrieve a valid move and ran out of PP", async () => {
+    game.override.moveset([ Moves.SKETCH, Moves.SKETCH ]);
+
+    await game.classicMode.startBattle([ Species.REGIELEKI ]);
+    const playerPokemon = game.scene.getPlayerPokemon();
+
+    game.move.select(Moves.SKETCH);
+    await game.phaseInterceptor.to("TurnEndPhase");
+    expect(playerPokemon?.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
+    const moveSlot0 = playerPokemon?.getMoveset()[0];
+    expect(moveSlot0?.moveId).toBe(Moves.SKETCH);
+    expect(moveSlot0?.getPpRatio()).toBe(0);
+
+    await game.toNextTurn();
+    game.move.select(Moves.SKETCH);
+    await game.phaseInterceptor.to("TurnEndPhase");
+    expect(playerPokemon?.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
+    // Can't verify if the player Pokemon's moveset was successfully changed because of overrides.
+  });
+});
diff --git a/src/test/utils/gameManagerUtils.ts b/src/test/utils/gameManagerUtils.ts
index 700d93082d8..543ee9627fe 100644
--- a/src/test/utils/gameManagerUtils.ts
+++ b/src/test/utils/gameManagerUtils.ts
@@ -86,7 +86,7 @@ export function waitUntil(truth) {
 export function getMovePosition(scene: BattleScene, pokemonIndex: 0 | 1, move: Moves) {
   const playerPokemon = scene.getPlayerField()[pokemonIndex];
   const moveSet = playerPokemon.getMoveset();
-  const index = moveSet.findIndex((m) => m?.moveId === move);
+  const index = moveSet.findIndex((m) => m?.moveId === move && m?.ppUsed < m?.getMovePp());
   console.log(`Move position for ${Moves[move]} (=${move}):`, index);
   return index;
 }

From ba7e26152e560032792e94257b11510160ea184f Mon Sep 17 00:00:00 2001
From: NightKev <34855794+DayKev@users.noreply.github.com>
Date: Thu, 10 Oct 2024 08:45:02 -0700
Subject: [PATCH 11/15] [Bug] Fix substitute interactions with
 `PostDefendAbAttr`s (#4570)

* Fixes some Substitute interactions

Specifically with Disguise/Ice Face and Gulp Missile

* Add tests

* Fix linting

* Add `hitsSubstitute()` checks to all `PostDefendAbAttr`s

Also fix comment indentation in `MoveEffectPhase`

* Revert `move-effect-phase.ts` changes
---
 src/data/ability.ts                     | 140 +++++++++++++-----------
 src/data/battler-tags.ts                |   4 +
 src/data/move.ts                        |  12 +-
 src/test/abilities/disguise.test.ts     |  16 ++-
 src/test/abilities/gulp_missile.test.ts |  31 +++++-
 src/test/abilities/ice_face.test.ts     |  38 +++++--
 6 files changed, 152 insertions(+), 89 deletions(-)

diff --git a/src/data/ability.ts b/src/data/ability.ts
index 43d02da1733..6a391818866 100644
--- a/src/data/ability.ts
+++ b/src/data/ability.ts
@@ -634,15 +634,15 @@ export class ReverseDrainAbAttr extends PostDefendAbAttr {
    * Examples include: Absorb, Draining Kiss, Bitter Blade, etc.
    * Also displays a message to show this ability was activated.
    * @param pokemon {@linkcode Pokemon} with this ability
-   * @param passive N/A
+   * @param _passive N/A
    * @param attacker {@linkcode Pokemon} that is attacking this Pokemon
    * @param move {@linkcode PokemonMove} that is being used
-   * @param hitResult N/A
-   * @args N/A
+   * @param _hitResult N/A
+   * @param _args N/A
    * @returns true if healing should be reversed on a healing move, false otherwise.
    */
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (move.hasAttr(HitHealAttr)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (move.hasAttr(HitHealAttr) && !move.hitsSubstitute(attacker, pokemon)) {
       if (!simulated) {
         pokemon.scene.queueMessage(i18next.t("abilityTriggers:reverseDrain", { pokemonNameWithAffix: getPokemonNameWithAffix(attacker) }));
       }
@@ -669,8 +669,8 @@ export class PostDefendStatStageChangeAbAttr extends PostDefendAbAttr {
     this.allOthers = allOthers;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (this.condition(pokemon, attacker, move)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon)) {
       if (simulated) {
         return true;
       }
@@ -707,13 +707,13 @@ export class PostDefendHpGatedStatStageChangeAbAttr extends PostDefendAbAttr {
     this.selfTarget = selfTarget;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    const hpGateFlat: integer = Math.ceil(pokemon.getMaxHp() * this.hpGate);
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    const hpGateFlat: number = Math.ceil(pokemon.getMaxHp() * this.hpGate);
     const lastAttackReceived = pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1];
     const damageReceived = lastAttackReceived?.damage || 0;
 
-    if (this.condition(pokemon, attacker, move) && (pokemon.hp <= hpGateFlat && (pokemon.hp + damageReceived) > hpGateFlat)) {
-      if (!simulated ) {
+    if (this.condition(pokemon, attacker, move) && (pokemon.hp <= hpGateFlat && (pokemon.hp + damageReceived) > hpGateFlat) && !move.hitsSubstitute(attacker, pokemon)) {
+      if (!simulated) {
         pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, (this.selfTarget ? pokemon : attacker).getBattlerIndex(), true, this.stats, this.stages));
       }
       return true;
@@ -734,8 +734,8 @@ export class PostDefendApplyArenaTrapTagAbAttr extends PostDefendAbAttr {
     this.tagType = tagType;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (this.condition(pokemon, attacker, move)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon)) {
       const tag = pokemon.scene.arena.getTag(this.tagType) as ArenaTrapTag;
       if (!pokemon.scene.arena.getTag(this.tagType) || tag.layers < tag.maxLayers) {
         if (!simulated) {
@@ -758,8 +758,8 @@ export class PostDefendApplyBattlerTagAbAttr extends PostDefendAbAttr {
     this.tagType = tagType;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (this.condition(pokemon, attacker, move)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon)) {
       if (!pokemon.getTag(this.tagType) && !simulated) {
         pokemon.addTag(this.tagType, undefined, undefined, pokemon.id);
         pokemon.scene.queueMessage(i18next.t("abilityTriggers:windPowerCharged", { pokemonName: getPokemonNameWithAffix(pokemon), moveName: move.name }));
@@ -771,8 +771,8 @@ export class PostDefendApplyBattlerTagAbAttr extends PostDefendAbAttr {
 }
 
 export class PostDefendTypeChangeAbAttr extends PostDefendAbAttr {
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (hitResult < HitResult.NO_EFFECT) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, _args: any[]): boolean {
+    if (hitResult < HitResult.NO_EFFECT && !move.hitsSubstitute(attacker, pokemon)) {
       if (simulated) {
         return true;
       }
@@ -787,7 +787,7 @@ export class PostDefendTypeChangeAbAttr extends PostDefendAbAttr {
     return false;
   }
 
-  getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string {
+  override getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string {
     return i18next.t("abilityTriggers:postDefendTypeChange", {
       pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
       abilityName,
@@ -805,8 +805,8 @@ export class PostDefendTerrainChangeAbAttr extends PostDefendAbAttr {
     this.terrainType = terrainType;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (hitResult < HitResult.NO_EFFECT) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, _args: any[]): boolean {
+    if (hitResult < HitResult.NO_EFFECT && !move.hitsSubstitute(attacker, pokemon)) {
       if (simulated) {
         return pokemon.scene.arena.terrain?.terrainType !== (this.terrainType || undefined);
       } else {
@@ -829,8 +829,9 @@ export class PostDefendContactApplyStatusEffectAbAttr extends PostDefendAbAttr {
     this.effects = effects;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.status && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.status
+      && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance) && !move.hitsSubstitute(attacker, pokemon)) {
       const effect = this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randSeedInt(this.effects.length)];
       if (simulated) {
         return attacker.canSetStatus(effect, true, false, pokemon);
@@ -869,8 +870,8 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr {
     this.turnCount = turnCount;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && pokemon.randSeedInt(100) < this.chance) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && pokemon.randSeedInt(100) < this.chance && !move.hitsSubstitute(attacker, pokemon)) {
       if (simulated) {
         return attacker.canAddTag(this.tagType);
       } else {
@@ -893,7 +894,11 @@ export class PostDefendCritStatStageChangeAbAttr extends PostDefendAbAttr {
     this.stages = stages;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (move.hitsSubstitute(attacker, pokemon)) {
+      return false;
+    }
+
     if (!simulated) {
       pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [ this.stat ], this.stages));
     }
@@ -901,7 +906,7 @@ export class PostDefendCritStatStageChangeAbAttr extends PostDefendAbAttr {
     return true;
   }
 
-  getCondition(): AbAttrCondition {
+  override getCondition(): AbAttrCondition {
     return (pokemon: Pokemon) => pokemon.turnData.attacksReceived.length !== 0 && pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1].critical;
   }
 }
@@ -915,8 +920,9 @@ export class PostDefendContactDamageAbAttr extends PostDefendAbAttr {
     this.damageRatio = damageRatio;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (!simulated && move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (!simulated && move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)
+      && !attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr) && !move.hitsSubstitute(attacker, pokemon)) {
       attacker.damageAndUpdate(Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER);
       attacker.turnData.damageTaken += Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio));
       return true;
@@ -925,7 +931,7 @@ export class PostDefendContactDamageAbAttr extends PostDefendAbAttr {
     return false;
   }
 
-  getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string {
+  override getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string {
     return i18next.t("abilityTriggers:postDefendContactDamage", {
       pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
       abilityName
@@ -948,8 +954,8 @@ export class PostDefendPerishSongAbAttr extends PostDefendAbAttr {
     this.turns = turns;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !move.hitsSubstitute(attacker, pokemon)) {
       if (pokemon.getTag(BattlerTagType.PERISH_SONG) || attacker.getTag(BattlerTagType.PERISH_SONG)) {
         return false;
       } else {
@@ -963,24 +969,24 @@ export class PostDefendPerishSongAbAttr extends PostDefendAbAttr {
     return false;
   }
 
-  getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string {
+  override getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string {
     return i18next.t("abilityTriggers:perishBody", { pokemonName: getPokemonNameWithAffix(pokemon), abilityName: abilityName });
   }
 }
 
 export class PostDefendWeatherChangeAbAttr extends PostDefendAbAttr {
   private weatherType: WeatherType;
-  protected condition: PokemonDefendCondition | null;
+  protected condition?: PokemonDefendCondition;
 
   constructor(weatherType: WeatherType, condition?: PokemonDefendCondition) {
     super();
 
     this.weatherType = weatherType;
-    this.condition = condition ?? null;
+    this.condition = condition;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (this.condition !== null && !this.condition(pokemon, attacker, move)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (this.condition && !this.condition(pokemon, attacker, move) || move.hitsSubstitute(attacker, pokemon)) {
       return false;
     }
     if (!pokemon.scene.arena.weather?.isImmutable()) {
@@ -999,8 +1005,9 @@ export class PostDefendAbilitySwapAbAttr extends PostDefendAbAttr {
     super();
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.getAbility().hasAttr(UnswappableAbilityAbAttr)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, args: any[]): boolean {
+    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)
+      && !attacker.getAbility().hasAttr(UnswappableAbilityAbAttr) && !move.hitsSubstitute(attacker, pokemon)) {
       if (!simulated) {
         const tempAbilityId = attacker.getAbility().id;
         attacker.summonData.ability = pokemon.getAbility().id;
@@ -1012,7 +1019,7 @@ export class PostDefendAbilitySwapAbAttr extends PostDefendAbAttr {
     return false;
   }
 
-  getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string {
+  override getTriggerMessage(pokemon: Pokemon, _abilityName: string, ..._args: any[]): string {
     return i18next.t("abilityTriggers:postDefendAbilitySwap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) });
   }
 }
@@ -1025,8 +1032,9 @@ export class PostDefendAbilityGiveAbAttr extends PostDefendAbAttr {
     this.ability = ability;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.getAbility().hasAttr(UnsuppressableAbilityAbAttr) && !attacker.getAbility().hasAttr(PostDefendAbilityGiveAbAttr)) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.getAbility().hasAttr(UnsuppressableAbilityAbAttr)
+      && !attacker.getAbility().hasAttr(PostDefendAbilityGiveAbAttr) && !move.hitsSubstitute(attacker, pokemon)) {
       if (!simulated) {
         attacker.summonData.ability = this.ability;
       }
@@ -1037,7 +1045,7 @@ export class PostDefendAbilityGiveAbAttr extends PostDefendAbAttr {
     return false;
   }
 
-  getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string {
+  override getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string {
     return i18next.t("abilityTriggers:postDefendAbilityGive", {
       pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
       abilityName
@@ -1056,8 +1064,8 @@ export class PostDefendMoveDisableAbAttr extends PostDefendAbAttr {
     this.chance = chance;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
-    if (attacker.getTag(BattlerTagType.DISABLED) === null) {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean {
+    if (attacker.getTag(BattlerTagType.DISABLED) === null && !move.hitsSubstitute(attacker, pokemon)) {
       if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance)) {
         if (simulated) {
           return true;
@@ -1724,17 +1732,17 @@ export class PostAttackApplyBattlerTagAbAttr extends PostAttackAbAttr {
 }
 
 export class PostDefendStealHeldItemAbAttr extends PostDefendAbAttr {
-  private condition: PokemonDefendCondition | null;
+  private condition?: PokemonDefendCondition;
 
   constructor(condition?: PokemonDefendCondition) {
     super();
 
-    this.condition = condition ?? null;
+    this.condition = condition;
   }
 
-  applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): Promise<boolean> {
+  override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, _args: any[]): Promise<boolean> {
     return new Promise<boolean>(resolve => {
-      if (!simulated && hitResult < HitResult.NO_EFFECT && (!this.condition || this.condition(pokemon, attacker, move))) {
+      if (!simulated && hitResult < HitResult.NO_EFFECT && (!this.condition || this.condition(pokemon, attacker, move)) && !move.hitsSubstitute(attacker, pokemon)) {
         const heldItems = this.getTargetHeldItems(attacker).filter(i => i.isTransferable);
         if (heldItems.length) {
           const stolenItem = heldItems[pokemon.randSeedInt(heldItems.length)];
@@ -4476,7 +4484,7 @@ export class PostSummonStatStageChangeOnArenaAbAttr extends PostSummonStatStageC
 export class FormBlockDamageAbAttr extends ReceivedMoveDamageMultiplierAbAttr {
   private multiplier: number;
   private tagType: BattlerTagType;
-  private recoilDamageFunc: ((pokemon: Pokemon) => number) | undefined;
+  private recoilDamageFunc?: ((pokemon: Pokemon) => number);
   private triggerMessageFunc: (pokemon: Pokemon, abilityName: string) => string;
 
   constructor(condition: PokemonDefendCondition, multiplier: number, tagType: BattlerTagType, triggerMessageFunc: (pokemon: Pokemon, abilityName: string) => string, recoilDamageFunc?: (pokemon: Pokemon) => number) {
@@ -4492,16 +4500,16 @@ export class FormBlockDamageAbAttr extends ReceivedMoveDamageMultiplierAbAttr {
    * Applies the pre-defense ability to the Pokémon.
    * Removes the appropriate `BattlerTagType` when hit by an attack and is in its defense form.
    *
-   * @param {Pokemon} pokemon The Pokémon with the ability.
-   * @param {boolean} passive n/a
-   * @param {Pokemon} attacker The attacking Pokémon.
-   * @param {PokemonMove} move The move being used.
-   * @param {Utils.BooleanHolder} cancelled n/a
-   * @param {any[]} args Additional arguments.
-   * @returns {boolean} Whether the immunity was applied.
+   * @param pokemon The Pokémon with the ability.
+   * @param _passive n/a
+   * @param attacker The attacking Pokémon.
+   * @param move The move being used.
+   * @param _cancelled n/a
+   * @param args Additional arguments.
+   * @returns `true` if the immunity was applied.
    */
-  applyPreDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, cancelled: Utils.BooleanHolder, args: any[]): boolean {
-    if (this.condition(pokemon, attacker, move)) {
+  override applyPreDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _cancelled: Utils.BooleanHolder, args: any[]): boolean {
+    if (this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon)) {
       if (!simulated) {
         (args[0] as Utils.NumberHolder).value = this.multiplier;
         pokemon.removeTag(this.tagType);
@@ -4517,12 +4525,12 @@ export class FormBlockDamageAbAttr extends ReceivedMoveDamageMultiplierAbAttr {
 
   /**
    * Gets the message triggered when the Pokémon avoids damage using the form-changing ability.
-   * @param {Pokemon} pokemon The Pokémon with the ability.
-   * @param {string} abilityName The name of the ability.
-   * @param {...any} args n/a
-   * @returns {string} The trigger message.
+   * @param pokemon The Pokémon with the ability.
+   * @param abilityName The name of the ability.
+   * @param _args n/a
+   * @returns The trigger message.
    */
-  getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string {
+  getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string {
     return this.triggerMessageFunc(pokemon, abilityName);
   }
 }
@@ -5503,7 +5511,8 @@ export function initAbilities() {
       .attr(NoFusionAbilityAbAttr)
       // Add BattlerTagType.DISGUISE if the pokemon is in its disguised form
       .conditionalAttr(pokemon => pokemon.formIndex === 0, PostSummonAddBattlerTagAbAttr, BattlerTagType.DISGUISE, 0, false)
-      .attr(FormBlockDamageAbAttr, (target, user, move) => !!target.getTag(BattlerTagType.DISGUISE) && target.getMoveEffectiveness(user, move) > 0, 0, BattlerTagType.DISGUISE,
+      .attr(FormBlockDamageAbAttr,
+        (target, user, move) => !!target.getTag(BattlerTagType.DISGUISE) && target.getMoveEffectiveness(user, move) > 0, 0, BattlerTagType.DISGUISE,
         (pokemon, abilityName) => i18next.t("abilityTriggers:disguiseAvoidedDamage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName: abilityName }),
         (pokemon) => Utils.toDmgValue(pokemon.getMaxHp() / 8))
       .attr(PostBattleInitFormChangeAbAttr, () => 0)
@@ -5665,7 +5674,8 @@ export function initAbilities() {
       .conditionalAttr(getWeatherCondition(WeatherType.HAIL, WeatherType.SNOW), PostSummonAddBattlerTagAbAttr, BattlerTagType.ICE_FACE, 0)
       // When weather changes to HAIL or SNOW while pokemon is fielded, add BattlerTagType.ICE_FACE
       .attr(PostWeatherChangeAddBattlerTagAttr, BattlerTagType.ICE_FACE, 0, WeatherType.HAIL, WeatherType.SNOW)
-      .attr(FormBlockDamageAbAttr, (target, user, move) => move.category === MoveCategory.PHYSICAL && !!target.getTag(BattlerTagType.ICE_FACE), 0, BattlerTagType.ICE_FACE,
+      .attr(FormBlockDamageAbAttr,
+        (target, user, move) => move.category === MoveCategory.PHYSICAL && !!target.getTag(BattlerTagType.ICE_FACE), 0, BattlerTagType.ICE_FACE,
         (pokemon, abilityName) => i18next.t("abilityTriggers:iceFaceAvoidedDamage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName: abilityName }))
       .attr(PostBattleInitFormChangeAbAttr, () => 0)
       .bypassFaint()
diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts
index 6307b3d28be..24c82e54427 100644
--- a/src/data/battler-tags.ts
+++ b/src/data/battler-tags.ts
@@ -2139,6 +2139,10 @@ export class GulpMissileTag extends BattlerTag {
         return false;
       }
 
+      if (moveEffectPhase.move.getMove().hitsSubstitute(attacker, pokemon)) {
+        return true;
+      }
+
       const cancelled = new Utils.BooleanHolder(false);
       applyAbAttrs(BlockNonDirectDamageAbAttr, attacker, cancelled);
 
diff --git a/src/data/move.ts b/src/data/move.ts
index bae8eea0d8a..ff0c24f5032 100644
--- a/src/data/move.ts
+++ b/src/data/move.ts
@@ -4844,14 +4844,14 @@ export class GulpMissileTagAttr extends MoveEffectAttr {
 
   /**
    * Adds BattlerTagType from GulpMissileTag based on the Pokemon's HP ratio.
-   * @param {Pokemon} user The Pokemon using the move.
-   * @param {Pokemon} target The Pokemon being targeted by the move.
-   * @param {Move} move The move being used.
-   * @param {any[]} args Additional arguments, if any.
+   * @param user The Pokemon using the move.
+   * @param _target N/A
+   * @param move The move being used.
+   * @param _args N/A
    * @returns Whether the BattlerTag is applied.
    */
-  apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean | Promise<boolean> {
-    if (!super.apply(user, target, move, args)) {
+  apply(user: Pokemon, _target: Pokemon, move: Move, _args: any[]): boolean {
+    if (!super.apply(user, _target, move, _args)) {
       return false;
     }
 
diff --git a/src/test/abilities/disguise.test.ts b/src/test/abilities/disguise.test.ts
index a295dd61443..0241aa4b9ea 100644
--- a/src/test/abilities/disguise.test.ts
+++ b/src/test/abilities/disguise.test.ts
@@ -1,8 +1,9 @@
+import { BattlerIndex } from "#app/battle";
+import { StatusEffect } from "#app/data/status-effect";
 import { toDmgValue } from "#app/utils";
 import { Abilities } from "#enums/abilities";
 import { Moves } from "#enums/moves";
 import { Species } from "#enums/species";
-import { StatusEffect } from "#app/data/status-effect";
 import { Stat } from "#enums/stat";
 import GameManager from "#test/utils/gameManager";
 import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@@ -222,4 +223,17 @@ describe("Abilities - Disguise", () => {
     expect(mimikyu.formIndex).toBe(bustedForm);
     expect(mimikyu.hp).toBe(maxHp - disguiseDamage);
   });
+
+  it("doesn't trigger if user is behind a substitute", async () => {
+    game.override
+      .enemyMoveset(Moves.SUBSTITUTE)
+      .moveset(Moves.POWER_TRIP);
+    await game.classicMode.startBattle();
+
+    game.move.select(Moves.POWER_TRIP);
+    await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
+    await game.toNextTurn();
+
+    expect(game.scene.getEnemyPokemon()!.formIndex).toBe(disguisedForm);
+  });
 });
diff --git a/src/test/abilities/gulp_missile.test.ts b/src/test/abilities/gulp_missile.test.ts
index 1ca208996b5..01b68d0c89d 100644
--- a/src/test/abilities/gulp_missile.test.ts
+++ b/src/test/abilities/gulp_missile.test.ts
@@ -1,13 +1,14 @@
-import { BattlerTagType } from "#enums/battler-tag-type";
-import { StatusEffect } from "#enums/status-effect";
+import { BattlerIndex } from "#app/battle";
 import Pokemon from "#app/field/pokemon";
-import GameManager from "#test/utils/gameManager";
 import { Abilities } from "#enums/abilities";
+import { BattlerTagType } from "#enums/battler-tag-type";
 import { Moves } from "#enums/moves";
 import { Species } from "#enums/species";
+import { Stat } from "#enums/stat";
+import { StatusEffect } from "#enums/status-effect";
+import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { Stat } from "#enums/stat";
 
 describe("Abilities - Gulp Missile", () => {
   let phaserGame: Phaser.Game;
@@ -40,8 +41,9 @@ describe("Abilities - Gulp Missile", () => {
   beforeEach(() => {
     game = new GameManager(phaserGame);
     game.override
+      .disableCrits()
       .battleType("single")
-      .moveset([ Moves.SURF, Moves.DIVE, Moves.SPLASH ])
+      .moveset([ Moves.SURF, Moves.DIVE, Moves.SPLASH, Moves.SUBSTITUTE ])
       .enemySpecies(Species.SNORLAX)
       .enemyAbility(Abilities.BALL_FETCH)
       .enemyMoveset(Moves.SPLASH)
@@ -234,6 +236,25 @@ describe("Abilities - Gulp Missile", () => {
     expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.DEF)).toBe(-1);
   });
 
+  it("doesn't trigger if user is behind a substitute", async () => {
+    game.override
+      .enemyAbility(Abilities.STURDY)
+      .enemyMoveset([ Moves.SPLASH, Moves.POWER_TRIP ]);
+    await game.classicMode.startBattle([ Species.CRAMORANT ]);
+
+    game.move.select(Moves.SURF);
+    await game.forceEnemyMove(Moves.SPLASH);
+    await game.toNextTurn();
+
+    expect(game.scene.getPlayerPokemon()!.formIndex).toBe(GULPING_FORM);
+
+    game.move.select(Moves.SUBSTITUTE);
+    await game.forceEnemyMove(Moves.POWER_TRIP);
+    await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
+    await game.toNextTurn();
+
+    expect(game.scene.getPlayerPokemon()!.formIndex).toBe(GULPING_FORM);
+  });
 
   it("cannot be suppressed", async () => {
     game.override.enemyMoveset(Moves.GASTRO_ACID);
diff --git a/src/test/abilities/ice_face.test.ts b/src/test/abilities/ice_face.test.ts
index 723d5e8d855..1c7f7bd6093 100644
--- a/src/test/abilities/ice_face.test.ts
+++ b/src/test/abilities/ice_face.test.ts
@@ -1,3 +1,4 @@
+import { BattlerIndex } from "#app/battle";
 import { MoveEffectPhase } from "#app/phases/move-effect-phase";
 import { MoveEndPhase } from "#app/phases/move-end-phase";
 import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase";
@@ -36,7 +37,7 @@ describe("Abilities - Ice Face", () => {
   });
 
   it("takes no damage from physical move and transforms to Noice", async () => {
-    await game.startBattle([ Species.HITMONLEE ]);
+    await game.classicMode.startBattle([ Species.HITMONLEE ]);
 
     game.move.select(Moves.TACKLE);
 
@@ -52,7 +53,7 @@ describe("Abilities - Ice Face", () => {
   it("takes no damage from the first hit of multihit physical move and transforms to Noice", async () => {
     game.override.moveset([ Moves.SURGING_STRIKES ]);
     game.override.enemyLevel(1);
-    await game.startBattle([ Species.HITMONLEE ]);
+    await game.classicMode.startBattle([ Species.HITMONLEE ]);
 
     game.move.select(Moves.SURGING_STRIKES);
 
@@ -78,7 +79,7 @@ describe("Abilities - Ice Face", () => {
   });
 
   it("takes damage from special moves", async () => {
-    await game.startBattle([ Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
     game.move.select(Moves.ICE_BEAM);
 
@@ -92,7 +93,7 @@ describe("Abilities - Ice Face", () => {
   });
 
   it("takes effects from status moves", async () => {
-    await game.startBattle([ Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
     game.move.select(Moves.TOXIC_THREAD);
 
@@ -108,7 +109,7 @@ describe("Abilities - Ice Face", () => {
     game.override.moveset([ Moves.QUICK_ATTACK ]);
     game.override.enemyMoveset([ Moves.HAIL, Moves.HAIL, Moves.HAIL, Moves.HAIL ]);
 
-    await game.startBattle([ Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
     game.move.select(Moves.QUICK_ATTACK);
 
@@ -130,7 +131,7 @@ describe("Abilities - Ice Face", () => {
     game.override.enemyMoveset([ Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE ]);
     game.override.moveset([ Moves.SNOWSCAPE ]);
 
-    await game.startBattle([ Species.EISCUE, Species.NINJASK ]);
+    await game.classicMode.startBattle([ Species.EISCUE, Species.NINJASK ]);
 
     game.move.select(Moves.SNOWSCAPE);
 
@@ -157,7 +158,7 @@ describe("Abilities - Ice Face", () => {
     game.override.enemySpecies(Species.SHUCKLE);
     game.override.enemyMoveset([ Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE ]);
 
-    await game.startBattle([ Species.EISCUE ]);
+    await game.classicMode.startBattle([ Species.EISCUE ]);
 
     game.move.select(Moves.HAIL);
     const eiscue = game.scene.getPlayerPokemon()!;
@@ -176,7 +177,7 @@ describe("Abilities - Ice Face", () => {
   it("persists form change when switched out", async () => {
     game.override.enemyMoveset([ Moves.QUICK_ATTACK, Moves.QUICK_ATTACK, Moves.QUICK_ATTACK, Moves.QUICK_ATTACK ]);
 
-    await game.startBattle([ Species.EISCUE, Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.EISCUE, Species.MAGIKARP ]);
 
     game.move.select(Moves.ICE_BEAM);
 
@@ -205,7 +206,7 @@ describe("Abilities - Ice Face", () => {
       [Species.EISCUE]: noiceForm,
     });
 
-    await game.startBattle([ Species.EISCUE ]);
+    await game.classicMode.startBattle([ Species.EISCUE ]);
 
     const eiscue = game.scene.getPlayerPokemon()!;
 
@@ -222,10 +223,23 @@ describe("Abilities - Ice Face", () => {
     expect(eiscue.getTag(BattlerTagType.ICE_FACE)).not.toBe(undefined);
   });
 
+  it("doesn't trigger if user is behind a substitute", async () => {
+    game.override
+      .enemyMoveset(Moves.SUBSTITUTE)
+      .moveset(Moves.POWER_TRIP);
+    await game.classicMode.startBattle();
+
+    game.move.select(Moves.POWER_TRIP);
+    await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
+    await game.toNextTurn();
+
+    expect(game.scene.getEnemyPokemon()!.formIndex).toBe(icefaceForm);
+  });
+
   it("cannot be suppressed", async () => {
     game.override.moveset([ Moves.GASTRO_ACID ]);
 
-    await game.startBattle([ Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
     game.move.select(Moves.GASTRO_ACID);
 
@@ -241,7 +255,7 @@ describe("Abilities - Ice Face", () => {
   it("cannot be swapped with another ability", async () => {
     game.override.moveset([ Moves.SKILL_SWAP ]);
 
-    await game.startBattle([ Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
     game.move.select(Moves.SKILL_SWAP);
 
@@ -257,7 +271,7 @@ describe("Abilities - Ice Face", () => {
   it("cannot be copied", async () => {
     game.override.ability(Abilities.TRACE);
 
-    await game.startBattle([ Species.MAGIKARP ]);
+    await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
     game.move.select(Moves.SIMPLE_BEAM);
 

From 0996789ee6f232c45cf4d6597c78906d8e7c2cdc Mon Sep 17 00:00:00 2001
From: "Adrian T." <68144167+torranx@users.noreply.github.com>
Date: Thu, 10 Oct 2024 23:54:43 +0800
Subject: [PATCH 12/15] [Refactor] Improve typing in `phaseInterceptor.ts`
 (#4560)

* improve typing in phaseInterceptor

* add more param typings

---------

Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
---
 src/test/utils/phaseInterceptor.ts | 126 +++++++++++++++++++++++++++--
 1 file changed, 118 insertions(+), 8 deletions(-)

diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts
index d108c4cb2ea..ec9309e2405 100644
--- a/src/test/utils/phaseInterceptor.ts
+++ b/src/test/utils/phaseInterceptor.ts
@@ -53,6 +53,7 @@ import {
 } from "#app/phases/mystery-encounter-phases";
 import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase";
 import { PartyExpPhase } from "#app/phases/party-exp-phase";
+import { ExpPhase } from "#app/phases/exp-phase";
 
 export interface PromptHandler {
   phaseTarget?: string;
@@ -61,7 +62,114 @@ export interface PromptHandler {
   expireFn?: () => void;
   awaitingActionInput?: boolean;
 }
-import { ExpPhase } from "#app/phases/exp-phase";
+
+type PhaseClass =
+  | typeof LoginPhase
+  | typeof TitlePhase
+  | typeof SelectGenderPhase
+  | typeof EncounterPhase
+  | typeof NewBiomeEncounterPhase
+  | typeof SelectStarterPhase
+  | typeof PostSummonPhase
+  | typeof SummonPhase
+  | typeof ToggleDoublePositionPhase
+  | typeof CheckSwitchPhase
+  | typeof ShowAbilityPhase
+  | typeof MessagePhase
+  | typeof TurnInitPhase
+  | typeof CommandPhase
+  | typeof EnemyCommandPhase
+  | typeof TurnStartPhase
+  | typeof MovePhase
+  | typeof MoveEffectPhase
+  | typeof DamagePhase
+  | typeof FaintPhase
+  | typeof BerryPhase
+  | typeof TurnEndPhase
+  | typeof BattleEndPhase
+  | typeof EggLapsePhase
+  | typeof SelectModifierPhase
+  | typeof NextEncounterPhase
+  | typeof NewBattlePhase
+  | typeof VictoryPhase
+  | typeof LearnMovePhase
+  | typeof MoveEndPhase
+  | typeof StatStageChangePhase
+  | typeof ShinySparklePhase
+  | typeof SelectTargetPhase
+  | typeof UnavailablePhase
+  | typeof QuietFormChangePhase
+  | typeof SwitchPhase
+  | typeof SwitchSummonPhase
+  | typeof PartyHealPhase
+  | typeof EvolutionPhase
+  | typeof EndEvolutionPhase
+  | typeof LevelCapPhase
+  | typeof AttemptRunPhase
+  | typeof SelectBiomePhase
+  | typeof MysteryEncounterPhase
+  | typeof MysteryEncounterOptionSelectedPhase
+  | typeof MysteryEncounterBattlePhase
+  | typeof MysteryEncounterRewardsPhase
+  | typeof PostMysteryEncounterPhase
+  | typeof ModifierRewardPhase
+  | typeof PartyExpPhase
+  | typeof ExpPhase;
+
+type PhaseString =
+  | "LoginPhase"
+  | "TitlePhase"
+  | "SelectGenderPhase"
+  | "EncounterPhase"
+  | "NewBiomeEncounterPhase"
+  | "SelectStarterPhase"
+  | "PostSummonPhase"
+  | "SummonPhase"
+  | "ToggleDoublePositionPhase"
+  | "CheckSwitchPhase"
+  | "ShowAbilityPhase"
+  | "MessagePhase"
+  | "TurnInitPhase"
+  | "CommandPhase"
+  | "EnemyCommandPhase"
+  | "TurnStartPhase"
+  | "MovePhase"
+  | "MoveEffectPhase"
+  | "DamagePhase"
+  | "FaintPhase"
+  | "BerryPhase"
+  | "TurnEndPhase"
+  | "BattleEndPhase"
+  | "EggLapsePhase"
+  | "SelectModifierPhase"
+  | "NextEncounterPhase"
+  | "NewBattlePhase"
+  | "VictoryPhase"
+  | "LearnMovePhase"
+  | "MoveEndPhase"
+  | "StatStageChangePhase"
+  | "ShinySparklePhase"
+  | "SelectTargetPhase"
+  | "UnavailablePhase"
+  | "QuietFormChangePhase"
+  | "SwitchPhase"
+  | "SwitchSummonPhase"
+  | "PartyHealPhase"
+  | "EvolutionPhase"
+  | "EndEvolutionPhase"
+  | "LevelCapPhase"
+  | "AttemptRunPhase"
+  | "SelectBiomePhase"
+  | "MysteryEncounterPhase"
+  | "MysteryEncounterOptionSelectedPhase"
+  | "MysteryEncounterBattlePhase"
+  | "MysteryEncounterRewardsPhase"
+  | "PostMysteryEncounterPhase"
+  | "ModifierRewardPhase"
+  | "PartyExpPhase"
+  | "ExpPhase";
+
+type PhaseInterceptorPhase = PhaseClass | PhaseString;
 
 export default class PhaseInterceptor {
   public scene;
@@ -172,7 +280,7 @@ export default class PhaseInterceptor {
    * @param phaseFrom - The phase to start from.
    * @returns The instance of the PhaseInterceptor.
    */
-  runFrom(phaseFrom) {
+  runFrom(phaseFrom: PhaseInterceptorPhase): PhaseInterceptor {
     this.phaseFrom = phaseFrom;
     return this;
   }
@@ -180,9 +288,10 @@ export default class PhaseInterceptor {
   /**
    * Method to transition to a target phase.
    * @param phaseTo - The phase to transition to.
+   * @param runTarget - Whether or not to run the target phase.
    * @returns A promise that resolves when the transition is complete.
    */
-  async to(phaseTo, runTarget: boolean = true): Promise<void> {
+  async to(phaseTo: PhaseInterceptorPhase, runTarget: boolean = true): Promise<void> {
     return new Promise(async (resolve, reject) => {
       ErrorInterceptor.getInstance().add(this);
       if (this.phaseFrom) {
@@ -219,7 +328,7 @@ export default class PhaseInterceptor {
    * @param skipFn - Optional skip function.
    * @returns A promise that resolves when the phase is run.
    */
-  run(phaseTarget, skipFn?): Promise<void> {
+  run(phaseTarget: PhaseInterceptorPhase, skipFn?: (className: PhaseClass) => boolean): Promise<void> {
     const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
     this.scene.moveAnimations = null; // Mandatory to avoid crash
     return new Promise(async (resolve, reject) => {
@@ -253,7 +362,7 @@ export default class PhaseInterceptor {
     });
   }
 
-  whenAboutToRun(phaseTarget, skipFn?): Promise<void> {
+  whenAboutToRun(phaseTarget: PhaseInterceptorPhase, skipFn?: (className: PhaseClass) => boolean): Promise<void> {
     const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
     this.scene.moveAnimations = null; // Mandatory to avoid crash
     return new Promise(async (resolve, reject) => {
@@ -311,7 +420,7 @@ export default class PhaseInterceptor {
    * Method to start a phase and log it.
    * @param phase - The phase to start.
    */
-  startPhase(phase) {
+  startPhase(phase: PhaseClass) {
     this.log.push(phase.name);
     const instance = this.scene.getCurrentPhase();
     this.onHold.push({
@@ -340,9 +449,10 @@ export default class PhaseInterceptor {
 
   /**
    * m2m to set mode.
-   * @param phase - The phase to start.
+   * @param mode - The {@linkcode Mode} to set.
+   * @param args - Additional arguments to pass to the original method.
    */
-  setMode(mode: Mode, ...args: any[]): Promise<void> {
+  setMode(mode: Mode, ...args: unknown[]): Promise<void> {
     const currentPhase = this.scene.getCurrentPhase();
     const instance = this.scene.ui;
     console.log("setMode", `${Mode[mode]} (=${mode})`, args);

From 6ad5ba972cc7eafc451ed264bae3e1c153463fd8 Mon Sep 17 00:00:00 2001
From: ImperialSympathizer <110984302+ben-lear@users.noreply.github.com>
Date: Thu, 10 Oct 2024 12:29:26 -0400
Subject: [PATCH 13/15] [Enhancement] Refactor Starter Species to use separate
 EggTier map (#4591)

* creates table for tracking species egg tiers

* creates table for tracking species egg tiers

* rename EggTier enum values

* replace clamp util function with Phaser function

---------

Co-authored-by: ImperialSympathizer <imperialsympathizer@gmail.com>
---
 src/data/balance/species-egg-tiers.ts         | 603 ++++++++++++++++++
 src/data/egg.ts                               |  91 ++-
 .../encounters/a-trainers-test-encounter.ts   |   4 +-
 .../the-expert-pokemon-breeder-encounter.ts   |   2 +-
 src/enums/egg-type.ts                         |   6 +-
 src/field/pokemon.ts                          |   2 +-
 src/test/eggs/egg.test.ts                     |  36 +-
 .../a-trainers-test-encounter.test.ts         |   4 +-
 .../the-expert-breeder-encounter.test.ts      |   6 +-
 src/ui/battle-info.ts                         |   2 +-
 src/ui/egg-gacha-ui-handler.ts                |   6 +-
 src/ui/text.ts                                |   6 +-
 src/utils.ts                                  |   4 -
 13 files changed, 676 insertions(+), 96 deletions(-)
 create mode 100644 src/data/balance/species-egg-tiers.ts

diff --git a/src/data/balance/species-egg-tiers.ts b/src/data/balance/species-egg-tiers.ts
new file mode 100644
index 00000000000..cd266dfcf54
--- /dev/null
+++ b/src/data/balance/species-egg-tiers.ts
@@ -0,0 +1,603 @@
+import { Species } from "#enums/species";
+import { EggTier } from "#enums/egg-type";
+
+/**
+ * Map of all starters and their respective {@linkcode EggTier}, which determines the type of egg the starter hatches from.
+ */
+export const speciesEggTiers = {
+  [Species.BULBASAUR]: EggTier.COMMON,
+  [Species.CHARMANDER]: EggTier.COMMON,
+  [Species.SQUIRTLE]: EggTier.COMMON,
+  [Species.CATERPIE]: EggTier.COMMON,
+  [Species.WEEDLE]: EggTier.COMMON,
+  [Species.PIDGEY]: EggTier.COMMON,
+  [Species.RATTATA]: EggTier.COMMON,
+  [Species.SPEAROW]: EggTier.COMMON,
+  [Species.EKANS]: EggTier.COMMON,
+  [Species.PIKACHU]: EggTier.COMMON,
+  [Species.SANDSHREW]: EggTier.COMMON,
+  [Species.NIDORAN_F]: EggTier.COMMON,
+  [Species.NIDORAN_M]: EggTier.COMMON,
+  [Species.CLEFAIRY]: EggTier.COMMON,
+  [Species.VULPIX]: EggTier.COMMON,
+  [Species.JIGGLYPUFF]: EggTier.COMMON,
+  [Species.ZUBAT]: EggTier.COMMON,
+  [Species.ODDISH]: EggTier.COMMON,
+  [Species.PARAS]: EggTier.COMMON,
+  [Species.VENONAT]: EggTier.COMMON,
+  [Species.DIGLETT]: EggTier.COMMON,
+  [Species.MEOWTH]: EggTier.COMMON,
+  [Species.PSYDUCK]: EggTier.COMMON,
+  [Species.MANKEY]: EggTier.RARE,
+  [Species.GROWLITHE]: EggTier.RARE,
+  [Species.POLIWAG]: EggTier.COMMON,
+  [Species.ABRA]: EggTier.RARE,
+  [Species.MACHOP]: EggTier.COMMON,
+  [Species.BELLSPROUT]: EggTier.COMMON,
+  [Species.TENTACOOL]: EggTier.COMMON,
+  [Species.GEODUDE]: EggTier.COMMON,
+  [Species.PONYTA]: EggTier.COMMON,
+  [Species.SLOWPOKE]: EggTier.COMMON,
+  [Species.MAGNEMITE]: EggTier.RARE,
+  [Species.FARFETCHD]: EggTier.COMMON,
+  [Species.DODUO]: EggTier.COMMON,
+  [Species.SEEL]: EggTier.COMMON,
+  [Species.GRIMER]: EggTier.COMMON,
+  [Species.SHELLDER]: EggTier.RARE,
+  [Species.GASTLY]: EggTier.RARE,
+  [Species.ONIX]: EggTier.COMMON,
+  [Species.DROWZEE]: EggTier.COMMON,
+  [Species.KRABBY]: EggTier.COMMON,
+  [Species.VOLTORB]: EggTier.COMMON,
+  [Species.EXEGGCUTE]: EggTier.COMMON,
+  [Species.CUBONE]: EggTier.COMMON,
+  [Species.HITMONLEE]: EggTier.RARE,
+  [Species.HITMONCHAN]: EggTier.RARE,
+  [Species.LICKITUNG]: EggTier.COMMON,
+  [Species.KOFFING]: EggTier.COMMON,
+  [Species.RHYHORN]: EggTier.COMMON,
+  [Species.CHANSEY]: EggTier.COMMON,
+  [Species.TANGELA]: EggTier.COMMON,
+  [Species.KANGASKHAN]: EggTier.RARE,
+  [Species.HORSEA]: EggTier.COMMON,
+  [Species.GOLDEEN]: EggTier.COMMON,
+  [Species.STARYU]: EggTier.COMMON,
+  [Species.MR_MIME]: EggTier.COMMON,
+  [Species.SCYTHER]: EggTier.RARE,
+  [Species.JYNX]: EggTier.RARE,
+  [Species.ELECTABUZZ]: EggTier.RARE,
+  [Species.MAGMAR]: EggTier.RARE,
+  [Species.PINSIR]: EggTier.RARE,
+  [Species.TAUROS]: EggTier.RARE,
+  [Species.MAGIKARP]: EggTier.RARE,
+  [Species.LAPRAS]: EggTier.RARE,
+  [Species.DITTO]: EggTier.COMMON,
+  [Species.EEVEE]: EggTier.COMMON,
+  [Species.PORYGON]: EggTier.RARE,
+  [Species.OMANYTE]: EggTier.COMMON,
+  [Species.KABUTO]: EggTier.COMMON,
+  [Species.AERODACTYL]: EggTier.RARE,
+  [Species.SNORLAX]: EggTier.RARE,
+  [Species.ARTICUNO]: EggTier.EPIC,
+  [Species.ZAPDOS]: EggTier.EPIC,
+  [Species.MOLTRES]: EggTier.EPIC,
+  [Species.DRATINI]: EggTier.RARE,
+  [Species.MEWTWO]: EggTier.LEGENDARY,
+  [Species.MEW]: EggTier.EPIC,
+
+  [Species.CHIKORITA]: EggTier.COMMON,
+  [Species.CYNDAQUIL]: EggTier.COMMON,
+  [Species.TOTODILE]: EggTier.COMMON,
+  [Species.SENTRET]: EggTier.COMMON,
+  [Species.HOOTHOOT]: EggTier.COMMON,
+  [Species.LEDYBA]: EggTier.COMMON,
+  [Species.SPINARAK]: EggTier.COMMON,
+  [Species.CHINCHOU]: EggTier.COMMON,
+  [Species.PICHU]: EggTier.COMMON,
+  [Species.CLEFFA]: EggTier.COMMON,
+  [Species.IGGLYBUFF]: EggTier.COMMON,
+  [Species.TOGEPI]: EggTier.COMMON,
+  [Species.NATU]: EggTier.COMMON,
+  [Species.MAREEP]: EggTier.COMMON,
+  [Species.MARILL]: EggTier.RARE,
+  [Species.SUDOWOODO]: EggTier.COMMON,
+  [Species.HOPPIP]: EggTier.COMMON,
+  [Species.AIPOM]: EggTier.COMMON,
+  [Species.SUNKERN]: EggTier.COMMON,
+  [Species.YANMA]: EggTier.COMMON,
+  [Species.WOOPER]: EggTier.COMMON,
+  [Species.MURKROW]: EggTier.COMMON,
+  [Species.MISDREAVUS]: EggTier.COMMON,
+  [Species.UNOWN]: EggTier.COMMON,
+  [Species.WOBBUFFET]: EggTier.COMMON,
+  [Species.GIRAFARIG]: EggTier.COMMON,
+  [Species.PINECO]: EggTier.COMMON,
+  [Species.DUNSPARCE]: EggTier.COMMON,
+  [Species.GLIGAR]: EggTier.COMMON,
+  [Species.SNUBBULL]: EggTier.COMMON,
+  [Species.QWILFISH]: EggTier.COMMON,
+  [Species.SHUCKLE]: EggTier.COMMON,
+  [Species.HERACROSS]: EggTier.RARE,
+  [Species.SNEASEL]: EggTier.RARE,
+  [Species.TEDDIURSA]: EggTier.RARE,
+  [Species.SLUGMA]: EggTier.COMMON,
+  [Species.SWINUB]: EggTier.COMMON,
+  [Species.CORSOLA]: EggTier.COMMON,
+  [Species.REMORAID]: EggTier.COMMON,
+  [Species.DELIBIRD]: EggTier.COMMON,
+  [Species.MANTINE]: EggTier.COMMON,
+  [Species.SKARMORY]: EggTier.RARE,
+  [Species.HOUNDOUR]: EggTier.COMMON,
+  [Species.PHANPY]: EggTier.COMMON,
+  [Species.STANTLER]: EggTier.COMMON,
+  [Species.SMEARGLE]: EggTier.COMMON,
+  [Species.TYROGUE]: EggTier.COMMON,
+  [Species.SMOOCHUM]: EggTier.COMMON,
+  [Species.ELEKID]: EggTier.COMMON,
+  [Species.MAGBY]: EggTier.COMMON,
+  [Species.MILTANK]: EggTier.RARE,
+  [Species.RAIKOU]: EggTier.EPIC,
+  [Species.ENTEI]: EggTier.EPIC,
+  [Species.SUICUNE]: EggTier.EPIC,
+  [Species.LARVITAR]: EggTier.RARE,
+  [Species.LUGIA]: EggTier.LEGENDARY,
+  [Species.HO_OH]: EggTier.LEGENDARY,
+  [Species.CELEBI]: EggTier.EPIC,
+
+  [Species.TREECKO]: EggTier.COMMON,
+  [Species.TORCHIC]: EggTier.RARE,
+  [Species.MUDKIP]: EggTier.COMMON,
+  [Species.POOCHYENA]: EggTier.COMMON,
+  [Species.ZIGZAGOON]: EggTier.COMMON,
+  [Species.WURMPLE]: EggTier.COMMON,
+  [Species.LOTAD]: EggTier.COMMON,
+  [Species.SEEDOT]: EggTier.COMMON,
+  [Species.TAILLOW]: EggTier.COMMON,
+  [Species.WINGULL]: EggTier.COMMON,
+  [Species.RALTS]: EggTier.COMMON,
+  [Species.SURSKIT]: EggTier.COMMON,
+  [Species.SHROOMISH]: EggTier.COMMON,
+  [Species.SLAKOTH]: EggTier.RARE,
+  [Species.NINCADA]: EggTier.RARE,
+  [Species.WHISMUR]: EggTier.COMMON,
+  [Species.MAKUHITA]: EggTier.COMMON,
+  [Species.AZURILL]: EggTier.RARE,
+  [Species.NOSEPASS]: EggTier.COMMON,
+  [Species.SKITTY]: EggTier.COMMON,
+  [Species.SABLEYE]: EggTier.COMMON,
+  [Species.MAWILE]: EggTier.COMMON,
+  [Species.ARON]: EggTier.COMMON,
+  [Species.MEDITITE]: EggTier.COMMON,
+  [Species.ELECTRIKE]: EggTier.COMMON,
+  [Species.PLUSLE]: EggTier.COMMON,
+  [Species.MINUN]: EggTier.COMMON,
+  [Species.VOLBEAT]: EggTier.COMMON,
+  [Species.ILLUMISE]: EggTier.COMMON,
+  [Species.ROSELIA]: EggTier.COMMON,
+  [Species.GULPIN]: EggTier.COMMON,
+  [Species.CARVANHA]: EggTier.COMMON,
+  [Species.WAILMER]: EggTier.COMMON,
+  [Species.NUMEL]: EggTier.COMMON,
+  [Species.TORKOAL]: EggTier.COMMON,
+  [Species.SPOINK]: EggTier.COMMON,
+  [Species.SPINDA]: EggTier.COMMON,
+  [Species.TRAPINCH]: EggTier.COMMON,
+  [Species.CACNEA]: EggTier.COMMON,
+  [Species.SWABLU]: EggTier.COMMON,
+  [Species.ZANGOOSE]: EggTier.RARE,
+  [Species.SEVIPER]: EggTier.COMMON,
+  [Species.LUNATONE]: EggTier.COMMON,
+  [Species.SOLROCK]: EggTier.COMMON,
+  [Species.BARBOACH]: EggTier.COMMON,
+  [Species.CORPHISH]: EggTier.COMMON,
+  [Species.BALTOY]: EggTier.COMMON,
+  [Species.LILEEP]: EggTier.COMMON,
+  [Species.ANORITH]: EggTier.COMMON,
+  [Species.FEEBAS]: EggTier.RARE,
+  [Species.CASTFORM]: EggTier.COMMON,
+  [Species.KECLEON]: EggTier.COMMON,
+  [Species.SHUPPET]: EggTier.COMMON,
+  [Species.DUSKULL]: EggTier.COMMON,
+  [Species.TROPIUS]: EggTier.COMMON,
+  [Species.CHIMECHO]: EggTier.COMMON,
+  [Species.ABSOL]: EggTier.RARE,
+  [Species.WYNAUT]: EggTier.COMMON,
+  [Species.SNORUNT]: EggTier.COMMON,
+  [Species.SPHEAL]: EggTier.COMMON,
+  [Species.CLAMPERL]: EggTier.COMMON,
+  [Species.RELICANTH]: EggTier.COMMON,
+  [Species.LUVDISC]: EggTier.COMMON,
+  [Species.BAGON]: EggTier.RARE,
+  [Species.BELDUM]: EggTier.RARE,
+  [Species.REGIROCK]: EggTier.EPIC,
+  [Species.REGICE]: EggTier.EPIC,
+  [Species.REGISTEEL]: EggTier.EPIC,
+  [Species.LATIAS]: EggTier.EPIC,
+  [Species.LATIOS]: EggTier.EPIC,
+  [Species.KYOGRE]: EggTier.LEGENDARY,
+  [Species.GROUDON]: EggTier.LEGENDARY,
+  [Species.RAYQUAZA]: EggTier.LEGENDARY,
+  [Species.JIRACHI]: EggTier.EPIC,
+  [Species.DEOXYS]: EggTier.EPIC,
+
+  [Species.TURTWIG]: EggTier.COMMON,
+  [Species.CHIMCHAR]: EggTier.COMMON,
+  [Species.PIPLUP]: EggTier.COMMON,
+  [Species.STARLY]: EggTier.COMMON,
+  [Species.BIDOOF]: EggTier.COMMON,
+  [Species.KRICKETOT]: EggTier.COMMON,
+  [Species.SHINX]: EggTier.COMMON,
+  [Species.BUDEW]: EggTier.COMMON,
+  [Species.CRANIDOS]: EggTier.COMMON,
+  [Species.SHIELDON]: EggTier.COMMON,
+  [Species.BURMY]: EggTier.COMMON,
+  [Species.COMBEE]: EggTier.COMMON,
+  [Species.PACHIRISU]: EggTier.COMMON,
+  [Species.BUIZEL]: EggTier.COMMON,
+  [Species.CHERUBI]: EggTier.COMMON,
+  [Species.SHELLOS]: EggTier.COMMON,
+  [Species.DRIFLOON]: EggTier.COMMON,
+  [Species.BUNEARY]: EggTier.COMMON,
+  [Species.GLAMEOW]: EggTier.COMMON,
+  [Species.CHINGLING]: EggTier.COMMON,
+  [Species.STUNKY]: EggTier.COMMON,
+  [Species.BRONZOR]: EggTier.COMMON,
+  [Species.BONSLY]: EggTier.COMMON,
+  [Species.MIME_JR]: EggTier.COMMON,
+  [Species.HAPPINY]: EggTier.COMMON,
+  [Species.CHATOT]: EggTier.COMMON,
+  [Species.SPIRITOMB]: EggTier.RARE,
+  [Species.GIBLE]: EggTier.RARE,
+  [Species.MUNCHLAX]: EggTier.RARE,
+  [Species.RIOLU]: EggTier.COMMON,
+  [Species.HIPPOPOTAS]: EggTier.COMMON,
+  [Species.SKORUPI]: EggTier.COMMON,
+  [Species.CROAGUNK]: EggTier.COMMON,
+  [Species.CARNIVINE]: EggTier.COMMON,
+  [Species.FINNEON]: EggTier.COMMON,
+  [Species.MANTYKE]: EggTier.COMMON,
+  [Species.SNOVER]: EggTier.COMMON,
+  [Species.ROTOM]: EggTier.RARE,
+  [Species.UXIE]: EggTier.EPIC,
+  [Species.MESPRIT]: EggTier.EPIC,
+  [Species.AZELF]: EggTier.EPIC,
+  [Species.DIALGA]: EggTier.LEGENDARY,
+  [Species.PALKIA]: EggTier.LEGENDARY,
+  [Species.HEATRAN]: EggTier.EPIC,
+  [Species.REGIGIGAS]: EggTier.EPIC,
+  [Species.GIRATINA]: EggTier.LEGENDARY,
+  [Species.CRESSELIA]: EggTier.EPIC,
+  [Species.PHIONE]: EggTier.RARE,
+  [Species.MANAPHY]: EggTier.EPIC,
+  [Species.DARKRAI]: EggTier.EPIC,
+  [Species.SHAYMIN]: EggTier.EPIC,
+  [Species.ARCEUS]: EggTier.LEGENDARY,
+
+  [Species.VICTINI]: EggTier.EPIC,
+  [Species.SNIVY]: EggTier.COMMON,
+  [Species.TEPIG]: EggTier.COMMON,
+  [Species.OSHAWOTT]: EggTier.COMMON,
+  [Species.PATRAT]: EggTier.COMMON,
+  [Species.LILLIPUP]: EggTier.COMMON,
+  [Species.PURRLOIN]: EggTier.COMMON,
+  [Species.PANSAGE]: EggTier.COMMON,
+  [Species.PANSEAR]: EggTier.COMMON,
+  [Species.PANPOUR]: EggTier.COMMON,
+  [Species.MUNNA]: EggTier.COMMON,
+  [Species.PIDOVE]: EggTier.COMMON,
+  [Species.BLITZLE]: EggTier.COMMON,
+  [Species.ROGGENROLA]: EggTier.COMMON,
+  [Species.WOOBAT]: EggTier.COMMON,
+  [Species.DRILBUR]: EggTier.RARE,
+  [Species.AUDINO]: EggTier.COMMON,
+  [Species.TIMBURR]: EggTier.RARE,
+  [Species.TYMPOLE]: EggTier.COMMON,
+  [Species.THROH]: EggTier.RARE,
+  [Species.SAWK]: EggTier.RARE,
+  [Species.SEWADDLE]: EggTier.COMMON,
+  [Species.VENIPEDE]: EggTier.COMMON,
+  [Species.COTTONEE]: EggTier.COMMON,
+  [Species.PETILIL]: EggTier.COMMON,
+  [Species.BASCULIN]: EggTier.RARE,
+  [Species.SANDILE]: EggTier.RARE,
+  [Species.DARUMAKA]: EggTier.RARE,
+  [Species.MARACTUS]: EggTier.COMMON,
+  [Species.DWEBBLE]: EggTier.COMMON,
+  [Species.SCRAGGY]: EggTier.COMMON,
+  [Species.SIGILYPH]: EggTier.RARE,
+  [Species.YAMASK]: EggTier.COMMON,
+  [Species.TIRTOUGA]: EggTier.COMMON,
+  [Species.ARCHEN]: EggTier.COMMON,
+  [Species.TRUBBISH]: EggTier.COMMON,
+  [Species.ZORUA]: EggTier.COMMON,
+  [Species.MINCCINO]: EggTier.COMMON,
+  [Species.GOTHITA]: EggTier.COMMON,
+  [Species.SOLOSIS]: EggTier.COMMON,
+  [Species.DUCKLETT]: EggTier.COMMON,
+  [Species.VANILLITE]: EggTier.COMMON,
+  [Species.DEERLING]: EggTier.COMMON,
+  [Species.EMOLGA]: EggTier.COMMON,
+  [Species.KARRABLAST]: EggTier.COMMON,
+  [Species.FOONGUS]: EggTier.COMMON,
+  [Species.FRILLISH]: EggTier.COMMON,
+  [Species.ALOMOMOLA]: EggTier.RARE,
+  [Species.JOLTIK]: EggTier.COMMON,
+  [Species.FERROSEED]: EggTier.COMMON,
+  [Species.KLINK]: EggTier.COMMON,
+  [Species.TYNAMO]: EggTier.COMMON,
+  [Species.ELGYEM]: EggTier.COMMON,
+  [Species.LITWICK]: EggTier.COMMON,
+  [Species.AXEW]: EggTier.RARE,
+  [Species.CUBCHOO]: EggTier.COMMON,
+  [Species.CRYOGONAL]: EggTier.RARE,
+  [Species.SHELMET]: EggTier.COMMON,
+  [Species.STUNFISK]: EggTier.COMMON,
+  [Species.MIENFOO]: EggTier.COMMON,
+  [Species.DRUDDIGON]: EggTier.RARE,
+  [Species.GOLETT]: EggTier.COMMON,
+  [Species.PAWNIARD]: EggTier.RARE,
+  [Species.BOUFFALANT]: EggTier.RARE,
+  [Species.RUFFLET]: EggTier.COMMON,
+  [Species.VULLABY]: EggTier.COMMON,
+  [Species.HEATMOR]: EggTier.COMMON,
+  [Species.DURANT]: EggTier.RARE,
+  [Species.DEINO]: EggTier.RARE,
+  [Species.LARVESTA]: EggTier.RARE,
+  [Species.COBALION]: EggTier.EPIC,
+  [Species.TERRAKION]: EggTier.EPIC,
+  [Species.VIRIZION]: EggTier.EPIC,
+  [Species.TORNADUS]: EggTier.EPIC,
+  [Species.THUNDURUS]: EggTier.EPIC,
+  [Species.RESHIRAM]: EggTier.LEGENDARY,
+  [Species.ZEKROM]: EggTier.LEGENDARY,
+  [Species.LANDORUS]: EggTier.EPIC,
+  [Species.KYUREM]: EggTier.LEGENDARY,
+  [Species.KELDEO]: EggTier.EPIC,
+  [Species.MELOETTA]: EggTier.EPIC,
+  [Species.GENESECT]: EggTier.EPIC,
+
+  [Species.CHESPIN]: EggTier.COMMON,
+  [Species.FENNEKIN]: EggTier.COMMON,
+  [Species.FROAKIE]: EggTier.RARE,
+  [Species.BUNNELBY]: EggTier.COMMON,
+  [Species.FLETCHLING]: EggTier.COMMON,
+  [Species.SCATTERBUG]: EggTier.COMMON,
+  [Species.LITLEO]: EggTier.COMMON,
+  [Species.FLABEBE]: EggTier.COMMON,
+  [Species.SKIDDO]: EggTier.COMMON,
+  [Species.PANCHAM]: EggTier.COMMON,
+  [Species.FURFROU]: EggTier.COMMON,
+  [Species.ESPURR]: EggTier.COMMON,
+  [Species.HONEDGE]: EggTier.RARE,
+  [Species.SPRITZEE]: EggTier.COMMON,
+  [Species.SWIRLIX]: EggTier.COMMON,
+  [Species.INKAY]: EggTier.COMMON,
+  [Species.BINACLE]: EggTier.COMMON,
+  [Species.SKRELP]: EggTier.COMMON,
+  [Species.CLAUNCHER]: EggTier.COMMON,
+  [Species.HELIOPTILE]: EggTier.COMMON,
+  [Species.TYRUNT]: EggTier.COMMON,
+  [Species.AMAURA]: EggTier.COMMON,
+  [Species.HAWLUCHA]: EggTier.RARE,
+  [Species.DEDENNE]: EggTier.COMMON,
+  [Species.CARBINK]: EggTier.COMMON,
+  [Species.GOOMY]: EggTier.RARE,
+  [Species.KLEFKI]: EggTier.COMMON,
+  [Species.PHANTUMP]: EggTier.COMMON,
+  [Species.PUMPKABOO]: EggTier.COMMON,
+  [Species.BERGMITE]: EggTier.COMMON,
+  [Species.NOIBAT]: EggTier.COMMON,
+  [Species.XERNEAS]: EggTier.LEGENDARY,
+  [Species.YVELTAL]: EggTier.LEGENDARY,
+  [Species.ZYGARDE]: EggTier.LEGENDARY,
+  [Species.DIANCIE]: EggTier.EPIC,
+  [Species.HOOPA]: EggTier.EPIC,
+  [Species.VOLCANION]: EggTier.EPIC,
+  [Species.ETERNAL_FLOETTE]: EggTier.RARE,
+
+  [Species.ROWLET]: EggTier.COMMON,
+  [Species.LITTEN]: EggTier.COMMON,
+  [Species.POPPLIO]: EggTier.RARE,
+  [Species.PIKIPEK]: EggTier.COMMON,
+  [Species.YUNGOOS]: EggTier.COMMON,
+  [Species.GRUBBIN]: EggTier.COMMON,
+  [Species.CRABRAWLER]: EggTier.COMMON,
+  [Species.ORICORIO]: EggTier.COMMON,
+  [Species.CUTIEFLY]: EggTier.COMMON,
+  [Species.ROCKRUFF]: EggTier.COMMON,
+  [Species.WISHIWASHI]: EggTier.COMMON,
+  [Species.MAREANIE]: EggTier.COMMON,
+  [Species.MUDBRAY]: EggTier.COMMON,
+  [Species.DEWPIDER]: EggTier.COMMON,
+  [Species.FOMANTIS]: EggTier.COMMON,
+  [Species.MORELULL]: EggTier.COMMON,
+  [Species.SALANDIT]: EggTier.COMMON,
+  [Species.STUFFUL]: EggTier.COMMON,
+  [Species.BOUNSWEET]: EggTier.COMMON,
+  [Species.COMFEY]: EggTier.RARE,
+  [Species.ORANGURU]: EggTier.RARE,
+  [Species.PASSIMIAN]: EggTier.RARE,
+  [Species.WIMPOD]: EggTier.COMMON,
+  [Species.SANDYGAST]: EggTier.COMMON,
+  [Species.PYUKUMUKU]: EggTier.COMMON,
+  [Species.TYPE_NULL]: EggTier.RARE,
+  [Species.MINIOR]: EggTier.RARE,
+  [Species.KOMALA]: EggTier.COMMON,
+  [Species.TURTONATOR]: EggTier.RARE,
+  [Species.TOGEDEMARU]: EggTier.COMMON,
+  [Species.MIMIKYU]: EggTier.RARE,
+  [Species.BRUXISH]: EggTier.RARE,
+  [Species.DRAMPA]: EggTier.RARE,
+  [Species.DHELMISE]: EggTier.RARE,
+  [Species.JANGMO_O]: EggTier.RARE,
+  [Species.TAPU_KOKO]: EggTier.EPIC,
+  [Species.TAPU_LELE]: EggTier.EPIC,
+  [Species.TAPU_BULU]: EggTier.EPIC,
+  [Species.TAPU_FINI]: EggTier.EPIC,
+  [Species.COSMOG]: EggTier.EPIC,
+  [Species.NIHILEGO]: EggTier.EPIC,
+  [Species.BUZZWOLE]: EggTier.EPIC,
+  [Species.PHEROMOSA]: EggTier.EPIC,
+  [Species.XURKITREE]: EggTier.EPIC,
+  [Species.CELESTEELA]: EggTier.EPIC,
+  [Species.KARTANA]: EggTier.EPIC,
+  [Species.GUZZLORD]: EggTier.EPIC,
+  [Species.NECROZMA]: EggTier.LEGENDARY,
+  [Species.MAGEARNA]: EggTier.EPIC,
+  [Species.MARSHADOW]: EggTier.EPIC,
+  [Species.POIPOLE]: EggTier.EPIC,
+  [Species.STAKATAKA]: EggTier.EPIC,
+  [Species.BLACEPHALON]: EggTier.EPIC,
+  [Species.ZERAORA]: EggTier.EPIC,
+  [Species.MELTAN]: EggTier.EPIC,
+  [Species.ALOLA_RATTATA]: EggTier.COMMON,
+  [Species.ALOLA_SANDSHREW]: EggTier.COMMON,
+  [Species.ALOLA_VULPIX]: EggTier.COMMON,
+  [Species.ALOLA_DIGLETT]: EggTier.COMMON,
+  [Species.ALOLA_MEOWTH]: EggTier.COMMON,
+  [Species.ALOLA_GEODUDE]: EggTier.COMMON,
+  [Species.ALOLA_GRIMER]: EggTier.COMMON,
+
+  [Species.GROOKEY]: EggTier.COMMON,
+  [Species.SCORBUNNY]: EggTier.RARE,
+  [Species.SOBBLE]: EggTier.COMMON,
+  [Species.SKWOVET]: EggTier.COMMON,
+  [Species.ROOKIDEE]: EggTier.COMMON,
+  [Species.BLIPBUG]: EggTier.COMMON,
+  [Species.NICKIT]: EggTier.COMMON,
+  [Species.GOSSIFLEUR]: EggTier.COMMON,
+  [Species.WOOLOO]: EggTier.COMMON,
+  [Species.CHEWTLE]: EggTier.COMMON,
+  [Species.YAMPER]: EggTier.COMMON,
+  [Species.ROLYCOLY]: EggTier.COMMON,
+  [Species.APPLIN]: EggTier.COMMON,
+  [Species.SILICOBRA]: EggTier.COMMON,
+  [Species.CRAMORANT]: EggTier.COMMON,
+  [Species.ARROKUDA]: EggTier.COMMON,
+  [Species.TOXEL]: EggTier.COMMON,
+  [Species.SIZZLIPEDE]: EggTier.COMMON,
+  [Species.CLOBBOPUS]: EggTier.COMMON,
+  [Species.SINISTEA]: EggTier.COMMON,
+  [Species.HATENNA]: EggTier.COMMON,
+  [Species.IMPIDIMP]: EggTier.COMMON,
+  [Species.MILCERY]: EggTier.COMMON,
+  [Species.FALINKS]: EggTier.RARE,
+  [Species.PINCURCHIN]: EggTier.COMMON,
+  [Species.SNOM]: EggTier.COMMON,
+  [Species.STONJOURNER]: EggTier.COMMON,
+  [Species.EISCUE]: EggTier.COMMON,
+  [Species.INDEEDEE]: EggTier.RARE,
+  [Species.MORPEKO]: EggTier.COMMON,
+  [Species.CUFANT]: EggTier.COMMON,
+  [Species.DRACOZOLT]: EggTier.RARE,
+  [Species.ARCTOZOLT]: EggTier.RARE,
+  [Species.DRACOVISH]: EggTier.RARE,
+  [Species.ARCTOVISH]: EggTier.RARE,
+  [Species.DURALUDON]: EggTier.RARE,
+  [Species.DREEPY]: EggTier.RARE,
+  [Species.ZACIAN]: EggTier.LEGENDARY,
+  [Species.ZAMAZENTA]: EggTier.LEGENDARY,
+  [Species.ETERNATUS]: EggTier.COMMON,
+  [Species.KUBFU]: EggTier.EPIC,
+  [Species.ZARUDE]: EggTier.EPIC,
+  [Species.REGIELEKI]: EggTier.EPIC,
+  [Species.REGIDRAGO]: EggTier.EPIC,
+  [Species.GLASTRIER]: EggTier.EPIC,
+  [Species.SPECTRIER]: EggTier.EPIC,
+  [Species.CALYREX]: EggTier.LEGENDARY,
+  [Species.GALAR_MEOWTH]: EggTier.COMMON,
+  [Species.GALAR_PONYTA]: EggTier.COMMON,
+  [Species.GALAR_SLOWPOKE]: EggTier.COMMON,
+  [Species.GALAR_FARFETCHD]: EggTier.COMMON,
+  [Species.GALAR_CORSOLA]: EggTier.COMMON,
+  [Species.GALAR_ZIGZAGOON]: EggTier.COMMON,
+  [Species.GALAR_DARUMAKA]: EggTier.RARE,
+  [Species.GALAR_YAMASK]: EggTier.COMMON,
+  [Species.GALAR_STUNFISK]: EggTier.COMMON,
+  [Species.GALAR_MR_MIME]: EggTier.COMMON,
+  [Species.GALAR_ARTICUNO]: EggTier.EPIC,
+  [Species.GALAR_ZAPDOS]: EggTier.EPIC,
+  [Species.GALAR_MOLTRES]: EggTier.EPIC,
+  [Species.HISUI_GROWLITHE]: EggTier.RARE,
+  [Species.HISUI_VOLTORB]: EggTier.COMMON,
+  [Species.HISUI_QWILFISH]: EggTier.RARE,
+  [Species.HISUI_SNEASEL]: EggTier.RARE,
+  [Species.HISUI_ZORUA]: EggTier.COMMON,
+  [Species.ENAMORUS]: EggTier.EPIC,
+
+  [Species.SPRIGATITO]: EggTier.RARE,
+  [Species.FUECOCO]: EggTier.RARE,
+  [Species.QUAXLY]: EggTier.RARE,
+  [Species.LECHONK]: EggTier.COMMON,
+  [Species.TAROUNTULA]: EggTier.COMMON,
+  [Species.NYMBLE]: EggTier.COMMON,
+  [Species.PAWMI]: EggTier.COMMON,
+  [Species.TANDEMAUS]: EggTier.RARE,
+  [Species.FIDOUGH]: EggTier.COMMON,
+  [Species.SMOLIV]: EggTier.COMMON,
+  [Species.SQUAWKABILLY]: EggTier.COMMON,
+  [Species.NACLI]: EggTier.RARE,
+  [Species.CHARCADET]: EggTier.RARE,
+  [Species.TADBULB]: EggTier.COMMON,
+  [Species.WATTREL]: EggTier.COMMON,
+  [Species.MASCHIFF]: EggTier.COMMON,
+  [Species.SHROODLE]: EggTier.COMMON,
+  [Species.BRAMBLIN]: EggTier.COMMON,
+  [Species.TOEDSCOOL]: EggTier.COMMON,
+  [Species.KLAWF]: EggTier.COMMON,
+  [Species.CAPSAKID]: EggTier.COMMON,
+  [Species.RELLOR]: EggTier.COMMON,
+  [Species.FLITTLE]: EggTier.COMMON,
+  [Species.TINKATINK]: EggTier.RARE,
+  [Species.WIGLETT]: EggTier.COMMON,
+  [Species.BOMBIRDIER]: EggTier.COMMON,
+  [Species.FINIZEN]: EggTier.COMMON,
+  [Species.VAROOM]: EggTier.RARE,
+  [Species.CYCLIZAR]: EggTier.RARE,
+  [Species.ORTHWORM]: EggTier.RARE,
+  [Species.GLIMMET]: EggTier.RARE,
+  [Species.GREAVARD]: EggTier.COMMON,
+  [Species.FLAMIGO]: EggTier.RARE,
+  [Species.CETODDLE]: EggTier.COMMON,
+  [Species.VELUZA]: EggTier.RARE,
+  [Species.DONDOZO]: EggTier.RARE,
+  [Species.TATSUGIRI]: EggTier.RARE,
+  [Species.GREAT_TUSK]: EggTier.EPIC,
+  [Species.SCREAM_TAIL]: EggTier.EPIC,
+  [Species.BRUTE_BONNET]: EggTier.EPIC,
+  [Species.FLUTTER_MANE]: EggTier.EPIC,
+  [Species.SLITHER_WING]: EggTier.EPIC,
+  [Species.SANDY_SHOCKS]: EggTier.EPIC,
+  [Species.IRON_TREADS]: EggTier.EPIC,
+  [Species.IRON_BUNDLE]: EggTier.EPIC,
+  [Species.IRON_HANDS]: EggTier.EPIC,
+  [Species.IRON_JUGULIS]: EggTier.EPIC,
+  [Species.IRON_MOTH]: EggTier.EPIC,
+  [Species.IRON_THORNS]: EggTier.EPIC,
+  [Species.FRIGIBAX]: EggTier.RARE,
+  [Species.GIMMIGHOUL]: EggTier.RARE,
+  [Species.WO_CHIEN]: EggTier.EPIC,
+  [Species.CHIEN_PAO]: EggTier.EPIC,
+  [Species.TING_LU]: EggTier.EPIC,
+  [Species.CHI_YU]: EggTier.EPIC,
+  [Species.ROARING_MOON]: EggTier.EPIC,
+  [Species.IRON_VALIANT]: EggTier.EPIC,
+  [Species.KORAIDON]: EggTier.LEGENDARY,
+  [Species.MIRAIDON]: EggTier.LEGENDARY,
+  [Species.WALKING_WAKE]: EggTier.EPIC,
+  [Species.IRON_LEAVES]: EggTier.EPIC,
+  [Species.POLTCHAGEIST]: EggTier.RARE,
+  [Species.OKIDOGI]: EggTier.EPIC,
+  [Species.MUNKIDORI]: EggTier.EPIC,
+  [Species.FEZANDIPITI]: EggTier.EPIC,
+  [Species.OGERPON]: EggTier.EPIC,
+  [Species.GOUGING_FIRE]: EggTier.EPIC,
+  [Species.RAGING_BOLT]: EggTier.EPIC,
+  [Species.IRON_BOULDER]: EggTier.EPIC,
+  [Species.IRON_CROWN]: EggTier.EPIC,
+  [Species.TERAPAGOS]: EggTier.LEGENDARY,
+  [Species.PECHARUNT]: EggTier.EPIC,
+  [Species.PALDEA_TAUROS]: EggTier.RARE,
+  [Species.PALDEA_WOOPER]: EggTier.COMMON,
+  [Species.BLOODMOON_URSALUNA]: EggTier.EPIC,
+};
diff --git a/src/data/egg.ts b/src/data/egg.ts
index 5fffe4fcece..c475fc729e6 100644
--- a/src/data/egg.ts
+++ b/src/data/egg.ts
@@ -11,6 +11,7 @@ import { EggTier } from "#enums/egg-type";
 import { Species } from "#enums/species";
 import { EggSourceType } from "#enums/egg-source-types";
 import { MANAPHY_EGG_MANAPHY_RATE, SAME_SPECIES_EGG_HA_RATE, GACHA_EGG_HA_RATE, GACHA_DEFAULT_RARE_EGGMOVE_RATE, SAME_SPECIES_EGG_RARE_EGGMOVE_RATE, GACHA_MOVE_UP_RARE_EGGMOVE_RATE, GACHA_DEFAULT_SHINY_RATE, GACHA_SHINY_UP_SHINY_RATE, SAME_SPECIES_EGG_SHINY_RATE, EGG_PITY_LEGENDARY_THRESHOLD, EGG_PITY_EPIC_THRESHOLD, EGG_PITY_RARE_THRESHOLD, SHINY_VARIANT_CHANCE, SHINY_EPIC_CHANCE, GACHA_DEFAULT_COMMON_EGG_THRESHOLD, GACHA_DEFAULT_RARE_EGG_THRESHOLD, GACHA_DEFAULT_EPIC_EGG_THRESHOLD, GACHA_LEGENDARY_UP_THRESHOLD_OFFSET, HATCH_WAVES_MANAPHY_EGG, HATCH_WAVES_COMMON_EGG, HATCH_WAVES_RARE_EGG, HATCH_WAVES_EPIC_EGG, HATCH_WAVES_LEGENDARY_EGG } from "#app/data/balance/rates";
+import { speciesEggTiers } from "#app/data/balance/species-egg-tiers";
 
 export const EGG_SEED = 1073741824;
 
@@ -160,7 +161,7 @@ export class Egg {
 
       // Override egg tier and hatchwaves if species was given
       if (eggOptions?.species) {
-        this._tier = this.getEggTierFromSpeciesStarterValue();
+        this._tier = this.getEggTier();
         this._hatchWaves = eggOptions.hatchWaves ?? this.getEggTierDefaultHatchWaves();
       }
       // If species has no variant, set variantTier to common. This needs to
@@ -261,11 +262,11 @@ export class Egg {
       return "Manaphy";
     }
     switch (this.tier) {
-    case EggTier.GREAT:
+    case EggTier.RARE:
       return i18next.t("egg:greatTier");
-    case EggTier.ULTRA:
+    case EggTier.EPIC:
       return i18next.t("egg:ultraTier");
-    case EggTier.MASTER:
+    case EggTier.LEGENDARY:
       return i18next.t("egg:masterTier");
     default:
       return i18next.t("egg:defaultTier");
@@ -336,9 +337,9 @@ export class Egg {
     switch (eggTier ?? this._tier) {
     case EggTier.COMMON:
       return HATCH_WAVES_COMMON_EGG;
-    case EggTier.GREAT:
+    case EggTier.RARE:
       return HATCH_WAVES_RARE_EGG;
-    case EggTier.ULTRA:
+    case EggTier.EPIC:
       return HATCH_WAVES_EPIC_EGG;
     }
     return HATCH_WAVES_LEGENDARY_EGG;
@@ -347,7 +348,7 @@ export class Egg {
   private rollEggTier(): EggTier {
     const tierValueOffset = this._sourceType === EggSourceType.GACHA_LEGENDARY ? GACHA_LEGENDARY_UP_THRESHOLD_OFFSET : 0;
     const tierValue = Utils.randInt(256);
-    return tierValue >= GACHA_DEFAULT_COMMON_EGG_THRESHOLD + tierValueOffset ? EggTier.COMMON : tierValue >= GACHA_DEFAULT_RARE_EGG_THRESHOLD + tierValueOffset ? EggTier.GREAT : tierValue >= GACHA_DEFAULT_EPIC_EGG_THRESHOLD + tierValueOffset ? EggTier.ULTRA : EggTier.MASTER;
+    return tierValue >= GACHA_DEFAULT_COMMON_EGG_THRESHOLD + tierValueOffset ? EggTier.COMMON : tierValue >= GACHA_DEFAULT_RARE_EGG_THRESHOLD + tierValueOffset ? EggTier.RARE : tierValue >= GACHA_DEFAULT_EPIC_EGG_THRESHOLD + tierValueOffset ? EggTier.EPIC : EggTier.LEGENDARY;
   }
 
   private rollSpecies(scene: BattleScene): Species | null {
@@ -367,7 +368,7 @@ export class Egg {
        */
       const rand = (Utils.randSeedInt(MANAPHY_EGG_MANAPHY_RATE) !== 1);
       return rand ? Species.PHIONE : Species.MANAPHY;
-    } else if (this.tier === EggTier.MASTER
+    } else if (this.tier === EggTier.LEGENDARY
       && this._sourceType === EggSourceType.GACHA_LEGENDARY) {
       if (!Utils.randSeedInt(2)) {
         return getLegendaryGachaSpeciesForTimestamp(scene, this.timestamp);
@@ -378,15 +379,15 @@ export class Egg {
     let maxStarterValue: integer;
 
     switch (this.tier) {
-    case EggTier.GREAT:
+    case EggTier.RARE:
       minStarterValue = 4;
       maxStarterValue = 5;
       break;
-    case EggTier.ULTRA:
+    case EggTier.EPIC:
       minStarterValue = 6;
       maxStarterValue = 7;
       break;
-    case EggTier.MASTER:
+    case EggTier.LEGENDARY:
       minStarterValue = 8;
       maxStarterValue = 9;
       break;
@@ -398,8 +399,8 @@ export class Egg {
 
     const ignoredSpecies = [ Species.PHIONE, Species.MANAPHY, Species.ETERNATUS ];
 
-    let speciesPool = Object.keys(speciesStarterCosts)
-      .filter(s => speciesStarterCosts[s] >= minStarterValue && speciesStarterCosts[s] <= maxStarterValue)
+    let speciesPool = Object.keys(speciesEggTiers)
+      .filter(s => speciesEggTiers[s] === this.tier)
       .map(s => parseInt(s) as Species)
       .filter(s => !pokemonPrevolutions.hasOwnProperty(s) && getPokemonSpecies(s).isObtainable() && ignoredSpecies.indexOf(s) === -1);
 
@@ -430,7 +431,9 @@ export class Egg {
     let totalWeight = 0;
     const speciesWeights : number[] = [];
     for (const speciesId of speciesPool) {
-      let weight = Math.floor((((maxStarterValue - speciesStarterCosts[speciesId]) / ((maxStarterValue - minStarterValue) + 1)) * 1.5 + 1) * 100);
+      // Accounts for species that have starter costs outside of the normal range for their EggTier
+      const speciesCostClamped = Phaser.Math.Clamp(speciesStarterCosts[speciesId], minStarterValue, maxStarterValue);
+      let weight = Math.floor((((maxStarterValue - speciesCostClamped) / ((maxStarterValue - minStarterValue) + 1)) * 1.5 + 1) * 100);
       const species = getPokemonSpecies(speciesId);
       if (species.isRegional()) {
         weight = Math.floor(weight / 2);
@@ -498,16 +501,16 @@ export class Egg {
 
   private checkForPityTierOverrides(scene: BattleScene): void {
     const tierValueOffset = this._sourceType === EggSourceType.GACHA_LEGENDARY ? GACHA_LEGENDARY_UP_THRESHOLD_OFFSET : 0;
-    scene.gameData.eggPity[EggTier.GREAT] += 1;
-    scene.gameData.eggPity[EggTier.ULTRA] += 1;
-    scene.gameData.eggPity[EggTier.MASTER] += 1 + tierValueOffset;
+    scene.gameData.eggPity[EggTier.RARE] += 1;
+    scene.gameData.eggPity[EggTier.EPIC] += 1;
+    scene.gameData.eggPity[EggTier.LEGENDARY] += 1 + tierValueOffset;
     // These numbers are roughly the 80% mark. That is, 80% of the time you'll get an egg before this gets triggered.
-    if (scene.gameData.eggPity[EggTier.MASTER] >= EGG_PITY_LEGENDARY_THRESHOLD && this._tier === EggTier.COMMON) {
-      this._tier = EggTier.MASTER;
-    } else if (scene.gameData.eggPity[EggTier.ULTRA] >= EGG_PITY_EPIC_THRESHOLD && this._tier === EggTier.COMMON) {
-      this._tier = EggTier.ULTRA;
-    } else if (scene.gameData.eggPity[EggTier.GREAT] >= EGG_PITY_RARE_THRESHOLD && this._tier === EggTier.COMMON) {
-      this._tier = EggTier.GREAT;
+    if (scene.gameData.eggPity[EggTier.LEGENDARY] >= EGG_PITY_LEGENDARY_THRESHOLD && this._tier === EggTier.COMMON) {
+      this._tier = EggTier.LEGENDARY;
+    } else if (scene.gameData.eggPity[EggTier.EPIC] >= EGG_PITY_EPIC_THRESHOLD && this._tier === EggTier.COMMON) {
+      this._tier = EggTier.EPIC;
+    } else if (scene.gameData.eggPity[EggTier.RARE] >= EGG_PITY_RARE_THRESHOLD && this._tier === EggTier.COMMON) {
+      this._tier = EggTier.RARE;
     }
     scene.gameData.eggPity[this._tier] = 0;
   }
@@ -516,38 +519,24 @@ export class Egg {
     scene.gameData.gameStats.eggsPulled++;
     if (this.isManaphyEgg()) {
       scene.gameData.gameStats.manaphyEggsPulled++;
-      this._hatchWaves = this.getEggTierDefaultHatchWaves(EggTier.ULTRA);
+      this._hatchWaves = this.getEggTierDefaultHatchWaves(EggTier.EPIC);
       return;
     }
     switch (this.tier) {
-    case EggTier.GREAT:
+    case EggTier.RARE:
       scene.gameData.gameStats.rareEggsPulled++;
       break;
-    case EggTier.ULTRA:
+    case EggTier.EPIC:
       scene.gameData.gameStats.epicEggsPulled++;
       break;
-    case EggTier.MASTER:
+    case EggTier.LEGENDARY:
       scene.gameData.gameStats.legendaryEggsPulled++;
       break;
     }
   }
 
-  private getEggTierFromSpeciesStarterValue(): EggTier {
-    const speciesStartValue = speciesStarterCosts[this.species];
-    if (speciesStartValue >= 1 && speciesStartValue <= 3) {
-      return EggTier.COMMON;
-    }
-    if (speciesStartValue >= 4 && speciesStartValue <= 5) {
-      return EggTier.GREAT;
-    }
-    if (speciesStartValue >= 6 && speciesStartValue <= 7) {
-      return EggTier.ULTRA;
-    }
-    if (speciesStartValue >= 8) {
-      return EggTier.MASTER;
-    }
-
-    return EggTier.COMMON;
+  private getEggTier(): EggTier {
+    return speciesEggTiers[this.species];
   }
 
   ////
@@ -556,8 +545,8 @@ export class Egg {
 }
 
 export function getLegendaryGachaSpeciesForTimestamp(scene: BattleScene, timestamp: number): Species {
-  const legendarySpecies = Object.entries(speciesStarterCosts)
-    .filter(s => s[1] >= 8 && s[1] <= 9)
+  const legendarySpecies = Object.entries(speciesEggTiers)
+    .filter(s => s[1] === EggTier.LEGENDARY)
     .map(s => parseInt(s[0]))
     .filter(s => getPokemonSpecies(s).isObtainable());
 
@@ -579,17 +568,9 @@ export function getLegendaryGachaSpeciesForTimestamp(scene: BattleScene, timesta
 
 /**
  * Check for a given species EggTier Value
- * @param species - Species for wich we will check the egg tier it belongs to
+ * @param pokemonSpecies - Species for wich we will check the egg tier it belongs to
  * @returns The egg tier of a given pokemon species
  */
 export function getEggTierForSpecies(pokemonSpecies :PokemonSpecies): EggTier {
-  const speciesBaseValue = speciesStarterCosts[pokemonSpecies.getRootSpeciesId()];
-  if (speciesBaseValue <= 3) {
-    return EggTier.COMMON;
-  } else if (speciesBaseValue <= 5) {
-    return EggTier.GREAT;
-  } else if (speciesBaseValue <= 7) {
-    return EggTier.ULTRA;
-  }
-  return EggTier.MASTER;
+  return speciesEggTiers[pokemonSpecies.getRootSpeciesId()];
 }
diff --git a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts
index f3b886ac0ac..4f3420f5194 100644
--- a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts
+++ b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts
@@ -150,7 +150,7 @@ export const ATrainersTestEncounter: MysteryEncounter =
           pulled: false,
           sourceType: EggSourceType.EVENT,
           eggDescriptor: encounter.misc.trainerEggDescription,
-          tier: EggTier.ULTRA
+          tier: EggTier.EPIC
         };
         encounter.setDialogueToken("eggType", i18next.t(`${namespace}:eggTypes.epic`));
         setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [ modifierTypes.SACRED_ASH ], guaranteedModifierTiers: [ ModifierTier.ROGUE, ModifierTier.ULTRA ], fillRemaining: true }, [ eggOptions ]);
@@ -172,7 +172,7 @@ export const ATrainersTestEncounter: MysteryEncounter =
           pulled: false,
           sourceType: EggSourceType.EVENT,
           eggDescriptor: encounter.misc.trainerEggDescription,
-          tier: EggTier.GREAT
+          tier: EggTier.RARE
         };
         encounter.setDialogueToken("eggType", i18next.t(`${namespace}:eggTypes.rare`));
         setEncounterRewards(scene, { fillRemaining: false, rerollMultiplier: -1 }, [ eggOptions ]);
diff --git a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts
index 4515736b30a..0ac82243862 100644
--- a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts
+++ b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts
@@ -494,7 +494,7 @@ function getEggOptions(scene: BattleScene, commonEggs: number, rareEggs: number)
         pulled: false,
         sourceType: EggSourceType.EVENT,
         eggDescriptor: eggDescription,
-        tier: EggTier.GREAT
+        tier: EggTier.RARE
       });
     }
   }
diff --git a/src/enums/egg-type.ts b/src/enums/egg-type.ts
index d8d0facb020..901e60b3c76 100644
--- a/src/enums/egg-type.ts
+++ b/src/enums/egg-type.ts
@@ -1,6 +1,6 @@
 export enum EggTier {
   COMMON,
-  GREAT,
-  ULTRA,
-  MASTER
+  RARE,
+  EPIC,
+  LEGENDARY
 }
diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts
index 4d85d5b8e1e..d8fcc281d1b 100644
--- a/src/field/pokemon.ts
+++ b/src/field/pokemon.ts
@@ -983,7 +983,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
         this.scene.applyModifier(PokemonIncrementingStatModifier, this.isPlayer(), this, s, statHolder);
       }
 
-      statHolder.value = Utils.clampInt(statHolder.value, 1, Number.MAX_SAFE_INTEGER);
+      statHolder.value = Phaser.Math.Clamp(statHolder.value, 1, Number.MAX_SAFE_INTEGER);
 
       this.setStat(s, statHolder.value);
     }
diff --git a/src/test/eggs/egg.test.ts b/src/test/eggs/egg.test.ts
index cf53cca5af8..6f57af63e6b 100644
--- a/src/test/eggs/egg.test.ts
+++ b/src/test/eggs/egg.test.ts
@@ -55,7 +55,7 @@ describe("Egg Generation Tests", () => {
     let gachaSpeciesCount = 0;
 
     for (let i = 0; i < EGG_HATCH_COUNT; i++) {
-      const result = new Egg({ scene, timestamp, sourceType: EggSourceType.GACHA_LEGENDARY, tier: EggTier.MASTER }).generatePlayerPokemon(scene).species.speciesId;
+      const result = new Egg({ scene, timestamp, sourceType: EggSourceType.GACHA_LEGENDARY, tier: EggTier.LEGENDARY }).generatePlayerPokemon(scene).species.speciesId;
       if (result === expectedSpecies) {
         gachaSpeciesCount++;
       }
@@ -82,7 +82,7 @@ describe("Egg Generation Tests", () => {
   });
   it("should return an rare tier egg", () => {
     const scene = game.scene;
-    const expectedTier = EggTier.GREAT;
+    const expectedTier = EggTier.RARE;
 
     const result = new Egg({ scene, tier: expectedTier }).tier;
 
@@ -90,7 +90,7 @@ describe("Egg Generation Tests", () => {
   });
   it("should return an epic tier egg", () => {
     const scene = game.scene;
-    const expectedTier = EggTier.ULTRA;
+    const expectedTier = EggTier.EPIC;
 
     const result = new Egg({ scene, tier: expectedTier }).tier;
 
@@ -98,7 +98,7 @@ describe("Egg Generation Tests", () => {
   });
   it("should return an legendary tier egg", () => {
     const scene = game.scene;
-    const expectedTier = EggTier.MASTER;
+    const expectedTier = EggTier.LEGENDARY;
 
     const result = new Egg({ scene, tier: expectedTier }).tier;
 
@@ -200,7 +200,7 @@ describe("Egg Generation Tests", () => {
     const scene = game.scene;
     const expectedEggTier = EggTier.COMMON;
 
-    const result = new Egg({ scene, tier: EggTier.MASTER, species: Species.BULBASAUR }).tier;
+    const result = new Egg({ scene, tier: EggTier.LEGENDARY, species: Species.BULBASAUR }).tier;
 
     expect(result).toBe(expectedEggTier);
   });
@@ -208,7 +208,7 @@ describe("Egg Generation Tests", () => {
     const scene = game.scene;
     const expectedHatchWaves = 10;
 
-    const result = new Egg({ scene, tier: EggTier.MASTER, species: Species.BULBASAUR }).hatchWaves;
+    const result = new Egg({ scene, tier: EggTier.LEGENDARY, species: Species.BULBASAUR }).hatchWaves;
 
     expect(result).toBe(expectedHatchWaves);
   });
@@ -229,7 +229,7 @@ describe("Egg Generation Tests", () => {
 
     const result = new EggData(legacyEgg).toEgg();
 
-    expect(result.tier).toBe(EggTier.GREAT);
+    expect(result.tier).toBe(EggTier.RARE);
     expect(result.id).toBe(legacyEgg.id);
     expect(result.timestamp).toBe(legacyEgg.timestamp);
     expect(result.hatchWaves).toBe(legacyEgg.hatchWaves);
@@ -241,9 +241,9 @@ describe("Egg Generation Tests", () => {
 
     new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.COMMON });
 
-    expect(scene.gameData.eggPity[EggTier.GREAT]).toBe(startPityValues[EggTier.GREAT] + 1);
-    expect(scene.gameData.eggPity[EggTier.ULTRA]).toBe(startPityValues[EggTier.ULTRA] + 1);
-    expect(scene.gameData.eggPity[EggTier.MASTER]).toBe(startPityValues[EggTier.MASTER] + 1);
+    expect(scene.gameData.eggPity[EggTier.RARE]).toBe(startPityValues[EggTier.RARE] + 1);
+    expect(scene.gameData.eggPity[EggTier.EPIC]).toBe(startPityValues[EggTier.EPIC] + 1);
+    expect(scene.gameData.eggPity[EggTier.LEGENDARY]).toBe(startPityValues[EggTier.LEGENDARY] + 1);
   });
   it("should increase legendary egg pity by two", () => {
     const scene = game.scene;
@@ -251,9 +251,9 @@ describe("Egg Generation Tests", () => {
 
     new Egg({ scene, sourceType: EggSourceType.GACHA_LEGENDARY, pulled: true, tier: EggTier.COMMON });
 
-    expect(scene.gameData.eggPity[EggTier.GREAT]).toBe(startPityValues[EggTier.GREAT] + 1);
-    expect(scene.gameData.eggPity[EggTier.ULTRA]).toBe(startPityValues[EggTier.ULTRA] + 1);
-    expect(scene.gameData.eggPity[EggTier.MASTER]).toBe(startPityValues[EggTier.MASTER] + 2);
+    expect(scene.gameData.eggPity[EggTier.RARE]).toBe(startPityValues[EggTier.RARE] + 1);
+    expect(scene.gameData.eggPity[EggTier.EPIC]).toBe(startPityValues[EggTier.EPIC] + 1);
+    expect(scene.gameData.eggPity[EggTier.LEGENDARY]).toBe(startPityValues[EggTier.LEGENDARY] + 2);
   });
   it("should not increase manaphy egg count if bulbasaurs are pulled", () => {
     const scene = game.scene;
@@ -277,7 +277,7 @@ describe("Egg Generation Tests", () => {
     const scene = game.scene;
     const startingRareEggsPulled = scene.gameData.gameStats.rareEggsPulled;
 
-    new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.GREAT });
+    new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.RARE });
 
     expect(scene.gameData.gameStats.rareEggsPulled).toBe(startingRareEggsPulled + 1);
   });
@@ -285,7 +285,7 @@ describe("Egg Generation Tests", () => {
     const scene = game.scene;
     const startingEpicEggsPulled = scene.gameData.gameStats.epicEggsPulled;
 
-    new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.ULTRA });
+    new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.EPIC });
 
     expect(scene.gameData.gameStats.epicEggsPulled).toBe(startingEpicEggsPulled + 1);
   });
@@ -293,7 +293,7 @@ describe("Egg Generation Tests", () => {
     const scene = game.scene;
     const startingLegendaryEggsPulled = scene.gameData.gameStats.legendaryEggsPulled;
 
-    new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.MASTER });
+    new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true, tier: EggTier.LEGENDARY });
 
     expect(scene.gameData.gameStats.legendaryEggsPulled).toBe(startingLegendaryEggsPulled + 1);
   });
@@ -301,8 +301,8 @@ describe("Egg Generation Tests", () => {
     vi.spyOn(Utils, "randInt").mockReturnValue(1);
 
     const scene = game.scene;
-    const expectedTier1 = EggTier.MASTER;
-    const expectedTier2 = EggTier.ULTRA;
+    const expectedTier1 = EggTier.LEGENDARY;
+    const expectedTier2 = EggTier.EPIC;
 
     const result1 = new Egg({ scene, sourceType: EggSourceType.GACHA_LEGENDARY, pulled: true }).tier;
     const result2 = new Egg({ scene, sourceType: EggSourceType.GACHA_MOVE, pulled: true }).tier;
diff --git a/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts b/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts
index b1aa378d82a..7d783958422 100644
--- a/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/a-trainers-test-encounter.test.ts
@@ -128,7 +128,7 @@ describe("A Trainer's Test - Mystery Encounter", () => {
       expect(eggsAfter).toBeDefined();
       expect(eggsBeforeLength + 1).toBe(eggsAfter.length);
       const eggTier = eggsAfter[eggsAfter.length - 1].tier;
-      expect(eggTier === EggTier.ULTRA || eggTier === EggTier.MASTER).toBeTruthy();
+      expect(eggTier === EggTier.EPIC || eggTier === EggTier.LEGENDARY).toBeTruthy();
     });
   });
 
@@ -176,7 +176,7 @@ describe("A Trainer's Test - Mystery Encounter", () => {
       expect(eggsAfter).toBeDefined();
       expect(eggsBeforeLength + 1).toBe(eggsAfter.length);
       const eggTier = eggsAfter[eggsAfter.length - 1].tier;
-      expect(eggTier).toBe(EggTier.GREAT);
+      expect(eggTier).toBe(EggTier.RARE);
     });
 
     it("should leave encounter without battle", async () => {
diff --git a/src/test/mystery-encounter/encounters/the-expert-breeder-encounter.test.ts b/src/test/mystery-encounter/encounters/the-expert-breeder-encounter.test.ts
index 7e445ac1fe2..bbb4f249feb 100644
--- a/src/test/mystery-encounter/encounters/the-expert-breeder-encounter.test.ts
+++ b/src/test/mystery-encounter/encounters/the-expert-breeder-encounter.test.ts
@@ -155,7 +155,7 @@ describe("The Expert Pokémon Breeder - Mystery Encounter", () => {
       expect(eggsAfter).toBeDefined();
       expect(eggsBeforeLength + commonEggs + rareEggs).toBe(eggsAfter.length);
       expect(eggsAfter.filter(egg => egg.tier === EggTier.COMMON).length).toBe(commonEggs);
-      expect(eggsAfter.filter(egg => egg.tier === EggTier.GREAT).length).toBe(rareEggs);
+      expect(eggsAfter.filter(egg => egg.tier === EggTier.RARE).length).toBe(rareEggs);
 
       game.phaseInterceptor.superEndPhase();
       await game.phaseInterceptor.to(PostMysteryEncounterPhase);
@@ -213,7 +213,7 @@ describe("The Expert Pokémon Breeder - Mystery Encounter", () => {
       expect(eggsAfter).toBeDefined();
       expect(eggsBeforeLength + commonEggs + rareEggs).toBe(eggsAfter.length);
       expect(eggsAfter.filter(egg => egg.tier === EggTier.COMMON).length).toBe(commonEggs);
-      expect(eggsAfter.filter(egg => egg.tier === EggTier.GREAT).length).toBe(rareEggs);
+      expect(eggsAfter.filter(egg => egg.tier === EggTier.RARE).length).toBe(rareEggs);
 
       game.phaseInterceptor.superEndPhase();
       await game.phaseInterceptor.to(PostMysteryEncounterPhase);
@@ -271,7 +271,7 @@ describe("The Expert Pokémon Breeder - Mystery Encounter", () => {
       expect(eggsAfter).toBeDefined();
       expect(eggsBeforeLength + commonEggs + rareEggs).toBe(eggsAfter.length);
       expect(eggsAfter.filter(egg => egg.tier === EggTier.COMMON).length).toBe(commonEggs);
-      expect(eggsAfter.filter(egg => egg.tier === EggTier.GREAT).length).toBe(rareEggs);
+      expect(eggsAfter.filter(egg => egg.tier === EggTier.RARE).length).toBe(rareEggs);
 
       game.phaseInterceptor.superEndPhase();
       await game.phaseInterceptor.to(PostMysteryEncounterPhase);
diff --git a/src/ui/battle-info.ts b/src/ui/battle-info.ts
index 79b51ba6c44..1d97998f491 100644
--- a/src/ui/battle-info.ts
+++ b/src/ui/battle-info.ts
@@ -593,7 +593,7 @@ export default class BattleInfo extends Phaser.GameObjects.Container {
       };
 
       const updatePokemonHp = () => {
-        let duration = !instant ? Utils.clampInt(Math.abs((this.lastHp) - pokemon.hp) * 5, 250, 5000) : 0;
+        let duration = !instant ? Phaser.Math.Clamp(Math.abs((this.lastHp) - pokemon.hp) * 5, 250, 5000) : 0;
         const speed = (this.scene as BattleScene).hpBarSpeed;
         if (speed) {
           duration = speed >= 3 ? 0 : duration / Math.pow(2, speed);
diff --git a/src/ui/egg-gacha-ui-handler.ts b/src/ui/egg-gacha-ui-handler.ts
index 366f1604740..3aa009b1b31 100644
--- a/src/ui/egg-gacha-ui-handler.ts
+++ b/src/ui/egg-gacha-ui-handler.ts
@@ -471,9 +471,9 @@ export default class EggGachaUiHandler extends MessageUiHandler {
   getGuaranteedEggTierFromPullCount(pullCount: number): EggTier {
     switch (pullCount) {
     case 10:
-      return EggTier.GREAT;
+      return EggTier.RARE;
     case 25:
-      return EggTier.ULTRA;
+      return EggTier.EPIC;
     default:
       return EggTier.COMMON;
     }
@@ -516,7 +516,7 @@ export default class EggGachaUiHandler extends MessageUiHandler {
 
           const eggText = addTextObject(this.scene, 0, 14, egg.getEggDescriptor(), TextStyle.PARTY, { align: "center" });
           eggText.setOrigin(0.5, 0);
-          eggText.setTint(getEggTierTextTint(!egg.isManaphyEgg() ? egg.tier : EggTier.ULTRA));
+          eggText.setTint(getEggTierTextTint(!egg.isManaphyEgg() ? egg.tier : EggTier.EPIC));
           ret.add(eggText);
 
           this.eggGachaSummaryContainer.addAt(ret, 0);
diff --git a/src/ui/text.ts b/src/ui/text.ts
index e6e1978118b..22dd3f4cd6a 100644
--- a/src/ui/text.ts
+++ b/src/ui/text.ts
@@ -356,11 +356,11 @@ export function getEggTierTextTint(tier: EggTier): integer {
   switch (tier) {
   case EggTier.COMMON:
     return getModifierTierTextTint(ModifierTier.COMMON);
-  case EggTier.GREAT:
+  case EggTier.RARE:
     return getModifierTierTextTint(ModifierTier.GREAT);
-  case EggTier.ULTRA:
+  case EggTier.EPIC:
     return getModifierTierTextTint(ModifierTier.ULTRA);
-  case EggTier.MASTER:
+  case EggTier.LEGENDARY:
     return getModifierTierTextTint(ModifierTier.MASTER);
   }
 }
diff --git a/src/utils.ts b/src/utils.ts
index 9cc95b00826..c2ee7100909 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -38,10 +38,6 @@ export function shiftCharCodes(str: string, shiftCount: integer) {
   return newStr;
 }
 
-export function clampInt(value: integer, min: integer, max: integer): integer {
-  return Math.min(Math.max(value, min), max);
-}
-
 export function randGauss(stdev: number, mean: number = 0): number {
   if (!stdev) {
     return 0;

From 5d0b36132061bae767dafdd3f27c6c1be12264df Mon Sep 17 00:00:00 2001
From: NightKev <34855794+DayKev@users.noreply.github.com>
Date: Thu, 10 Oct 2024 10:19:05 -0700
Subject: [PATCH 14/15] [P2] Syrup Bomb effect is removed when user leaves the
 field (#4606)

* Syrup Bomb's effect expires when the move user leaves the field

* Add test

* Remove check for the affected pokemon being switched out
---
 src/data/battler-tags.ts          | 29 +++++++++++++----------------
 src/test/moves/syrup_bomb.test.ts | 26 ++++++++++++++++++++------
 2 files changed, 33 insertions(+), 22 deletions(-)

diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts
index 24c82e54427..3cc109df264 100644
--- a/src/data/battler-tags.ts
+++ b/src/data/battler-tags.ts
@@ -2640,16 +2640,16 @@ export class ImprisonTag extends MoveRestrictionBattlerTag {
 /**
  * Battler Tag that applies the effects of Syrup Bomb to the target Pokemon.
  * For three turns, starting from the turn of hit, at the end of each turn, the target Pokemon's speed will decrease by 1.
- * The tag can also expire by taking the target Pokemon off the field.
+ * The tag can also expire by taking the target Pokemon off the field, or the Pokemon that originally used the move.
  */
 export class SyrupBombTag extends BattlerTag {
-  constructor() {
-    super(BattlerTagType.SYRUP_BOMB, BattlerTagLapseType.TURN_END, 3, Moves.SYRUP_BOMB);
+  constructor(sourceId: number) {
+    super(BattlerTagType.SYRUP_BOMB, BattlerTagLapseType.TURN_END, 3, Moves.SYRUP_BOMB, sourceId);
   }
 
   /**
    * Adds the Syrup Bomb battler tag to the target Pokemon.
-   * @param {Pokemon} pokemon the target Pokemon
+   * @param pokemon - The target {@linkcode Pokemon}
    */
   override onAdd(pokemon: Pokemon) {
     super.onAdd(pokemon);
@@ -2658,15 +2658,16 @@ export class SyrupBombTag extends BattlerTag {
 
   /**
    * Applies the single-stage speed down to the target Pokemon and decrements the tag's turn count
-   * @param {Pokemon} pokemon the target Pokemon
-   * @param {BattlerTagLapseType} _lapseType
-   * @returns `true` if the turnCount is still greater than 0 | `false` if the turnCount is 0 or the target Pokemon has been removed from the field
+   * @param pokemon - The target {@linkcode Pokemon}
+   * @param _lapseType - N/A
+   * @returns `true` if the `turnCount` is still greater than `0`; `false` if the `turnCount` is `0` or the target or source Pokemon has been removed from the field
    */
   override lapse(pokemon: Pokemon, _lapseType: BattlerTagLapseType): boolean {
-    if (!pokemon.isActive(true)) {
+    if (this.sourceId && !pokemon.scene.getPokemonById(this.sourceId)?.isActive(true)) {
       return false;
     }
-    pokemon.scene.queueMessage(i18next.t("battlerTags:syrupBombLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); // Custom message in lieu of an animation in mainline
+    // Custom message in lieu of an animation in mainline
+    pokemon.scene.queueMessage(i18next.t("battlerTags:syrupBombLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }));
     pokemon.scene.unshiftPhase(new StatStageChangePhase(
       pokemon.scene, pokemon.getBattlerIndex(), true,
       [ Stat.SPD ], -1, true, false, true
@@ -2677,12 +2678,8 @@ export class SyrupBombTag extends BattlerTag {
 
 /**
  * Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID.
- *
- * @param {BattlerTagType} tagType the type of the {@linkcode BattlerTagType}.
- * @param turnCount the turn count.
- * @param {Moves} sourceMove the source {@linkcode Moves}.
- * @param sourceId the source ID.
- * @returns {BattlerTag} the corresponding {@linkcode BattlerTag} object.
+ * @param sourceId - The ID of the pokemon adding the tag
+ * @returns The corresponding {@linkcode BattlerTag} object.
  */
 export function getBattlerTag(tagType: BattlerTagType, turnCount: number, sourceMove: Moves, sourceId: number): BattlerTag {
   switch (tagType) {
@@ -2851,7 +2848,7 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
   case BattlerTagType.IMPRISON:
     return new ImprisonTag(sourceId);
   case BattlerTagType.SYRUP_BOMB:
-    return new SyrupBombTag();
+    return new SyrupBombTag(sourceId);
   case BattlerTagType.NONE:
   default:
     return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);
diff --git a/src/test/moves/syrup_bomb.test.ts b/src/test/moves/syrup_bomb.test.ts
index 7f914e45cc6..ea2f8b6bab3 100644
--- a/src/test/moves/syrup_bomb.test.ts
+++ b/src/test/moves/syrup_bomb.test.ts
@@ -1,4 +1,3 @@
-import { allMoves } from "#app/data/move";
 import { Moves } from "#enums/moves";
 import { Species } from "#enums/species";
 import { Abilities } from "#enums/abilities";
@@ -7,7 +6,7 @@ import { Stat } from "#enums/stat";
 import GameManager from "#test/utils/gameManager";
 import Phaser from "phaser";
 import { BattlerIndex } from "#app/battle";
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
 
 describe("Moves - SYRUP BOMB", () => {
   let phaserGame: Phaser.Game;
@@ -26,20 +25,21 @@ describe("Moves - SYRUP BOMB", () => {
   beforeEach(() => {
     game = new GameManager(phaserGame);
     game.override
-      .starterSpecies(Species.MAGIKARP)
+      .battleType("single")
       .enemySpecies(Species.SNORLAX)
+      .enemyAbility(Abilities.BALL_FETCH)
+      .ability(Abilities.BALL_FETCH)
       .startingLevel(30)
       .enemyLevel(100)
       .moveset([ Moves.SYRUP_BOMB, Moves.SPLASH ])
       .enemyMoveset(Moves.SPLASH);
-    vi.spyOn(allMoves[Moves.SYRUP_BOMB], "accuracy", "get").mockReturnValue(100);
   });
 
   //Bulbapedia Reference: https://bulbapedia.bulbagarden.net/wiki/syrup_bomb_(move)
 
   it("decreases the target Pokemon's speed stat once per turn for 3 turns",
     async () => {
-      await game.startBattle([ Species.MAGIKARP ]);
+      await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
       const targetPokemon = game.scene.getEnemyPokemon()!;
       expect(targetPokemon.getStatStage(Stat.SPD)).toBe(0);
@@ -66,7 +66,7 @@ describe("Moves - SYRUP BOMB", () => {
   it("does not affect Pokemon with the ability Bulletproof",
     async () => {
       game.override.enemyAbility(Abilities.BULLETPROOF);
-      await game.startBattle([ Species.MAGIKARP ]);
+      await game.classicMode.startBattle([ Species.MAGIKARP ]);
 
       const targetPokemon = game.scene.getEnemyPokemon()!;
 
@@ -79,4 +79,18 @@ describe("Moves - SYRUP BOMB", () => {
       expect(targetPokemon.getStatStage(Stat.SPD)).toBe(0);
     }
   );
+
+  it("stops lowering the target's speed if the user leaves the field", async () => {
+    await game.classicMode.startBattle([ Species.FEEBAS, Species.MILOTIC ]);
+
+    game.move.select(Moves.SYRUP_BOMB);
+    await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
+    await game.move.forceHit();
+    await game.toNextTurn();
+
+    game.doSwitchPokemon(1);
+    await game.toNextTurn();
+
+    expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.SPD)).toBe(-1);
+  });
 });

From 3f63c147a38ef9afb05c54bfe0983ed572d6f35b Mon Sep 17 00:00:00 2001
From: "Amani H." <109637146+xsn34kzx@users.noreply.github.com>
Date: Thu, 10 Oct 2024 15:44:51 -0400
Subject: [PATCH 15/15] [P3] Fix "Stat Won't Go Any Lower/Higher" Not Appearing
 (#4635)

---
 src/enums/stat.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/enums/stat.ts b/src/enums/stat.ts
index a12d53e8559..6b3f7dc6d79 100644
--- a/src/enums/stat.ts
+++ b/src/enums/stat.ts
@@ -50,7 +50,7 @@ export function getStatStageChangeDescriptionKey(stages: number, isIncrease: boo
     return isIncrease ? "battle:statRose" : "battle:statFell";
   } else if (stages === 2) {
     return isIncrease ? "battle:statSharplyRose" : "battle:statHarshlyFell";
-  } else if (stages <= 6) {
+  } else if (stages > 2 && stages <= 6) {
     return isIncrease ? "battle:statRoseDrastically" : "battle:statSeverelyFell";
   }
   return isIncrease ? "battle:statWontGoAnyHigher" : "battle:statWontGoAnyLower";