diff --git a/src/@types/dex-data.ts b/src/@types/dex-data.ts index 88cc16886bd..741b9a872b3 100644 --- a/src/@types/dex-data.ts +++ b/src/@types/dex-data.ts @@ -1,3 +1,5 @@ +import type { IVTuple } from "#app/@types/stat-types"; + export interface DexData { [key: number]: DexEntry; } @@ -9,5 +11,5 @@ export interface DexEntry { seenCount: number; caughtCount: number; hatchedCount: number; - ivs: number[]; + ivs: IVTuple; } diff --git a/src/@types/stat-types.ts b/src/@types/stat-types.ts new file mode 100644 index 00000000000..9b9ec8f5fea --- /dev/null +++ b/src/@types/stat-types.ts @@ -0,0 +1,60 @@ +import type { Head, Tail } from "#app/@types/utility-types/tuple"; + +// biome-ignore lint/correctness/noUnusedImports: Type Imports +import type { PermanentStat, BattleStat } from "#enums/stat"; + +type StatTuple = [ + hp: number, + atk: number, + def: number, + spAtk: number, + spDef: number, + spd: number, + acc: number, + eva: number, +]; + +/** Tuple containing all {@linkcode PermanentStat}s of a Pokemon. */ +export type PermanentStatTuple = Head>; +/** Tuple containing all {@linkcode BattleStat}s of a Pokemon. */ +export type BattleStatTuple = Tail; + +/** Integer literal union containing all numbers from 0-31 inclusive; used to strongly type Pokemon IVs. */ +export type IVType = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24 + | 25 + | 26 + | 27 + | 28 + | 29 + | 30 + | 31; + +type toIvTuple = { [k in keyof T]: IVType }; + +/** A 6-length tuple of integers in the range [0-31]; used to strongly type Pokemon IVs. */ +export type IVTuple = toIvTuple; diff --git a/src/@types/utility-types/global-augments.ts b/src/@types/utility-types/global-augments.ts new file mode 100644 index 00000000000..c0a74b42d9b --- /dev/null +++ b/src/@types/utility-types/global-augments.ts @@ -0,0 +1,9 @@ +export {}; + +declare global { + // Array global augments to allow semi-easy working with tuples...??? + interface Array { + map(callbackfn: (value: T, index: number, array: this) => U, thisArg?: any): { [K in keyof this]: U }; + slice(start: 0): { [K in keyof this]: T }; + } +} diff --git a/src/@types/utility-types/tuple.ts b/src/@types/utility-types/tuple.ts new file mode 100644 index 00000000000..4c01c330f98 --- /dev/null +++ b/src/@types/utility-types/tuple.ts @@ -0,0 +1,5 @@ +/** Extract the elements of a tuple type, excluding the first element. */ +export type Tail = Required extends [any, ...infer Head] ? Head : never; + +/** Extract the elements of a tuple type, excluding the last element. */ +export type Head = Required extends [...infer Head, any] ? Head : never; diff --git a/src/battle-scene.ts b/src/battle-scene.ts index b802466ee19..c10f5f6acb0 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -161,12 +161,16 @@ import { timedEventManager } from "./global-event-manager"; import { starterColors } from "./global-vars/starter-colors"; import { startingWave } from "./starting-wave"; import { PhaseManager } from "./phase-manager"; +import type { IVTuple, IVType } from "#app/@types/stat-types"; 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)); +const OPP_IVS_OVERRIDE_VALIDATED: IVTuple | null = + Overrides.OPP_IVS_OVERRIDE === null + ? null + : Array.isArray(Overrides.OPP_IVS_OVERRIDE) + ? Overrides.OPP_IVS_OVERRIDE + : (new Array(6).fill(Overrides.OPP_IVS_OVERRIDE) as IVTuple); export interface PokeballCounts { [pb: string]: number; @@ -907,7 +911,7 @@ export default class BattleScene extends SceneBase { gender?: Gender, shiny?: boolean, variant?: Variant, - ivs?: number[], + ivs?: IVTuple, nature?: Nature, dataSource?: Pokemon | PokemonData, postProcess?: (playerPokemon: PlayerPokemon) => void, @@ -955,28 +959,22 @@ export default class BattleScene extends SceneBase { } if (boss && !dataSource) { + // Generate a 2nd higher roll and linearly interpolate between the two. const secondaryIvs = getIvsFromId(randSeedInt(4294967296)); - for (let s = 0; s < pokemon.ivs.length; s++) { - pokemon.ivs[s] = Math.round( - Phaser.Math.Linear( - Math.min(pokemon.ivs[s], secondaryIvs[s]), - Math.max(pokemon.ivs[s], secondaryIvs[s]), - 0.75, - ), - ); - } + pokemon.ivs = pokemon.ivs.map( + (iv, i) => + Math.round(Phaser.Math.Linear(Math.min(iv, secondaryIvs[i]), Math.max(iv, secondaryIvs[i]), 0.75)) as IVType, // ts doesn't know the number will be between 0-31 + ); } + if (postProcess) { postProcess(pokemon); } - for (let i = 0; i < pokemon.ivs.length; i++) { - if (OPP_IVS_OVERRIDE_VALIDATED[i] > -1) { - pokemon.ivs[i] = OPP_IVS_OVERRIDE_VALIDATED[i]; - } + if (OPP_IVS_OVERRIDE_VALIDATED !== null) { + pokemon.ivs = OPP_IVS_OVERRIDE_VALIDATED; } - pokemon.init(); return pokemon; } @@ -2084,7 +2082,7 @@ export default class BattleScene extends SceneBase { let scoreIncrease = enemy.getSpeciesForm().getBaseExp() * (enemy.level / this.getMaxExpLevel()) * - ((enemy.ivs.reduce((iv: number, total: number) => (total += iv), 0) / 93) * 0.2 + 0.8); + ((enemy.ivs.reduce((total, iv) => total + iv, 0) / 93) * 0.2 + 0.8); this.findModifiers(m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemy.id, false).map( m => (scoreIncrease *= (m as PokemonHeldItemModifier).getScoreMultiplier()), ); diff --git a/src/data/egg.ts b/src/data/egg.ts index a6e2e04a5fe..f99aff1bebe 100644 --- a/src/data/egg.ts +++ b/src/data/egg.ts @@ -38,6 +38,7 @@ import { HATCH_WAVES_LEGENDARY_EGG, } from "#app/data/balance/rates"; import { speciesEggTiers } from "#app/data/balance/species-egg-tiers"; +import type { IVType } from "#app/@types/stat-types"; export const EGG_SEED = 1073741824; @@ -270,9 +271,9 @@ export class Egg { const secondaryIvs = getIvsFromId(randSeedInt(4294967295)); - for (let s = 0; s < ret.ivs.length; s++) { - ret.ivs[s] = Math.max(ret.ivs[s], secondaryIvs[s]); - } + ret.ivs = ret.ivs.map( + (iv, i) => Math.max(iv, secondaryIvs[i]) as IVType, // ts doesn't know the number will be between 0-31 + ); }; ret = ret!; // Tell TS compiler it's defined now diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts index 1ba756c7f5d..d73e4206ceb 100644 --- a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts +++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts @@ -512,7 +512,7 @@ async function postProcessTransformedPokemon( const hiddenAbilityChance = new NumberHolder(256); globalScene.applyModifiers(HiddenAbilityRateBoosterModifier, true, hiddenAbilityChance); - const hasHiddenAbility = !randSeedInt(hiddenAbilityChance.value); + const hasHiddenAbility = randSeedInt(hiddenAbilityChance.value) === 0; if (hasHiddenAbility) { newPokemon.abilityIndex = hiddenIndex; diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index bb74f11ce60..3d354707df8 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -53,6 +53,7 @@ import { PokemonType } from "#enums/pokemon-type"; import { getNatureName } from "#app/data/nature"; import { getPokemonNameWithAffix } from "#app/messages"; import { timedEventManager } from "#app/global-event-manager"; +import type { IVTuple } from "#app/@types/stat-types"; /** * Animates exclamation sprite over trainer's head at start of encounter @@ -95,7 +96,7 @@ export interface EnemyPokemonConfig { passive?: boolean; moveSet?: MoveId[]; nature?: Nature; - ivs?: [number, number, number, number, number, number]; + ivs?: IVTuple; shiny?: boolean; /** Is only checked if Pokemon is shiny */ variant?: Variant; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e9cc4f70d70..62083e8dd0d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -190,6 +190,7 @@ import { AiType } from "#enums/ai-type"; import type { MoveResult } from "#enums/move-result"; import { PokemonMove } from "#app/data/moves/pokemon-move"; import type { AbAttrMap, AbAttrString } from "#app/@types/ability-types"; +import type { IVTuple, PermanentStatTuple } from "#app/@types/stat-types"; /** Base typeclass for damage parameter methods, used for DRY */ type damageParams = { @@ -244,8 +245,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { public levelExp: number; public gender: Gender; public hp: number; - public stats: number[]; - public ivs: number[]; + public stats: PermanentStatTuple; + public ivs: IVTuple; public nature: Nature; public moveset: PokemonMove[]; public status: Status | null; @@ -314,7 +315,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { gender?: Gender, shiny?: boolean, variant?: Variant, - ivs?: number[], + ivs?: IVTuple, nature?: Nature, dataSource?: Pokemon | PokemonData, ) { @@ -5492,7 +5493,7 @@ export class PlayerPokemon extends Pokemon { gender?: Gender, shiny?: boolean, variant?: Variant, - ivs?: number[], + ivs?: IVTuple, nature?: Nature, dataSource?: Pokemon | PokemonData, ) { @@ -6091,10 +6092,12 @@ export class EnemyPokemon extends Pokemon { if (this.hasTrainer() && globalScene.currentBattle) { const { waveIndex } = globalScene.currentBattle; - const ivs: number[] = []; - while (ivs.length < 6) { - ivs.push(randSeedIntRange(Math.floor(waveIndex / 10), 31)); - } + const ivs = Array.from( + { + length: 6, + }, + () => randSeedIntRange(Math.floor(waveIndex / 10), 31), + ) as IVTuple; this.ivs = ivs; } } diff --git a/src/overrides.ts b/src/overrides.ts index b390b9fa70f..fc57701e9f1 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -22,6 +22,7 @@ import { TimeOfDay } from "#enums/time-of-day"; import { TrainerType } from "#enums/trainer-type"; import { VariantTier } from "#enums/variant-tier"; import { WeatherType } from "#enums/weather-type"; +import type { IVTuple, IVType } from "#app/@types/stat-types"; /** * This comment block exists to prevent IDEs from automatically removing unused imports @@ -181,7 +182,11 @@ 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[] = []; + /** + * An array of IVs to give the opposing pokemon. + * Specifying a single number will use it for all 6 IVs, while `null` will disable the override. + */ + readonly OPP_IVS_OVERRIDE: IVType | IVTuple | null = null; readonly OPP_FORM_OVERRIDES: Partial> = {}; /** * Override to give the enemy Pokemon a given amount of health segments diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index f2c23384627..d56a06918af 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -121,7 +121,8 @@ export class EncounterPhase extends BattlePhase { !!globalScene.getEncounterBossSegments(battle.waveIndex, level, enemySpecies), ); if (globalScene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { - battle.enemyParty[e].ivs = new Array(6).fill(31); + // max IVs for eternatus + battle.enemyParty[e].ivs = [31, 31, 31, 31, 31, 31]; } globalScene .getPlayerParty() diff --git a/src/system/game-data.ts b/src/system/game-data.ts index e933c5704f9..16adbd8dae9 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -69,6 +69,7 @@ import { DexAttr } from "#enums/dex-attr"; import { AbilityAttr } from "#enums/ability-attr"; import { defaultStarterSpecies, saveKey } from "#app/constants"; import { encrypt, decrypt } from "#app/utils/data"; +import type { IVTuple } from "#app/@types/stat-types"; function getDataTypeKey(dataType: GameDataType, slotId = 0): string { switch (dataType) { @@ -1946,7 +1947,13 @@ export class GameData { _unlockSpeciesNature(species.speciesId); } - updateSpeciesDexIvs(speciesId: SpeciesId, ivs: number[]): void { + /** + * When a pokemon is caught or added, maximize its lowest pre-evolution's {@linkcode DexData} + * with the IVs of the newly caught pokemon. + * @param speciesId - The {@linkcode SpeciesId} to update dex data for. + * @param ivs - The {@linkcode IVTuple | IVs} of the caught pokemon. + */ + updateSpeciesDexIvs(speciesId: SpeciesId, ivs: IVTuple): void { let dexEntry: DexEntry; do { dexEntry = globalScene.gameData.dexData[speciesId]; @@ -1956,7 +1963,7 @@ export class GameData { dexIvs[i] = ivs[i]; } } - if (dexIvs.filter(iv => iv === 31).length === 6) { + if (dexIvs.every(iv => iv === 31)) { globalScene.validateAchv(achvs.PERFECT_IVS); } } while (pokemonPrevolutions.hasOwnProperty(speciesId) && (speciesId = pokemonPrevolutions[speciesId])); diff --git a/src/system/pokemon-data.ts b/src/system/pokemon-data.ts index 050da57e0be..b30a2ecc162 100644 --- a/src/system/pokemon-data.ts +++ b/src/system/pokemon-data.ts @@ -15,6 +15,7 @@ import type { MoveId } from "#enums/move-id"; import type { SpeciesId } from "#enums/species-id"; import { CustomPokemonData } from "#app/data/custom-pokemon-data"; import type { PokemonType } from "#enums/pokemon-type"; +import type { IVTuple, PermanentStatTuple } from "#app/@types/stat-types"; export default class PokemonData { public id: number; @@ -32,8 +33,8 @@ export default class PokemonData { public levelExp: number; public gender: Gender; public hp: number; - public stats: number[]; - public ivs: number[]; + public stats: PermanentStatTuple; + public ivs: IVTuple; public nature: Nature; public moveset: PokemonMove[]; public status: Status | null; diff --git a/src/ui/pokemon-info-container.ts b/src/ui/pokemon-info-container.ts index 0056c3e2f11..3077c847a3a 100644 --- a/src/ui/pokemon-info-container.ts +++ b/src/ui/pokemon-info-container.ts @@ -14,6 +14,7 @@ import ConfirmUiHandler from "./confirm-ui-handler"; import { StatsContainer } from "./stats-container"; import { TextStyle, addBBCodeTextObject, addTextObject, getTextColor } from "./text"; import { addWindow } from "./ui-theme"; +import type { IVTuple } from "#app/@types/stat-types"; interface LanguageSetting { infoContainerTextSize: string; @@ -383,7 +384,7 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { } const starterSpeciesId = pokemon.species.getRootSpeciesId(); - const originalIvs: number[] | null = eggInfo + const originalIvs: IVTuple | null = eggInfo ? dexEntry.caughtAttr ? dexEntry.ivs : null diff --git a/src/utils/common.ts b/src/utils/common.ts index e19e5976507..a01083a6abc 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -3,6 +3,7 @@ import { MoveId } from "#enums/move-id"; import i18next from "i18next"; import { pokerogueApi } from "#app/plugins/api/pokerogue-api"; import type { Variant } from "#app/sprites/variant"; +import type { IVTuple, IVType } from "#app/@types/stat-types"; export type nil = null | undefined; @@ -87,6 +88,8 @@ export function randInt(range: number, min = 0): number { return Math.floor(Math.random() * range) + min; } +export function randSeedInt(range: 31, min?: IVType): IVType; +export function randSeedInt(range: number, min?: number): number; /** * Generate a random integer using the global seed, or the current battle's seed if called via `Battle.randSeedInt` * @param range - How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min} @@ -182,7 +185,7 @@ export function getPlayTimeString(totalSeconds: number): string { * @param id 32-bit number * @returns An array of six numbers corresponding to 5-bit chunks from {@linkcode id} */ -export function getIvsFromId(id: number): number[] { +export function getIvsFromId(id: number): IVTuple { return [ (id & 0x3e000000) >>> 25, (id & 0x01f00000) >>> 20, @@ -190,7 +193,7 @@ export function getIvsFromId(id: number): number[] { (id & 0x00007c00) >>> 10, (id & 0x000003e0) >>> 5, id & 0x0000001f, - ]; + ] as IVTuple; // TS doesn't know the numbers are between 0-31 } export function formatLargeNumber(count: number, threshold: number): string { diff --git a/test/moves/fusion_flare_bolt.test.ts b/test/moves/fusion_flare_bolt.test.ts index 1967e9f12d1..d32fbf7343e 100644 --- a/test/moves/fusion_flare_bolt.test.ts +++ b/test/moves/fusion_flare_bolt.test.ts @@ -11,6 +11,7 @@ import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PermanentStatTuple } from "#app/@types/stat-types"; describe("Moves - Fusion Flare and Fusion Bolt", () => { let phaserGame: Phaser.Game; @@ -156,6 +157,7 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); }); + // TODO: Clean these tests up it("FUSION_FLARE and FUSION_BOLT alternating throughout turn should double power of subsequent moves", async () => { game.override.enemyMoveset(fusionFlare.id); await game.classicMode.startBattle([SpeciesId.ZEKROM, SpeciesId.ZEKROM]); @@ -168,7 +170,10 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { game.scene.clearEnemyModifiers(); // Mock stats by replacing entries in copy with desired values for specific stats - const stats = { + const stats: { + enemy: [PermanentStatTuple, PermanentStatTuple]; + player: [PermanentStatTuple, PermanentStatTuple]; + } = { enemy: [[...enemyParty[0].stats], [...enemyParty[1].stats]], player: [[...party[0].stats], [...party[1].stats]], }; @@ -222,7 +227,10 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { game.scene.clearEnemyModifiers(); // Mock stats by replacing entries in copy with desired values for specific stats - const stats = { + const stats: { + enemy: [PermanentStatTuple, PermanentStatTuple]; + player: [PermanentStatTuple, PermanentStatTuple]; + } = { enemy: [[...enemyParty[0].stats], [...enemyParty[1].stats]], player: [[...party[0].stats], [...party[1].stats]], };