From ef7437815a884e7177080d51a0cb48e7f1789495 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Thu, 31 Jul 2025 21:31:23 -0400 Subject: [PATCH 01/14] [Challenge] Add Nuzlocke-related Challenges Co-authored-by: =?UTF-8?q?Matilde=20Sim=C3=B5es?= Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Sirzento --- src/data/challenge.ts | 250 +++++++++++++++++- src/data/moves/pokemon-move.ts | 15 +- .../utils/encounter-pokemon-utils.ts | 8 + src/enums/challenge-type.ts | 42 ++- src/enums/challenges.ts | 3 + src/game-mode.ts | 11 +- src/modifier/modifier-type.ts | 22 +- src/phases/attempt-capture-phase.ts | 7 + src/phases/command-phase.ts | 33 ++- src/phases/party-heal-phase.ts | 15 +- src/phases/select-biome-phase.ts | 6 +- src/phases/victory-phase.ts | 4 +- src/ui/modifier-select-ui-handler.ts | 6 +- 13 files changed, 384 insertions(+), 38 deletions(-) diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 938ee482d01..d3e28a75a7f 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -13,15 +13,16 @@ import { Challenges } from "#enums/challenges"; import { TypeColor, TypeShadow } from "#enums/color"; import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves"; import { ModifierTier } from "#enums/modifier-tier"; -import type { MoveId } from "#enums/move-id"; +import { MoveId } from "#enums/move-id"; import type { MoveSourceType } from "#enums/move-source-type"; import { Nature } from "#enums/nature"; import { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; import { TrainerType } from "#enums/trainer-type"; import { TrainerVariant } from "#enums/trainer-variant"; -import type { Pokemon } from "#field/pokemon"; +import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; import { Trainer } from "#field/trainer"; +import type { ModifierTypeOption } from "#modifiers/modifier-type"; import { PokemonMove } from "#moves/pokemon-move"; import type { DexAttrProps, GameData } from "#system/game-data"; import { BooleanHolder, type NumberHolder, randSeedItem } from "#utils/common"; @@ -345,6 +346,75 @@ export abstract class Challenge { applyFlipStat(_pokemon: Pokemon, _baseStats: number[]) { return false; } + + /** + * An apply function for PARTY_HEAL. Derived classes should alter this. + * @returns Whether party healing is enabled or not + */ + applyPartyHeal(): boolean { + return true; + } + + /** + * An apply function for SHOP. Derived classes should alter this. + * @returns Whether the shop is or is not available after a wave + */ + applyShop() { + return true; + } + + /** + * An apply function for POKEMON_ADD_TO_PARTY. Derived classes should alter this. + * @param _pokemon - The pokemon being caught + * @return Whether the pokemon can be added to the party or not + */ + applyPokemonAddToParty(_pokemon: EnemyPokemon): boolean { + return true; + } + + /** + * An apply function for POKEMON_FUSION. Derived classes should alter this. + * @param _pokemon - The pokemon being checked + * @returns Whether the selected pokemon is allowed to fuse or not + */ + applyPokemonFusion(_pokemon: PlayerPokemon): boolean { + return false; + } + + /** + * An apply function for POKEMON_MOVE. Derived classes should alter this. + * @param _moveId - The move being checked + * @returns Whether the move can be used or not + */ + applyPokemonMove(_moveId: MoveId): boolean { + return true; + } + + /** + * An apply function for SHOP_ITEM. Derived classes should alter this. + * @param _shopItem - The item being checked + * @returns Whether the item should be added to the shop or not + */ + applyShopItem(_shopItem: ModifierTypeOption | null): boolean { + return true; + } + + /** + * An apply function for WAVE_REWARD. Derived classes should alter this. + * @param _reward - The reward being checked + * @returns Whether the reward should be added to the reward options or not + */ + applyWaveReward(_reward: ModifierTypeOption | null): boolean { + return true; + } + + /** + * An apply function for PREVENT_REVIVE. Derived classes should alter this. + * @returns Whether fainting is a permanent status or not + */ + applyPreventRevive(): boolean { + return false; + } } type ChallengeCondition = (data: GameData) => boolean; @@ -889,6 +959,89 @@ export class LowerStarterPointsChallenge extends Challenge { } } +/** + * Implements a No Support challenge + */ +export class NoSupportChallenge extends Challenge { + // 1 is no_heal + // 2 is no_shop + // 3 is both + constructor() { + super(Challenges.NO_SUPPORT, 3); + } + + override applyPartyHeal(): boolean { + return this.value === 2; + } + + override applyShop(): boolean { + return this.value === 1; + } + + static override loadChallenge(source: NoSupportChallenge | any): NoSupportChallenge { + const newChallenge = new NoSupportChallenge(); + newChallenge.value = source.value; + newChallenge.severity = source.severity; + return newChallenge; + } +} + +/** + * Implements a Limited Catch challenge + */ +export class LimitedCatchChallenge extends Challenge { + constructor() { + super(Challenges.LIMITED_CATCH, 1); + } + + override applyPokemonAddToParty(pokemon: EnemyPokemon): boolean { + return pokemon.metWave % 10 === 1; + } + + static override loadChallenge(source: LimitedCatchChallenge | any): LimitedCatchChallenge { + const newChallenge = new LimitedCatchChallenge(); + newChallenge.value = source.value; + newChallenge.severity = source.severity; + return newChallenge; + } +} + +/** + * Implements a Permanent Faint challenge + */ +export class PermanentFaintChallenge extends Challenge { + constructor() { + super(Challenges.PERMANENT_FAINT, 1); + } + + override applyPokemonFusion(pokemon: PlayerPokemon): boolean { + return !pokemon.isFainted(); + } + + override applyShopItem(shopItem: ModifierTypeOption | null): boolean { + return shopItem?.type.group !== "revive"; + } + + override applyWaveReward(reward: ModifierTypeOption | null): boolean { + return this.applyShopItem(reward); + } + + override applyPokemonMove(moveId: MoveId) { + return moveId !== MoveId.REVIVAL_BLESSING; + } + + override applyPreventRevive(): boolean { + return true; + } + + static override loadChallenge(source: PermanentFaintChallenge | any): PermanentFaintChallenge { + const newChallenge = new PermanentFaintChallenge(); + newChallenge.value = source.value; + newChallenge.severity = source.severity; + return newChallenge; + } +} + /** * Apply all challenges that modify starter choice. * @param challengeType {@link ChallengeType} ChallengeType.STARTER_CHOICE @@ -1041,6 +1194,66 @@ export function applyChallenges( export function applyChallenges(challengeType: ChallengeType.FLIP_STAT, pokemon: Pokemon, baseStats: number[]): boolean; +/** + * Apply all challenges that conditionally enable or disable automatic party healing during biome transitions + * @param challengeType - {@linkcode ChallengeType.PARTY_HEAL} + * @returns Whether party healing is enabled or not + */ +export function applyChallenges(challengeType: ChallengeType.PARTY_HEAL): boolean; + +/** + * Apply all challenges that conditionally enable or disable the shop + * @returns Whether the shop is or is not available after a wave + */ +export function applyChallenges(challengeType: ChallengeType.SHOP): boolean; + +/** + * Apply all challenges that validate whether a pokemon can be added to the player's party or not + * @param challengeType - {@linkcode ChallengeType.POKEMON_ADD_TO_PARTY} + * @param pokemon - The pokemon being caught + * @return Whether the pokemon can be added to the party or not + */ +export function applyChallenges(challengeType: ChallengeType.POKEMON_ADD_TO_PARTY, pokemon: EnemyPokemon): boolean; + +/** + * Apply all challenges that validate whether a pokemon is allowed to fuse or not + * @param challengeType - {@linkcode ChallengeType.POKEMON_FUSION} + * @param pokemon - The pokemon being checked + * @returns Whether the selected pokemon is allowed to fuse or not + */ +export function applyChallenges(challengeType: ChallengeType.POKEMON_FUSION, pokemon: PlayerPokemon): boolean; + +/** + * Apply all challenges that validate whether particular moves can or cannot be used + * @param challengeType - {@linkcode ChallengeType.POKEMON_MOVE} + * @param moveId - The move being checked + * @returns Whether the move can be used or not + */ +export function applyChallenges(challengeType: ChallengeType.POKEMON_MOVE, moveId: MoveId): boolean; + +/** + * Apply all challenges that validate whether particular items are or are not sold in the shop + * @param challengeType - {@linkcode ChallengeType.SHOP_ITEM} + * @param shopItem - The item being checked + * @returns Whether the item should be added to the shop or not + */ +export function applyChallenges(challengeType: ChallengeType.SHOP_ITEM, shopItem: ModifierTypeOption | null): boolean; + +/** + * Apply all challenges that validate whether particular items will be given as a reward after a wave + * @param challengeType - {@linkcode ChallengeType.WAVE_REWARD} + * @param reward - The reward being checked + * @returns Whether the reward should be added to the reward options or not + */ +export function applyChallenges(challengeType: ChallengeType.WAVE_REWARD, reward: ModifierTypeOption | null): boolean; + +/** + * Apply all challenges that prevent recovery from fainting + * @param challengeType - {@linkcode ChallengeType.PREVENT_REVIVE} + * @returns Whether fainting is a permanent status or not + */ +export function applyChallenges(challengeType: ChallengeType.PREVENT_REVIVE): boolean; + export function applyChallenges(challengeType: ChallengeType, ...args: any[]): boolean { let ret = false; globalScene.gameMode.challenges.forEach(c => { @@ -1088,6 +1301,30 @@ export function applyChallenges(challengeType: ChallengeType, ...args: any[]): b case ChallengeType.FLIP_STAT: ret ||= c.applyFlipStat(args[0], args[1]); break; + case ChallengeType.PARTY_HEAL: + ret ||= c.applyPartyHeal(); + break; + case ChallengeType.SHOP: + ret ||= c.applyShop(); + break; + case ChallengeType.POKEMON_ADD_TO_PARTY: + ret ||= c.applyPokemonAddToParty(args[0]); + break; + case ChallengeType.POKEMON_FUSION: + ret ||= c.applyPokemonFusion(args[0]); + break; + case ChallengeType.POKEMON_MOVE: + ret ||= c.applyPokemonMove(args[0]); + break; + case ChallengeType.SHOP_ITEM: + ret ||= c.applyShopItem(args[0]); + break; + case ChallengeType.WAVE_REWARD: + ret ||= c.applyWaveReward(args[0]); + break; + case ChallengeType.PREVENT_REVIVE: + ret ||= c.applyPreventRevive(); + break; } } }); @@ -1115,6 +1352,12 @@ export function copyChallenge(source: Challenge | any): Challenge { return InverseBattleChallenge.loadChallenge(source); case Challenges.FLIP_STAT: return FlipStatChallenge.loadChallenge(source); + case Challenges.LIMITED_CATCH: + return LimitedCatchChallenge.loadChallenge(source); + case Challenges.NO_SUPPORT: + return NoSupportChallenge.loadChallenge(source); + case Challenges.PERMANENT_FAINT: + return PermanentFaintChallenge.loadChallenge(source); } throw new Error("Unknown challenge copied"); } @@ -1128,6 +1371,9 @@ export function initChallenges() { new FreshStartChallenge(), new InverseBattleChallenge(), new FlipStatChallenge(), + new LimitedCatchChallenge(), + new NoSupportChallenge(), + new PermanentFaintChallenge(), ); } diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index d3f68fe9db4..96966f241c8 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -1,4 +1,6 @@ +import { applyChallenges } from "#data/challenge"; import { allMoves } from "#data/data-lists"; +import { ChallengeType } from "#enums/challenge-type"; import type { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; import type { Move } from "#moves/move"; @@ -46,15 +48,12 @@ export class PokemonMove { */ isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { // TODO: Add Sky Drop's 1 turn stall - if (this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)) { - return false; - } + const isBattleRestricted = this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon); + const hasPp = ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1; + const isNotChallengeRestricted = !pokemon.isPlayer() || applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId); + const isUnimplemented = this.getMove().name.endsWith(" (N)"); - if (this.getMove().name.endsWith(" (N)")) { - return false; - } - - return ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1; + return !isBattleRestricted && hasPp && isNotChallengeRestricted && !isUnimplemented; } getMove(): Move { diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index 19f06707257..72a418e5e7a 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -1,6 +1,7 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { speciesStarterCosts } from "#balance/starters"; +import { applyChallenges } from "#data/challenge"; import { modifierTypes } from "#data/data-lists"; import { Gender } from "#data/gender"; import { @@ -13,6 +14,7 @@ import { CustomPokemonData } from "#data/pokemon-data"; import type { PokemonSpecies } from "#data/pokemon-species"; import { getStatusEffectCatchRateMultiplier } from "#data/status-effect"; import type { AbilityId } from "#enums/ability-id"; +import { ChallengeType } from "#enums/challenge-type"; import { PlayerGender } from "#enums/player-gender"; import type { PokeballType } from "#enums/pokeball"; import type { PokemonType } from "#enums/pokemon-type"; @@ -706,6 +708,12 @@ export async function catchPokemon( }); }; Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { + // TODO: Address ME edge cases (Safari Zone, etc.) + if (!applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon)) { + removePokemon(); + end(); + return; + } if (globalScene.getPlayerParty().length === 6) { const promptRelease = () => { globalScene.ui.showText( diff --git a/src/enums/challenge-type.ts b/src/enums/challenge-type.ts index d9b1fce3e6e..053bcf92011 100644 --- a/src/enums/challenge-type.ts +++ b/src/enums/challenge-type.ts @@ -65,5 +65,45 @@ export enum ChallengeType { /** * Modifies what the pokemon stats for Flip Stat Mode. */ - FLIP_STAT + FLIP_STAT, + /** + * Challenges which conditionally enable or disable automatic party healing during biome transitions + * @see {@linkcode Challenge.applyPartyHealAvailability} + */ + PARTY_HEAL, + /** + * Challenges which conditionally enable or disable the shop + * @see {@linkcode Challenge.applyShopAvailability} + */ + SHOP, + /** + * Challenges which validate whether a pokemon can be added to the player's party or not + * @see {@linkcode Challenge.applyCatchAvailability} + */ + POKEMON_ADD_TO_PARTY, + /** + * Challenges which validate whether a pokemon is allowed to fuse or not + * @see {@linkcode Challenge.applyFusionAvailability} + */ + POKEMON_FUSION, + /** + * Challenges which validate whether particular moves can or cannot be used + * @see {@linkcode Challenge.applyMoveAvailability} + */ + POKEMON_MOVE, + /** + * Challenges which validate whether particular items are or are not sold in the shop + * @see {@linkcode Challenge.applyShopItems} + */ + SHOP_ITEM, + /** + * Challenges which validate whether particular items will be given as a reward after a wave + * @see {@linkcode Challenge.applyWaveRewards} + */ + WAVE_REWARD, + /** + * Challenges which prevent recovery from fainting + * @see {@linkcode Challenge.applyPermanentFaint} + */ + PREVENT_REVIVE, } diff --git a/src/enums/challenges.ts b/src/enums/challenges.ts index 7b506a61a2f..951c8707255 100644 --- a/src/enums/challenges.ts +++ b/src/enums/challenges.ts @@ -6,4 +6,7 @@ export enum Challenges { FRESH_START, INVERSE_BATTLE, FLIP_STAT, + LIMITED_CATCH, + NO_SUPPORT, + PERMANENT_FAINT, } diff --git a/src/game-mode.ts b/src/game-mode.ts index c5ab120e218..3aea94b8dda 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -2,8 +2,7 @@ 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 Overrides from "#app/overrides"; -import type { Challenge } from "#data/challenge"; -import { allChallenges, applyChallenges, copyChallenge } from "#data/challenge"; +import { allChallenges, applyChallenges, type Challenge, copyChallenge } from "#data/challenge"; import { getDailyStartingBiome } from "#data/daily-run"; import { allSpecies } from "#data/data-lists"; import type { PokemonSpecies } from "#data/pokemon-species"; @@ -311,6 +310,14 @@ export class GameMode implements GameModeConfig { return this.battleConfig[waveIndex]; } + /** + * Checks if the game mode has the shop enabled or not + * @returns Whether the shop is available or not + */ + getShopAvailability(): boolean { + return !this.hasNoShop && this.modeId === GameModes.CHALLENGE && applyChallenges(ChallengeType.SHOP); + } + getClearScoreBonus(): number { switch (this.modeId) { case GameModes.CLASSIC: diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index b359ec756e6..28616bc1b00 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -6,6 +6,7 @@ import Overrides from "#app/overrides"; import { EvolutionItem, pokemonEvolutions } from "#balance/pokemon-evolutions"; import { tmPoolTiers, tmSpecies } from "#balance/tms"; import { getBerryEffectDescription, getBerryName } from "#data/berry"; +import { applyChallenges } from "#data/challenge"; import { allMoves, modifierTypes } from "#data/data-lists"; import { SpeciesFormChangeItemTrigger } from "#data/form-change-triggers"; import { getNatureName, getNatureStatMultiplier } from "#data/nature"; @@ -14,6 +15,7 @@ import { pokemonFormChanges, SpeciesFormChangeCondition } from "#data/pokemon-fo import { getStatusEffectDescriptor } from "#data/status-effect"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; +import { ChallengeType } from "#enums/challenge-type"; import { FormChangeItem } from "#enums/form-change-item"; import { ModifierPoolType } from "#enums/modifier-pool-type"; import { ModifierTier } from "#enums/modifier-tier"; @@ -533,7 +535,7 @@ export class PokemonReviveModifierType extends PokemonHpRestoreModifierType { ); this.selectFilter = (pokemon: PlayerPokemon) => { - if (pokemon.hp) { + if (pokemon.hp || applyChallenges(ChallengeType.PREVENT_REVIVE)) { return PartyUiHandler.NoEffectMessage; } return null; @@ -1262,7 +1264,7 @@ export class FusePokemonModifierType extends PokemonModifierType { iconImage, (_type, args) => new FusePokemonModifier(this, (args[0] as PlayerPokemon).id, (args[1] as PlayerPokemon).id), (pokemon: PlayerPokemon) => { - if (pokemon.isFusion()) { + if (pokemon.isFusion() || !applyChallenges(ChallengeType.POKEMON_FUSION, pokemon)) { return PartyUiHandler.NoEffectMessage; } return null; @@ -2574,11 +2576,14 @@ function getModifierTypeOptionWithRetry( ): ModifierTypeOption { allowLuckUpgrades = allowLuckUpgrades ?? true; let candidate = getNewModifierTypeOption(party, ModifierPoolType.PLAYER, tier, undefined, 0, allowLuckUpgrades); + let candidateValidity = applyChallenges(ChallengeType.WAVE_REWARD, candidate); let r = 0; while ( - existingOptions.length && - ++r < retryCount && - existingOptions.filter(o => o.type.name === candidate?.type.name || o.type.group === candidate?.type.group).length + (existingOptions.length && + ++r < retryCount && + existingOptions.filter(o => o.type.name === candidate?.type.name || o.type.group === candidate?.type.group) + .length) || + !candidateValidity ) { candidate = getNewModifierTypeOption( party, @@ -2588,6 +2593,7 @@ function getModifierTypeOptionWithRetry( 0, allowLuckUpgrades, ); + candidateValidity = applyChallenges(ChallengeType.WAVE_REWARD, candidate); } return candidate!; } @@ -2648,7 +2654,11 @@ export function getPlayerShopModifierTypeOptionsForWave(waveIndex: number, baseC [new ModifierTypeOption(modifierTypeInitObj.FULL_RESTORE(), 0, baseCost * 2.25)], [new ModifierTypeOption(modifierTypeInitObj.SACRED_ASH(), 0, baseCost * 10)], ]; - return options.slice(0, Math.ceil(Math.max(waveIndex + 10, 0) / 30)).flat(); + + return options + .slice(0, Math.ceil(Math.max(waveIndex + 10, 0) / 30)) + .flat() + .filter(shopItem => applyChallenges(ChallengeType.SHOP_ITEM, shopItem)); } export function getEnemyBuffModifierForWave( diff --git a/src/phases/attempt-capture-phase.ts b/src/phases/attempt-capture-phase.ts index fcddd23dd20..b6ffc18b047 100644 --- a/src/phases/attempt-capture-phase.ts +++ b/src/phases/attempt-capture-phase.ts @@ -2,6 +2,7 @@ import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { SubstituteTag } from "#data/battler-tags"; +import { applyChallenges } from "#data/challenge"; import { Gender } from "#data/gender"; import { doPokeballBounceAnim, @@ -12,6 +13,7 @@ import { } from "#data/pokeball"; import { getStatusEffectCatchRateMultiplier } from "#data/status-effect"; import { BattlerIndex } from "#enums/battler-index"; +import { ChallengeType } from "#enums/challenge-type"; import type { PokeballType } from "#enums/pokeball"; import { StatusEffect } from "#enums/status-effect"; import { UiMode } from "#enums/ui-mode"; @@ -287,6 +289,11 @@ export class AttemptCapturePhase extends PokemonPhase { }); }; Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { + if (!applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon)) { + removePokemon(); + end(); + return; + } if (globalScene.getPlayerParty().length === PLAYER_PARTY_MAX_SIZE) { const promptRelease = () => { globalScene.ui.showText( diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 14674037fbe..3eba211e9af 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -4,12 +4,14 @@ import { getPokemonNameWithAffix } from "#app/messages"; import { speciesStarterCosts } from "#balance/starters"; import type { EncoreTag } from "#data/battler-tags"; import { TrappedTag } from "#data/battler-tags"; +import { applyChallenges } from "#data/challenge"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleType } from "#enums/battle-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BiomeId } from "#enums/biome-id"; +import { ChallengeType } from "#enums/challenge-type"; import { Command } from "#enums/command"; import { FieldPosition } from "#enums/field-position"; import { MoveId } from "#enums/move-id"; @@ -220,18 +222,31 @@ export class CommandPhase extends FieldPhase { const move = playerPokemon.getMoveset()[cursor]; globalScene.ui.setMode(UiMode.MESSAGE); - // Decides between a Disabled, Not Implemented, or No PP translation message - const errorMessage = playerPokemon.isMoveRestricted(move.moveId, playerPokemon) - ? playerPokemon - .getRestrictingTag(move.moveId, playerPokemon)! - .selectionDeniedText(playerPokemon, move.moveId) - : move.getName().endsWith(" (N)") - ? "battle:moveNotImplemented" - : "battle:moveNoPP"; + // Set the translation key for why the move cannot be selected. The reasons can be: + // - If the move has been restricted in battle (e.g., Disable). + // - If the move has no more PP. + // - If the move is restricted by a challenge. + // - If the move is not implemented + let cannotUseKey: string; + if (playerPokemon.isMoveRestricted(move.moveId, playerPokemon)) { + cannotUseKey = playerPokemon + .getRestrictingTag(move.moveId, playerPokemon)! + .selectionDeniedText(playerPokemon, move.moveId); + } else if (move.getPpRatio() === 0) { + cannotUseKey = "battle:moveNoPP"; + } else if (!applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId)) { + cannotUseKey = "battle:moveCannotUseChallenge"; + } else if (move.getName().endsWith(" (N)")) { + cannotUseKey = "battle:moveNotImplemented"; + } else { + // TODO: Consider a message that signals a being unusable for an unknown reason + cannotUseKey = ""; + } + const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator globalScene.ui.showText( - i18next.t(errorMessage, { moveName: moveName }), + i18next.t(cannotUseKey, { moveName: moveName }), null, () => { globalScene.ui.clearText(); diff --git a/src/phases/party-heal-phase.ts b/src/phases/party-heal-phase.ts index 80d8b315102..f89efaf7c72 100644 --- a/src/phases/party-heal-phase.ts +++ b/src/phases/party-heal-phase.ts @@ -1,4 +1,6 @@ import { globalScene } from "#app/global-scene"; +import { applyChallenges } from "#data/challenge"; +import { ChallengeType } from "#enums/challenge-type"; import { BattlePhase } from "#phases/battle-phase"; import { fixedInt } from "#utils/common"; @@ -20,13 +22,16 @@ export class PartyHealPhase extends BattlePhase { globalScene.fadeOutBgm(1000, false); } globalScene.ui.fadeOut(1000).then(() => { + const preventRevive = applyChallenges(ChallengeType.PREVENT_REVIVE); for (const pokemon of globalScene.getPlayerParty()) { - pokemon.hp = pokemon.getMaxHp(); - pokemon.resetStatus(true, false, false, true); - for (const move of pokemon.moveset) { - move.ppUsed = 0; + if (!(pokemon.isFainted() && preventRevive)) { + pokemon.hp = pokemon.getMaxHp(); + pokemon.resetStatus(true, false, false, true); + for (const move of pokemon.moveset) { + move.ppUsed = 0; + } + pokemon.updateInfo(true); } - pokemon.updateInfo(true); } const healSong = globalScene.playSoundWithoutBgm("heal"); globalScene.time.delayedCall(fixedInt(healSong.totalDuration * 1000), () => { diff --git a/src/phases/select-biome-phase.ts b/src/phases/select-biome-phase.ts index ab96bf5c45e..a0f3e1cc982 100644 --- a/src/phases/select-biome-phase.ts +++ b/src/phases/select-biome-phase.ts @@ -1,6 +1,8 @@ import { globalScene } from "#app/global-scene"; import { biomeLinks, getBiomeName } from "#balance/biomes"; +import { applyChallenges } from "#data/challenge"; import { BiomeId } from "#enums/biome-id"; +import { ChallengeType } from "#enums/challenge-type"; import { UiMode } from "#enums/ui-mode"; import { MapModifier, MoneyInterestModifier } from "#modifiers/modifier"; import { BattlePhase } from "#phases/battle-phase"; @@ -20,7 +22,9 @@ export class SelectBiomePhase extends BattlePhase { const setNextBiome = (nextBiome: BiomeId) => { if (nextWaveIndex % 10 === 1) { globalScene.applyModifiers(MoneyInterestModifier, true); - globalScene.phaseManager.unshiftNew("PartyHealPhase", false); + if (applyChallenges(ChallengeType.PARTY_HEAL)) { + globalScene.phaseManager.unshiftNew("PartyHealPhase", false); + } } globalScene.phaseManager.unshiftNew("SwitchBiomePhase", nextBiome); this.end(); diff --git a/src/phases/victory-phase.ts b/src/phases/victory-phase.ts index 4b1a79d7443..dde1ca66a2a 100644 --- a/src/phases/victory-phase.ts +++ b/src/phases/victory-phase.ts @@ -1,8 +1,10 @@ import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; +import { applyChallenges } from "#data/challenge"; import { modifierTypes } from "#data/data-lists"; import { BattleType } from "#enums/battle-type"; import type { BattlerIndex } from "#enums/battler-index"; +import { ChallengeType } from "#enums/challenge-type"; import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves"; import type { CustomModifierSettings } from "#modifiers/modifier-type"; import { handleMysteryEncounterVictory } from "#mystery-encounters/encounter-phase-utils"; @@ -63,7 +65,7 @@ export class VictoryPhase extends PokemonPhase { break; } } - if (globalScene.currentBattle.waveIndex % 10) { + if (globalScene.currentBattle.waveIndex % 10 !== 0 || !applyChallenges(ChallengeType.PARTY_HEAL)) { globalScene.phaseManager.pushNew( "SelectModifierPhase", undefined, diff --git a/src/ui/modifier-select-ui-handler.ts b/src/ui/modifier-select-ui-handler.ts index 50d88738d32..34ef5d0cee7 100644 --- a/src/ui/modifier-select-ui-handler.ts +++ b/src/ui/modifier-select-ui-handler.ts @@ -209,10 +209,10 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler { this.updateRerollCostText(); const typeOptions = args[1] as ModifierTypeOption[]; - const removeHealShop = globalScene.gameMode.hasNoShop; + const hasShop = globalScene.gameMode.getShopAvailability(); const baseShopCost = new NumberHolder(globalScene.getWaveMoneyAmount(1)); globalScene.applyModifier(HealShopCostModifier, true, baseShopCost); - const shopTypeOptions = !removeHealShop + const shopTypeOptions = hasShop ? getPlayerShopModifierTypeOptionsForWave(globalScene.currentBattle.waveIndex, baseShopCost.value) : []; const optionsYOffset = @@ -370,7 +370,7 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler { if (globalScene.shopCursorTarget === ShopCursorTarget.CHECK_TEAM) { this.setRowCursor(0); this.setCursor(2); - } else if (globalScene.shopCursorTarget === ShopCursorTarget.SHOP && globalScene.gameMode.hasNoShop) { + } else if (globalScene.shopCursorTarget === ShopCursorTarget.SHOP && !hasShop) { this.setRowCursor(ShopCursorTarget.REWARDS); this.setCursor(0); } else { From 80151b3338e6efb98edc9b9d32075b72c8c047a4 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Thu, 31 Jul 2025 22:24:38 -0400 Subject: [PATCH 02/14] Add Sacred Ash to `revive` Group --- src/modifier/modifier-type.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 28616bc1b00..bf6ff6fec1e 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -1013,6 +1013,7 @@ class AllPokemonFullReviveModifierType extends AllPokemonFullHpRestoreModifierTy "modifierType:ModifierType.AllPokemonFullReviveModifierType", (_type, _args) => new PokemonHpRestoreModifier(this, -1, 0, 100, false, true), ); + this.group = "revive"; } } From 5c2c7cf3d140a4b04c5c28955cc967573e3b8eb8 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Fri, 1 Aug 2025 00:49:44 -0400 Subject: [PATCH 03/14] Separate Challenge Utility Functions --- src/data/challenge.ts | 372 +---------------- src/data/moves/move.ts | 4 +- src/data/moves/pokemon-move.ts | 14 +- .../utils/encounter-pokemon-utils.ts | 2 +- src/field/pokemon.ts | 2 +- src/game-mode.ts | 3 +- src/modifier/modifier-type.ts | 2 +- src/phases/attempt-capture-phase.ts | 2 +- src/phases/command-phase.ts | 2 +- src/phases/party-heal-phase.ts | 2 +- src/phases/select-biome-phase.ts | 2 +- src/phases/select-starter-phase.ts | 2 +- src/phases/victory-phase.ts | 2 +- src/system/game-data.ts | 2 +- src/ui/party-ui-handler.ts | 2 +- src/ui/starter-select-ui-handler.ts | 2 +- src/utils/challenge-utils.ts | 380 ++++++++++++++++++ 17 files changed, 404 insertions(+), 393 deletions(-) create mode 100644 src/utils/challenge-utils.ts diff --git a/src/data/challenge.ts b/src/data/challenge.ts index f008b822e17..76a78a0c2ce 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -1,15 +1,11 @@ import type { FixedBattleConfig } from "#app/battle"; import { getRandomTrainerFunc } from "#app/battle"; import { defaultStarterSpecies } from "#app/constants"; -import { globalScene } from "#app/global-scene"; -import { pokemonEvolutions } from "#balance/pokemon-evolutions"; import { speciesStarterCosts } from "#balance/starters"; import { getEggTierForSpecies } from "#data/egg"; -import { pokemonFormChanges } from "#data/pokemon-forms"; import type { PokemonSpecies } from "#data/pokemon-species"; import { getPokemonSpeciesForm } from "#data/pokemon-species"; import { BattleType } from "#enums/battle-type"; -import { ChallengeType } from "#enums/challenge-type"; import { Challenges } from "#enums/challenges"; import { TypeColor, TypeShadow } from "#enums/color"; import { EggTier } from "#enums/egg-type"; @@ -27,7 +23,7 @@ import { Trainer } from "#field/trainer"; import type { ModifierTypeOption } from "#modifiers/modifier-type"; import { PokemonMove } from "#moves/pokemon-move"; import type { DexAttrProps, GameData } from "#system/game-data"; -import { BooleanHolder, type NumberHolder, randSeedItem } from "#utils/common"; +import { type BooleanHolder, type NumberHolder, randSeedItem } from "#utils/common"; import { deepCopy } from "#utils/data"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { toCamelCase, toSnakeCase } from "#utils/strings"; @@ -1016,295 +1012,6 @@ export class PermanentFaintChallenge extends Challenge { } } -/** - * Apply all challenges that modify starter choice. - * @param challengeType {@link ChallengeType} ChallengeType.STARTER_CHOICE - * @param pokemon {@link PokemonSpecies} The pokemon to check the validity of. - * @param valid {@link BooleanHolder} A BooleanHolder, the value gets set to false if the pokemon isn't allowed. - * @param dexAttr {@link DexAttrProps} The dex attributes of the pokemon. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.STARTER_CHOICE, - pokemon: PokemonSpecies, - valid: BooleanHolder, - dexAttr: DexAttrProps, -): boolean; -/** - * Apply all challenges that modify available total starter points. - * @param challengeType {@link ChallengeType} ChallengeType.STARTER_POINTS - * @param points {@link NumberHolder} The amount of points you have available. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges(challengeType: ChallengeType.STARTER_POINTS, points: NumberHolder): boolean; -/** - * Apply all challenges that modify the cost of a starter. - * @param challengeType {@link ChallengeType} ChallengeType.STARTER_COST - * @param species {@link SpeciesId} The pokemon to change the cost of. - * @param points {@link NumberHolder} The cost of the pokemon. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.STARTER_COST, - species: SpeciesId, - cost: NumberHolder, -): boolean; -/** - * Apply all challenges that modify a starter after selection. - * @param challengeType {@link ChallengeType} ChallengeType.STARTER_MODIFY - * @param pokemon {@link Pokemon} The starter pokemon to modify. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges(challengeType: ChallengeType.STARTER_MODIFY, pokemon: Pokemon): boolean; -/** - * Apply all challenges that what pokemon you can have in battle. - * @param challengeType {@link ChallengeType} ChallengeType.POKEMON_IN_BATTLE - * @param pokemon {@link Pokemon} The pokemon to check the validity of. - * @param valid {@link BooleanHolder} A BooleanHolder, the value gets set to false if the pokemon isn't allowed. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.POKEMON_IN_BATTLE, - pokemon: Pokemon, - valid: BooleanHolder, -): boolean; -/** - * Apply all challenges that modify what fixed battles there are. - * @param challengeType {@link ChallengeType} ChallengeType.FIXED_BATTLES - * @param waveIndex {@link Number} The current wave index. - * @param battleConfig {@link FixedBattleConfig} The battle config to modify. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.FIXED_BATTLES, - waveIndex: number, - battleConfig: FixedBattleConfig, -): boolean; -/** - * Apply all challenges that modify type effectiveness. - * @param challengeType {@linkcode ChallengeType} ChallengeType.TYPE_EFFECTIVENESS - * @param effectiveness {@linkcode NumberHolder} The current effectiveness of the move. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges(challengeType: ChallengeType.TYPE_EFFECTIVENESS, effectiveness: NumberHolder): boolean; -/** - * Apply all challenges that modify what level AI are. - * @param challengeType {@link ChallengeType} ChallengeType.AI_LEVEL - * @param level {@link NumberHolder} The generated level of the pokemon. - * @param levelCap {@link Number} The maximum level cap for the current wave. - * @param isTrainer {@link Boolean} Whether this is a trainer pokemon. - * @param isBoss {@link Boolean} Whether this is a non-trainer boss pokemon. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.AI_LEVEL, - level: NumberHolder, - levelCap: number, - isTrainer: boolean, - isBoss: boolean, -): boolean; -/** - * Apply all challenges that modify how many move slots the AI has. - * @param challengeType {@link ChallengeType} ChallengeType.AI_MOVE_SLOTS - * @param pokemon {@link Pokemon} The pokemon being considered. - * @param moveSlots {@link NumberHolder} The amount of move slots. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.AI_MOVE_SLOTS, - pokemon: Pokemon, - moveSlots: NumberHolder, -): boolean; -/** - * Apply all challenges that modify whether a pokemon has its passive. - * @param challengeType {@link ChallengeType} ChallengeType.PASSIVE_ACCESS - * @param pokemon {@link Pokemon} The pokemon to modify. - * @param hasPassive {@link BooleanHolder} Whether it has its passive. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.PASSIVE_ACCESS, - pokemon: Pokemon, - hasPassive: BooleanHolder, -): boolean; -/** - * Apply all challenges that modify the game modes settings. - * @param challengeType {@link ChallengeType} ChallengeType.GAME_MODE_MODIFY - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges(challengeType: ChallengeType.GAME_MODE_MODIFY): boolean; -/** - * Apply all challenges that modify what level a pokemon can access a move. - * @param challengeType {@link ChallengeType} ChallengeType.MOVE_ACCESS - * @param pokemon {@link Pokemon} What pokemon would learn the move. - * @param moveSource {@link MoveSourceType} What source the pokemon would get the move from. - * @param move {@link MoveId} The move in question. - * @param level {@link NumberHolder} The level threshold for access. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.MOVE_ACCESS, - pokemon: Pokemon, - moveSource: MoveSourceType, - move: MoveId, - level: NumberHolder, -): boolean; -/** - * Apply all challenges that modify what weight a pokemon gives to move generation - * @param challengeType {@link ChallengeType} ChallengeType.MOVE_WEIGHT - * @param pokemon {@link Pokemon} What pokemon would learn the move. - * @param moveSource {@link MoveSourceType} What source the pokemon would get the move from. - * @param move {@link MoveId} The move in question. - * @param weight {@link NumberHolder} The weight of the move. - * @returns True if any challenge was successfully applied. - */ -export function applyChallenges( - challengeType: ChallengeType.MOVE_WEIGHT, - pokemon: Pokemon, - moveSource: MoveSourceType, - move: MoveId, - weight: NumberHolder, -): boolean; - -export function applyChallenges(challengeType: ChallengeType.FLIP_STAT, pokemon: Pokemon, baseStats: number[]): boolean; - -/** - * Apply all challenges that conditionally enable or disable automatic party healing during biome transitions - * @param challengeType - {@linkcode ChallengeType.PARTY_HEAL} - * @returns Whether party healing is enabled or not - */ -export function applyChallenges(challengeType: ChallengeType.PARTY_HEAL): boolean; - -/** - * Apply all challenges that conditionally enable or disable the shop - * @returns Whether the shop is or is not available after a wave - */ -export function applyChallenges(challengeType: ChallengeType.SHOP): boolean; - -/** - * Apply all challenges that validate whether a pokemon can be added to the player's party or not - * @param challengeType - {@linkcode ChallengeType.POKEMON_ADD_TO_PARTY} - * @param pokemon - The pokemon being caught - * @return Whether the pokemon can be added to the party or not - */ -export function applyChallenges(challengeType: ChallengeType.POKEMON_ADD_TO_PARTY, pokemon: EnemyPokemon): boolean; - -/** - * Apply all challenges that validate whether a pokemon is allowed to fuse or not - * @param challengeType - {@linkcode ChallengeType.POKEMON_FUSION} - * @param pokemon - The pokemon being checked - * @returns Whether the selected pokemon is allowed to fuse or not - */ -export function applyChallenges(challengeType: ChallengeType.POKEMON_FUSION, pokemon: PlayerPokemon): boolean; - -/** - * Apply all challenges that validate whether particular moves can or cannot be used - * @param challengeType - {@linkcode ChallengeType.POKEMON_MOVE} - * @param moveId - The move being checked - * @returns Whether the move can be used or not - */ -export function applyChallenges(challengeType: ChallengeType.POKEMON_MOVE, moveId: MoveId): boolean; - -/** - * Apply all challenges that validate whether particular items are or are not sold in the shop - * @param challengeType - {@linkcode ChallengeType.SHOP_ITEM} - * @param shopItem - The item being checked - * @returns Whether the item should be added to the shop or not - */ -export function applyChallenges(challengeType: ChallengeType.SHOP_ITEM, shopItem: ModifierTypeOption | null): boolean; - -/** - * Apply all challenges that validate whether particular items will be given as a reward after a wave - * @param challengeType - {@linkcode ChallengeType.WAVE_REWARD} - * @param reward - The reward being checked - * @returns Whether the reward should be added to the reward options or not - */ -export function applyChallenges(challengeType: ChallengeType.WAVE_REWARD, reward: ModifierTypeOption | null): boolean; - -/** - * Apply all challenges that prevent recovery from fainting - * @param challengeType - {@linkcode ChallengeType.PREVENT_REVIVE} - * @returns Whether fainting is a permanent status or not - */ -export function applyChallenges(challengeType: ChallengeType.PREVENT_REVIVE): boolean; - -export function applyChallenges(challengeType: ChallengeType, ...args: any[]): boolean { - let ret = false; - globalScene.gameMode.challenges.forEach(c => { - if (c.value !== 0) { - switch (challengeType) { - case ChallengeType.STARTER_CHOICE: - ret ||= c.applyStarterChoice(args[0], args[1], args[2]); - break; - case ChallengeType.STARTER_POINTS: - ret ||= c.applyStarterPoints(args[0]); - break; - case ChallengeType.STARTER_COST: - ret ||= c.applyStarterCost(args[0], args[1]); - break; - case ChallengeType.STARTER_MODIFY: - ret ||= c.applyStarterModify(args[0]); - break; - case ChallengeType.POKEMON_IN_BATTLE: - ret ||= c.applyPokemonInBattle(args[0], args[1]); - break; - case ChallengeType.FIXED_BATTLES: - ret ||= c.applyFixedBattle(args[0], args[1]); - break; - case ChallengeType.TYPE_EFFECTIVENESS: - ret ||= c.applyTypeEffectiveness(args[0]); - break; - case ChallengeType.AI_LEVEL: - ret ||= c.applyLevelChange(args[0], args[1], args[2], args[3]); - break; - case ChallengeType.AI_MOVE_SLOTS: - ret ||= c.applyMoveSlot(args[0], args[1]); - break; - case ChallengeType.PASSIVE_ACCESS: - ret ||= c.applyPassiveAccess(args[0], args[1]); - break; - case ChallengeType.GAME_MODE_MODIFY: - ret ||= c.applyGameModeModify(); - break; - case ChallengeType.MOVE_ACCESS: - ret ||= c.applyMoveAccessLevel(args[0], args[1], args[2], args[3]); - break; - case ChallengeType.MOVE_WEIGHT: - ret ||= c.applyMoveWeight(args[0], args[1], args[2], args[3]); - break; - case ChallengeType.FLIP_STAT: - ret ||= c.applyFlipStat(args[0], args[1]); - break; - case ChallengeType.PARTY_HEAL: - ret ||= c.applyPartyHeal(); - break; - case ChallengeType.SHOP: - ret ||= c.applyShop(); - break; - case ChallengeType.POKEMON_ADD_TO_PARTY: - ret ||= c.applyPokemonAddToParty(args[0]); - break; - case ChallengeType.POKEMON_FUSION: - ret ||= c.applyPokemonFusion(args[0]); - break; - case ChallengeType.POKEMON_MOVE: - ret ||= c.applyPokemonMove(args[0]); - break; - case ChallengeType.SHOP_ITEM: - ret ||= c.applyShopItem(args[0]); - break; - case ChallengeType.WAVE_REWARD: - ret ||= c.applyWaveReward(args[0]); - break; - case ChallengeType.PREVENT_REVIVE: - ret ||= c.applyPreventRevive(); - break; - } - } - }); - return ret; -} - /** * * @param source A challenge to copy, or an object of a challenge's properties. Missing values are treated as defaults. @@ -1350,80 +1057,3 @@ export function initChallenges() { new PermanentFaintChallenge(), ); } - -/** - * Apply all challenges to the given starter (and form) to check its validity. - * Differs from {@linkcode checkSpeciesValidForChallenge} which only checks form changes. - * @param species - The {@linkcode PokemonSpecies} to check the validity of. - * @param dexAttr - The {@linkcode DexAttrProps | dex attributes} of the species, including its form index. - * @param soft - If `true`, allow it if it could become valid through evolution or form change. - * @returns `true` if the species is considered valid. - */ -export function checkStarterValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { - if (!soft) { - const isValidForChallenge = new BooleanHolder(true); - applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); - return isValidForChallenge.value; - } - // We check the validity of every evolution and form change, and require that at least one is valid - const speciesToCheck = [species.speciesId]; - while (speciesToCheck.length) { - const checking = speciesToCheck.pop(); - // Linter complains if we don't handle this - if (!checking) { - return false; - } - const checkingSpecies = getPokemonSpecies(checking); - if (checkSpeciesValidForChallenge(checkingSpecies, props, true)) { - return true; - } - if (checking && pokemonEvolutions.hasOwnProperty(checking)) { - pokemonEvolutions[checking].forEach(e => { - // Form check to deal with cases such as Basculin -> Basculegion - // TODO: does this miss anything if checking forms of a stage 2 Pokémon? - if (!e?.preFormKey || e.preFormKey === species.forms[props.formIndex].formKey) { - speciesToCheck.push(e.speciesId); - } - }); - } - } - return false; -} - -/** - * Apply all challenges to the given species (and form) to check its validity. - * Differs from {@linkcode checkStarterValidForChallenge} which also checks evolutions. - * @param species - The {@linkcode PokemonSpecies} to check the validity of. - * @param dexAttr - The {@linkcode DexAttrProps | dex attributes} of the species, including its form index. - * @param soft - If `true`, allow it if it could become valid through a form change. - * @returns `true` if the species is considered valid. - */ -function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { - const isValidForChallenge = new BooleanHolder(true); - applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); - if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) { - return isValidForChallenge.value; - } - // If the form in props is valid, return true before checking other form changes - if (soft && isValidForChallenge.value) { - return true; - } - - const result = pokemonFormChanges[species.speciesId].some(f1 => { - // Exclude form changes that require the mon to be on the field to begin with - if (!("item" in f1.trigger)) { - return false; - } - - return species.forms.some((f2, formIndex) => { - if (f1.formKey === f2.formKey) { - const formProps = { ...props, formIndex }; - const isFormValidForChallenge = new BooleanHolder(true); - applyChallenges(ChallengeType.STARTER_CHOICE, species, isFormValidForChallenge, formProps); - return isFormValidForChallenge.value; - } - return false; - }); - }); - return result; -} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index bde5f2977d8..7d63c4cde3d 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -22,7 +22,6 @@ import { TypeBoostTag, } from "#data/battler-tags"; import { getBerryEffectFunc } from "#data/berry"; -import { applyChallenges } from "#data/challenge"; import { allAbilities, allMoves } from "#data/data-lists"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; import { DelayedAttackTag } from "#data/positional-tags/positional-tag"; @@ -93,6 +92,7 @@ import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randS import { getEnumValues } from "#utils/enums"; import { toTitleCase } from "#utils/strings"; import i18next from "i18next"; +import { applyChallenges } from "#utils/challenge-utils"; /** * A function used to conditionally determine execution of a given {@linkcode MoveAttr}. @@ -124,7 +124,7 @@ export abstract class Move implements Localizable { /** * Check if the move is of the given subclass without requiring `instanceof`. * - * ⚠️ Does _not_ work for {@linkcode ChargingAttackMove} and {@linkcode ChargingSelfStatusMove} subclasses. For those, + * ! Does _not_ work for {@linkcode ChargingAttackMove} and {@linkcode ChargingSelfStatusMove} subclasses. For those, * use {@linkcode isChargingMove} instead. * * @param moveKind - The string name of the move to check against diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 96966f241c8..86ad3f8b3f1 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -1,9 +1,9 @@ -import { applyChallenges } from "#data/challenge"; import { allMoves } from "#data/data-lists"; import { ChallengeType } from "#enums/challenge-type"; import type { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; import type { Move } from "#moves/move"; +import { applyChallenges } from "#utils/challenge-utils"; import { toDmgValue } from "#utils/common"; /** @@ -48,12 +48,12 @@ export class PokemonMove { */ isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { // TODO: Add Sky Drop's 1 turn stall - const isBattleRestricted = this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon); - const hasPp = ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1; - const isNotChallengeRestricted = !pokemon.isPlayer() || applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId); - const isUnimplemented = this.getMove().name.endsWith(" (N)"); - - return !isBattleRestricted && hasPp && isNotChallengeRestricted && !isUnimplemented; + return ( + !(this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)) && + (ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1) && + (!pokemon.isPlayer() || applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId)) && + !this.getMove().name.endsWith(" (N)") + ); } getMove(): Move { diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index 72a418e5e7a..07513da2b37 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -1,7 +1,6 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { speciesStarterCosts } from "#balance/starters"; -import { applyChallenges } from "#data/challenge"; import { modifierTypes } from "#data/data-lists"; import { Gender } from "#data/gender"; import { @@ -35,6 +34,7 @@ import { achvs } from "#system/achv"; import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; import { SummaryUiMode } from "#ui/summary-ui-handler"; +import { applyChallenges } from "#utils/challenge-utils"; import { isNullOrUndefined, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 7aecc0c8e75..dea55a45d89 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -40,7 +40,6 @@ import { TrappedTag, TypeImmuneTag, } from "#data/battler-tags"; -import { applyChallenges } from "#data/challenge"; import { allAbilities, allMoves } from "#data/data-lists"; import { getLevelTotalExp } from "#data/exp"; import { @@ -149,6 +148,7 @@ import { EnemyBattleInfo } from "#ui/enemy-battle-info"; import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiHandler, PartyUiMode } from "#ui/party-ui-handler"; import { PlayerBattleInfo } from "#ui/player-battle-info"; +import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, type Constructor, diff --git a/src/game-mode.ts b/src/game-mode.ts index 3aea94b8dda..415b16f83ed 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -2,7 +2,7 @@ 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 Overrides from "#app/overrides"; -import { allChallenges, applyChallenges, type Challenge, copyChallenge } from "#data/challenge"; +import { allChallenges, type Challenge, copyChallenge } from "#data/challenge"; import { getDailyStartingBiome } from "#data/daily-run"; import { allSpecies } from "#data/data-lists"; import type { PokemonSpecies } from "#data/pokemon-species"; @@ -13,6 +13,7 @@ import { GameModes } from "#enums/game-modes"; import { SpeciesId } from "#enums/species-id"; import type { Arena } from "#field/arena"; import { classicFixedBattles, type FixedBattleConfigs } from "#trainers/fixed-battle-configs"; +import { applyChallenges } from "#utils/challenge-utils"; import { isNullOrUndefined, randSeedInt, randSeedItem } from "#utils/common"; import i18next from "i18next"; diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index bf6ff6fec1e..f0692b98991 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -6,7 +6,6 @@ import Overrides from "#app/overrides"; import { EvolutionItem, pokemonEvolutions } from "#balance/pokemon-evolutions"; import { tmPoolTiers, tmSpecies } from "#balance/tms"; import { getBerryEffectDescription, getBerryName } from "#data/berry"; -import { applyChallenges } from "#data/challenge"; import { allMoves, modifierTypes } from "#data/data-lists"; import { SpeciesFormChangeItemTrigger } from "#data/form-change-triggers"; import { getNatureName, getNatureStatMultiplier } from "#data/nature"; @@ -118,6 +117,7 @@ import type { ModifierTypeFunc, WeightedModifierTypeWeightFunc } from "#types/mo import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#ui/party-ui-handler"; import { PartyUiHandler } from "#ui/party-ui-handler"; import { getModifierTierTextTint } from "#ui/text"; +import { applyChallenges } from "#utils/challenge-utils"; import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt, randSeedItem } from "#utils/common"; import { getEnumKeys, getEnumValues } from "#utils/enums"; import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils"; diff --git a/src/phases/attempt-capture-phase.ts b/src/phases/attempt-capture-phase.ts index b6ffc18b047..6283a18fc28 100644 --- a/src/phases/attempt-capture-phase.ts +++ b/src/phases/attempt-capture-phase.ts @@ -2,7 +2,6 @@ import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { SubstituteTag } from "#data/battler-tags"; -import { applyChallenges } from "#data/challenge"; import { Gender } from "#data/gender"; import { doPokeballBounceAnim, @@ -25,6 +24,7 @@ import { achvs } from "#system/achv"; import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; import { SummaryUiMode } from "#ui/summary-ui-handler"; +import { applyChallenges } from "#utils/challenge-utils"; import i18next from "i18next"; // TODO: Refactor and split up to allow for overriding capture chance diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index a713714570c..a589cfe6134 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -3,7 +3,6 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { speciesStarterCosts } from "#balance/starters"; import { TrappedTag } from "#data/battler-tags"; -import { applyChallenges } from "#data/challenge"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; @@ -23,6 +22,7 @@ import type { MoveTargetSet } from "#moves/move"; import { getMoveTargets } from "#moves/move-utils"; import { FieldPhase } from "#phases/field-phase"; import type { TurnMove } from "#types/turn-move"; +import { applyChallenges } from "#utils/challenge-utils"; import i18next from "i18next"; export class CommandPhase extends FieldPhase { diff --git a/src/phases/party-heal-phase.ts b/src/phases/party-heal-phase.ts index f89efaf7c72..d16e732a87f 100644 --- a/src/phases/party-heal-phase.ts +++ b/src/phases/party-heal-phase.ts @@ -1,7 +1,7 @@ import { globalScene } from "#app/global-scene"; -import { applyChallenges } from "#data/challenge"; import { ChallengeType } from "#enums/challenge-type"; import { BattlePhase } from "#phases/battle-phase"; +import { applyChallenges } from "#utils/challenge-utils"; import { fixedInt } from "#utils/common"; export class PartyHealPhase extends BattlePhase { diff --git a/src/phases/select-biome-phase.ts b/src/phases/select-biome-phase.ts index a0f3e1cc982..be76ef721f5 100644 --- a/src/phases/select-biome-phase.ts +++ b/src/phases/select-biome-phase.ts @@ -1,12 +1,12 @@ import { globalScene } from "#app/global-scene"; import { biomeLinks, getBiomeName } from "#balance/biomes"; -import { applyChallenges } from "#data/challenge"; import { BiomeId } from "#enums/biome-id"; import { ChallengeType } from "#enums/challenge-type"; import { UiMode } from "#enums/ui-mode"; import { MapModifier, MoneyInterestModifier } from "#modifiers/modifier"; import { BattlePhase } from "#phases/battle-phase"; import type { OptionSelectItem } from "#ui/abstact-option-select-ui-handler"; +import { applyChallenges } from "#utils/challenge-utils"; import { randSeedInt } from "#utils/common"; export class SelectBiomePhase extends BattlePhase { diff --git a/src/phases/select-starter-phase.ts b/src/phases/select-starter-phase.ts index 6456bacd0e3..c46b7ab9388 100644 --- a/src/phases/select-starter-phase.ts +++ b/src/phases/select-starter-phase.ts @@ -1,7 +1,6 @@ import { globalScene } from "#app/global-scene"; import Overrides from "#app/overrides"; import { Phase } from "#app/phase"; -import { applyChallenges } from "#data/challenge"; import { SpeciesFormChangeMoveLearnedTrigger } from "#data/form-change-triggers"; import { Gender } from "#data/gender"; import { ChallengeType } from "#enums/challenge-type"; @@ -10,6 +9,7 @@ import { UiMode } from "#enums/ui-mode"; import { overrideHeldItems, overrideModifiers } from "#modifiers/modifier"; import { SaveSlotUiMode } from "#ui/save-slot-select-ui-handler"; import type { Starter } from "#ui/starter-select-ui-handler"; +import { applyChallenges } from "#utils/challenge-utils"; import { isNullOrUndefined } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; diff --git a/src/phases/victory-phase.ts b/src/phases/victory-phase.ts index dde1ca66a2a..dce108ae66b 100644 --- a/src/phases/victory-phase.ts +++ b/src/phases/victory-phase.ts @@ -1,6 +1,5 @@ import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; -import { applyChallenges } from "#data/challenge"; import { modifierTypes } from "#data/data-lists"; import { BattleType } from "#enums/battle-type"; import type { BattlerIndex } from "#enums/battler-index"; @@ -9,6 +8,7 @@ import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves"; import type { CustomModifierSettings } from "#modifiers/modifier-type"; import { handleMysteryEncounterVictory } from "#mystery-encounters/encounter-phase-utils"; import { PokemonPhase } from "#phases/pokemon-phase"; +import { applyChallenges } from "#utils/challenge-utils"; export class VictoryPhase extends PokemonPhase { public readonly phaseName = "VictoryPhase"; diff --git a/src/system/game-data.ts b/src/system/game-data.ts index d899afa19ef..ae559072e35 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -11,7 +11,6 @@ import { speciesEggMoves } from "#balance/egg-moves"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { speciesStarterCosts } from "#balance/starters"; import { ArenaTrapTag } from "#data/arena-tag"; -import { applyChallenges } from "#data/challenge"; import { allMoves, allSpecies } from "#data/data-lists"; import type { Egg } from "#data/egg"; import { pokemonFormChanges } from "#data/pokemon-forms"; @@ -63,6 +62,7 @@ import { VoucherType, vouchers } from "#system/voucher"; import { trainerConfigs } from "#trainers/trainer-config"; import type { DexData, DexEntry } from "#types/dex-data"; import { RUN_HISTORY_LIMIT } from "#ui/run-history-ui-handler"; +import { applyChallenges } from "#utils/challenge-utils"; import { executeIf, fixedInt, isLocal, NumberHolder, randInt, randSeedItem } from "#utils/common"; import { decrypt, encrypt } from "#utils/data"; import { getEnumKeys } from "#utils/enums"; diff --git a/src/ui/party-ui-handler.ts b/src/ui/party-ui-handler.ts index b259316f6fa..5689850372b 100644 --- a/src/ui/party-ui-handler.ts +++ b/src/ui/party-ui-handler.ts @@ -1,7 +1,6 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { pokemonEvolutions } from "#balance/pokemon-evolutions"; -import { applyChallenges } from "#data/challenge"; import { allMoves } from "#data/data-lists"; import { SpeciesFormChangeItemTrigger } from "#data/form-change-triggers"; import { Gender, getGenderColor, getGenderSymbol } from "#data/gender"; @@ -26,6 +25,7 @@ import { MoveInfoOverlay } from "#ui/move-info-overlay"; import { PokemonIconAnimHandler, PokemonIconAnimMode } from "#ui/pokemon-icon-anim-handler"; import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text"; import { addWindow } from "#ui/ui-theme"; +import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, getLocalizedSpriteKey, randInt } from "#utils/common"; import { toTitleCase } from "#utils/strings"; import i18next from "i18next"; diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index 6929d6f818d..44d83c8874b 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -16,7 +16,6 @@ import { POKERUS_STARTER_COUNT, speciesStarterCosts, } from "#balance/starters"; -import { applyChallenges, checkStarterValidForChallenge } from "#data/challenge"; import { allAbilities, allMoves, allSpecies } from "#data/data-lists"; import { Egg, getEggTierForSpecies } from "#data/egg"; import { GrowthRate, getGrowthRateColor } from "#data/exp"; @@ -60,6 +59,7 @@ import { StarterContainer } from "#ui/starter-container"; import { StatsContainer } from "#ui/stats-container"; import { addBBCodeTextObject, addTextObject } from "#ui/text"; import { addWindow } from "#ui/ui-theme"; +import { applyChallenges, checkStarterValidForChallenge } from "#utils/challenge-utils"; import { BooleanHolder, fixedInt, diff --git a/src/utils/challenge-utils.ts b/src/utils/challenge-utils.ts new file mode 100644 index 00000000000..67f1d2d2be3 --- /dev/null +++ b/src/utils/challenge-utils.ts @@ -0,0 +1,380 @@ +import type { FixedBattleConfig } from "#app/battle"; +import { globalScene } from "#app/global-scene"; +import { pokemonEvolutions } from "#balance/pokemon-evolutions"; +import { pokemonFormChanges } from "#data/pokemon-forms"; +import type { PokemonSpecies } from "#data/pokemon-species"; +import { ChallengeType } from "#enums/challenge-type"; +import type { MoveId } from "#enums/move-id"; +import type { MoveSourceType } from "#enums/move-source-type"; +import type { SpeciesId } from "#enums/species-id"; +import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; +import type { ModifierTypeOption } from "#modifiers/modifier-type"; +import type { DexAttrProps } from "#system/game-data"; +import { BooleanHolder, type NumberHolder } from "./common"; +import { getPokemonSpecies } from "./pokemon-utils"; + +/** + * Apply all challenges that modify starter choice. + * @param challengeType {@link ChallengeType} ChallengeType.STARTER_CHOICE + * @param pokemon {@link PokemonSpecies} The pokemon to check the validity of. + * @param valid {@link BooleanHolder} A BooleanHolder, the value gets set to false if the pokemon isn't allowed. + * @param dexAttr {@link DexAttrProps} The dex attributes of the pokemon. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.STARTER_CHOICE, + pokemon: PokemonSpecies, + valid: BooleanHolder, + dexAttr: DexAttrProps, +): boolean; +/** + * Apply all challenges that modify available total starter points. + * @param challengeType {@link ChallengeType} ChallengeType.STARTER_POINTS + * @param points {@link NumberHolder} The amount of points you have available. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges(challengeType: ChallengeType.STARTER_POINTS, points: NumberHolder): boolean; +/** + * Apply all challenges that modify the cost of a starter. + * @param challengeType {@link ChallengeType} ChallengeType.STARTER_COST + * @param species {@link SpeciesId} The pokemon to change the cost of. + * @param points {@link NumberHolder} The cost of the pokemon. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.STARTER_COST, + species: SpeciesId, + cost: NumberHolder, +): boolean; +/** + * Apply all challenges that modify a starter after selection. + * @param challengeType {@link ChallengeType} ChallengeType.STARTER_MODIFY + * @param pokemon {@link Pokemon} The starter pokemon to modify. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges(challengeType: ChallengeType.STARTER_MODIFY, pokemon: Pokemon): boolean; +/** + * Apply all challenges that what pokemon you can have in battle. + * @param challengeType {@link ChallengeType} ChallengeType.POKEMON_IN_BATTLE + * @param pokemon {@link Pokemon} The pokemon to check the validity of. + * @param valid {@link BooleanHolder} A BooleanHolder, the value gets set to false if the pokemon isn't allowed. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.POKEMON_IN_BATTLE, + pokemon: Pokemon, + valid: BooleanHolder, +): boolean; +/** + * Apply all challenges that modify what fixed battles there are. + * @param challengeType {@link ChallengeType} ChallengeType.FIXED_BATTLES + * @param waveIndex {@link Number} The current wave index. + * @param battleConfig {@link FixedBattleConfig} The battle config to modify. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.FIXED_BATTLES, + waveIndex: number, + battleConfig: FixedBattleConfig, +): boolean; +/** + * Apply all challenges that modify type effectiveness. + * @param challengeType {@linkcode ChallengeType} ChallengeType.TYPE_EFFECTIVENESS + * @param effectiveness {@linkcode NumberHolder} The current effectiveness of the move. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges(challengeType: ChallengeType.TYPE_EFFECTIVENESS, effectiveness: NumberHolder): boolean; +/** + * Apply all challenges that modify what level AI are. + * @param challengeType {@link ChallengeType} ChallengeType.AI_LEVEL + * @param level {@link NumberHolder} The generated level of the pokemon. + * @param levelCap {@link Number} The maximum level cap for the current wave. + * @param isTrainer {@link Boolean} Whether this is a trainer pokemon. + * @param isBoss {@link Boolean} Whether this is a non-trainer boss pokemon. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.AI_LEVEL, + level: NumberHolder, + levelCap: number, + isTrainer: boolean, + isBoss: boolean, +): boolean; +/** + * Apply all challenges that modify how many move slots the AI has. + * @param challengeType {@link ChallengeType} ChallengeType.AI_MOVE_SLOTS + * @param pokemon {@link Pokemon} The pokemon being considered. + * @param moveSlots {@link NumberHolder} The amount of move slots. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.AI_MOVE_SLOTS, + pokemon: Pokemon, + moveSlots: NumberHolder, +): boolean; +/** + * Apply all challenges that modify whether a pokemon has its passive. + * @param challengeType {@link ChallengeType} ChallengeType.PASSIVE_ACCESS + * @param pokemon {@link Pokemon} The pokemon to modify. + * @param hasPassive {@link BooleanHolder} Whether it has its passive. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.PASSIVE_ACCESS, + pokemon: Pokemon, + hasPassive: BooleanHolder, +): boolean; +/** + * Apply all challenges that modify the game modes settings. + * @param challengeType {@link ChallengeType} ChallengeType.GAME_MODE_MODIFY + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges(challengeType: ChallengeType.GAME_MODE_MODIFY): boolean; +/** + * Apply all challenges that modify what level a pokemon can access a move. + * @param challengeType {@link ChallengeType} ChallengeType.MOVE_ACCESS + * @param pokemon {@link Pokemon} What pokemon would learn the move. + * @param moveSource {@link MoveSourceType} What source the pokemon would get the move from. + * @param move {@link MoveId} The move in question. + * @param level {@link NumberHolder} The level threshold for access. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.MOVE_ACCESS, + pokemon: Pokemon, + moveSource: MoveSourceType, + move: MoveId, + level: NumberHolder, +): boolean; +/** + * Apply all challenges that modify what weight a pokemon gives to move generation + * @param challengeType {@link ChallengeType} ChallengeType.MOVE_WEIGHT + * @param pokemon {@link Pokemon} What pokemon would learn the move. + * @param moveSource {@link MoveSourceType} What source the pokemon would get the move from. + * @param move {@link MoveId} The move in question. + * @param weight {@link NumberHolder} The weight of the move. + * @returns True if any challenge was successfully applied. + */ +export function applyChallenges( + challengeType: ChallengeType.MOVE_WEIGHT, + pokemon: Pokemon, + moveSource: MoveSourceType, + move: MoveId, + weight: NumberHolder, +): boolean; + +export function applyChallenges(challengeType: ChallengeType.FLIP_STAT, pokemon: Pokemon, baseStats: number[]): boolean; + +/** + * Apply all challenges that conditionally enable or disable automatic party healing during biome transitions + * @param challengeType - {@linkcode ChallengeType.PARTY_HEAL} + * @returns Whether party healing is enabled or not + */ +export function applyChallenges(challengeType: ChallengeType.PARTY_HEAL): boolean; + +/** + * Apply all challenges that conditionally enable or disable the shop + * @returns Whether the shop is or is not available after a wave + */ +export function applyChallenges(challengeType: ChallengeType.SHOP): boolean; + +/** + * Apply all challenges that validate whether a pokemon can be added to the player's party or not + * @param challengeType - {@linkcode ChallengeType.POKEMON_ADD_TO_PARTY} + * @param pokemon - The pokemon being caught + * @return Whether the pokemon can be added to the party or not + */ +export function applyChallenges(challengeType: ChallengeType.POKEMON_ADD_TO_PARTY, pokemon: EnemyPokemon): boolean; + +/** + * Apply all challenges that validate whether a pokemon is allowed to fuse or not + * @param challengeType - {@linkcode ChallengeType.POKEMON_FUSION} + * @param pokemon - The pokemon being checked + * @returns Whether the selected pokemon is allowed to fuse or not + */ +export function applyChallenges(challengeType: ChallengeType.POKEMON_FUSION, pokemon: PlayerPokemon): boolean; + +/** + * Apply all challenges that validate whether particular moves can or cannot be used + * @param challengeType - {@linkcode ChallengeType.POKEMON_MOVE} + * @param moveId - The move being checked + * @returns Whether the move can be used or not + */ +export function applyChallenges(challengeType: ChallengeType.POKEMON_MOVE, moveId: MoveId): boolean; + +/** + * Apply all challenges that validate whether particular items are or are not sold in the shop + * @param challengeType - {@linkcode ChallengeType.SHOP_ITEM} + * @param shopItem - The item being checked + * @returns Whether the item should be added to the shop or not + */ +export function applyChallenges(challengeType: ChallengeType.SHOP_ITEM, shopItem: ModifierTypeOption | null): boolean; + +/** + * Apply all challenges that validate whether particular items will be given as a reward after a wave + * @param challengeType - {@linkcode ChallengeType.WAVE_REWARD} + * @param reward - The reward being checked + * @returns Whether the reward should be added to the reward options or not + */ +export function applyChallenges(challengeType: ChallengeType.WAVE_REWARD, reward: ModifierTypeOption | null): boolean; + +/** + * Apply all challenges that prevent recovery from fainting + * @param challengeType - {@linkcode ChallengeType.PREVENT_REVIVE} + * @returns Whether fainting is a permanent status or not + */ +export function applyChallenges(challengeType: ChallengeType.PREVENT_REVIVE): boolean; + +export function applyChallenges(challengeType: ChallengeType, ...args: any[]): boolean { + let ret = false; + globalScene.gameMode.challenges.forEach(c => { + if (c.value !== 0) { + switch (challengeType) { + case ChallengeType.STARTER_CHOICE: + ret ||= c.applyStarterChoice(args[0], args[1], args[2]); + break; + case ChallengeType.STARTER_POINTS: + ret ||= c.applyStarterPoints(args[0]); + break; + case ChallengeType.STARTER_COST: + ret ||= c.applyStarterCost(args[0], args[1]); + break; + case ChallengeType.STARTER_MODIFY: + ret ||= c.applyStarterModify(args[0]); + break; + case ChallengeType.POKEMON_IN_BATTLE: + ret ||= c.applyPokemonInBattle(args[0], args[1]); + break; + case ChallengeType.FIXED_BATTLES: + ret ||= c.applyFixedBattle(args[0], args[1]); + break; + case ChallengeType.TYPE_EFFECTIVENESS: + ret ||= c.applyTypeEffectiveness(args[0]); + break; + case ChallengeType.AI_LEVEL: + ret ||= c.applyLevelChange(args[0], args[1], args[2], args[3]); + break; + case ChallengeType.AI_MOVE_SLOTS: + ret ||= c.applyMoveSlot(args[0], args[1]); + break; + case ChallengeType.PASSIVE_ACCESS: + ret ||= c.applyPassiveAccess(args[0], args[1]); + break; + case ChallengeType.GAME_MODE_MODIFY: + ret ||= c.applyGameModeModify(); + break; + case ChallengeType.MOVE_ACCESS: + ret ||= c.applyMoveAccessLevel(args[0], args[1], args[2], args[3]); + break; + case ChallengeType.MOVE_WEIGHT: + ret ||= c.applyMoveWeight(args[0], args[1], args[2], args[3]); + break; + case ChallengeType.FLIP_STAT: + ret ||= c.applyFlipStat(args[0], args[1]); + break; + case ChallengeType.PARTY_HEAL: + ret ||= c.applyPartyHeal(); + break; + case ChallengeType.SHOP: + ret ||= c.applyShop(); + break; + case ChallengeType.POKEMON_ADD_TO_PARTY: + ret ||= c.applyPokemonAddToParty(args[0]); + break; + case ChallengeType.POKEMON_FUSION: + ret ||= c.applyPokemonFusion(args[0]); + break; + case ChallengeType.POKEMON_MOVE: + ret ||= c.applyPokemonMove(args[0]); + break; + case ChallengeType.SHOP_ITEM: + ret ||= c.applyShopItem(args[0]); + break; + case ChallengeType.WAVE_REWARD: + ret ||= c.applyWaveReward(args[0]); + break; + case ChallengeType.PREVENT_REVIVE: + ret ||= c.applyPreventRevive(); + break; + } + } + }); + return ret; +} + +/** + * Apply all challenges to the given starter (and form) to check its validity. + * Differs from {@linkcode checkSpeciesValidForChallenge} which only checks form changes. + * @param species - The {@linkcode PokemonSpecies} to check the validity of. + * @param dexAttr - The {@linkcode DexAttrProps | dex attributes} of the species, including its form index. + * @param soft - If `true`, allow it if it could become valid through evolution or form change. + * @returns `true` if the species is considered valid. + */ +export function checkStarterValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { + if (!soft) { + const isValidForChallenge = new BooleanHolder(true); + applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); + return isValidForChallenge.value; + } + // We check the validity of every evolution and form change, and require that at least one is valid + const speciesToCheck = [species.speciesId]; + while (speciesToCheck.length) { + const checking = speciesToCheck.pop(); + // Linter complains if we don't handle this + if (!checking) { + return false; + } + const checkingSpecies = getPokemonSpecies(checking); + if (checkSpeciesValidForChallenge(checkingSpecies, props, true)) { + return true; + } + if (checking && pokemonEvolutions.hasOwnProperty(checking)) { + pokemonEvolutions[checking].forEach(e => { + // Form check to deal with cases such as Basculin -> Basculegion + // TODO: does this miss anything if checking forms of a stage 2 Pokémon? + if (!e?.preFormKey || e.preFormKey === species.forms[props.formIndex].formKey) { + speciesToCheck.push(e.speciesId); + } + }); + } + } + return false; +} + +/** + * Apply all challenges to the given species (and form) to check its validity. + * Differs from {@linkcode checkStarterValidForChallenge} which also checks evolutions. + * @param species - The {@linkcode PokemonSpecies} to check the validity of. + * @param dexAttr - The {@linkcode DexAttrProps | dex attributes} of the species, including its form index. + * @param soft - If `true`, allow it if it could become valid through a form change. + * @returns `true` if the species is considered valid. + */ +function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { + const isValidForChallenge = new BooleanHolder(true); + applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); + if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) { + return isValidForChallenge.value; + } + // If the form in props is valid, return true before checking other form changes + if (soft && isValidForChallenge.value) { + return true; + } + + const result = pokemonFormChanges[species.speciesId].some(f1 => { + // Exclude form changes that require the mon to be on the field to begin with + if (!("item" in f1.trigger)) { + return false; + } + + return species.forms.some((f2, formIndex) => { + if (f1.formKey === f2.formKey) { + const formProps = { ...props, formIndex }; + const isFormValidForChallenge = new BooleanHolder(true); + applyChallenges(ChallengeType.STARTER_CHOICE, species, isFormValidForChallenge, formProps); + return isFormValidForChallenge.value; + } + return false; + }); + }); + return result; +} From 809a981893100879cc8d6a0938840d86d51de824 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Fri, 1 Aug 2025 12:12:16 -0400 Subject: [PATCH 04/14] Misc. Changes --- src/data/moves/move.ts | 2 +- src/data/moves/pokemon-move.ts | 10 +++++----- src/phases/command-phase.ts | 14 +++++++------- src/phases/victory-phase.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 7d63c4cde3d..908e0faade2 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -124,7 +124,7 @@ export abstract class Move implements Localizable { /** * Check if the move is of the given subclass without requiring `instanceof`. * - * ! Does _not_ work for {@linkcode ChargingAttackMove} and {@linkcode ChargingSelfStatusMove} subclasses. For those, + * ⚠️ Does _not_ work for {@linkcode ChargingAttackMove} and {@linkcode ChargingSelfStatusMove} subclasses. For those, * use {@linkcode isChargingMove} instead. * * @param moveKind - The string name of the move to check against diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 86ad3f8b3f1..3d75a565edf 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -1,9 +1,7 @@ import { allMoves } from "#data/data-lists"; -import { ChallengeType } from "#enums/challenge-type"; import type { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; import type { Move } from "#moves/move"; -import { applyChallenges } from "#utils/challenge-utils"; import { toDmgValue } from "#utils/common"; /** @@ -47,12 +45,14 @@ export class PokemonMove { * @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon. */ isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { + const move = this.getMove(); // TODO: Add Sky Drop's 1 turn stall return ( !(this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)) && - (ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1) && - (!pokemon.isPlayer() || applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId)) && - !this.getMove().name.endsWith(" (N)") + (ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) && + // TODO: Fix apply calls + //(!pokemon.isPlayer() || applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId)) && + !move.name.endsWith(" (N)") ); } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index a589cfe6134..a6865fa1522 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -213,24 +213,24 @@ export class CommandPhase extends FieldPhase { globalScene.ui.setMode(UiMode.MESSAGE); // Set the translation key for why the move cannot be selected - let cannotUseKey: string; + let cannotSelectKey: string; if (user.isMoveRestricted(move.moveId, user)) { - cannotUseKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId); + cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId); } else if (move.getPpRatio() === 0) { - cannotUseKey = "battle:moveNoPP"; + cannotSelectKey = "battle:moveNoPP"; } else if (!applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId)) { - cannotUseKey = "battle:moveCannotUseChallenge"; + cannotSelectKey = "battle:moveCannotUseChallenge"; } else if (move.getName().endsWith(" (N)")) { - cannotUseKey = "battle:moveNotImplemented"; + cannotSelectKey = "battle:moveNotImplemented"; } else { // TODO: Consider a message that signals a being unusable for an unknown reason - cannotUseKey = ""; + cannotSelectKey = ""; } const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator globalScene.ui.showText( - i18next.t(cannotUseKey, { moveName: moveName }), + i18next.t(cannotSelectKey, { moveName: moveName }), null, () => { globalScene.ui.clearText(); diff --git a/src/phases/victory-phase.ts b/src/phases/victory-phase.ts index dce108ae66b..8ec195a99db 100644 --- a/src/phases/victory-phase.ts +++ b/src/phases/victory-phase.ts @@ -65,7 +65,7 @@ export class VictoryPhase extends PokemonPhase { break; } } - if (globalScene.currentBattle.waveIndex % 10 !== 0 || !applyChallenges(ChallengeType.PARTY_HEAL)) { + if (globalScene.currentBattle.waveIndex % 10 || !applyChallenges(ChallengeType.PARTY_HEAL)) { globalScene.phaseManager.pushNew( "SelectModifierPhase", undefined, From b2e59831539e386304153437da282275befbd61e Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Fri, 1 Aug 2025 15:05:20 -0400 Subject: [PATCH 05/14] Transition to `BooleanHolder` --- src/data/challenge.ts | 115 +++++++++++------- src/data/moves/pokemon-move.ts | 18 +-- .../utils/encounter-pokemon-utils.ts | 6 +- src/game-mode.ts | 8 +- src/modifier/modifier-type.ts | 31 +++-- src/phases/attempt-capture-phase.ts | 5 +- src/phases/command-phase.ts | 11 +- src/phases/party-heal-phase.ts | 7 +- src/phases/select-biome-phase.ts | 6 +- src/phases/victory-phase.ts | 5 +- src/ui/modifier-select-ui-handler.ts | 2 +- src/utils/challenge-utils.ts | 77 ++++++++---- 12 files changed, 195 insertions(+), 96 deletions(-) diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 76a78a0c2ce..4a86cb1a6ef 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -344,70 +344,78 @@ export abstract class Challenge { /** * An apply function for PARTY_HEAL. Derived classes should alter this. - * @returns Whether party healing is enabled or not + * @param _status - Whether party healing is enabled or not + * @returns Whether this function did anything */ - applyPartyHeal(): boolean { - return true; + applyPartyHeal(_status: BooleanHolder): boolean { + return false; } /** * An apply function for SHOP. Derived classes should alter this. - * @returns Whether the shop is or is not available after a wave + * @param _status - Whether the shop is or is not available after a wave + * @returns Whether this function did anything */ - applyShop() { - return true; + applyShop(_status: BooleanHolder) { + return false; } /** * An apply function for POKEMON_ADD_TO_PARTY. Derived classes should alter this. * @param _pokemon - The pokemon being caught - * @return Whether the pokemon can be added to the party or not + * @param _status - Whether the pokemon can be added to the party or not + * @return Whether this function did anything */ - applyPokemonAddToParty(_pokemon: EnemyPokemon): boolean { - return true; + applyPokemonAddToParty(_pokemon: EnemyPokemon, _status: BooleanHolder): boolean { + return false; } /** * An apply function for POKEMON_FUSION. Derived classes should alter this. * @param _pokemon - The pokemon being checked - * @returns Whether the selected pokemon is allowed to fuse or not + * @param _status - Whether the selected pokemon is allowed to fuse or not + * @returns Whether this function did anything */ - applyPokemonFusion(_pokemon: PlayerPokemon): boolean { + applyPokemonFusion(_pokemon: PlayerPokemon, _status: BooleanHolder): boolean { return false; } /** * An apply function for POKEMON_MOVE. Derived classes should alter this. * @param _moveId - The move being checked - * @returns Whether the move can be used or not + * @param _status - Whether the move can be used or not + * @returns Whether this function did anything */ - applyPokemonMove(_moveId: MoveId): boolean { - return true; + applyPokemonMove(_moveId: MoveId, _status: BooleanHolder): boolean { + return false; } /** * An apply function for SHOP_ITEM. Derived classes should alter this. * @param _shopItem - The item being checked - * @returns Whether the item should be added to the shop or not + * @param _status - Whether the item should be added to the shop or not + * @returns Whether this function did anything */ - applyShopItem(_shopItem: ModifierTypeOption | null): boolean { - return true; + applyShopItem(_shopItem: ModifierTypeOption | null, _status: BooleanHolder): boolean { + return false; } /** * An apply function for WAVE_REWARD. Derived classes should alter this. * @param _reward - The reward being checked - * @returns Whether the reward should be added to the reward options or not + * @param _status - Whether the reward should be added to the reward options or not + * @returns Whether this function did anything */ - applyWaveReward(_reward: ModifierTypeOption | null): boolean { - return true; + applyWaveReward(_reward: ModifierTypeOption | null, _status: BooleanHolder): boolean { + return false; } /** * An apply function for PREVENT_REVIVE. Derived classes should alter this. - * @returns Whether fainting is a permanent status or not + * @param _status - Whether fainting is a permanent status or not + * @returns Whether this function did anything */ - applyPreventRevive(): boolean { + applyPreventRevive(_status: BooleanHolder): boolean { return false; } } @@ -933,19 +941,24 @@ export class LowerStarterPointsChallenge extends Challenge { * Implements a No Support challenge */ export class NoSupportChallenge extends Challenge { - // 1 is no_heal - // 2 is no_shop - // 3 is both constructor() { super(Challenges.NO_SUPPORT, 3); } - override applyPartyHeal(): boolean { - return this.value === 2; + override applyPartyHeal(status: BooleanHolder): boolean { + if (status.value) { + status.value = this.value === 2; + return true; + } + return false; } - override applyShop(): boolean { - return this.value === 1; + override applyShop(status: BooleanHolder): boolean { + if (status.value) { + status.value = this.value === 1; + return true; + } + return false; } static override loadChallenge(source: NoSupportChallenge | any): NoSupportChallenge { @@ -964,8 +977,12 @@ export class LimitedCatchChallenge extends Challenge { super(Challenges.LIMITED_CATCH, 1); } - override applyPokemonAddToParty(pokemon: EnemyPokemon): boolean { - return pokemon.metWave % 10 === 1; + override applyPokemonAddToParty(pokemon: EnemyPokemon, status: BooleanHolder): boolean { + if (status.value) { + status.value = pokemon.metWave % 10 === 1; + return true; + } + return false; } static override loadChallenge(source: LimitedCatchChallenge | any): LimitedCatchChallenge { @@ -984,24 +1001,40 @@ export class PermanentFaintChallenge extends Challenge { super(Challenges.PERMANENT_FAINT, 1); } - override applyPokemonFusion(pokemon: PlayerPokemon): boolean { - return !pokemon.isFainted(); + override applyPokemonFusion(pokemon: PlayerPokemon, status: BooleanHolder): boolean { + if (!status.value) { + status.value = pokemon.isFainted(); + return true; + } + return false; } - override applyShopItem(shopItem: ModifierTypeOption | null): boolean { - return shopItem?.type.group !== "revive"; + override applyShopItem(shopItem: ModifierTypeOption | null, status: BooleanHolder): boolean { + if (status.value) { + status.value = shopItem?.type.group !== "revive"; + return true; + } + return false; } - override applyWaveReward(reward: ModifierTypeOption | null): boolean { - return this.applyShopItem(reward); + override applyWaveReward(reward: ModifierTypeOption | null, status: BooleanHolder): boolean { + return this.applyShopItem(reward, status); } - override applyPokemonMove(moveId: MoveId) { - return moveId !== MoveId.REVIVAL_BLESSING; + override applyPokemonMove(moveId: MoveId, status: BooleanHolder) { + if (status.value) { + status.value = moveId !== MoveId.REVIVAL_BLESSING; + return true; + } + return false; } - override applyPreventRevive(): boolean { - return true; + override applyPreventRevive(status: BooleanHolder): boolean { + if (!status.value) { + status.value = true; + return true; + } + return false; } static override loadChallenge(source: PermanentFaintChallenge | any): PermanentFaintChallenge { diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 3d75a565edf..9678889ea51 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -1,8 +1,10 @@ import { allMoves } from "#data/data-lists"; +import { ChallengeType } from "#enums/challenge-type"; import type { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; import type { Move } from "#moves/move"; -import { toDmgValue } from "#utils/common"; +import { applyChallenges } from "#utils/challenge-utils"; +import { BooleanHolder, toDmgValue } from "#utils/common"; /** * Wrapper class for the {@linkcode Move} class for Pokemon to interact with. @@ -47,13 +49,15 @@ export class PokemonMove { isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { const move = this.getMove(); // TODO: Add Sky Drop's 1 turn stall - return ( - !(this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)) && - (ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) && - // TODO: Fix apply calls - //(!pokemon.isPlayer() || applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId)) && - !move.name.endsWith(" (N)") + const usability = new BooleanHolder( + !move.name.endsWith(" (N)") && + (ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) && + !(this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)), ); + if (usability.value && pokemon.isPlayer()) { + applyChallenges(ChallengeType.POKEMON_MOVE, move.id, usability); + } + return usability.value; } getMove(): Move { diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index 07513da2b37..ff79b9fc312 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -35,7 +35,7 @@ import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; import { SummaryUiMode } from "#ui/summary-ui-handler"; import { applyChallenges } from "#utils/challenge-utils"; -import { isNullOrUndefined, randSeedInt } from "#utils/common"; +import { BooleanHolder, isNullOrUndefined, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -709,7 +709,9 @@ export async function catchPokemon( }; Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { // TODO: Address ME edge cases (Safari Zone, etc.) - if (!applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon)) { + const addStatus = new BooleanHolder(true); + applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus); + if (!addStatus.value) { removePokemon(); end(); return; diff --git a/src/game-mode.ts b/src/game-mode.ts index 415b16f83ed..645a6b8c449 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -14,7 +14,7 @@ import { SpeciesId } from "#enums/species-id"; import type { Arena } from "#field/arena"; import { classicFixedBattles, type FixedBattleConfigs } from "#trainers/fixed-battle-configs"; import { applyChallenges } from "#utils/challenge-utils"; -import { isNullOrUndefined, randSeedInt, randSeedItem } from "#utils/common"; +import { BooleanHolder, isNullOrUndefined, randSeedInt, randSeedItem } from "#utils/common"; import i18next from "i18next"; interface GameModeConfig { @@ -315,8 +315,10 @@ export class GameMode implements GameModeConfig { * Checks if the game mode has the shop enabled or not * @returns Whether the shop is available or not */ - getShopAvailability(): boolean { - return !this.hasNoShop && this.modeId === GameModes.CHALLENGE && applyChallenges(ChallengeType.SHOP); + getShopStatus(): boolean { + const status = new BooleanHolder(!this.hasNoShop); + applyChallenges(ChallengeType.SHOP, status); + return status.value; } getClearScoreBonus(): number { diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index f0692b98991..9ec9ee0ad48 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -118,7 +118,15 @@ import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#ui/party-ui- import { PartyUiHandler } from "#ui/party-ui-handler"; import { getModifierTierTextTint } from "#ui/text"; import { applyChallenges } from "#utils/challenge-utils"; -import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt, randSeedItem } from "#utils/common"; +import { + BooleanHolder, + formatMoney, + isNullOrUndefined, + NumberHolder, + padInt, + randSeedInt, + randSeedItem, +} from "#utils/common"; import { getEnumKeys, getEnumValues } from "#utils/enums"; import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils"; import i18next from "i18next"; @@ -535,7 +543,9 @@ export class PokemonReviveModifierType extends PokemonHpRestoreModifierType { ); this.selectFilter = (pokemon: PlayerPokemon) => { - if (pokemon.hp || applyChallenges(ChallengeType.PREVENT_REVIVE)) { + const selectStatus = new BooleanHolder(pokemon.hp !== 0); + applyChallenges(ChallengeType.PREVENT_REVIVE, selectStatus); + if (selectStatus.value) { return PartyUiHandler.NoEffectMessage; } return null; @@ -1265,7 +1275,9 @@ export class FusePokemonModifierType extends PokemonModifierType { iconImage, (_type, args) => new FusePokemonModifier(this, (args[0] as PlayerPokemon).id, (args[1] as PlayerPokemon).id), (pokemon: PlayerPokemon) => { - if (pokemon.isFusion() || !applyChallenges(ChallengeType.POKEMON_FUSION, pokemon)) { + const selectStatus = new BooleanHolder(pokemon.isFusion()); + applyChallenges(ChallengeType.POKEMON_FUSION, pokemon, selectStatus); + if (selectStatus.value) { return PartyUiHandler.NoEffectMessage; } return null; @@ -2577,14 +2589,15 @@ function getModifierTypeOptionWithRetry( ): ModifierTypeOption { allowLuckUpgrades = allowLuckUpgrades ?? true; let candidate = getNewModifierTypeOption(party, ModifierPoolType.PLAYER, tier, undefined, 0, allowLuckUpgrades); - let candidateValidity = applyChallenges(ChallengeType.WAVE_REWARD, candidate); + const candidateValidity = new BooleanHolder(true); + applyChallenges(ChallengeType.WAVE_REWARD, candidate, candidateValidity); let r = 0; while ( (existingOptions.length && ++r < retryCount && existingOptions.filter(o => o.type.name === candidate?.type.name || o.type.group === candidate?.type.group) .length) || - !candidateValidity + !candidateValidity.value ) { candidate = getNewModifierTypeOption( party, @@ -2594,7 +2607,7 @@ function getModifierTypeOptionWithRetry( 0, allowLuckUpgrades, ); - candidateValidity = applyChallenges(ChallengeType.WAVE_REWARD, candidate); + applyChallenges(ChallengeType.WAVE_REWARD, candidate, candidateValidity); } return candidate!; } @@ -2659,7 +2672,11 @@ export function getPlayerShopModifierTypeOptionsForWave(waveIndex: number, baseC return options .slice(0, Math.ceil(Math.max(waveIndex + 10, 0) / 30)) .flat() - .filter(shopItem => applyChallenges(ChallengeType.SHOP_ITEM, shopItem)); + .filter(shopItem => { + const status = new BooleanHolder(true); + applyChallenges(ChallengeType.SHOP_ITEM, shopItem, status); + return status.value; + }); } export function getEnemyBuffModifierForWave( diff --git a/src/phases/attempt-capture-phase.ts b/src/phases/attempt-capture-phase.ts index 6283a18fc28..aea39cff294 100644 --- a/src/phases/attempt-capture-phase.ts +++ b/src/phases/attempt-capture-phase.ts @@ -25,6 +25,7 @@ import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; import { SummaryUiMode } from "#ui/summary-ui-handler"; import { applyChallenges } from "#utils/challenge-utils"; +import { BooleanHolder } from "#utils/common"; import i18next from "i18next"; // TODO: Refactor and split up to allow for overriding capture chance @@ -289,7 +290,9 @@ export class AttemptCapturePhase extends PokemonPhase { }); }; Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { - if (!applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon)) { + const addStatus = new BooleanHolder(true); + applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus); + if (!addStatus.value) { removePokemon(); end(); return; diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index a6865fa1522..c7eb466157d 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -23,6 +23,7 @@ import { getMoveTargets } from "#moves/move-utils"; import { FieldPhase } from "#phases/field-phase"; import type { TurnMove } from "#types/turn-move"; import { applyChallenges } from "#utils/challenge-utils"; +import { BooleanHolder } from "#utils/common"; import i18next from "i18next"; export class CommandPhase extends FieldPhase { @@ -214,14 +215,16 @@ export class CommandPhase extends FieldPhase { // Set the translation key for why the move cannot be selected let cannotSelectKey: string; - if (user.isMoveRestricted(move.moveId, user)) { - cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId); + const moveStatus = new BooleanHolder(true); + applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId, moveStatus); + if (!moveStatus.value) { + cannotSelectKey = "battle:moveCannotUseChallenge"; } else if (move.getPpRatio() === 0) { cannotSelectKey = "battle:moveNoPP"; - } else if (!applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId)) { - cannotSelectKey = "battle:moveCannotUseChallenge"; } else if (move.getName().endsWith(" (N)")) { cannotSelectKey = "battle:moveNotImplemented"; + } else if (user.isMoveRestricted(move.moveId, user)) { + cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId); } else { // TODO: Consider a message that signals a being unusable for an unknown reason cannotSelectKey = ""; diff --git a/src/phases/party-heal-phase.ts b/src/phases/party-heal-phase.ts index d16e732a87f..9596be86c09 100644 --- a/src/phases/party-heal-phase.ts +++ b/src/phases/party-heal-phase.ts @@ -2,7 +2,7 @@ import { globalScene } from "#app/global-scene"; import { ChallengeType } from "#enums/challenge-type"; import { BattlePhase } from "#phases/battle-phase"; import { applyChallenges } from "#utils/challenge-utils"; -import { fixedInt } from "#utils/common"; +import { BooleanHolder, fixedInt } from "#utils/common"; export class PartyHealPhase extends BattlePhase { public readonly phaseName = "PartyHealPhase"; @@ -22,9 +22,10 @@ export class PartyHealPhase extends BattlePhase { globalScene.fadeOutBgm(1000, false); } globalScene.ui.fadeOut(1000).then(() => { - const preventRevive = applyChallenges(ChallengeType.PREVENT_REVIVE); + const preventRevive = new BooleanHolder(false); + applyChallenges(ChallengeType.PREVENT_REVIVE, preventRevive); for (const pokemon of globalScene.getPlayerParty()) { - if (!(pokemon.isFainted() && preventRevive)) { + if (!(pokemon.isFainted() && preventRevive.value)) { pokemon.hp = pokemon.getMaxHp(); pokemon.resetStatus(true, false, false, true); for (const move of pokemon.moveset) { diff --git a/src/phases/select-biome-phase.ts b/src/phases/select-biome-phase.ts index be76ef721f5..fd6e69e04a7 100644 --- a/src/phases/select-biome-phase.ts +++ b/src/phases/select-biome-phase.ts @@ -7,7 +7,7 @@ import { MapModifier, MoneyInterestModifier } from "#modifiers/modifier"; import { BattlePhase } from "#phases/battle-phase"; import type { OptionSelectItem } from "#ui/abstact-option-select-ui-handler"; import { applyChallenges } from "#utils/challenge-utils"; -import { randSeedInt } from "#utils/common"; +import { BooleanHolder, randSeedInt } from "#utils/common"; export class SelectBiomePhase extends BattlePhase { public readonly phaseName = "SelectBiomePhase"; @@ -22,7 +22,9 @@ export class SelectBiomePhase extends BattlePhase { const setNextBiome = (nextBiome: BiomeId) => { if (nextWaveIndex % 10 === 1) { globalScene.applyModifiers(MoneyInterestModifier, true); - if (applyChallenges(ChallengeType.PARTY_HEAL)) { + const healStatus = new BooleanHolder(false); + applyChallenges(ChallengeType.PARTY_HEAL, healStatus); + if (healStatus.value) { globalScene.phaseManager.unshiftNew("PartyHealPhase", false); } } diff --git a/src/phases/victory-phase.ts b/src/phases/victory-phase.ts index 8ec195a99db..c0f4a32d7e1 100644 --- a/src/phases/victory-phase.ts +++ b/src/phases/victory-phase.ts @@ -9,6 +9,7 @@ import type { CustomModifierSettings } from "#modifiers/modifier-type"; import { handleMysteryEncounterVictory } from "#mystery-encounters/encounter-phase-utils"; import { PokemonPhase } from "#phases/pokemon-phase"; import { applyChallenges } from "#utils/challenge-utils"; +import { BooleanHolder } from "#utils/common"; export class VictoryPhase extends PokemonPhase { public readonly phaseName = "VictoryPhase"; @@ -65,7 +66,9 @@ export class VictoryPhase extends PokemonPhase { break; } } - if (globalScene.currentBattle.waveIndex % 10 || !applyChallenges(ChallengeType.PARTY_HEAL)) { + const healStatus = new BooleanHolder(globalScene.currentBattle.waveIndex % 10 === 0); + applyChallenges(ChallengeType.PARTY_HEAL, healStatus); + if (!healStatus.value) { globalScene.phaseManager.pushNew( "SelectModifierPhase", undefined, diff --git a/src/ui/modifier-select-ui-handler.ts b/src/ui/modifier-select-ui-handler.ts index e8d20323c74..d90b3310fb0 100644 --- a/src/ui/modifier-select-ui-handler.ts +++ b/src/ui/modifier-select-ui-handler.ts @@ -209,7 +209,7 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler { this.updateRerollCostText(); const typeOptions = args[1] as ModifierTypeOption[]; - const hasShop = globalScene.gameMode.getShopAvailability(); + const hasShop = globalScene.gameMode.getShopStatus(); const baseShopCost = new NumberHolder(globalScene.getWaveMoneyAmount(1)); globalScene.applyModifier(HealShopCostModifier, true, baseShopCost); const shopTypeOptions = hasShop diff --git a/src/utils/challenge-utils.ts b/src/utils/challenge-utils.ts index 67f1d2d2be3..c38bb887a00 100644 --- a/src/utils/challenge-utils.ts +++ b/src/utils/challenge-utils.ts @@ -168,62 +168,91 @@ export function applyChallenges(challengeType: ChallengeType.FLIP_STAT, pokemon: /** * Apply all challenges that conditionally enable or disable automatic party healing during biome transitions * @param challengeType - {@linkcode ChallengeType.PARTY_HEAL} - * @returns Whether party healing is enabled or not + * @param status - Whether party healing is enabled or not + * @returns `true` if any challenge was successfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.PARTY_HEAL): boolean; +export function applyChallenges(challengeType: ChallengeType.PARTY_HEAL, status: BooleanHolder): boolean; /** * Apply all challenges that conditionally enable or disable the shop - * @returns Whether the shop is or is not available after a wave + * @param challengeType - {@linkcode ChallengeType.SHOP} + * @param status - Whether party healing is enabled or not + * @returns `true` if any challenge was successfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.SHOP): boolean; +export function applyChallenges(challengeType: ChallengeType.SHOP, status: BooleanHolder): boolean; /** * Apply all challenges that validate whether a pokemon can be added to the player's party or not * @param challengeType - {@linkcode ChallengeType.POKEMON_ADD_TO_PARTY} * @param pokemon - The pokemon being caught - * @return Whether the pokemon can be added to the party or not + * @param status - Whether the pokemon can be added to the party or not + * @return `true` if any challenge was sucessfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.POKEMON_ADD_TO_PARTY, pokemon: EnemyPokemon): boolean; +export function applyChallenges( + challengeType: ChallengeType.POKEMON_ADD_TO_PARTY, + pokemon: EnemyPokemon, + status: BooleanHolder, +): boolean; /** * Apply all challenges that validate whether a pokemon is allowed to fuse or not * @param challengeType - {@linkcode ChallengeType.POKEMON_FUSION} * @param pokemon - The pokemon being checked - * @returns Whether the selected pokemon is allowed to fuse or not + * @param status - Whether the selected pokemon is allowed to fuse or not + * @return `true` if any challenge was sucessfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.POKEMON_FUSION, pokemon: PlayerPokemon): boolean; +export function applyChallenges( + challengeType: ChallengeType.POKEMON_FUSION, + pokemon: PlayerPokemon, + status: BooleanHolder, +): boolean; /** * Apply all challenges that validate whether particular moves can or cannot be used * @param challengeType - {@linkcode ChallengeType.POKEMON_MOVE} * @param moveId - The move being checked - * @returns Whether the move can be used or not + * @param status - Whether the move can be used or not + * @return `true` if any challenge was sucessfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.POKEMON_MOVE, moveId: MoveId): boolean; +export function applyChallenges( + challengeType: ChallengeType.POKEMON_MOVE, + moveId: MoveId, + status: BooleanHolder, +): boolean; /** * Apply all challenges that validate whether particular items are or are not sold in the shop * @param challengeType - {@linkcode ChallengeType.SHOP_ITEM} * @param shopItem - The item being checked - * @returns Whether the item should be added to the shop or not + * @param status - Whether the item should be added to the shop or not + * @return `true` if any challenge was sucessfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.SHOP_ITEM, shopItem: ModifierTypeOption | null): boolean; +export function applyChallenges( + challengeType: ChallengeType.SHOP_ITEM, + shopItem: ModifierTypeOption | null, + status: BooleanHolder, +): boolean; /** * Apply all challenges that validate whether particular items will be given as a reward after a wave * @param challengeType - {@linkcode ChallengeType.WAVE_REWARD} * @param reward - The reward being checked - * @returns Whether the reward should be added to the reward options or not + * @param status - Whether the reward should be added to the reward options or not + * @return `true` if any challenge was sucessfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.WAVE_REWARD, reward: ModifierTypeOption | null): boolean; +export function applyChallenges( + challengeType: ChallengeType.WAVE_REWARD, + reward: ModifierTypeOption | null, + status: BooleanHolder, +): boolean; /** * Apply all challenges that prevent recovery from fainting * @param challengeType - {@linkcode ChallengeType.PREVENT_REVIVE} - * @returns Whether fainting is a permanent status or not + * @param status - Whether fainting is a permanent status or not + * @return `true` if any challenge was sucessfully applied, `false` otherwise */ -export function applyChallenges(challengeType: ChallengeType.PREVENT_REVIVE): boolean; +export function applyChallenges(challengeType: ChallengeType.PREVENT_REVIVE, status: BooleanHolder): boolean; export function applyChallenges(challengeType: ChallengeType, ...args: any[]): boolean { let ret = false; @@ -273,28 +302,28 @@ export function applyChallenges(challengeType: ChallengeType, ...args: any[]): b ret ||= c.applyFlipStat(args[0], args[1]); break; case ChallengeType.PARTY_HEAL: - ret ||= c.applyPartyHeal(); + ret ||= c.applyPartyHeal(args[0]); break; case ChallengeType.SHOP: - ret ||= c.applyShop(); + ret ||= c.applyShop(args[0]); break; case ChallengeType.POKEMON_ADD_TO_PARTY: - ret ||= c.applyPokemonAddToParty(args[0]); + ret ||= c.applyPokemonAddToParty(args[0], args[1]); break; case ChallengeType.POKEMON_FUSION: - ret ||= c.applyPokemonFusion(args[0]); + ret ||= c.applyPokemonFusion(args[0], args[1]); break; case ChallengeType.POKEMON_MOVE: - ret ||= c.applyPokemonMove(args[0]); + ret ||= c.applyPokemonMove(args[0], args[1]); break; case ChallengeType.SHOP_ITEM: - ret ||= c.applyShopItem(args[0]); + ret ||= c.applyShopItem(args[0], args[1]); break; case ChallengeType.WAVE_REWARD: - ret ||= c.applyWaveReward(args[0]); + ret ||= c.applyWaveReward(args[0], args[1]); break; case ChallengeType.PREVENT_REVIVE: - ret ||= c.applyPreventRevive(); + ret ||= c.applyPreventRevive(args[0]); break; } } From 2b28a9dcecd0755e36f9be57409a7c8ceb446f50 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Fri, 1 Aug 2025 17:10:40 -0400 Subject: [PATCH 06/14] Add "Nuzlocke" Achievement --- src/system/achv.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/system/achv.ts b/src/system/achv.ts index 69eade02e35..195d458c59d 100644 --- a/src/system/achv.ts +++ b/src/system/achv.ts @@ -5,6 +5,7 @@ import { FlipStatChallenge, FreshStartChallenge, InverseBattleChallenge, + LimitedCatchChallenge, SingleGenerationChallenge, SingleTypeChallenge, } from "#data/challenge"; @@ -922,6 +923,18 @@ export const achvs = { c.value > 0 && globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0), ).setSecret(), + // TODO: Decide on icon, description, etc. + NUZLOCKE: new ChallengeAchv( + "NUZLOCKE", + "", + "NUZLOCKE.description", + "leaf_stone", + 100, + c => + c instanceof LimitedCatchChallenge && + c.value > 0 && + globalScene.gameMode.challenges.some(c => c.id === Challenges.PERMANENT_FAINT && c.value > 0), + ), BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(), }; From 8673e10892615a3acecff88282ec45bd0b5cb023 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Fri, 1 Aug 2025 17:51:02 -0400 Subject: [PATCH 07/14] Change Challenge Order --- src/data/challenge.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 4a86cb1a6ef..6d74350ed72 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -1080,13 +1080,13 @@ export const allChallenges: Challenge[] = []; export function initChallenges() { allChallenges.push( - new SingleGenerationChallenge(), - new SingleTypeChallenge(), new FreshStartChallenge(), - new InverseBattleChallenge(), - new FlipStatChallenge(), + new PermanentFaintChallenge(), new LimitedCatchChallenge(), new NoSupportChallenge(), - new PermanentFaintChallenge(), + new SingleGenerationChallenge(), + new SingleTypeChallenge(), + new InverseBattleChallenge(), + new FlipStatChallenge(), ); } From 3df44ea8993b677d03adcaf7a8ba595391d734c2 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Sat, 2 Aug 2025 23:18:25 -0400 Subject: [PATCH 08/14] Adjust Nuzlocke Achievement to Include Fresh Start --- src/system/achv.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/system/achv.ts b/src/system/achv.ts index 195d458c59d..62ee389a2ad 100644 --- a/src/system/achv.ts +++ b/src/system/achv.ts @@ -933,7 +933,8 @@ export const achvs = { c => c instanceof LimitedCatchChallenge && c.value > 0 && - globalScene.gameMode.challenges.some(c => c.id === Challenges.PERMANENT_FAINT && c.value > 0), + globalScene.gameMode.challenges.some(c => c.id === Challenges.PERMANENT_FAINT && c.value > 0) && + globalScene.gameMode.challenges.some(c => c.id === Challenges.FRESH_START && c.value > 0), ), BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(), }; From 2a7da4681f471ef33c13f66fd0589019b6b17e7f Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Sun, 3 Aug 2025 22:47:47 -0400 Subject: [PATCH 09/14] Fix Infinite Reward Reroll Bug --- src/data/challenge.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 75a1e0d5438..1be0d243eb3 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -1010,11 +1010,8 @@ export class PermanentFaintChallenge extends Challenge { } override applyShopItem(shopItem: ModifierTypeOption | null, status: BooleanHolder): boolean { - if (status.value) { - status.value = shopItem?.type.group !== "revive"; - return true; - } - return false; + status.value = shopItem?.type.group !== "revive"; + return true; } override applyWaveReward(reward: ModifierTypeOption | null, status: BooleanHolder): boolean { From ac8020984850ba449aef8fd42983cb6f8870fe78 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Sun, 3 Aug 2025 23:57:40 -0400 Subject: [PATCH 10/14] Fix Party Heal --- src/phases/select-biome-phase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/phases/select-biome-phase.ts b/src/phases/select-biome-phase.ts index fd6e69e04a7..f4fd11636fc 100644 --- a/src/phases/select-biome-phase.ts +++ b/src/phases/select-biome-phase.ts @@ -22,7 +22,7 @@ export class SelectBiomePhase extends BattlePhase { const setNextBiome = (nextBiome: BiomeId) => { if (nextWaveIndex % 10 === 1) { globalScene.applyModifiers(MoneyInterestModifier, true); - const healStatus = new BooleanHolder(false); + const healStatus = new BooleanHolder(true); applyChallenges(ChallengeType.PARTY_HEAL, healStatus); if (healStatus.value) { globalScene.phaseManager.unshiftNew("PartyHealPhase", false); From 968b4d410f7ea60a102e5efc6c75323c26a4a54e Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Mon, 4 Aug 2025 12:57:40 -0400 Subject: [PATCH 11/14] Minor Change --- src/data/moves/pokemon-move.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 9678889ea51..f179dc360fc 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -54,7 +54,7 @@ export class PokemonMove { (ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) && !(this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)), ); - if (usability.value && pokemon.isPlayer()) { + if (pokemon.isPlayer()) { applyChallenges(ChallengeType.POKEMON_MOVE, move.id, usability); } return usability.value; From b25dbe773210eea3156af81eb7fdb77b38082e49 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Mon, 4 Aug 2025 13:02:23 -0400 Subject: [PATCH 12/14] Adjust TODOs --- src/data/mystery-encounters/utils/encounter-pokemon-utils.ts | 1 - src/system/achv.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index ff79b9fc312..7617fb5a89e 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -708,7 +708,6 @@ export async function catchPokemon( }); }; Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { - // TODO: Address ME edge cases (Safari Zone, etc.) const addStatus = new BooleanHolder(true); applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus); if (!addStatus.value) { diff --git a/src/system/achv.ts b/src/system/achv.ts index 62ee389a2ad..26c0ebfb0e9 100644 --- a/src/system/achv.ts +++ b/src/system/achv.ts @@ -923,7 +923,7 @@ export const achvs = { c.value > 0 && globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0), ).setSecret(), - // TODO: Decide on icon, description, etc. + // TODO: Decide on icon NUZLOCKE: new ChallengeAchv( "NUZLOCKE", "", From 02c0347411508820eff69e448662f568cd623752 Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Tue, 5 Aug 2025 02:30:18 -0400 Subject: [PATCH 13/14] Add Unit Tests --- test/challenges/limited-catch.test.ts | 56 ++++++++ test/challenges/no-support.test.ts | 99 ++++++++++++++ test/challenges/permanent-faint.test.ts | 165 ++++++++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 test/challenges/limited-catch.test.ts create mode 100644 test/challenges/no-support.test.ts create mode 100644 test/challenges/permanent-faint.test.ts diff --git a/test/challenges/limited-catch.test.ts b/test/challenges/limited-catch.test.ts new file mode 100644 index 00000000000..87ac5351e7c --- /dev/null +++ b/test/challenges/limited-catch.test.ts @@ -0,0 +1,56 @@ +import { AbilityId } from "#enums/ability-id"; +import { Challenges } from "#enums/challenges"; +import { MoveId } from "#enums/move-id"; +import { PokeballType } from "#enums/pokeball"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Challenges - Limited Catch", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + game.challengeMode.addChallenge(Challenges.LIMITED_CATCH, 1, 1); + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.VOLTORB) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingModifier([{ name: "MASTER_BALL", count: 1 }]) + .moveset(MoveId.RAZOR_LEAF); + }); + + it("allows Pokémon to be caught on X1 waves", async () => { + game.override.startingWave(31); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); + + game.doThrowPokeball(PokeballType.MASTER_BALL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerParty().length).toBe(2); + }); + + it("prevents Pokémon from being caught on waves that aren't X1 waves", async () => { + game.override.startingWave(53); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); + + game.doThrowPokeball(PokeballType.MASTER_BALL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerParty().length).toBe(1); + }); +}); diff --git a/test/challenges/no-support.test.ts b/test/challenges/no-support.test.ts new file mode 100644 index 00000000000..aa1254f1968 --- /dev/null +++ b/test/challenges/no-support.test.ts @@ -0,0 +1,99 @@ +import { AbilityId } from "#enums/ability-id"; +import { Challenges } from "#enums/challenges"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { UiMode } from "#enums/ui-mode"; +import { GameManager } from "#test/test-utils/game-manager"; +import { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Challenges - No Support", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.VOLTORB) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .moveset(MoveId.RAZOR_LEAF); + }); + + it('disables the shop in "No Shop"', async () => { + game.override.startingWave(181); + game.challengeMode.addChallenge(Challenges.NO_SUPPORT, 2, 1); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + expect(game.scene.ui.getMode()).to.equal(UiMode.MODIFIER_SELECT); + const modifierSelectHandler = game.scene.ui.handlers.find( + h => h instanceof ModifierSelectUiHandler, + ) as ModifierSelectUiHandler; + expect(modifierSelectHandler.shopOptionsRows.length).toBe(0); + }); + + it('disables the automatic party heal in "No Heal"', async () => { + game.override.startingWave(10); + game.challengeMode.addChallenge(Challenges.NO_SUPPORT, 1, 1); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); + + const playerPokemon = game.scene.getPlayerPokemon(); + playerPokemon!.damageAndUpdate(playerPokemon!.hp / 2); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + game.doSelectModifier(); + + // Next wave + await game.phaseInterceptor.to("TurnInitPhase"); + expect(playerPokemon!.isFullHp()).toBe(false); + }); + + it('disables the automatic party heal and the shop in "Both"', async () => { + game.override.startingWave(10); + game.challengeMode.addChallenge(Challenges.NO_SUPPORT, 3, 1); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); + + const playerPokemon = game.scene.getPlayerPokemon(); + playerPokemon!.damageAndUpdate(playerPokemon!.hp / 2); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + game.doSelectModifier(); + + // Next wave + await game.phaseInterceptor.to("TurnInitPhase"); + expect(playerPokemon!.isFullHp()).toBe(false); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + expect(game.scene.ui.getMode()).to.equal(UiMode.MODIFIER_SELECT); + const modifierSelectHandler = game.scene.ui.handlers.find( + h => h instanceof ModifierSelectUiHandler, + ) as ModifierSelectUiHandler; + expect(modifierSelectHandler.shopOptionsRows.length).toBe(0); + }); +}); diff --git a/test/challenges/permanent-faint.test.ts b/test/challenges/permanent-faint.test.ts new file mode 100644 index 00000000000..d7222097bdf --- /dev/null +++ b/test/challenges/permanent-faint.test.ts @@ -0,0 +1,165 @@ +import { Status } from "#data/status-effect"; +import { AbilityId } from "#enums/ability-id"; +import { Button } from "#enums/buttons"; +import { Challenges } from "#enums/challenges"; +import { MoveId } from "#enums/move-id"; +import { ShopCursorTarget } from "#enums/shop-cursor-target"; +import { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; +import { UiMode } from "#enums/ui-mode"; +import { GameManager } from "#test/test-utils/game-manager"; +import { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Challenges - Permanent Faint", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + game.challengeMode.addChallenge(Challenges.PERMANENT_FAINT, 1, 1); + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.VOLTORB) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .moveset(MoveId.RAZOR_LEAF); + }); + + it("disables REVIVAL_BLESSING for the player only", async () => { + game.override.enemyMoveset(MoveId.REVIVAL_BLESSING).moveset(MoveId.REVIVAL_BLESSING); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); + + game.move.select(MoveId.REVIVAL_BLESSING); + + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.field.getEnemyPokemon()).toHaveUsedMove(MoveId.REVIVAL_BLESSING); + expect(game.field.getPlayerPokemon()).toHaveUsedMove(MoveId.STRUGGLE); + }); + + it("prevents REVIVE items in shop and in wave rewards", async () => { + game.override.startingWave(181).startingLevel(200); + await game.challengeMode.startBattle(); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + expect(game.scene.ui.getMode()).to.equal(UiMode.MODIFIER_SELECT); + const modifierSelectHandler = game.scene.ui.handlers.find( + h => h instanceof ModifierSelectUiHandler, + ) as ModifierSelectUiHandler; + expect( + modifierSelectHandler.options.find(reward => reward.modifierTypeOption.type.group === "revive"), + ).toBeUndefined(); + expect( + modifierSelectHandler.shopOptionsRows.find(row => + row.find(item => item.modifierTypeOption.type.group === "revive"), + ), + ).toBeUndefined(); + }); + + it("prevents the automatic party heal from reviving fainted Pokémon", async () => { + game.override.startingWave(10).startingLevel(200); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF, SpeciesId.WHISMUR]); + + const faintedPokemon = game.scene.getPlayerParty()[1]; + faintedPokemon.hp = 0; + faintedPokemon.status = new Status(StatusEffect.FAINT); + expect(faintedPokemon.isFainted()).toBe(true); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.toNextWave(); + + expect(faintedPokemon.isFainted()).toBe(true); + }); + + // TODO: Couldn't figure out how to select party Pokémon + it.skip("prevents fusion with a fainted Pokémon", async () => { + game.override.itemRewards([{ name: "DNA_SPLICERS" }]); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF, SpeciesId.WHISMUR]); + + const faintedPokemon = game.scene.getPlayerParty()[1]; + faintedPokemon.hp = 0; + faintedPokemon.status = new Status(StatusEffect.FAINT); + expect(faintedPokemon.isFainted()).toBe(true); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + game.onNextPrompt( + "SelectModifierPhase", + UiMode.MODIFIER_SELECT, + () => { + const handler = game.scene.ui.getHandler() as ModifierSelectUiHandler; + // Traverse to and select first modifier + handler.setCursor(0); + handler.setRowCursor(ShopCursorTarget.REWARDS); + handler.processInput(Button.ACTION); + + // Go to fainted Pokémon and try to select it + handler.processInput(Button.RIGHT); + handler.processInput(Button.ACTION); + handler.processInput(Button.ACTION); + handler.processInput(Button.ACTION); + + expect(game.scene.getPlayerParty().length).toBe(2); + }, + () => game.isCurrentPhase("CommandPhase") || game.isCurrentPhase("NewBattlePhase"), + true, + ); + }); + + // TODO: Couldn't figure out how to select party Pokémon + it.skip("prevents fainted Pokémon from being revived", async () => { + game.override.itemRewards([{ name: "MAX_REVIVE" }]); + await game.challengeMode.startBattle([SpeciesId.NUZLEAF, SpeciesId.WHISMUR]); + + const faintedPokemon = game.scene.getPlayerParty()[1]; + faintedPokemon.hp = 0; + faintedPokemon.status = new Status(StatusEffect.FAINT); + expect(faintedPokemon.isFainted()).toBe(true); + + game.move.select(MoveId.RAZOR_LEAF); + await game.doKillOpponents(); + + await game.phaseInterceptor.to("SelectModifierPhase"); + game.onNextPrompt( + "SelectModifierPhase", + UiMode.MODIFIER_SELECT, + () => { + const handler = game.scene.ui.getHandler() as ModifierSelectUiHandler; + // Traverse to and select first modifier + handler.setCursor(0); + handler.setRowCursor(ShopCursorTarget.REWARDS); + handler.processInput(Button.ACTION); + + // Go to fainted Pokémon and try to select it + handler.processInput(Button.RIGHT); + handler.processInput(Button.ACTION); + handler.processInput(Button.ACTION); + handler.processInput(Button.ACTION); + + expect(faintedPokemon.isFainted()).toBe(true); + }, + () => game.isCurrentPhase("CommandPhase") || game.isCurrentPhase("NewBattlePhase"), + true, + ); + }); +}); From a0e08ec9545e6d0e5c63c6c3084ce07381cd549a Mon Sep 17 00:00:00 2001 From: xsn34kzx Date: Tue, 5 Aug 2025 15:34:05 -0400 Subject: [PATCH 14/14] Tweak Faint Cry in Permanent Faint --- src/field/pokemon.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 26d80a1ffeb..bc069aa1fc2 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4558,8 +4558,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } const key = this.species.getCryKey(this.formIndex); - let rate = 0.85; - const cry = globalScene.playSound(key, { rate: rate }) as AnySound; + const crySoundConfig = { rate: 0.85, detune: 0 }; + if (this.isPlayer()) { + // If fainting is permanent, emphasize impact + const preventRevive = new BooleanHolder(false); + applyChallenges(ChallengeType.PREVENT_REVIVE, preventRevive); + if (preventRevive.value) { + crySoundConfig.detune = -100; + crySoundConfig.rate = 0.7; + } + } + const cry = globalScene.playSound(key, crySoundConfig) as AnySound; if (!cry || globalScene.fieldVolume === 0) { callback(); return; @@ -4578,7 +4587,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { delay: fixedInt(delay), repeat: -1, callback: () => { - frameThreshold = sprite.anims.msPerFrame / rate; + frameThreshold = sprite.anims.msPerFrame / crySoundConfig.rate; frameProgress += delay; while (frameProgress > frameThreshold) { if (sprite.anims.duration) { @@ -4588,8 +4597,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { frameProgress -= frameThreshold; } if (cry && !cry.pendingRemove) { - rate *= 0.99; - cry.setRate(rate); + cry.setRate(crySoundConfig.rate * 0.99); } else { faintCryTimer?.destroy(); faintCryTimer = null;