import { globalScene } from "#app/global-scene"; import { speciesDataRegistry } from "#app/global-species-data-registry"; import { SHINY_CATCH_RATE_MULTIPLIER } from "#balance/rates"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER } from "#balance/starters"; import { timedEvents } from "#balance/timed-events"; import type { PokemonSpeciesFilter } from "#data/pokemon-species"; import type { BiomeId } from "#enums/biome-id"; import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import type { SpeciesId } from "#enums/species-id"; import type { TrainerType } from "#enums/trainer-type"; import type { ModifierTypeKeys } from "#modifiers/modifier-type"; import type { BiomeLinks } from "#types/biomes"; import type { EventEncounter, EventMysteryEncounterTier, EventWeatherPools, TimedEvent } from "#types/game-events"; import { randSeedShuffle } from "#utils/common"; import i18next from "i18next"; export class TimedEventManager { /** * Whether the timed event manager is disabled. * Used to disable events in testing. */ private disabled: boolean; private cachedReplacementMap: Map | null = null; isActive(event: TimedEvent) { if (this.disabled) { return false; } const now = new Date(); return event.startDate < now && now < event.endDate; } /** * For getting the active event * @returns The first active {@linkcode TimedEvent} or `undefined` if there are no active events */ activeEvent(): TimedEvent | undefined { return timedEvents.find((te: TimedEvent) => this.isActive(te)); } isEventActive(): boolean { return timedEvents.some((te: TimedEvent) => this.isActive(te)); } /** * Check whether the current {@linkcode TimedEvent} is active and for April Fools. * @returns Whether the April Fools event is currently active. */ isAprilFoolsActive(): boolean { const now = new Date(); return now.getUTCDate() === 1 && now.getUTCMonth() === 3; } activeEventHasBanner(): boolean { return this.activeEvent()?.bannerKey != null; } /** * Get the multiplier for shiny encounters during a shiny {@linkcode TimedEvent} * @returns the shiny encounter multiplier */ getShinyEncounterMultiplier(): number { return this.activeEvent()?.shinyEncounterMultiplier ?? 1; } /** * Get the multiplier for shiny catches during a shiny {@linkcode TimedEvent} * @returns the shiny catch multiplier */ getShinyCatchMultiplier(): number { return this.activeEvent()?.shinyCatchMultiplier ?? SHINY_CATCH_RATE_MULTIPLIER; } getEventBannerFilename(): string { return this.activeEvent()?.bannerKey ?? ""; } public getLogoKey(): string { return this.activeEvent()?.logo ?? "logo"; } getEventBannerLangs(): string[] { return [...(this.activeEvent()?.availableLangs ?? [])]; } getEventEncounters(): EventEncounter[] { return [...(this.activeEvent()?.eventEncounters ?? [])]; } getAllValidEventEncounters( allowSubLegendary = true, allowLegendary = true, allowMythical = true, speciesFilter: PokemonSpeciesFilter, ): EventEncounter[] { return this.getEventEncounters().filter(enc => { const species = speciesDataRegistry.getSpecies(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} */ getClassicFriendshipMultiplier(): number { return this.activeEvent()?.classicFriendshipMultiplier ?? CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER; } /** * For events where defeated bosses (Gym Leaders, E4 etc) give out Voucher Plus even if they were defeated before * @returns Whether vouchers should be upgraded */ getUpgradeUnlockedVouchers(): boolean { return this.activeEvent()?.upgradeUnlockedVouchers ?? false; } /** * Get the amount of extra candy to add when catching a Pokemon. * @returns The amount of extra candy to add when catching a Pokemon */ public getExtraCatchCandy(): number { return this.activeEvent()?.extraCatchCandy ?? 0; } /** * For events where Delibirdy gives extra items * @returns list of ids of {@linkcode ModifierType}s that Delibirdy hands out as a bonus */ getDelibirdyBuff(): string[] { return [...(this.activeEvent()?.delibirdyBuff ?? [])]; } /** * For events where there's a set weather for town biome (other biomes are hard) * @returns Event weathers for town */ getWeather(): EventWeatherPools | undefined { return this.activeEvent()?.weather; } getAllMysteryEncounterChanges(): EventMysteryEncounterTier[] { const ret: EventMysteryEncounterTier[] = []; for (const te of timedEvents) { if (this.isActive(te) && te.mysteryEncounterTierChanges != null) { ret.push(...te.mysteryEncounterTierChanges); } } return ret; } getEventMysteryEncountersDisabled(): MysteryEncounterType[] { const ret: MysteryEncounterType[] = []; const metChanges = this.activeEvent()?.mysteryEncounterTierChanges ?? []; for (const metc of metChanges) { if (metc.disable) { ret.push(metc.mysteryEncounter); } } return ret; } getMysteryEncounterTierForEvent( encounterType: MysteryEncounterType, normal: MysteryEncounterTier, ): MysteryEncounterTier { const metChanges = this.activeEvent()?.mysteryEncounterTierChanges ?? []; for (const metc of metChanges) { if (metc.mysteryEncounter === encounterType) { return metc.tier ?? normal; } } return normal; } getEventLuckBoost(): number { return this.activeEvent()?.luckBoost ?? 0; } getEventLuckBoostedSpecies(): SpeciesId[] { return [...(this.activeEvent()?.luckBoostedSpecies ?? [])]; } areFusionsBoosted(): boolean { return this.activeEvent()?.boostFusions ?? false; } public isSootheBellBoosted(): boolean { return this.activeEvent()?.boostSootheBell ?? false; } /** * Gets all the modifier types associated with a certain wave during an event * @see EventWaveReward * @param wave the wave to check for associated rewards * @returns array of strings of the event modifier reward types */ getFixedBattleEventRewards(wave: number): ModifierTypeKeys[] { return ( this.activeEvent() ?.classicWaveRewards?.filter(cwr => cwr.wave === wave) .map(cwr => cwr.type) ?? [] ); } /** * Get the extra shiny chance for trainers due to event */ getClassicTrainerShinyChance(): number { return this.activeEvent()?.trainerShinyChance ?? 0; } getEventBgmReplacement(bgm: string): string { const eventMusicReplacements = this.activeEvent()?.music ?? []; for (const emr of eventMusicReplacements) { if (emr[0] === bgm) { console.log(`it is ${this.activeEvent()?.name} so instead of ${emr[0]} we play ${emr[1]}`); return emr[1]; } } return bgm; } /** * Get the event sprite replacement for a given species and form, if it exists. If the active event has `fillRandom` enabled, \ * will return a random replacement for any species/form pair that isn't explicitly listed in the `pokemonReplacements` array. * @param species - The species ID of the pokemon to check for a sprite replacement * @param formIndex - The form index of the pokemon to check for a sprite replacement. Defaults to 0 * @returns An object containing the species ID and form index of the replacement sprite, or null if no replacement exists. */ public getEventPokemonSpriteReplacement( species: SpeciesId, formIndex = 0, ): { speciesId: SpeciesId; formIndex: number; } | null { const event = this.activeEvent(); if (!event) { return null; } const sprites = event?.sprites; if (!sprites) { return null; } const eventSpriteReplacements = sprites.pokemonReplacements ?? []; const fillRandom = sprites.fillRandom ?? false; for (const esr of eventSpriteReplacements) { const [sourceSpeciesIdStr, sourceFormIndexStr] = esr[0].split("/"); if (sourceSpeciesIdStr === species.toString() && (sourceFormIndexStr ?? "0") === formIndex.toString()) { const [targetSpeciesIdStr, targetFormIndexStr] = esr[1].split("/"); return { speciesId: Number(targetSpeciesIdStr) as SpeciesId, formIndex: Number(targetFormIndexStr ?? "0") }; } } if (fillRandom) { // Multiply by 100000 to avoid collisions const key = species * 100_000 + formIndex; this.fillRandomPokemonSpriteReplacements(); return this.cachedReplacementMap!.get(key) ?? null; } return null; } /** * Assign each species/form pair a random other species/form pair for sprite replacement. */ private fillRandomPokemonSpriteReplacements(): void { if (this.cachedReplacementMap) { return; } this.cachedReplacementMap = new Map(); const allPairs: { speciesId: SpeciesId; formIndex: number }[] = []; for (const species of speciesDataRegistry.getAllSpecies()) { const formCount = species.forms.length || 1; for (let f = 0; f < formCount; f++) { allPairs.push({ speciesId: species.speciesId, formIndex: f }); } } globalScene.executeWithSeedOffset( () => { const shuffled = randSeedShuffle([...allPairs]); for (let i = 0; i < allPairs.length; i++) { const sourceKey = allPairs[i].speciesId * 100_000 + allPairs[i].formIndex; this.cachedReplacementMap!.set(sourceKey, shuffled[i]); } }, 0, this.activeEvent()!.name, ); } /** * Get the event trainer sprite replacement for a given trainer type, if it exists. * @param trainerType - The trainer type to check for a sprite replacement * @returns The trainer type of the replacement sprite, or null if no replacement exists. */ public getEventTrainerSpriteReplacement(trainerType: TrainerType): string | null { const event = this.activeEvent(); if (!event) { return null; } const trainerReplacements = event.sprites?.trainerReplacements ?? []; for (const tr of trainerReplacements) { if (tr[0] === trainerType) { return tr[1]; } } return null; } /** * Get the event image replacement for a given image key, if it exists. * @param imageKey - The image key to check for a replacement * @returns The replacement image **FILENAME** if it exists, otherwise null. * @remarks * The image has to be in the `events` folder for it to work. */ public getEventImageReplacement(imageKey: string): string | null { const event = this.activeEvent(); if (!event) { return null; } const imageReplacements = event.sprites?.imageReplacements ?? []; for (const ir of imageReplacements) { if (ir[0] === imageKey) { return ir[1]; } } return null; } /** * Return the key replacement for the given i18n key if it exists in the active event, otherwise return the original key. * @param key The i18n key to check for a replacement * @returns The replacement key if it exists, otherwise the original key */ public getEventTextReplacement(key: string): string { const event = this.activeEvent(); if (!event || !event.textReplacements) { return key; } for (const [source, target] of event.textReplacements) { if (key === source && i18next.exists(target)) { return target; } } return key; } /** * Check if the current active event has any text replacements. \ * This is used to determine whether the i18next proxy should be loaded. * @returns Whether the active event has text replacements */ public hasEventTextReplacement(): boolean { const event = this.activeEvent(); if (!event) { return false; } return event.textReplacements != null && event.textReplacements.length > 0; } public getEventDailyStartingItems(): readonly ModifierTypeKeys[] { return this.activeEvent()?.dailyRunStartingItems ?? []; } /** * Get the biomelinks for a specific biome in the active event. * @param biomeId - the biomeId to get the links for * @returns the {@linkcode BiomeLinks} for the specified biome. */ public getEventBiomeLinks(biomeId: BiomeId): BiomeLinks { const biomes = this.activeEvent()?.biomes ?? null; return biomes?.[biomeId] ?? []; } /** * Disable the timed event manager. Used for testing. */ public disable(): void { this.disabled = true; } // todo: add option to enable to allow for testing timed events }