From 07f5c3009c63a855f9be0f523e62a3fa8b5cfc0d Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Thu, 26 Jun 2025 12:01:54 -0700 Subject: [PATCH] [Dev] Add player and enemy Nature and IV overrides (#6032) * [Dev] Add player and enemy Nature and IV overrides * Fix broken tests --- src/battle-scene.ts | 49 +++++++-- src/overrides.ts | 25 ++++- test/abilities/wimp_out.test.ts | 4 +- test/moves/heal_block.test.ts | 2 +- test/moves/instruct.test.ts | 40 ++++--- test/testUtils/helpers/classicModeHelper.ts | 15 ++- test/testUtils/helpers/overridesHelper.ts | 116 ++++++++++++++++++-- 7 files changed, 209 insertions(+), 42 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 2ac13033412..59c0e28422b 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -18,6 +18,7 @@ import { isNullOrUndefined, BooleanHolder, type Constructor, + isBetween, } from "#app/utils/common"; import { deepMergeSpriteData } from "#app/utils/data"; import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier"; @@ -164,10 +165,6 @@ import { PhaseManager } from "./phase-manager"; const DEBUG_RNG = false; -const OPP_IVS_OVERRIDE_VALIDATED: number[] = ( - Array.isArray(Overrides.OPP_IVS_OVERRIDE) ? Overrides.OPP_IVS_OVERRIDE : new Array(6).fill(Overrides.OPP_IVS_OVERRIDE) -).map(iv => (Number.isNaN(iv) || iv === null || iv > 31 ? -1 : iv)); - export interface PokeballCounts { [pb: string]: number; } @@ -934,9 +931,32 @@ export default class BattleScene extends SceneBase { nature, dataSource, ); + if (postProcess) { postProcess(pokemon); } + + if (Overrides.IVS_OVERRIDE === null) { + // do nothing + } else if (Array.isArray(Overrides.IVS_OVERRIDE)) { + if (Overrides.IVS_OVERRIDE.length !== 6) { + throw new Error("The Player IVs override must be an array of length 6 or a number!"); + } + if (Overrides.IVS_OVERRIDE.some(value => !isBetween(value, 0, 31))) { + throw new Error("All IVs in the player IV override must be between 0 and 31!"); + } + pokemon.ivs = Overrides.IVS_OVERRIDE; + } else { + if (!isBetween(Overrides.IVS_OVERRIDE, 0, 31)) { + throw new Error("The Player IV override must be a value between 0 and 31!"); + } + pokemon.ivs = new Array(6).fill(Overrides.IVS_OVERRIDE); + } + + if (Overrides.NATURE_OVERRIDE !== null) { + pokemon.nature = Overrides.NATURE_OVERRIDE; + } + pokemon.init(); return pokemon; } @@ -981,10 +1001,25 @@ export default class BattleScene extends SceneBase { postProcess(pokemon); } - for (let i = 0; i < pokemon.ivs.length; i++) { - if (OPP_IVS_OVERRIDE_VALIDATED[i] > -1) { - pokemon.ivs[i] = OPP_IVS_OVERRIDE_VALIDATED[i]; + if (Overrides.ENEMY_IVS_OVERRIDE === null) { + // do nothing + } else if (Array.isArray(Overrides.ENEMY_IVS_OVERRIDE)) { + if (Overrides.ENEMY_IVS_OVERRIDE.length !== 6) { + throw new Error("The Enemy IVs override must be an array of length 6 or a number!"); } + if (Overrides.ENEMY_IVS_OVERRIDE.some(value => !isBetween(value, 0, 31))) { + throw new Error("All IVs in the enemy IV override must be between 0 and 31!"); + } + pokemon.ivs = Overrides.ENEMY_IVS_OVERRIDE; + } else { + if (!isBetween(Overrides.ENEMY_IVS_OVERRIDE, 0, 31)) { + throw new Error("The Enemy IV override must be a value between 0 and 31!"); + } + pokemon.ivs = new Array(6).fill(Overrides.ENEMY_IVS_OVERRIDE); + } + + if (Overrides.ENEMY_NATURE_OVERRIDE !== null) { + pokemon.nature = Overrides.ENEMY_NATURE_OVERRIDE; } pokemon.init(); diff --git a/src/overrides.ts b/src/overrides.ts index b390b9fa70f..82462431fb0 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -1,18 +1,18 @@ import { type PokeballCounts } from "#app/battle-scene"; import { EvolutionItem } from "#app/data/balance/pokemon-evolutions"; import { Gender } from "#app/data/gender"; -import { FormChangeItem } from "#enums/form-change-item"; import { type ModifierOverride } from "#app/modifier/modifier-type"; import { Variant } from "#app/sprites/variant"; -import { Unlockables } from "#enums/unlockables"; import { AbilityId } from "#enums/ability-id"; import { BattleType } from "#enums/battle-type"; import { BerryType } from "#enums/berry-type"; import { BiomeId } from "#enums/biome-id"; import { EggTier } from "#enums/egg-type"; +import { FormChangeItem } from "#enums/form-change-item"; import { MoveId } from "#enums/move-id"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Nature } from "#enums/nature"; import { PokeballType } from "#enums/pokeball"; import { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; @@ -20,6 +20,7 @@ import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; import { TimeOfDay } from "#enums/time-of-day"; import { TrainerType } from "#enums/trainer-type"; +import { Unlockables } from "#enums/unlockables"; import { VariantTier } from "#enums/variant-tier"; import { WeatherType } from "#enums/weather-type"; @@ -159,10 +160,20 @@ class DefaultOverrides { readonly MOVESET_OVERRIDE: MoveId | Array = []; readonly SHINY_OVERRIDE: boolean | null = null; readonly VARIANT_OVERRIDE: Variant | null = null; + /** + * Overrides the IVs of player pokemon. Values must never be outside the range `0` to `31`! + * - If set to a number between `0` and `31`, set all IVs of all player pokemon to that number. + * - If set to an array, set the IVs of all player pokemon to that array. Array length must be exactly `6`! + * - If set to `null`, disable the override. + */ + readonly IVS_OVERRIDE: number | number[] | null = null; + /** Override the nature of all player pokemon to the specified nature. Disabled if `null`. */ + readonly NATURE_OVERRIDE: Nature | null = null; // -------------------------- // OPPONENT / ENEMY OVERRIDES // -------------------------- + // TODO: rename `OPP_` to `ENEMY_` readonly OPP_SPECIES_OVERRIDE: SpeciesId | number = 0; /** * This will make all opponents fused Pokemon @@ -181,7 +192,15 @@ class DefaultOverrides { readonly OPP_MOVESET_OVERRIDE: MoveId | Array = []; readonly OPP_SHINY_OVERRIDE: boolean | null = null; readonly OPP_VARIANT_OVERRIDE: Variant | null = null; - readonly OPP_IVS_OVERRIDE: number | number[] = []; + /** + * Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`! + * - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number. + * - If set to an array, set the IVs of all enemy pokemon to that array. Array length must be exactly `6`! + * - If set to `null`, disable the override. + */ + readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null; + /** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */ + readonly ENEMY_NATURE_OVERRIDE: Nature | null = null; readonly OPP_FORM_OVERRIDES: Partial> = {}; /** * Override to give the enemy Pokemon a given amount of health segments diff --git a/test/abilities/wimp_out.test.ts b/test/abilities/wimp_out.test.ts index 1db0b80fcd0..8e97618d46f 100644 --- a/test/abilities/wimp_out.test.ts +++ b/test/abilities/wimp_out.test.ts @@ -108,7 +108,7 @@ describe("Abilities - Wimp Out", () => { }); it("Trapping moves do not prevent Wimp Out from activating.", async () => { - game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(53).enemyLevel(45); + game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(1).passiveAbility(AbilityId.STURDY); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); game.move.select(MoveId.SPLASH); @@ -123,7 +123,7 @@ describe("Abilities - Wimp Out", () => { }); it("If this Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out.", async () => { - game.override.startingLevel(95).enemyMoveset([MoveId.U_TURN]); + game.override.startingLevel(1).enemyMoveset([MoveId.U_TURN]).passiveAbility(AbilityId.STURDY); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); game.move.select(MoveId.SPLASH); diff --git a/test/moves/heal_block.test.ts b/test/moves/heal_block.test.ts index 77a10927930..dc69b5c2974 100644 --- a/test/moves/heal_block.test.ts +++ b/test/moves/heal_block.test.ts @@ -42,7 +42,7 @@ describe("Moves - Heal Block", () => { const player = game.scene.getPlayerPokemon()!; const enemy = game.scene.getEnemyPokemon()!; - player.damageAndUpdate(enemy.getMaxHp() - 1); + player.damageAndUpdate(player.getMaxHp() - 1); game.move.select(MoveId.ABSORB); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); diff --git a/test/moves/instruct.test.ts b/test/moves/instruct.test.ts index d12859301b6..5c853dd280e 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -1,12 +1,13 @@ -import { BattlerIndex } from "#enums/battler-index"; -import { RandomMoveAttr } from "#app/data/moves/move"; import { allMoves } from "#app/data/data-lists"; +import { RandomMoveAttr } from "#app/data/moves/move"; import type Pokemon from "#app/field/pokemon"; -import { MoveResult } from "#enums/move-result"; +import type { TurnMove } from "#app/field/pokemon"; import type { MovePhase } from "#app/phases/move-phase"; import { AbilityId } from "#enums/ability-id"; -import { MoveUseMode } from "#enums/move-use-mode"; +import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import GameManager from "#test/testUtils/gameManager"; @@ -202,21 +203,32 @@ describe("Moves - Instruct", () => { game.override.battleStyle("double").enemyMoveset(MoveId.SPLASH).enemySpecies(SpeciesId.MAGIKARP).enemyLevel(1); await game.classicMode.startBattle([SpeciesId.HISUI_ELECTRODE, SpeciesId.KOMMO_O]); - const [electrode, kommo_o] = game.scene.getPlayerField()!; - game.move.changeMoveset(electrode, MoveId.CHLOROBLAST); + const [electrode, kommo_o] = game.scene.getPlayerField(); + game.move.changeMoveset(electrode, MoveId.THUNDERBOLT); game.move.changeMoveset(kommo_o, MoveId.INSTRUCT); - game.move.select(MoveId.CHLOROBLAST, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.select(MoveId.THUNDERBOLT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); game.move.select(MoveId.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); - await game.phaseInterceptor.to("BerryPhase"); + await game.toEndOfTurn(); - // Chloroblast always deals 50% max HP% recoil UNLESS you whiff - // due to lack of targets or similar, - // so all we have to do is check whether electrode fainted or not. - // Naturally, both karps should also be dead as well. - expect(electrode.isFainted()).toBe(true); - const [karp1, karp2] = game.scene.getEnemyField()!; + expect(electrode.getMoveHistory()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + result: MoveResult.SUCCESS, + move: MoveId.THUNDERBOLT, + targets: [BattlerIndex.ENEMY], + useMode: MoveUseMode.NORMAL, + }), + expect.objectContaining({ + result: MoveResult.SUCCESS, + move: MoveId.THUNDERBOLT, + targets: [BattlerIndex.ENEMY_2], + useMode: MoveUseMode.NORMAL, + }), + ]), + ); + const [karp1, karp2] = game.scene.getEnemyField(); expect(karp1.isFainted()).toBe(true); expect(karp2.isFainted()).toBe(true); }); diff --git a/test/testUtils/helpers/classicModeHelper.ts b/test/testUtils/helpers/classicModeHelper.ts index eff97483777..24c4a97e9bf 100644 --- a/test/testUtils/helpers/classicModeHelper.ts +++ b/test/testUtils/helpers/classicModeHelper.ts @@ -1,15 +1,16 @@ import { BattleStyle } from "#app/enums/battle-style"; -import type { SpeciesId } from "#enums/species-id"; import { getGameMode } from "#app/game-mode"; -import { GameModes } from "#enums/game-modes"; import overrides from "#app/overrides"; import { CommandPhase } from "#app/phases/command-phase"; import { EncounterPhase } from "#app/phases/encounter-phase"; import { SelectStarterPhase } from "#app/phases/select-starter-phase"; import { TurnInitPhase } from "#app/phases/turn-init-phase"; +import { GameModes } from "#enums/game-modes"; +import { Nature } from "#enums/nature"; +import type { SpeciesId } from "#enums/species-id"; import { UiMode } from "#enums/ui-mode"; -import { generateStarter } from "../gameManagerUtils"; -import { GameManagerHelper } from "./gameManagerHelper"; +import { generateStarter } from "#test/testUtils/gameManagerUtils"; +import { GameManagerHelper } from "#test/testUtils/helpers/gameManagerHelper"; /** * Helper to handle classic-mode specific operations. @@ -36,6 +37,12 @@ export class ClassicModeHelper extends GameManagerHelper { if (this.game.override.disableShinies) { this.game.override.shiny(false).enemyShiny(false); } + if (this.game.override.normalizeIVs) { + this.game.override.playerIVs(31).enemyIVs(31); + } + if (this.game.override.normalizeNatures) { + this.game.override.nature(Nature.HARDY).enemyNature(Nature.HARDY); + } this.game.onNextPrompt("TitlePhase", UiMode.TITLE, () => { this.game.scene.gameMode = getGameMode(GameModes.CLASSIC); diff --git a/test/testUtils/helpers/overridesHelper.ts b/test/testUtils/helpers/overridesHelper.ts index 3bf0fbbda47..a26dc883a30 100644 --- a/test/testUtils/helpers/overridesHelper.ts +++ b/test/testUtils/helpers/overridesHelper.ts @@ -1,35 +1,55 @@ -import type { Variant } from "#app/sprites/variant"; +/** biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */ +import type { NewArenaEvent } from "#app/events/battle-scene"; +/** biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ + import { Weather } from "#app/data/weather"; -import { AbilityId } from "#enums/ability-id"; import type { ModifierOverride } from "#app/modifier/modifier-type"; -import type { BattleStyle } from "#app/overrides"; +import type { BattleStyle, RandomTrainerOverride } from "#app/overrides"; import Overrides, { defaultOverrides } from "#app/overrides"; -import type { Unlockables } from "#enums/unlockables"; +import type { Variant } from "#app/sprites/variant"; +import { coerceArray, shiftCharCodes } from "#app/utils/common"; +import { AbilityId } from "#enums/ability-id"; +import type { BattleType } from "#enums/battle-type"; import { BiomeId } from "#enums/biome-id"; import { MoveId } from "#enums/move-id"; import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Nature } from "#enums/nature"; import { SpeciesId } from "#enums/species-id"; import { StatusEffect } from "#enums/status-effect"; +import type { Unlockables } from "#enums/unlockables"; import type { WeatherType } from "#enums/weather-type"; +import { GameManagerHelper } from "#test/testUtils/helpers/gameManagerHelper"; import { expect, vi } from "vitest"; -import { GameManagerHelper } from "./gameManagerHelper"; -import { coerceArray, shiftCharCodes } from "#app/utils/common"; -import type { RandomTrainerOverride } from "#app/overrides"; -import type { BattleType } from "#enums/battle-type"; /** * Helper to handle overrides in tests */ export class OverridesHelper extends GameManagerHelper { - /** If `true`, removes the starting items from enemies at the start of each test; default `true` */ + /** + * If `true`, removes the starting items from enemies at the start of each test. + * @defaultValue `true` + */ public removeEnemyStartingItems = true; - /** If `true`, sets the shiny overrides to disable shinies at the start of each test; default `true` */ + /** + * If `true`, sets the shiny overrides to disable shinies at the start of each test. + * @defaultValue `true` + */ public disableShinies = true; + /** + * If `true`, will set the IV overrides for player and enemy pokemon to `31` at the start of each test. + * @defaultValue `true` + */ + public normalizeIVs = true; + /** + * If `true`, will set the Nature overrides for player and enemy pokemon to a neutral nature at the start of each test. + * @defaultValue `true` + */ + public normalizeNatures = true; /** * Override the starting biome - * @warning Any event listeners that are attached to [NewArenaEvent](events\battle-scene.ts) may need to be handled down the line + * @warning Any event listeners that are attached to {@linkcode NewArenaEvent} may need to be handled down the line * @param biome - The biome to set */ public startingBiome(biome: BiomeId): this { @@ -219,6 +239,80 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Overrides the IVs of the player pokemon + * @param ivs - If set to a number, all IVs are set to the same value. Must be between `0` and `31`! + * + * If set to an array, that array is applied to the pokemon's IV field as-is. + * All values must be between `0` and `31`, and the array must be of exactly length `6`! + * + * If set to `null`, the override is disabled. + * @returns `this` + */ + public playerIVs(ivs: number | number[] | null): this { + this.normalizeIVs = false; + vi.spyOn(Overrides, "IVS_OVERRIDE", "get").mockReturnValue(ivs); + if (ivs === null) { + this.log("Player IVs override disabled!"); + } else { + this.log(`Player IVs set to ${ivs}!`); + } + return this; + } + + /** + * Overrides the nature of the player's pokemon + * @param nature - The nature to set, or `null` to disable the override. + * @returns `this` + */ + public nature(nature: Nature | null): this { + this.normalizeNatures = false; + vi.spyOn(Overrides, "NATURE_OVERRIDE", "get").mockReturnValue(nature); + if (nature === null) { + this.log("Player Nature override disabled!"); + } else { + this.log(`Player Nature set to ${Nature[nature]} (=${nature})!`); + } + return this; + } + + /** + * Overrides the IVs of the enemy pokemon + * @param ivs - If set to a number, all IVs are set to the same value. Must be between `0` and `31`! + * + * If set to an array, that array is applied to the pokemon's IV field as-is. + * All values must be between `0` and `31`, and the array must be of exactly length `6`! + * + * If set to `null`, the override is disabled. + * @returns `this` + */ + public enemyIVs(ivs: number | number[] | null): this { + this.normalizeIVs = false; + vi.spyOn(Overrides, "ENEMY_IVS_OVERRIDE", "get").mockReturnValue(ivs); + if (ivs === null) { + this.log("Enemy IVs override disabled!"); + } else { + this.log(`Enemy IVs set to ${ivs}!`); + } + return this; + } + + /** + * Overrides the nature of the enemy's pokemon + * @param nature - The nature to set, or `null` to disable the override. + * @returns `this` + */ + public enemyNature(nature: Nature | null): this { + this.normalizeNatures = false; + vi.spyOn(Overrides, "ENEMY_NATURE_OVERRIDE", "get").mockReturnValue(nature); + if (nature === null) { + this.log("Enemy Nature override disabled!"); + } else { + this.log(`Enemy Nature set to ${Nature[nature]} (=${nature})!`); + } + return this; + } + /** * Override each wave to not have standard trainer battles * @returns `this`