diff --git a/src/@types/pokemon-common.ts b/src/@types/pokemon-common.ts new file mode 100644 index 00000000000..b56647c32a0 --- /dev/null +++ b/src/@types/pokemon-common.ts @@ -0,0 +1,98 @@ +import type { PokemonSpecies, PokemonSpeciesFilter } from "#data/pokemon-species"; +import type { SpeciesId } from "#enums/species-id"; +import type { BooleanHolder } from "#utils/common"; + +/** + * The type that {@linkcode PokemonSpeciesForm} is converted to when an object containing it serializes it. + */ +export type SerializedSpeciesForm = { + id: SpeciesId; + formIdx: number; +}; + +export interface RandomEncounterParams { + /** The level of the mon */ + level: number; + + /** A custom function used to return the {@linkcode PokemonSpecies} to generate */ + speciesFunction?: () => PokemonSpecies; + + /** + * Whether the Pokemon should be a Boss. + * @defaultValue `false` + */ + isBoss?: boolean; + + /** + * Whether Sub-legendaries can be encountered, mainly for event encounters + * @defaultValue `true` + */ + includeSubLegendary?: boolean; + + /** + * Whether Legendaries can be encountered + * @defaultValue `true` + */ + includeLegendary?: boolean; + + /** + * Whether Mythicals can be encountered + * @defaultValue `true` + */ + includeMythical?: boolean; + + /** + * The chance out of 100 to pick an event encounter + * @defaultValue `50` + */ + eventChance?: number; + + /** + * Number of rerolls for Hidden Ability (HA) that should be attempted + * @defaultValue `0` + */ + hiddenRerolls?: number; + + /** + * Number of rerolls for shininess/variants that should be attempted + * @defaultValue `0` + */ + shinyRerolls?: number; + + /** + * Number of extra HA rerolls for event mons + * @defaultValue `0` + */ + eventHiddenRerolls?: number; + + /** + * Number of extra shiny rerolls for event mons + * @defaultValue `0` + */ + eventShinyRerolls?: number; + + /** + * The overridden HA chance, defaults to base + */ + hiddenAbilityChance?: number; + + /** + * The overridden shiny chance, defaults to base + */ + shinyChance?: number; + + /** + * The max shiny threshold after modifiers are applied. Values below 1 mean no maximum + * @defaultValue `0` (no maximum) + */ + maxShinyChance?: number; + + /** + * An optional filter for eligible mons, applied to the event encounter pool. + * If omitted, no filter will be applied. + */ + speciesFilter?: PokemonSpeciesFilter; + + /** An optional {@linkcode BooleanHolder} used to let the caller know if it pulled from an event. */ + isEventEncounter?: BooleanHolder; +} diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts index a4f2b00b04b..41a3f7dd55a 100644 --- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -18,7 +18,7 @@ import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils import { generateModifierType, generateModifierTypeOption, - getRandomEncounterSpecies, + getRandomEncounterPokemon, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterExp, @@ -66,7 +66,12 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder. // Calculate boss mon const level = getEncounterPokemonLevelForWave(STANDARD_ENCOUNTER_BOOSTED_LEVEL_MODIFIER); - const bossPokemon = getRandomEncounterSpecies(level, true); + const bossPokemon = getRandomEncounterPokemon({ + level, + isBoss: true, + eventShinyRerolls: 2, + eventHiddenRerolls: 1, + }); encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon)); const config: EnemyPartyConfig = { pokemonConfigs: [ 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 a46bac013cc..1a5d74852c1 100644 --- a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts +++ b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts @@ -12,7 +12,7 @@ import { getPlayerModifierTypeOptions, regenerateModifierPoolThresholds } from " import { queueEncounterMessage } from "#mystery-encounters/encounter-dialogue-utils"; import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils"; import { - getRandomEncounterSpecies, + getRandomEncounterPokemon, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterExp, @@ -58,7 +58,12 @@ export const FightOrFlightEncounter: MysteryEncounter = MysteryEncounterBuilder. // Calculate boss mon const level = getEncounterPokemonLevelForWave(STANDARD_ENCOUNTER_BOOSTED_LEVEL_MODIFIER); - const bossPokemon = getRandomEncounterSpecies(level, true); + const bossPokemon = getRandomEncounterPokemon({ + level, + isBoss: true, + eventShinyRerolls: 2, + eventHiddenRerolls: 1, + }); encounter.setDialogueToken("enemyPokemon", bossPokemon.getNameToRender()); const config: EnemyPartyConfig = { pokemonConfigs: [ 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 fd323edd236..0941283147a 100644 --- a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts +++ b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts @@ -1,5 +1,4 @@ import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; -import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; import { allSpecies } from "#data/data-lists"; import { Gender, getGenderSymbol } from "#data/gender"; @@ -20,17 +19,13 @@ import { doShinySparkleAnim } from "#field/anims"; import type { PlayerPokemon, Pokemon } from "#field/pokemon"; import { EnemyPokemon } from "#field/pokemon"; import type { PokemonHeldItemModifier } from "#modifiers/modifier"; -import { - HiddenAbilityRateBoosterModifier, - PokemonFormChangeItemModifier, - ShinyRateBoosterModifier, - SpeciesStatBoosterModifier, -} from "#modifiers/modifier"; +import { PokemonFormChangeItemModifier, SpeciesStatBoosterModifier } from "#modifiers/modifier"; import type { ModifierTypeOption } from "#modifiers/modifier-type"; import { getPlayerModifierTypeOptions, regenerateModifierPoolThresholds } from "#modifiers/modifier-type"; import { PokemonMove } from "#moves/pokemon-move"; import { getEncounterText, showEncounterText } from "#mystery-encounters/encounter-dialogue-utils"; import { + getRandomEncounterPokemon, leaveEncounterWithoutBattle, selectPokemonForOption, setEncounterRewards, @@ -43,7 +38,7 @@ import { PartySizeRequirement } from "#mystery-encounters/mystery-encounter-requ import { PokemonData } from "#system/pokemon-data"; import { MusicPreference } from "#system/settings"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; -import { NumberHolder, randInt, randSeedInt, randSeedItem, randSeedShuffle } from "#utils/common"; +import { randInt, randSeedInt, randSeedItem, randSeedShuffle } from "#utils/common"; import { getEnumKeys } from "#utils/enums"; import { getRandomLocaleEntry } from "#utils/i18n"; import { getPokemonSpecies } from "#utils/pokemon-utils"; @@ -58,6 +53,8 @@ const WONDER_TRADE_SHINY_CHANCE = 512; /** Max shiny chance of 4096/65536 -> 1/16 odds. */ const MAX_WONDER_TRADE_SHINY_CHANCE = 4096; +const WONDER_TRADE_HIDDEN_ABILITY_CHANCE = 64; + const LEGENDARY_TRADE_POOLS = { 1: [SpeciesId.RATTATA, SpeciesId.PIDGEY, SpeciesId.WEEDLE], 2: [SpeciesId.SENTRET, SpeciesId.HOOTHOOT, SpeciesId.LEDYBA], @@ -273,38 +270,23 @@ export const GlobalTradeSystemEncounter: MysteryEncounter = MysteryEncounterBuil const encounter = globalScene.currentBattle.mysteryEncounter!; const onPokemonSelected = (pokemon: PlayerPokemon) => { // Randomly generate a Wonder Trade pokemon - const randomTradeOption = generateTradeOption(globalScene.getPlayerParty().map(p => p.species)); - const tradePokemon = new EnemyPokemon(randomTradeOption, pokemon.level, TrainerSlot.NONE, false); - // Extra shiny roll at 1/128 odds (boosted by events and charms) - if (!tradePokemon.shiny) { - const shinyThreshold = new NumberHolder(WONDER_TRADE_SHINY_CHANCE); - if (timedEventManager.isEventActive()) { - shinyThreshold.value *= timedEventManager.getShinyEncounterMultiplier(); - } - globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold); - - // Base shiny chance of 512/65536 -> 1/128, affected by events and Shiny Charms - // Maximum shiny chance of 4096/65536 -> 1/16, cannot improve further after that - const shinyChance = Math.min(shinyThreshold.value, MAX_WONDER_TRADE_SHINY_CHANCE); - - tradePokemon.trySetShinySeed(shinyChance, false); - } - - // Extra HA roll at base 1/64 odds (boosted by events and charms) - const hiddenIndex = tradePokemon.species.ability2 ? 2 : 1; - if (tradePokemon.species.abilityHidden && tradePokemon.abilityIndex < hiddenIndex) { - const hiddenAbilityChance = new NumberHolder(64); - globalScene.applyModifiers(HiddenAbilityRateBoosterModifier, true, hiddenAbilityChance); - - const hasHiddenAbility = !randSeedInt(hiddenAbilityChance.value); - - if (hasHiddenAbility) { - tradePokemon.abilityIndex = hiddenIndex; - } - } + const tradePokemon = getRandomEncounterPokemon({ + level: pokemon.level, + speciesFunction: () => generateTradeOption(globalScene.getPlayerParty().map(p => p.species)), + isBoss: false, + eventChance: 100, + shinyRerolls: 1, + hiddenRerolls: 1, + eventShinyRerolls: 1, + eventHiddenRerolls: 1, + hiddenAbilityChance: WONDER_TRADE_HIDDEN_ABILITY_CHANCE, + shinyChance: WONDER_TRADE_SHINY_CHANCE, + maxShinyChance: MAX_WONDER_TRADE_SHINY_CHANCE, + speciesFilter: s => !globalScene.getPlayerParty().some(p => p.species === s), + }); // If Pokemon is still not shiny or with HA, give the Pokemon a random Common egg move in its moveset - if (!tradePokemon.shiny && (!tradePokemon.species.abilityHidden || tradePokemon.abilityIndex < hiddenIndex)) { + if (!tradePokemon.shiny && (!tradePokemon.species.abilityHidden || tradePokemon.abilityIndex < 2)) { const eggMoves = tradePokemon.getEggMoves(); if (eggMoves) { // Cannot gen the rare egg move, only 1 of the first 3 common moves diff --git a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts index cfa862b5743..5960989929d 100644 --- a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts +++ b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts @@ -9,11 +9,11 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PlayerGender } from "#enums/player-gender"; import { PokeballType } from "#enums/pokeball"; -import { TrainerSlot } from "#enums/trainer-slot"; import type { EnemyPokemon } from "#field/pokemon"; -import { HiddenAbilityRateBoosterModifier, IvScannerModifier } from "#modifiers/modifier"; +import { IvScannerModifier } from "#modifiers/modifier"; import { getEncounterText, showEncounterText } from "#mystery-encounters/encounter-dialogue-utils"; import { + getRandomEncounterPokemon, initSubsequentOptionSelect, leaveEncounterWithoutBattle, transitionMysteryEncounterIntroVisuals, @@ -30,7 +30,7 @@ import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter"; import type { MysteryEncounterOption } from "#mystery-encounters/mystery-encounter-option"; import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option"; import { MoneyRequirement } from "#mystery-encounters/mystery-encounter-requirements"; -import { NumberHolder, randSeedInt } from "#utils/common"; +import { BooleanHolder, NumberHolder, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; /** the i18n namespace for the encounter */ @@ -42,6 +42,9 @@ const SAFARI_MONEY_MULTIPLIER = 2; const NUM_SAFARI_ENCOUNTERS = 3; +const eventEncs = new NumberHolder(0); +const eventChance = new NumberHolder(50); + /** * Safari Zone encounter. * @see {@link https://github.com/pagefaultgames/pokerogue/issues/3800 | GitHub Issue #3800} @@ -74,6 +77,8 @@ export const SafariZoneEncounter: MysteryEncounter = MysteryEncounterBuilder.wit .withQuery(`${namespace}:query`) .withOnInit(() => { globalScene.currentBattle.mysteryEncounter?.setDialogueToken("numEncounters", NUM_SAFARI_ENCOUNTERS.toString()); + eventEncs.value = 0; + eventChance.value = 50; return true; }) .withOption( @@ -279,37 +284,37 @@ async function summonSafariPokemon() { // Generate pokemon using safariPokemonRemaining so they are always the same pokemon no matter how many turns are taken // Safari pokemon roll twice on shiny and HA chances, but are otherwise normal - let enemySpecies: PokemonSpecies; let pokemon: any; globalScene.executeWithSeedOffset( () => { - enemySpecies = getSafariSpeciesSpawn(); - const level = globalScene.currentBattle.getLevelForWave(); - enemySpecies = getPokemonSpecies(enemySpecies.getWildSpeciesForLevel(level, true, false, globalScene.gameMode)); - pokemon = globalScene.addEnemyPokemon(enemySpecies, level, TrainerSlot.NONE, false); + console.log("Event chance %d", eventChance.value); + const fromEvent = new BooleanHolder(false); + pokemon = getRandomEncounterPokemon({ + level: globalScene.currentBattle.getLevelForWave(), + includeLegendary: false, + includeSubLegendary: false, + includeMythical: false, + speciesFunction: getSafariSpeciesSpawn, + shinyRerolls: 1, + eventShinyRerolls: 1, + hiddenRerolls: 1, + eventHiddenRerolls: 1, + eventChance: eventChance.value, + isEventEncounter: fromEvent, + }); - // Roll shiny twice - if (!pokemon.shiny) { - pokemon.trySetShinySeed(); + pokemon.init(); + + // Increase chance of event encounter by 25% until one spawns + if (fromEvent.value) { + console.log("Safari zone encounter is from event"); + eventEncs.value++; + eventChance.value = 50; + } else if (eventEncs.value === 0) { + console.log("Safari zone encounter is not from event"); + eventChance.value += 25; } - // Roll HA twice - if (pokemon.species.abilityHidden) { - const hiddenIndex = pokemon.species.ability2 ? 2 : 1; - if (pokemon.abilityIndex < hiddenIndex) { - const hiddenAbilityChance = new NumberHolder(256); - globalScene.applyModifiers(HiddenAbilityRateBoosterModifier, true, hiddenAbilityChance); - - const hasHiddenAbility = !randSeedInt(hiddenAbilityChance.value); - - if (hasHiddenAbility) { - pokemon.abilityIndex = hiddenIndex; - } - } - } - - pokemon.calculateStats(); - globalScene.currentBattle.enemyParty.unshift(pokemon); }, globalScene.currentBattle.waveIndex * 1000 * encounter.misc.safariPokemonRemaining, @@ -569,7 +574,7 @@ async function doEndTurn(cursorIndex: number) { } /** - * @returns A random species that has at most 5 starter cost and is not Mythical, Paradox, etc. + * @returns A function to get a random species that has at most 5 starter cost and is not Mythical, Paradox, etc. */ export function getSafariSpeciesSpawn(): PokemonSpecies { return getPokemonSpecies( 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 51efa0c7586..763b1229ee5 100644 --- a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts @@ -77,6 +77,8 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui .withOnInit(() => { const encounter = globalScene.currentBattle.mysteryEncounter!; + let isEventEncounter = false; + let species = getSalesmanSpeciesOffer(); let tries = 0; @@ -88,16 +90,12 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui const r = randSeedInt(SHINY_MAGIKARP_WEIGHT); - const validEventEncounters = timedEventManager - .getEventEncounters() - .filter( - s => - !getPokemonSpecies(s.species).legendary - && !getPokemonSpecies(s.species).subLegendary - && !getPokemonSpecies(s.species).mythical - && !NON_LEGEND_PARADOX_POKEMON.includes(s.species) - && !NON_LEGEND_ULTRA_BEASTS.includes(s.species), - ); + const validEventEncounters = timedEventManager.getAllValidEventEncounters( + false, + false, + false, + s => !NON_LEGEND_PARADOX_POKEMON.includes(s.speciesId) && !NON_LEGEND_ULTRA_BEASTS.includes(s.speciesId), + ); let pokemon: PlayerPokemon; /** @@ -122,7 +120,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui ) { tries = 0; do { - // If you roll 20%, give event encounter with 3 extra shiny rolls and its HA, if it has one + // If you roll 50%, give event encounter with 3 extra shiny rolls and its HA, if it has one const enc = randSeedItem(validEventEncounters); species = getPokemonSpecies(enc.species); pokemon = new PlayerPokemon( @@ -135,6 +133,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui pokemon.trySetShinySeed(); pokemon.trySetShinySeed(); if (pokemon.shiny || pokemon.abilityIndex === 2) { + isEventEncounter = true; break; } tries++; @@ -149,6 +148,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui pokemon.trySetShinySeed(); pokemon.trySetShinySeed(); pokemon.trySetShinySeed(); + isEventEncounter = true; } else { // If there's, and this would never happen, no eligible event encounters with a hidden ability, just do Magikarp species = getPokemonSpecies(SpeciesId.MAGIKARP); @@ -177,7 +177,9 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui if (pokemon.shiny) { // Always max price for shiny (flip HA back to normal), and add special messaging priceMultiplier = MAX_POKEMON_PRICE_MULTIPLIER; - pokemon.abilityIndex = 0; + if (!isEventEncounter) { + pokemon.abilityIndex = 0; + } encounter.dialogue.encounterOptionsDialogue!.description = `${namespace}:descriptionShiny`; encounter.options[0].dialogue!.buttonTooltip = `${namespace}:option.1.tooltipShiny`; } diff --git a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts index cd61a6852f7..7cd6b5e62cc 100644 --- a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts +++ b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts @@ -15,7 +15,7 @@ import { PokemonMove } from "#moves/pokemon-move"; import { queueEncounterMessage } from "#mystery-encounters/encounter-dialogue-utils"; import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils"; import { - getRandomEncounterSpecies, + getRandomEncounterPokemon, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, setEncounterExp, @@ -62,7 +62,12 @@ export const UncommonBreedEncounter: MysteryEncounter = MysteryEncounterBuilder. // Calculate boss mon // Level equal to 2 below highest party member const level = getHighestLevelPlayerPokemon(false, true).level - 2; - const pokemon = getRandomEncounterSpecies(level, true, true); + const pokemon = getRandomEncounterPokemon({ + level, + isBoss: true, + eventShinyRerolls: 2, + eventHiddenRerolls: 1, + }); // Pokemon will always have one of its egg moves in its moveset const eggMoves = pokemon.getEggMoves(); diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index d5aecf7055a..64162a1b2ad 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -4,6 +4,7 @@ import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { biomeLinks } from "#balance/biomes"; +import { BASE_HIDDEN_ABILITY_CHANCE, BASE_SHINY_CHANCE } from "#balance/rates"; import { initMoveAnim, loadMoveAnimAssets } from "#data/battle-anims"; import { modifierTypes } from "#data/data-lists"; import type { IEggOptions } from "#data/egg"; @@ -47,11 +48,12 @@ import type { PokemonData } from "#system/pokemon-data"; import type { TrainerConfig } from "#trainers/trainer-config"; import { trainerConfigs } from "#trainers/trainer-config"; import type { HeldModifierConfig } from "#types/held-modifier-config"; +import type { RandomEncounterParams } from "#types/pokemon-common"; import type { OptionSelectConfig, OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import type { PartyOption, PokemonSelectFilter } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; import { coerceArray } from "#utils/array"; -import { randomString, randSeedInt, randSeedItem } from "#utils/common"; +import { BooleanHolder, randomString, randSeedInt, randSeedItem } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -990,19 +992,39 @@ export function handleMysteryEncounterTurnStartEffects(): boolean { /** * Helper function for encounters such as {@linkcode UncommonBreedEncounter} which call for a random species including event encounters. - * If the mon is from the event encounter list, it will do an extra shiny roll. - * @param level the level of the mon, which differs between MEs - * @param isBoss whether the mon should be a Boss - * @param rerollHidden whether the mon should get an extra roll for Hidden Ability - * @returns for the requested encounter + * If the mon is from the event encounter list, it may do an extra shiny or HA roll. + * @param params - The {@linkcode RandomEncounterParams} used to configure the encounter + * @returns The generated {@linkcode EnemyPokemon} for the requested encounter */ -export function getRandomEncounterSpecies(level: number, isBoss = false, rerollHidden = false): EnemyPokemon { +export function getRandomEncounterPokemon(params: RandomEncounterParams): EnemyPokemon { + let { + level, + speciesFunction, + isBoss = false, + includeSubLegendary = true, + includeLegendary = true, + includeMythical = true, + eventChance = 50, + hiddenRerolls = 0, + shinyRerolls = 0, + eventHiddenRerolls = 0, + eventShinyRerolls = 0, + hiddenAbilityChance = BASE_HIDDEN_ABILITY_CHANCE, + shinyChance = BASE_SHINY_CHANCE, + maxShinyChance = 0, + speciesFilter = () => true, + isEventEncounter = new BooleanHolder(false), + } = params; let bossSpecies: PokemonSpecies; - let isEventEncounter = false; - const eventEncounters = timedEventManager.getEventEncounters(); + const eventEncounters = timedEventManager.getAllValidEventEncounters( + includeSubLegendary, + includeLegendary, + includeMythical, + speciesFilter, + ); let formIndex: number | undefined; - if (eventEncounters.length > 0 && randSeedInt(2) === 1) { + if (eventChance && eventEncounters.length > 0 && (eventChance === 100 || randSeedInt(100) < eventChance)) { const eventEncounter = randSeedItem(eventEncounters); const levelSpecies = getPokemonSpecies(eventEncounter.species).getWildSpeciesForLevel( level, @@ -1010,9 +1032,13 @@ export function getRandomEncounterSpecies(level: number, isBoss = false, rerollH isBoss, globalScene.gameMode, ); - isEventEncounter = true; + if (params.isEventEncounter) { + params.isEventEncounter.value = true; + } bossSpecies = getPokemonSpecies(levelSpecies); formIndex = eventEncounter.formIndex; + } else if (speciesFunction) { + bossSpecies = speciesFunction(); } else { bossSpecies = globalScene.arena.randomSpecies( globalScene.currentBattle.waveIndex, @@ -1027,13 +1053,19 @@ export function getRandomEncounterSpecies(level: number, isBoss = false, rerollH ret.formIndex = formIndex; } - //Reroll shiny or variant for event encounters - if (isEventEncounter) { - ret.trySetShinySeed(); + if (isEventEncounter.value) { + hiddenRerolls += eventHiddenRerolls; + shinyRerolls += eventShinyRerolls; } - //Reroll hidden ability - if (rerollHidden && ret.abilityIndex !== 2 && ret.species.abilityHidden) { - ret.tryRerollHiddenAbilitySeed(); + + while (shinyRerolls > 0) { + ret.trySetShinySeed(shinyChance, true, maxShinyChance); + shinyRerolls--; + } + + while (hiddenRerolls > 0) { + ret.tryRerollHiddenAbilitySeed(hiddenAbilityChance, true); + hiddenRerolls--; } return ret; diff --git a/src/data/pokemon/pokemon-data.ts b/src/data/pokemon/pokemon-data.ts index 0094e7310e9..f0a0fc88c59 100644 --- a/src/data/pokemon/pokemon-data.ts +++ b/src/data/pokemon/pokemon-data.ts @@ -13,18 +13,11 @@ import type { SpeciesId } from "#enums/species-id"; import { StatusEffect } from "#enums/status-effect"; import type { AttackMoveResult } from "#types/attack-move-result"; import type { IllusionData } from "#types/illusion-data"; +import type { SerializedSpeciesForm } from "#types/pokemon-common"; import type { TurnMove } from "#types/turn-move"; import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; -/** - * The type that {@linkcode PokemonSpeciesForm} is converted to when an object containing it serializes it. - */ -type SerializedSpeciesForm = { - id: SpeciesId; - formIdx: number; -}; - /** * Permanent data that can customize a Pokemon in non-standard ways from its Species. * Includes abilities, nature, changed types, etc. diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 6f9d8b53249..835b9d92c89 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2903,12 +2903,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (thresholdOverride === undefined) { if (timedEventManager.isEventActive()) { const tchance = timedEventManager.getClassicTrainerShinyChance(); - shinyThreshold.value *= timedEventManager.getShinyEncounterMultiplier(); - if (this.hasTrainer() && tchance > 0) { + if (this.isEnemy() && this.hasTrainer() && tchance > 0) { shinyThreshold.value = Math.max(tchance, shinyThreshold.value); // Choose the higher boost + } else { + // Wild shiny event multiplier + shinyThreshold.value *= timedEventManager.getShinyEncounterMultiplier(); } } - if (!this.hasTrainer()) { + if (this.isPlayer() || !this.hasTrainer()) { + // Apply shiny modifiers only to Player or wild mons globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold); } } else { @@ -2932,11 +2935,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * If it rolls shiny, or if it's already shiny, also sets a random variant and give the Pokemon the associated luck. * * The base shiny odds are {@linkcode BASE_SHINY_CHANCE} / `65536` - * @param thresholdOverride - number that is divided by `2^16` (`65536`) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) - * @param applyModifiersToOverride - If {@linkcode thresholdOverride} is set and this is true, will apply Shiny Charm and event modifiers to {@linkcode thresholdOverride} + * @param thresholdOverride number that is divided by `2^16` (`65536`) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) + * @param applyModifiersToOverride If {@linkcode thresholdOverride} is set and this is true, will apply Shiny Charm and event modifiers to {@linkcode thresholdOverride} + * @param maxThreshold The maximum threshold allowed after applying modifiers * @returns Whether this Pokémon was set to shiny */ - public trySetShinySeed(thresholdOverride?: number, applyModifiersToOverride?: boolean): boolean { + public trySetShinySeed( + thresholdOverride?: number, + applyModifiersToOverride?: boolean, + maxThreshold?: number, + ): boolean { if (!this.shiny) { const shinyThreshold = new NumberHolder(thresholdOverride ?? BASE_SHINY_CHANCE); if (applyModifiersToOverride) { @@ -2946,6 +2954,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold); } + if (maxThreshold && maxThreshold > 0) { + shinyThreshold.value = Math.min(maxThreshold, shinyThreshold.value); + } + this.shiny = randSeedInt(65536) < shinyThreshold.value; } diff --git a/src/timed-event-manager.ts b/src/timed-event-manager.ts index 391b68d414e..68c0fd07a4c 100644 --- a/src/timed-event-manager.ts +++ b/src/timed-event-manager.ts @@ -1,6 +1,7 @@ import { globalScene } from "#app/global-scene"; import { SHINY_CATCH_RATE_MULTIPLIER } from "#balance/rates"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER } from "#balance/starters"; +import type { PokemonSpeciesFilter } from "#data/pokemon-species"; import type { WeatherPoolEntry } from "#data/weather"; import { Challenges } from "#enums/challenges"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -11,6 +12,7 @@ import { WeatherType } from "#enums/weather-type"; import type { ModifierTypeKeys } from "#modifiers/modifier-type"; import type { nil } from "#types/common"; import { addTextObject } from "#ui/text"; +import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; export enum EventType { @@ -27,7 +29,7 @@ interface EventBanner { readonly availableLangs?: readonly string[]; } -interface EventEncounter { +export interface EventEncounter { readonly species: SpeciesId; readonly blockEvolution?: boolean; readonly formIndex?: number; @@ -390,6 +392,49 @@ const timedEvents: readonly TimedEvent[] = [ ], dailyRunStartingItems: ["SHINY_CHARM", "ABILITY_CHARM"], }, + { + name: "Halloween 25", + eventType: EventType.SHINY, + startDate: new Date(Date.UTC(2025, 9, 29)), + endDate: new Date(Date.UTC(2025, 10, 10)), + bannerKey: "halloween2025", + scale: 0.25, // Replace with actual number + availableLangs: ["en", "de", "it", "fr", "ja", "ko", "es-ES", "es-419", "pt-BR", "zh-Hans", "zh-Hant"], + shinyEncounterMultiplier: 2, + shinyCatchMultiplier: 3, + eventEncounters: [ + { species: SpeciesId.CATERPIE }, + { species: SpeciesId.SPEAROW }, + { species: SpeciesId.PARAS }, + { species: SpeciesId.LICKITUNG }, + { species: SpeciesId.AERODACTYL }, + { species: SpeciesId.SMOOCHUM }, + { species: SpeciesId.RALTS }, + { species: SpeciesId.GULPIN }, + { species: SpeciesId.FEEBAS }, + { species: SpeciesId.WYNAUT }, + { species: SpeciesId.CLAMPERL }, + { species: SpeciesId.BUDEW }, + { species: SpeciesId.DEOXYS }, + { species: SpeciesId.CHINGLING }, + { species: SpeciesId.DWEBBLE }, + { species: SpeciesId.TIRTOUGA }, + { species: SpeciesId.LARVESTA }, + { species: SpeciesId.SPRITZEE }, + { species: SpeciesId.SWIRLIX }, + { species: SpeciesId.BINACLE }, + { species: SpeciesId.PUMPKABOO }, + { species: SpeciesId.SANDYGAST }, + ], + classicWaveRewards: [ + { wave: 8, type: "SHINY_CHARM" }, + { wave: 8, type: "ABILITY_CHARM" }, + { wave: 8, type: "CATCHING_CHARM" }, + { wave: 25, type: "SHINY_CHARM" }, + { wave: 25, type: "CANDY_JAR" }, + ], + dailyRunStartingItems: ["ABILITY_CHARM", "SHINY_CHARM", "CANDY_JAR"], + }, ]; export class TimedEventManager { @@ -450,6 +495,23 @@ export class TimedEventManager { return [...(this.activeEvent()?.eventEncounters ?? [])]; } + getAllValidEventEncounters( + allowSubLegendary = true, + allowLegendary = true, + allowMythical = true, + speciesFilter: PokemonSpeciesFilter, + ): EventEncounter[] { + return this.getEventEncounters().filter(enc => { + const species = getPokemonSpecies(enc.species); + return ( + (allowSubLegendary || !species.subLegendary) + && (allowLegendary || !species.legendary) + && (allowMythical || !species.mythical) + && speciesFilter(species) + ); + }); + } + /** * For events that change the classic candy friendship multiplier * @returns The classic friendship multiplier of the active {@linkcode TimedEvent}, or the default {@linkcode CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER}