[Refactor] Refactor ME mon generation and event encounters, add to Safari Zone & GTS (#6695)

* Refactor event encounters

* Fix safari test

* Apply biome fixes

* Cleanup, 100% event chance for WT

* Fix Safari Zone

* Fix shiny chance

* Run biome

* Apply suggestions from code review

Co-authored-by: Fabi <192151969+fabske0@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* Documentation for randomEncParams

* other

* Updated doc comments on interface to be less jank

* >'less janky'>look inside>linting error

* Update encounter-phase-utils.ts doc comment

* Update encounter-phase-utils.ts

* Update src/data/mystery-encounters/encounters/global-trade-system-encounter.ts

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* thing

---------

Co-authored-by: Fabi <192151969+fabske0@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
Co-authored-by: damocleas <damocleas25@gmail.com>
This commit is contained in:
Austin Fontaine 2025-10-30 15:46:35 -04:00 committed by GitHub
parent e5154850c6
commit 79148452e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 318 additions and 117 deletions

View File

@ -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;
}

View File

@ -18,7 +18,7 @@ import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils
import { import {
generateModifierType, generateModifierType,
generateModifierTypeOption, generateModifierTypeOption,
getRandomEncounterSpecies, getRandomEncounterPokemon,
initBattleWithEnemyConfig, initBattleWithEnemyConfig,
leaveEncounterWithoutBattle, leaveEncounterWithoutBattle,
setEncounterExp, setEncounterExp,
@ -66,7 +66,12 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder.
// Calculate boss mon // Calculate boss mon
const level = getEncounterPokemonLevelForWave(STANDARD_ENCOUNTER_BOOSTED_LEVEL_MODIFIER); 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)); encounter.setDialogueToken("enemyPokemon", getPokemonNameWithAffix(bossPokemon));
const config: EnemyPartyConfig = { const config: EnemyPartyConfig = {
pokemonConfigs: [ pokemonConfigs: [

View File

@ -12,7 +12,7 @@ import { getPlayerModifierTypeOptions, regenerateModifierPoolThresholds } from "
import { queueEncounterMessage } from "#mystery-encounters/encounter-dialogue-utils"; import { queueEncounterMessage } from "#mystery-encounters/encounter-dialogue-utils";
import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils"; import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils";
import { import {
getRandomEncounterSpecies, getRandomEncounterPokemon,
initBattleWithEnemyConfig, initBattleWithEnemyConfig,
leaveEncounterWithoutBattle, leaveEncounterWithoutBattle,
setEncounterExp, setEncounterExp,
@ -58,7 +58,12 @@ export const FightOrFlightEncounter: MysteryEncounter = MysteryEncounterBuilder.
// Calculate boss mon // Calculate boss mon
const level = getEncounterPokemonLevelForWave(STANDARD_ENCOUNTER_BOOSTED_LEVEL_MODIFIER); 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()); encounter.setDialogueToken("enemyPokemon", bossPokemon.getNameToRender());
const config: EnemyPartyConfig = { const config: EnemyPartyConfig = {
pokemonConfigs: [ pokemonConfigs: [

View File

@ -1,5 +1,4 @@
import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants";
import { timedEventManager } from "#app/global-event-manager";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { allSpecies } from "#data/data-lists"; import { allSpecies } from "#data/data-lists";
import { Gender, getGenderSymbol } from "#data/gender"; import { Gender, getGenderSymbol } from "#data/gender";
@ -20,17 +19,13 @@ import { doShinySparkleAnim } from "#field/anims";
import type { PlayerPokemon, Pokemon } from "#field/pokemon"; import type { PlayerPokemon, Pokemon } from "#field/pokemon";
import { EnemyPokemon } from "#field/pokemon"; import { EnemyPokemon } from "#field/pokemon";
import type { PokemonHeldItemModifier } from "#modifiers/modifier"; import type { PokemonHeldItemModifier } from "#modifiers/modifier";
import { import { PokemonFormChangeItemModifier, SpeciesStatBoosterModifier } from "#modifiers/modifier";
HiddenAbilityRateBoosterModifier,
PokemonFormChangeItemModifier,
ShinyRateBoosterModifier,
SpeciesStatBoosterModifier,
} from "#modifiers/modifier";
import type { ModifierTypeOption } from "#modifiers/modifier-type"; import type { ModifierTypeOption } from "#modifiers/modifier-type";
import { getPlayerModifierTypeOptions, regenerateModifierPoolThresholds } from "#modifiers/modifier-type"; import { getPlayerModifierTypeOptions, regenerateModifierPoolThresholds } from "#modifiers/modifier-type";
import { PokemonMove } from "#moves/pokemon-move"; import { PokemonMove } from "#moves/pokemon-move";
import { getEncounterText, showEncounterText } from "#mystery-encounters/encounter-dialogue-utils"; import { getEncounterText, showEncounterText } from "#mystery-encounters/encounter-dialogue-utils";
import { import {
getRandomEncounterPokemon,
leaveEncounterWithoutBattle, leaveEncounterWithoutBattle,
selectPokemonForOption, selectPokemonForOption,
setEncounterRewards, setEncounterRewards,
@ -43,7 +38,7 @@ import { PartySizeRequirement } from "#mystery-encounters/mystery-encounter-requ
import { PokemonData } from "#system/pokemon-data"; import { PokemonData } from "#system/pokemon-data";
import { MusicPreference } from "#system/settings"; import { MusicPreference } from "#system/settings";
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; 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 { getEnumKeys } from "#utils/enums";
import { getRandomLocaleEntry } from "#utils/i18n"; import { getRandomLocaleEntry } from "#utils/i18n";
import { getPokemonSpecies } from "#utils/pokemon-utils"; 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. */ /** Max shiny chance of 4096/65536 -> 1/16 odds. */
const MAX_WONDER_TRADE_SHINY_CHANCE = 4096; const MAX_WONDER_TRADE_SHINY_CHANCE = 4096;
const WONDER_TRADE_HIDDEN_ABILITY_CHANCE = 64;
const LEGENDARY_TRADE_POOLS = { const LEGENDARY_TRADE_POOLS = {
1: [SpeciesId.RATTATA, SpeciesId.PIDGEY, SpeciesId.WEEDLE], 1: [SpeciesId.RATTATA, SpeciesId.PIDGEY, SpeciesId.WEEDLE],
2: [SpeciesId.SENTRET, SpeciesId.HOOTHOOT, SpeciesId.LEDYBA], 2: [SpeciesId.SENTRET, SpeciesId.HOOTHOOT, SpeciesId.LEDYBA],
@ -273,38 +270,23 @@ export const GlobalTradeSystemEncounter: MysteryEncounter = MysteryEncounterBuil
const encounter = globalScene.currentBattle.mysteryEncounter!; const encounter = globalScene.currentBattle.mysteryEncounter!;
const onPokemonSelected = (pokemon: PlayerPokemon) => { const onPokemonSelected = (pokemon: PlayerPokemon) => {
// Randomly generate a Wonder Trade pokemon // Randomly generate a Wonder Trade pokemon
const randomTradeOption = generateTradeOption(globalScene.getPlayerParty().map(p => p.species)); const tradePokemon = getRandomEncounterPokemon({
const tradePokemon = new EnemyPokemon(randomTradeOption, pokemon.level, TrainerSlot.NONE, false); level: pokemon.level,
// Extra shiny roll at 1/128 odds (boosted by events and charms) speciesFunction: () => generateTradeOption(globalScene.getPlayerParty().map(p => p.species)),
if (!tradePokemon.shiny) { isBoss: false,
const shinyThreshold = new NumberHolder(WONDER_TRADE_SHINY_CHANCE); eventChance: 100,
if (timedEventManager.isEventActive()) { shinyRerolls: 1,
shinyThreshold.value *= timedEventManager.getShinyEncounterMultiplier(); hiddenRerolls: 1,
} eventShinyRerolls: 1,
globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold); eventHiddenRerolls: 1,
hiddenAbilityChance: WONDER_TRADE_HIDDEN_ABILITY_CHANCE,
// Base shiny chance of 512/65536 -> 1/128, affected by events and Shiny Charms shinyChance: WONDER_TRADE_SHINY_CHANCE,
// Maximum shiny chance of 4096/65536 -> 1/16, cannot improve further after that maxShinyChance: MAX_WONDER_TRADE_SHINY_CHANCE,
const shinyChance = Math.min(shinyThreshold.value, MAX_WONDER_TRADE_SHINY_CHANCE); speciesFilter: s => !globalScene.getPlayerParty().some(p => p.species === s),
});
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;
}
}
// If Pokemon is still not shiny or with HA, give the Pokemon a random Common egg move in its moveset // 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(); const eggMoves = tradePokemon.getEggMoves();
if (eggMoves) { if (eggMoves) {
// Cannot gen the rare egg move, only 1 of the first 3 common moves // Cannot gen the rare egg move, only 1 of the first 3 common moves

View File

@ -9,11 +9,11 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { PlayerGender } from "#enums/player-gender"; import { PlayerGender } from "#enums/player-gender";
import { PokeballType } from "#enums/pokeball"; import { PokeballType } from "#enums/pokeball";
import { TrainerSlot } from "#enums/trainer-slot";
import type { EnemyPokemon } from "#field/pokemon"; 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 { getEncounterText, showEncounterText } from "#mystery-encounters/encounter-dialogue-utils";
import { import {
getRandomEncounterPokemon,
initSubsequentOptionSelect, initSubsequentOptionSelect,
leaveEncounterWithoutBattle, leaveEncounterWithoutBattle,
transitionMysteryEncounterIntroVisuals, transitionMysteryEncounterIntroVisuals,
@ -30,7 +30,7 @@ import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import type { MysteryEncounterOption } from "#mystery-encounters/mystery-encounter-option"; import type { MysteryEncounterOption } from "#mystery-encounters/mystery-encounter-option";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option"; import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import { MoneyRequirement } from "#mystery-encounters/mystery-encounter-requirements"; 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"; import { getPokemonSpecies } from "#utils/pokemon-utils";
/** the i18n namespace for the encounter */ /** the i18n namespace for the encounter */
@ -42,6 +42,9 @@ const SAFARI_MONEY_MULTIPLIER = 2;
const NUM_SAFARI_ENCOUNTERS = 3; const NUM_SAFARI_ENCOUNTERS = 3;
const eventEncs = new NumberHolder(0);
const eventChance = new NumberHolder(50);
/** /**
* Safari Zone encounter. * Safari Zone encounter.
* @see {@link https://github.com/pagefaultgames/pokerogue/issues/3800 | GitHub Issue #3800} * @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`) .withQuery(`${namespace}:query`)
.withOnInit(() => { .withOnInit(() => {
globalScene.currentBattle.mysteryEncounter?.setDialogueToken("numEncounters", NUM_SAFARI_ENCOUNTERS.toString()); globalScene.currentBattle.mysteryEncounter?.setDialogueToken("numEncounters", NUM_SAFARI_ENCOUNTERS.toString());
eventEncs.value = 0;
eventChance.value = 50;
return true; return true;
}) })
.withOption( .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 // 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 // Safari pokemon roll twice on shiny and HA chances, but are otherwise normal
let enemySpecies: PokemonSpecies;
let pokemon: any; let pokemon: any;
globalScene.executeWithSeedOffset( globalScene.executeWithSeedOffset(
() => { () => {
enemySpecies = getSafariSpeciesSpawn(); console.log("Event chance %d", eventChance.value);
const level = globalScene.currentBattle.getLevelForWave(); const fromEvent = new BooleanHolder(false);
enemySpecies = getPokemonSpecies(enemySpecies.getWildSpeciesForLevel(level, true, false, globalScene.gameMode)); pokemon = getRandomEncounterPokemon({
pokemon = globalScene.addEnemyPokemon(enemySpecies, level, TrainerSlot.NONE, false); 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 pokemon.init();
if (!pokemon.shiny) {
pokemon.trySetShinySeed(); // 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.enemyParty.unshift(pokemon);
}, },
globalScene.currentBattle.waveIndex * 1000 * encounter.misc.safariPokemonRemaining, 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 { export function getSafariSpeciesSpawn(): PokemonSpecies {
return getPokemonSpecies( return getPokemonSpecies(

View File

@ -77,6 +77,8 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
.withOnInit(() => { .withOnInit(() => {
const encounter = globalScene.currentBattle.mysteryEncounter!; const encounter = globalScene.currentBattle.mysteryEncounter!;
let isEventEncounter = false;
let species = getSalesmanSpeciesOffer(); let species = getSalesmanSpeciesOffer();
let tries = 0; let tries = 0;
@ -88,16 +90,12 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
const r = randSeedInt(SHINY_MAGIKARP_WEIGHT); const r = randSeedInt(SHINY_MAGIKARP_WEIGHT);
const validEventEncounters = timedEventManager const validEventEncounters = timedEventManager.getAllValidEventEncounters(
.getEventEncounters() false,
.filter( false,
s => false,
!getPokemonSpecies(s.species).legendary s => !NON_LEGEND_PARADOX_POKEMON.includes(s.speciesId) && !NON_LEGEND_ULTRA_BEASTS.includes(s.speciesId),
&& !getPokemonSpecies(s.species).subLegendary );
&& !getPokemonSpecies(s.species).mythical
&& !NON_LEGEND_PARADOX_POKEMON.includes(s.species)
&& !NON_LEGEND_ULTRA_BEASTS.includes(s.species),
);
let pokemon: PlayerPokemon; let pokemon: PlayerPokemon;
/** /**
@ -122,7 +120,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
) { ) {
tries = 0; tries = 0;
do { 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); const enc = randSeedItem(validEventEncounters);
species = getPokemonSpecies(enc.species); species = getPokemonSpecies(enc.species);
pokemon = new PlayerPokemon( pokemon = new PlayerPokemon(
@ -135,6 +133,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
pokemon.trySetShinySeed(); pokemon.trySetShinySeed();
pokemon.trySetShinySeed(); pokemon.trySetShinySeed();
if (pokemon.shiny || pokemon.abilityIndex === 2) { if (pokemon.shiny || pokemon.abilityIndex === 2) {
isEventEncounter = true;
break; break;
} }
tries++; tries++;
@ -149,6 +148,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
pokemon.trySetShinySeed(); pokemon.trySetShinySeed();
pokemon.trySetShinySeed(); pokemon.trySetShinySeed();
pokemon.trySetShinySeed(); pokemon.trySetShinySeed();
isEventEncounter = true;
} else { } else {
// If there's, and this would never happen, no eligible event encounters with a hidden ability, just do Magikarp // If there's, and this would never happen, no eligible event encounters with a hidden ability, just do Magikarp
species = getPokemonSpecies(SpeciesId.MAGIKARP); species = getPokemonSpecies(SpeciesId.MAGIKARP);
@ -177,7 +177,9 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
if (pokemon.shiny) { if (pokemon.shiny) {
// Always max price for shiny (flip HA back to normal), and add special messaging // Always max price for shiny (flip HA back to normal), and add special messaging
priceMultiplier = MAX_POKEMON_PRICE_MULTIPLIER; priceMultiplier = MAX_POKEMON_PRICE_MULTIPLIER;
pokemon.abilityIndex = 0; if (!isEventEncounter) {
pokemon.abilityIndex = 0;
}
encounter.dialogue.encounterOptionsDialogue!.description = `${namespace}:descriptionShiny`; encounter.dialogue.encounterOptionsDialogue!.description = `${namespace}:descriptionShiny`;
encounter.options[0].dialogue!.buttonTooltip = `${namespace}:option.1.tooltipShiny`; encounter.options[0].dialogue!.buttonTooltip = `${namespace}:option.1.tooltipShiny`;
} }

View File

@ -15,7 +15,7 @@ import { PokemonMove } from "#moves/pokemon-move";
import { queueEncounterMessage } from "#mystery-encounters/encounter-dialogue-utils"; import { queueEncounterMessage } from "#mystery-encounters/encounter-dialogue-utils";
import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils"; import type { EnemyPartyConfig } from "#mystery-encounters/encounter-phase-utils";
import { import {
getRandomEncounterSpecies, getRandomEncounterPokemon,
initBattleWithEnemyConfig, initBattleWithEnemyConfig,
leaveEncounterWithoutBattle, leaveEncounterWithoutBattle,
setEncounterExp, setEncounterExp,
@ -62,7 +62,12 @@ export const UncommonBreedEncounter: MysteryEncounter = MysteryEncounterBuilder.
// Calculate boss mon // Calculate boss mon
// Level equal to 2 below highest party member // Level equal to 2 below highest party member
const level = getHighestLevelPlayerPokemon(false, true).level - 2; 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 // Pokemon will always have one of its egg moves in its moveset
const eggMoves = pokemon.getEggMoves(); const eggMoves = pokemon.getEggMoves();

View File

@ -4,6 +4,7 @@ import { timedEventManager } from "#app/global-event-manager";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import { biomeLinks } from "#balance/biomes"; import { biomeLinks } from "#balance/biomes";
import { BASE_HIDDEN_ABILITY_CHANCE, BASE_SHINY_CHANCE } from "#balance/rates";
import { initMoveAnim, loadMoveAnimAssets } from "#data/battle-anims"; import { initMoveAnim, loadMoveAnimAssets } from "#data/battle-anims";
import { modifierTypes } from "#data/data-lists"; import { modifierTypes } from "#data/data-lists";
import type { IEggOptions } from "#data/egg"; 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 type { TrainerConfig } from "#trainers/trainer-config";
import { trainerConfigs } from "#trainers/trainer-config"; import { trainerConfigs } from "#trainers/trainer-config";
import type { HeldModifierConfig } from "#types/held-modifier-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 { OptionSelectConfig, OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
import type { PartyOption, PokemonSelectFilter } from "#ui/party-ui-handler"; import type { PartyOption, PokemonSelectFilter } from "#ui/party-ui-handler";
import { PartyUiMode } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler";
import { coerceArray } from "#utils/array"; 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 { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next"; 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. * 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. * If the mon is from the event encounter list, it may do an extra shiny or HA roll.
* @param level the level of the mon, which differs between MEs * @param params - The {@linkcode RandomEncounterParams} used to configure the encounter
* @param isBoss whether the mon should be a Boss * @returns The generated {@linkcode EnemyPokemon} for the requested encounter
* @param rerollHidden whether the mon should get an extra roll for Hidden Ability
* @returns 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 bossSpecies: PokemonSpecies;
let isEventEncounter = false; const eventEncounters = timedEventManager.getAllValidEventEncounters(
const eventEncounters = timedEventManager.getEventEncounters(); includeSubLegendary,
includeLegendary,
includeMythical,
speciesFilter,
);
let formIndex: number | undefined; 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 eventEncounter = randSeedItem(eventEncounters);
const levelSpecies = getPokemonSpecies(eventEncounter.species).getWildSpeciesForLevel( const levelSpecies = getPokemonSpecies(eventEncounter.species).getWildSpeciesForLevel(
level, level,
@ -1010,9 +1032,13 @@ export function getRandomEncounterSpecies(level: number, isBoss = false, rerollH
isBoss, isBoss,
globalScene.gameMode, globalScene.gameMode,
); );
isEventEncounter = true; if (params.isEventEncounter) {
params.isEventEncounter.value = true;
}
bossSpecies = getPokemonSpecies(levelSpecies); bossSpecies = getPokemonSpecies(levelSpecies);
formIndex = eventEncounter.formIndex; formIndex = eventEncounter.formIndex;
} else if (speciesFunction) {
bossSpecies = speciesFunction();
} else { } else {
bossSpecies = globalScene.arena.randomSpecies( bossSpecies = globalScene.arena.randomSpecies(
globalScene.currentBattle.waveIndex, globalScene.currentBattle.waveIndex,
@ -1027,13 +1053,19 @@ export function getRandomEncounterSpecies(level: number, isBoss = false, rerollH
ret.formIndex = formIndex; ret.formIndex = formIndex;
} }
//Reroll shiny or variant for event encounters if (isEventEncounter.value) {
if (isEventEncounter) { hiddenRerolls += eventHiddenRerolls;
ret.trySetShinySeed(); shinyRerolls += eventShinyRerolls;
} }
//Reroll hidden ability
if (rerollHidden && ret.abilityIndex !== 2 && ret.species.abilityHidden) { while (shinyRerolls > 0) {
ret.tryRerollHiddenAbilitySeed(); ret.trySetShinySeed(shinyChance, true, maxShinyChance);
shinyRerolls--;
}
while (hiddenRerolls > 0) {
ret.tryRerollHiddenAbilitySeed(hiddenAbilityChance, true);
hiddenRerolls--;
} }
return ret; return ret;

View File

@ -13,18 +13,11 @@ import type { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import type { AttackMoveResult } from "#types/attack-move-result"; import type { AttackMoveResult } from "#types/attack-move-result";
import type { IllusionData } from "#types/illusion-data"; import type { IllusionData } from "#types/illusion-data";
import type { SerializedSpeciesForm } from "#types/pokemon-common";
import type { TurnMove } from "#types/turn-move"; import type { TurnMove } from "#types/turn-move";
import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers"; import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; 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. * Permanent data that can customize a Pokemon in non-standard ways from its Species.
* Includes abilities, nature, changed types, etc. * Includes abilities, nature, changed types, etc.

View File

@ -2903,12 +2903,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (thresholdOverride === undefined) { if (thresholdOverride === undefined) {
if (timedEventManager.isEventActive()) { if (timedEventManager.isEventActive()) {
const tchance = timedEventManager.getClassicTrainerShinyChance(); const tchance = timedEventManager.getClassicTrainerShinyChance();
shinyThreshold.value *= timedEventManager.getShinyEncounterMultiplier(); if (this.isEnemy() && this.hasTrainer() && tchance > 0) {
if (this.hasTrainer() && tchance > 0) {
shinyThreshold.value = Math.max(tchance, shinyThreshold.value); // Choose the higher boost 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); globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold);
} }
} else { } 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. * 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` * 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 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 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 * @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) { if (!this.shiny) {
const shinyThreshold = new NumberHolder(thresholdOverride ?? BASE_SHINY_CHANCE); const shinyThreshold = new NumberHolder(thresholdOverride ?? BASE_SHINY_CHANCE);
if (applyModifiersToOverride) { if (applyModifiersToOverride) {
@ -2946,6 +2954,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold); globalScene.applyModifiers(ShinyRateBoosterModifier, true, shinyThreshold);
} }
if (maxThreshold && maxThreshold > 0) {
shinyThreshold.value = Math.min(maxThreshold, shinyThreshold.value);
}
this.shiny = randSeedInt(65536) < shinyThreshold.value; this.shiny = randSeedInt(65536) < shinyThreshold.value;
} }

View File

@ -1,6 +1,7 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { SHINY_CATCH_RATE_MULTIPLIER } from "#balance/rates"; import { SHINY_CATCH_RATE_MULTIPLIER } from "#balance/rates";
import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER } from "#balance/starters"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER } from "#balance/starters";
import type { PokemonSpeciesFilter } from "#data/pokemon-species";
import type { WeatherPoolEntry } from "#data/weather"; import type { WeatherPoolEntry } from "#data/weather";
import { Challenges } from "#enums/challenges"; import { Challenges } from "#enums/challenges";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; 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 { ModifierTypeKeys } from "#modifiers/modifier-type";
import type { nil } from "#types/common"; import type { nil } from "#types/common";
import { addTextObject } from "#ui/text"; import { addTextObject } from "#ui/text";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next"; import i18next from "i18next";
export enum EventType { export enum EventType {
@ -27,7 +29,7 @@ interface EventBanner {
readonly availableLangs?: readonly string[]; readonly availableLangs?: readonly string[];
} }
interface EventEncounter { export interface EventEncounter {
readonly species: SpeciesId; readonly species: SpeciesId;
readonly blockEvolution?: boolean; readonly blockEvolution?: boolean;
readonly formIndex?: number; readonly formIndex?: number;
@ -390,6 +392,49 @@ const timedEvents: readonly TimedEvent[] = [
], ],
dailyRunStartingItems: ["SHINY_CHARM", "ABILITY_CHARM"], 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 { export class TimedEventManager {
@ -450,6 +495,23 @@ export class TimedEventManager {
return [...(this.activeEvent()?.eventEncounters ?? [])]; 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 * 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} * @returns The classic friendship multiplier of the active {@linkcode TimedEvent}, or the default {@linkcode CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER}