[Bug][Challenge][UI/UX] Exclude invalid starters when combining challenges (#5509)

* Filtering correctly when combining gen and monotype challenges

* Clean-up

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Always check requested form first

* Fixing Basculin

* Only check forms which are starter selectable

* Exclude form changes that are not triggered by an item

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
Wlowscha 2025-03-24 03:40:17 +01:00 committed by GitHub
parent dbc8ac26af
commit 37e51e9657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 115 additions and 142 deletions

View File

@ -18,9 +18,10 @@ import { TrainerType } from "#enums/trainer-type";
import { Nature } from "#enums/nature";
import type { Moves } from "#enums/moves";
import { TypeColor, TypeShadow } from "#enums/color";
import { pokemonEvolutions } from "#app/data/balance/pokemon-evolutions";
import { pokemonFormChanges } from "#app/data/pokemon-forms";
import { ModifierTier } from "#app/modifier/modifier-tier";
import { globalScene } from "#app/global-scene";
import { pokemonFormChanges } from "./pokemon-forms";
import { pokemonEvolutions } from "./balance/pokemon-evolutions";
/** A constant for the default max cost of the starting party before a run */
const DEFAULT_PARTY_MAX_COST = 10;
@ -285,15 +286,9 @@ export abstract class Challenge {
* @param _pokemon {@link PokemonSpecies} The pokemon to check the validity of.
* @param _valid {@link Utils.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.
* @param _soft {@link boolean} If true, allow it if it could become a valid pokemon.
* @returns {@link boolean} Whether this function did anything.
*/
applyStarterChoice(
_pokemon: PokemonSpecies,
_valid: Utils.BooleanHolder,
_dexAttr: DexAttrProps,
_soft = false,
): boolean {
applyStarterChoice(_pokemon: PokemonSpecies, _valid: Utils.BooleanHolder, _dexAttr: DexAttrProps): boolean {
return false;
}
@ -445,27 +440,8 @@ export class SingleGenerationChallenge extends Challenge {
super(Challenges.SINGLE_GENERATION, 9);
}
applyStarterChoice(
pokemon: PokemonSpecies,
valid: Utils.BooleanHolder,
_dexAttr: DexAttrProps,
soft = false,
): boolean {
const generations = [pokemon.generation];
if (soft) {
const speciesToCheck = [pokemon.speciesId];
while (speciesToCheck.length) {
const checking = speciesToCheck.pop();
if (checking && pokemonEvolutions.hasOwnProperty(checking)) {
pokemonEvolutions[checking].forEach(e => {
speciesToCheck.push(e.speciesId);
generations.push(getPokemonSpecies(e.speciesId).generation);
});
}
}
}
if (!generations.includes(this.value)) {
applyStarterChoice(pokemon: PokemonSpecies, valid: Utils.BooleanHolder): boolean {
if (pokemon.generation !== this.value) {
valid.value = false;
return true;
}
@ -739,41 +715,14 @@ export class SingleTypeChallenge extends Challenge {
{ species: Species.CASTFORM, type: PokemonType.NORMAL, fusion: false },
];
// TODO: Find a solution for all Pokemon with this ssui issue, including Basculin and Burmy
private static SPECIES_OVERRIDES: Species[] = [Species.MELOETTA];
constructor() {
super(Challenges.SINGLE_TYPE, 18);
}
override applyStarterChoice(
pokemon: PokemonSpecies,
valid: Utils.BooleanHolder,
dexAttr: DexAttrProps,
soft = false,
): boolean {
override applyStarterChoice(pokemon: PokemonSpecies, valid: Utils.BooleanHolder, dexAttr: DexAttrProps): boolean {
const speciesForm = getPokemonSpeciesForm(pokemon.speciesId, dexAttr.formIndex);
const types = [speciesForm.type1, speciesForm.type2];
if (soft && !SingleTypeChallenge.SPECIES_OVERRIDES.includes(pokemon.speciesId)) {
const speciesToCheck = [pokemon.speciesId];
while (speciesToCheck.length) {
const checking = speciesToCheck.pop();
if (checking && pokemonEvolutions.hasOwnProperty(checking)) {
pokemonEvolutions[checking].forEach(e => {
speciesToCheck.push(e.speciesId);
types.push(getPokemonSpecies(e.speciesId).type1, getPokemonSpecies(e.speciesId).type2);
});
}
if (checking && pokemonFormChanges.hasOwnProperty(checking)) {
pokemonFormChanges[checking].forEach(f1 => {
getPokemonSpecies(checking).forms.forEach(f2 => {
if (f1.formKey === f2.formKey) {
types.push(f2.type1, f2.type2);
}
});
});
}
}
}
if (!types.includes(this.value - 1)) {
valid.value = false;
return true;
@ -1030,7 +979,6 @@ export class LowerStarterPointsChallenge extends Challenge {
* @param pokemon {@link PokemonSpecies} The pokemon to check the validity of.
* @param valid {@link Utils.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.
* @param soft {@link boolean} If true, allow it if it could become a valid pokemon.
* @returns True if any challenge was successfully applied.
*/
export function applyChallenges(
@ -1039,7 +987,6 @@ export function applyChallenges(
pokemon: PokemonSpecies,
valid: Utils.BooleanHolder,
dexAttr: DexAttrProps,
soft: boolean,
): boolean;
/**
* Apply all challenges that modify available total starter points.
@ -1222,7 +1169,7 @@ export function applyChallenges(gameMode: GameMode, challengeType: ChallengeType
if (c.value !== 0) {
switch (challengeType) {
case ChallengeType.STARTER_CHOICE:
ret ||= c.applyStarterChoice(args[0], args[1], args[2], args[3]);
ret ||= c.applyStarterChoice(args[0], args[1], args[2]);
break;
case ChallengeType.STARTER_POINTS:
ret ||= c.applyStarterPoints(args[0]);
@ -1305,3 +1252,87 @@ export function initChallenges() {
new FlipStatChallenge(),
);
}
/**
* 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 Utils.BooleanHolder(true);
applyChallenges(globalScene.gameMode, 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 Utils.BooleanHolder(true);
applyChallenges(globalScene.gameMode, 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;
}
pokemonFormChanges[species.speciesId].forEach(f1 => {
// Exclude form changes that require the mon to be on the field to begin with,
// such as Castform
if (!("item" in f1)) {
return;
}
species.forms.forEach((f2, formIndex) => {
if (f1.formKey === f2.formKey) {
const formProps = { ...props };
formProps.formIndex = formIndex;
const isFormValidForChallenge = new Utils.BooleanHolder(true);
applyChallenges(
globalScene.gameMode,
ChallengeType.STARTER_CHOICE,
species,
isFormValidForChallenge,
formProps,
);
if (isFormValidForChallenge.value) {
return true;
}
}
});
});
return false;
}

View File

@ -20,7 +20,6 @@ import { allSpecies, getPokemonSpecies, getPokemonSpeciesForm, normalForm } from
import { getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters";
import { starterPassiveAbilities } from "#app/data/balance/passives";
import { PokemonType } from "#enums/pokemon-type";
import { GameModes } from "#app/game-mode";
import type { DexEntry, StarterAttributes } from "#app/system/game-data";
import { AbilityAttr, DexAttr } from "#app/system/game-data";
import type { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler";
@ -33,7 +32,6 @@ import { Egg } from "#app/data/egg";
import Overrides from "#app/overrides";
import { SettingKeyboard } from "#app/system/settings/settings-keyboard";
import { Passive as PassiveAttr } from "#enums/passive";
import * as Challenge from "#app/data/challenge";
import MoveInfoOverlay from "#app/ui/move-info-overlay";
import PokedexInfoOverlay from "#app/ui/pokedex-info-overlay";
import { getEggTierForSpecies } from "#app/data/egg";
@ -51,7 +49,6 @@ import {
BooleanHolder,
getLocalizedSpriteKey,
isNullOrUndefined,
NumberHolder,
padInt,
rgbHexToRgba,
toReadableString,
@ -2128,22 +2125,6 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
}
}
getValueLimit(): number {
const valueLimit = new NumberHolder(0);
switch (globalScene.gameMode.modeId) {
case GameModes.ENDLESS:
case GameModes.SPLICED_ENDLESS:
valueLimit.value = 15;
break;
default:
valueLimit.value = 10;
}
Challenge.applyChallenges(globalScene.gameMode, Challenge.ChallengeType.STARTER_POINTS, valueLimit);
return valueLimit.value;
}
setCursor(cursor: number): boolean {
const ret = super.setCursor(cursor);

View File

@ -1,6 +1,6 @@
import type { CandyUpgradeNotificationChangedEvent } from "#app/events/battle-scene";
import { BattleSceneEventType } from "#app/events/battle-scene";
import { pokemonPrevolutions } from "#app/data/balance/pokemon-evolutions";
import { pokemonEvolutions, pokemonPrevolutions } from "#app/data/balance/pokemon-evolutions";
import type { Variant } from "#app/data/variant";
import { getVariantTint, getVariantIcon } from "#app/data/variant";
import { argbFromRgba } from "@material/material-color-utilities";
@ -19,7 +19,7 @@ import { pokemonFormChanges } from "#app/data/pokemon-forms";
import type { LevelMoves } from "#app/data/balance/pokemon-level-moves";
import { pokemonFormLevelMoves, pokemonSpeciesLevelMoves } from "#app/data/balance/pokemon-level-moves";
import type PokemonSpecies from "#app/data/pokemon-species";
import { allSpecies, getPokemonSpeciesForm, getPokerusStarters } from "#app/data/pokemon-species";
import { allSpecies, getPokemonSpecies, getPokemonSpeciesForm, getPokerusStarters } from "#app/data/pokemon-species";
import { getStarterValueFriendshipCap, speciesStarterCosts, POKERUS_STARTER_COUNT } from "#app/data/balance/starters";
import { PokemonType } from "#enums/pokemon-type";
import { GameModes } from "#app/game-mode";
@ -80,6 +80,7 @@ import { PLAYER_PARTY_MAX_SIZE } from "#app/constants";
import { achvs } from "#app/system/achv";
import * as Utils from "../utils";
import type { GameObjects } from "phaser";
import { checkStarterValidForChallenge } from "#app/data/challenge";
export type StarterSelectCallback = (starters: Starter[]) => void;
@ -1760,21 +1761,14 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
const species = starter.species;
const [isDupe] = this.isInParty(species);
const starterCost = globalScene.gameData.getSpeciesStarterValue(species.speciesId);
const isValidForChallenge = new BooleanHolder(true);
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isValidForChallenge = checkStarterValidForChallenge(
species,
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(species, this.getCurrentDexProps(species.speciesId)),
this.isPartyValid(),
);
const isCaught = globalScene.gameData.dexData[species.speciesId].caughtAttr;
return (
!isDupe &&
isValidForChallenge.value &&
currentPartyValue + starterCost <= this.getValueLimit() &&
isCaught
!isDupe && isValidForChallenge && currentPartyValue + starterCost <= this.getValueLimit() && isCaught
);
});
if (validStarters.length === 0) {
@ -1861,16 +1855,11 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
const ui = this.getUi();
let options: any[] = []; // TODO: add proper type
const [isDupe, removeIndex]: [boolean, number] = this.isInParty(this.lastSpecies); // checks to see if the pokemon is a duplicate; if it is, returns the index that will be removed
const [isDupe, removeIndex]: [boolean, number] = this.isInParty(this.lastSpecies);
const isPartyValid = this.isPartyValid();
const isValidForChallenge = new BooleanHolder(true);
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isValidForChallenge = checkStarterValidForChallenge(
this.lastSpecies,
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(
this.lastSpecies,
this.getCurrentDexProps(this.lastSpecies.speciesId),
@ -1888,11 +1877,10 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
const newCost = globalScene.gameData.getSpeciesStarterValue(this.lastSpecies.speciesId);
if (
!isDupe &&
isValidForChallenge.value &&
isValidForChallenge &&
currentPartyValue + newCost <= this.getValueLimit() &&
this.starterSpecies.length < PLAYER_PARTY_MAX_SIZE
) {
// this checks to make sure the pokemon doesn't exist in your party, it's valid for the challenge and that it won't go over the cost limit; if it meets all these criteria it will add it to your party
options = [
{
label: i18next.t("starterSelectUiHandler:addToParty"),
@ -1902,7 +1890,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
globalScene.gameData.getSpeciesStarterValue(this.lastSpecies.speciesId),
true,
);
if (!isDupe && isValidForChallenge.value && isOverValueLimit) {
if (!isDupe && isValidForChallenge && isOverValueLimit) {
const cursorObj = this.starterCursorObjs[this.starterSpecies.length];
cursorObj.setVisible(true);
cursorObj.setPosition(this.cursorObj.x, this.cursorObj.y);
@ -2993,32 +2981,27 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
/* Here we are making a fake form index dex props for challenges
* Since some pokemon rely on forms to be valid (i.e. blaze tauros for fire challenges), we make a fake form and dex props to use in the challenge
*/
if (!species.forms[i].isStarterSelectable) {
continue;
}
const tempFormProps = BigInt(Math.pow(2, i)) * DexAttr.DEFAULT_FORM;
const isValidForChallenge = new BooleanHolder(true);
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isValidForChallenge = checkStarterValidForChallenge(
container.species,
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(species, tempFormProps),
true,
);
allFormsValid = allFormsValid || isValidForChallenge.value;
allFormsValid = allFormsValid || isValidForChallenge;
}
} else {
const isValidForChallenge = new BooleanHolder(true);
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isValidForChallenge = checkStarterValidForChallenge(
container.species,
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(
species,
globalScene.gameData.getSpeciesDefaultDexAttr(container.species, false, true),
),
true,
);
allFormsValid = isValidForChallenge.value;
allFormsValid = isValidForChallenge;
}
if (allFormsValid) {
this.validStarterContainers.push(container);
@ -3851,15 +3834,6 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonSprite.setVisible(!this.statsMode);
}
const isValidForChallenge = new BooleanHolder(true);
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
species,
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(species, this.dexAttrCursor),
!!this.starterSpecies.length,
);
const currentFilteredContainer = this.filteredStarterContainers.find(
p => p.species.speciesId === species.speciesId,
);
@ -4233,20 +4207,15 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
globalScene.time.delayedCall(fixedInt(500), () => this.tryUpdateValue());
return false;
}
let isPartyValid: boolean = this.isPartyValid(); // this checks to see if the party is valid
let isPartyValid: boolean = this.isPartyValid();
if (addingToParty) {
// this does a check to see if the pokemon being added is valid; if so, it will update the isPartyValid boolean
const isNewPokemonValid = new BooleanHolder(true);
const species = this.filteredStarterContainers[this.cursor].species;
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isNewPokemonValid = checkStarterValidForChallenge(
species,
isNewPokemonValid,
globalScene.gameData.getSpeciesDexAttrProps(species, this.getCurrentDexProps(species.speciesId)),
false,
);
isPartyValid = isPartyValid || isNewPokemonValid.value;
isPartyValid = isPartyValid || isNewPokemonValid;
}
/**
@ -4270,12 +4239,8 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
* If speciesStarterDexEntry?.caughtAttr is true, this species registered in stater.
* we change to can AddParty value to true since the user has enough cost to choose this pokemon and this pokemon registered too.
*/
const isValidForChallenge = new BooleanHolder(true);
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isValidForChallenge = checkStarterValidForChallenge(
this.allSpecies[s],
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(
this.allSpecies[s],
this.getCurrentDexProps(this.allSpecies[s].speciesId),
@ -4283,7 +4248,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
isPartyValid,
);
const canBeChosen = remainValue >= speciesStarterValue && isValidForChallenge.value;
const canBeChosen = remainValue >= speciesStarterValue && isValidForChallenge;
const isPokemonInParty = this.isInParty(this.allSpecies[s])[0]; // this will get the valud of isDupe from isInParty. This will let us see if the pokemon in question is in our party already so we don't grey out the sprites if they're invalid
@ -4417,17 +4382,13 @@ export default class StarterSelectUiHandler extends MessageUiHandler {
isPartyValid(): boolean {
let canStart = false;
for (let s = 0; s < this.starterSpecies.length; s++) {
const isValidForChallenge = new BooleanHolder(true);
const species = this.starterSpecies[s];
Challenge.applyChallenges(
globalScene.gameMode,
Challenge.ChallengeType.STARTER_CHOICE,
const isValidForChallenge = checkStarterValidForChallenge(
species,
isValidForChallenge,
globalScene.gameData.getSpeciesDexAttrProps(species, this.getCurrentDexProps(species.speciesId)),
false,
);
canStart = canStart || isValidForChallenge.value;
canStart = canStart || isValidForChallenge;
}
return canStart;
}