import { FixedBattleConfig } from "#app/battle"; import { CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES, CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { globalScene } from "#app/global-scene"; import { speciesDataRegistry } from "#app/global-species-data-registry"; import { activeOverrides } from "#app/overrides"; import { allChallenges, type Challenge, copyChallenge } from "#data/challenge"; import { getDailyEventSeedBoss, getDailyForcedWaveSpecies, getDailyStartingBiome, getDailyStartingMoney, getDailyTrainerManipulation, } from "#data/daily-run"; import { parseDailySeed } from "#data/daily-seed-utils"; import type { PokemonSpecies } from "#data/pokemon-species"; import { BiomeId } from "#enums/biome-id"; import { ChallengeType } from "#enums/challenge-type"; import { Challenges } from "#enums/challenges"; import { GameModes } from "#enums/game-modes"; import { SpeciesId } from "#enums/species-id"; import { classicFixedBattles, type FixedBattleConfigs } from "#trainers/fixed-battle-configs"; import type { CustomDailyRunConfig } from "#types/daily-run"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, randSeedInt, randSeedItem } from "#utils/common"; import i18next from "i18next"; interface GameModeConfig { isClassic?: boolean; isEndless?: boolean; isDaily?: boolean; hasTrainers?: boolean; hasNoShop?: boolean; hasShortBiomes?: boolean; hasRandomBiomes?: boolean; hasRandomBosses?: boolean; isSplicedOnly?: boolean; isChallenge?: boolean; hasMysteryEncounters?: boolean; } export class GameMode implements GameModeConfig { public modeId: GameModes; public isClassic: boolean; public isEndless: boolean; public isDaily: boolean; public dailyConfig?: CustomDailyRunConfig | undefined; public hasTrainers: boolean; public hasNoShop: boolean; public hasShortBiomes: boolean; public hasRandomBiomes: boolean; public hasRandomBosses: boolean; public isSplicedOnly: boolean; public isChallenge: boolean; public challenges: Challenge[]; public battleConfig: FixedBattleConfigs; public hasMysteryEncounters: boolean; public minMysteryEncounterWave: number; public maxMysteryEncounterWave: number; constructor(modeId: GameModes, config: GameModeConfig, battleConfig?: FixedBattleConfigs) { this.modeId = modeId; this.challenges = []; Object.assign(this, config); if (this.isChallenge) { this.challenges = allChallenges.map(c => copyChallenge(c)); } this.battleConfig = battleConfig || {}; } /** * Enables challenges if they are disabled and sets the specified challenge's value * @param challenge - The challenge to set * @param value - The value to give the challenge. Impact depends on the specific challenge * @param severity - If provided, will override the given severity amount. Unused if `challenge` does not use severity * @todo Add severity support to daily mode challenge setting */ setChallengeValue(challenge: Challenges, value: number, severity?: number) { if (!this.isChallenge) { this.isChallenge = true; this.challenges = allChallenges.map(c => copyChallenge(c)); } this.challenges .filter((chal: Challenge) => chal.id === challenge) .forEach(chal => { chal.value = value; if (chal.hasSeverity()) { chal.severity = severity ?? chal.severity; } }); } /** * Helper function to see if a GameMode has a specific challenge type * @param challenge the Challenges it looks for * @returns true if the game mode has that challenge */ hasChallenge(challenge: Challenges): boolean { return this.challenges.some(c => c.id === challenge && c.value !== 0); } /** * Helper function to see if a GameMode has any challenges, needed in tests * @returns true if the game mode has at least one challenge */ hasAnyChallenges(): boolean { return this.challenges.length > 0; } /** * Helper function to see if the game mode is using fresh start * @returns true if a fresh start challenge is being applied */ isFreshStartChallenge(): boolean { return this.hasChallenge(Challenges.FRESH_START); } /** * Helper function to see if the game mode is using fresh start * @returns true if a fresh start challenge is being applied */ isFullFreshStartChallenge(): boolean { for (const challenge of this.challenges) { if (challenge.id === Challenges.FRESH_START && challenge.value === 1) { return true; } } return false; } /** * Helper function to get starting level for game mode. * @returns either: * - starting level override from overrides.ts * - 20 for Daily Runs * - 5 for all other modes */ getStartingLevel(): number { if (activeOverrides.STARTING_LEVEL_OVERRIDE > 0) { return activeOverrides.STARTING_LEVEL_OVERRIDE; } switch (this.modeId) { case GameModes.DAILY: return 20; default: return 5; } } /** * @returns either: * - override from overrides.ts * - 1000 * - override from a custom daily seed */ getStartingMoney(): number { if (activeOverrides.STARTING_MONEY_OVERRIDE > 0) { return activeOverrides.STARTING_MONEY_OVERRIDE; } switch (this.modeId) { // biome-ignore lint/suspicious/noFallthroughSwitchClause: Intentional case GameModes.DAILY: { const dailyStartingMoney = getDailyStartingMoney(); if (dailyStartingMoney != null) { return dailyStartingMoney; } } default: return 1000; } } /** * @returns either: * - override from overrides.ts * - random biome for Daily mode * - Town */ getStartingBiome(): BiomeId { if (activeOverrides.STARTING_BIOME_OVERRIDE != null) { return activeOverrides.STARTING_BIOME_OVERRIDE; } switch (this.modeId) { case GameModes.DAILY: return getDailyStartingBiome(); default: return BiomeId.TOWN; } } getWaveForDifficulty(waveIndex: number, ignoreCurveChanges = false): number { switch (this.modeId) { case GameModes.DAILY: return waveIndex + 30 + (ignoreCurveChanges ? 0 : Math.floor(waveIndex / 5)); default: return waveIndex; } } /** * Determines whether or not to generate a trainer * @param waveIndex - The current floor the player is on (trainer sprites fail to generate on X1 floors) * @returns Whether a trainer should be generated */ public isWaveTrainer(waveIndex: number): boolean { const { arena, offsetGym } = globalScene; // Daily spawns trainers on floors 5, 15, 20, 25, 30, 35, 40, and 45 if (this.isDaily) { const trainerManipulation = getDailyTrainerManipulation(waveIndex); if (trainerManipulation != null) { return trainerManipulation; } return waveIndex % 10 === 5 || (!(waveIndex % 10) && waveIndex > 10 && !this.isWaveFinal(waveIndex)); } if (waveIndex % 30 === (offsetGym ? 0 : 20) && !this.isWaveFinal(waveIndex)) { return true; } if (waveIndex % 10 !== 1 && waveIndex % 10) { /** * Do not check X1 floors since there's a bug that stops trainer sprites from appearing * after a X0 full party heal, this also allows for a smoother biome transition for general gameplay feel */ const trainerChance = arena.trainerChance; let allowTrainerBattle = true; if (trainerChance) { const waveBase = Math.floor(waveIndex / 10) * 10; // Stop generic trainers from spawning in within 2 waves of a fixed trainer battle for (let w = Math.max(waveIndex - 2, waveBase + 2); w <= Math.min(waveIndex + 2, waveBase + 10); w++) { if (w === waveIndex) { continue; } if (w % 30 === (offsetGym ? 0 : 20) || this.isFixedBattle(w)) { allowTrainerBattle = false; break; } if (w < waveIndex) { globalScene.executeWithSeedOffset(() => { const waveTrainerChance = arena.trainerChance; if (!randSeedInt(waveTrainerChance)) { allowTrainerBattle = false; } }, w); if (!allowTrainerBattle) { break; } } } } return Boolean(allowTrainerBattle && trainerChance && !randSeedInt(trainerChance)); } return false; } isTrainerBoss(waveIndex: number, biomeType: BiomeId, offsetGym: boolean): boolean { switch (this.modeId) { case GameModes.DAILY: return waveIndex > 10 && waveIndex < 50 && !(waveIndex % 10); default: return ( waveIndex % 30 === (offsetGym ? 0 : 20) && (biomeType !== BiomeId.END || this.isClassic || this.isWaveFinal(waveIndex)) ); } } getOverrideSpecies(waveIndex: number): PokemonSpecies | null { if (this.isDaily && this.isWaveFinal(waveIndex)) { const eventBoss = getDailyEventSeedBoss(); if (eventBoss?.speciesId != null) { // Cannot set form index here, it will be overriden when adding it as enemy pokemon. return speciesDataRegistry.getSpecies(eventBoss.speciesId); } const allFinalBossSpecies = speciesDataRegistry.getAllSpecies().filter(s => { return ( (s.subLegendary || s.legendary || s.mythical) && s.baseTotal >= 600 && s.speciesId !== SpeciesId.ETERNATUS && s.speciesId !== SpeciesId.ARCEUS ); }); return randSeedItem(allFinalBossSpecies); } return getDailyForcedWaveSpecies(waveIndex); } /** * Checks if wave provided is the final for current or specified game mode * @param waveIndex * @param modeId game mode * @returns if the current wave is final for classic or daily OR a minor boss in endless */ isWaveFinal(waveIndex: number, modeId: GameModes = this.modeId): boolean { switch (modeId) { case GameModes.CLASSIC: case GameModes.CHALLENGE: return waveIndex === 200; case GameModes.ENDLESS: case GameModes.SPLICED_ENDLESS: return waveIndex % 250 === 0; case GameModes.DAILY: return waveIndex === 50; } } /** * Every 10 waves is a boss battle * @returns true if waveIndex is a multiple of 10 */ isBoss(waveIndex: number): boolean { return waveIndex % 10 === 0; } /** * @returns `true` if the current battle is against classic mode's final boss */ isBattleClassicFinalBoss(waveIndex: number): boolean { return (this.modeId === GameModes.CLASSIC || this.modeId === GameModes.CHALLENGE) && this.isWaveFinal(waveIndex); } /** * Check whether the current wave is an Endless boss of any kind. * @param waveIndex - The current wave number. * @returns Whether `waveIndex` corresponds to an Endless boss. */ isEndlessBoss(waveIndex: number): boolean { return waveIndex % 50 === 0 && (this.modeId === GameModes.ENDLESS || this.modeId === GameModes.SPLICED_ENDLESS); } /** * Check whether the current wave is an Endless minor boss. * Currently is normal Eternatus. * @param waveIndex - The current wave number. * @returns Whether `waveIndex` is a multiple of 250 during endless mode. */ public isEndlessMinorBoss(waveIndex: number): boolean { return waveIndex % 250 === 0 && (this.modeId === GameModes.ENDLESS || this.modeId === GameModes.SPLICED_ENDLESS); } /** * Every 1000 waves of an Endless mode is a major boss * At this time it is Eternamax Eternatus * @returns true if waveIndex is a multiple of 1000 in Endless */ isEndlessMajorBoss(waveIndex: number): boolean { return waveIndex % 1000 === 0 && (this.modeId === GameModes.ENDLESS || this.modeId === GameModes.SPLICED_ENDLESS); } /** * Checks whether there is a fixed battle on this gamemode on a given wave. * @param waveIndex The wave to check. * @returns If this game mode has a fixed battle on this wave */ isFixedBattle(waveIndex: number): boolean { const dummyConfig = new FixedBattleConfig(); return ( Object.hasOwn(this.battleConfig, waveIndex) || applyChallenges(ChallengeType.FIXED_BATTLES, waveIndex, dummyConfig) ); } /** * Returns the config for the fixed battle for a particular wave. * @param waveIndex The wave to check. * @returns The fixed battle for this wave. */ getFixedBattle(waveIndex: number): FixedBattleConfig | undefined { const challengeConfig = new FixedBattleConfig(); if (applyChallenges(ChallengeType.FIXED_BATTLES, waveIndex, challengeConfig)) { return challengeConfig; } return this.battleConfig[waveIndex]; } /** * Check if the current game mode has the shop enabled or not * @returns Whether the shop is available in the current mode */ public getShopStatus(): boolean { const status = new BooleanHolder(!this.hasNoShop); applyChallenges(ChallengeType.SHOP, status); return status.value; } getClearScoreBonus(): number { switch (this.modeId) { case GameModes.CLASSIC: case GameModes.CHALLENGE: return 5000; case GameModes.DAILY: return 2500; default: return 0; } } getEnemyModifierChance(isBoss: boolean): number { switch (this.modeId) { case GameModes.CLASSIC: case GameModes.CHALLENGE: case GameModes.DAILY: return isBoss ? 6 : 18; case GameModes.ENDLESS: case GameModes.SPLICED_ENDLESS: return isBoss ? 4 : 12; } } getName(): string { switch (this.modeId) { case GameModes.CLASSIC: return i18next.t("gameMode:classic"); case GameModes.ENDLESS: return i18next.t("gameMode:endless"); case GameModes.SPLICED_ENDLESS: return i18next.t("gameMode:endlessSpliced"); case GameModes.DAILY: return i18next.t("gameMode:dailyRun"); case GameModes.CHALLENGE: return i18next.t("gameMode:challenge"); } } /** * Returns the wave range where MEs can spawn for the game mode [min, max] */ getMysteryEncounterLegalWaves(): [minWave: number, maxWave: number] { switch (this.modeId) { case GameModes.CLASSIC: return CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES; case GameModes.CHALLENGE: return CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES; default: return [0, 0]; } } /** * Sets the daily config if the seed is a custom seed. * @param seed - The seed to check * @returns The seed to use. * @remarks * If it is not a custom seed, it will return the original seed. */ public trySetCustomDailyConfig(seed: string): string { this.dailyConfig = parseDailySeed(seed); return this.dailyConfig?.seed ?? seed; } static getModeName(modeId: GameModes): string { switch (modeId) { case GameModes.CLASSIC: return i18next.t("gameMode:classic"); case GameModes.ENDLESS: return i18next.t("gameMode:endless"); case GameModes.SPLICED_ENDLESS: return i18next.t("gameMode:endlessSpliced"); case GameModes.DAILY: return i18next.t("gameMode:dailyRun"); case GameModes.CHALLENGE: return i18next.t("gameMode:challenge"); } } } export function getGameMode(gameMode: GameModes): GameMode { switch (gameMode) { case GameModes.CLASSIC: return new GameMode( GameModes.CLASSIC, { isClassic: true, hasTrainers: true, hasMysteryEncounters: true }, classicFixedBattles, ); case GameModes.ENDLESS: return new GameMode(GameModes.ENDLESS, { isEndless: true, hasShortBiomes: true, hasRandomBosses: true, }); case GameModes.SPLICED_ENDLESS: return new GameMode(GameModes.SPLICED_ENDLESS, { isEndless: true, hasShortBiomes: true, hasRandomBosses: true, isSplicedOnly: true, }); case GameModes.DAILY: return new GameMode(GameModes.DAILY, { isDaily: true, hasTrainers: true, hasNoShop: true, }); case GameModes.CHALLENGE: return new GameMode( GameModes.CHALLENGE, { isClassic: true, hasTrainers: true, isChallenge: true, hasMysteryEncounters: true, }, classicFixedBattles, ); } }