[Balance][Challenge] Block master ball catching final boss in challenges (#6273)

* Block catching bosses in nuzlockes

* Changes to conditions to restrict master ball use

* Implemented new can't catch messages

* Fixed some bugs which prevented correct usage of balls

* Special casing full fresh start

* fix text Update command-phase.ts

* Added tests for failing catches

* Using `mockI18next`

* Shorten a couple of variable declarations

* Fixed bug that allowed catching trainer pokemon in end; showing double battle failure only if other failure messages do not apply

* Fixed order of error messages

* Changed description of tests with "in end biome" instead of "paradox mon(s)"

* Not override nature after selection

* Update test/field/catching.test.ts

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

---------

Co-authored-by: damocleas <damocleas25@gmail.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
This commit is contained in:
Wlowscha 2025-08-21 01:24:55 +02:00 committed by GitHub
parent 93cdffccc4
commit 14a0a23abc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 366 additions and 16 deletions

View File

@ -10,7 +10,6 @@ import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves";
import { ModifierTier } from "#enums/modifier-tier";
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";
@ -800,7 +799,6 @@ export class FreshStartChallenge extends Challenge {
applyStarterModify(pokemon: Pokemon): boolean {
pokemon.abilityIndex = pokemon.abilityIndex % 2; // Always base ability, if you set it to hidden it wraps to first ability
pokemon.passive = false; // Passive isn't unlocked
pokemon.nature = Nature.HARDY; // Neutral nature
let validMoves = pokemon.species
.getLevelMoves()
.filter(m => isBetween(m[0], 1, 5))

View File

@ -82,6 +82,14 @@ export class GameMode implements GameModeConfig {
return this.challenges.some(c => c.id === challenge && c.value !== 0);
}
/**
* Helper function to see if a GameMode has any challenges, needed in tests
* @returns true if the game mode has at least one challenge
*/
hasAnyChallenges(): boolean {
return this.challenges.length > 0;
}
/**
* Helper function to see if the game mode is using fresh start
* @returns true if a fresh start challenge is being applied
@ -90,6 +98,19 @@ export class GameMode implements GameModeConfig {
return this.hasChallenge(Challenges.FRESH_START);
}
/**
* Helper function to see if the game mode is using fresh start
* @returns true if a fresh start challenge is being applied
*/
isFullFreshStartChallenge(): boolean {
for (const challenge of this.challenges) {
if (challenge.id === Challenges.FRESH_START && challenge.value === 1) {
return true;
}
}
return false;
}
/**
* Helper function to get starting level for game mode.
* @returns either:

View File

@ -376,7 +376,6 @@ export class CommandPhase extends FieldPhase {
* - It is a trainer battle
* - The player is in the {@linkcode BiomeId.END | End} biome and
* - it is not classic mode; or
* - the fresh start challenge is active; or
* - the player has not caught the target before and the player is still missing more than one starter
* - The player is in a mystery encounter that disallows catching the pokemon
* @returns Whether a pokeball can be thrown
@ -385,19 +384,37 @@ export class CommandPhase extends FieldPhase {
const { arena, currentBattle, gameData, gameMode } = globalScene;
const { battleType } = currentBattle;
const { biomeType } = arena;
const { isClassic } = gameMode;
const { isClassic, isEndless, isDaily } = gameMode;
const { dexData } = gameData;
const isClassicFinalBoss = gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex);
const isEndlessMinorBoss = gameMode.isEndlessMinorBoss(globalScene.currentBattle.waveIndex);
const isFullFreshStart = gameMode.isFullFreshStartChallenge();
const someUncaughtSpeciesOnField = globalScene
.getEnemyField()
.some(p => p.isActive() && !dexData[p.species.speciesId].caughtAttr);
const missingMultipleStarters =
gameData.getStarterCount(d => !!d.caughtAttr) < Object.keys(speciesStarterCosts).length - 1;
if (biomeType === BiomeId.END && battleType === BattleType.WILD) {
if (
biomeType === BiomeId.END &&
(!isClassic || gameMode.isFreshStartChallenge() || (someUncaughtSpeciesOnField && missingMultipleStarters))
(isClassic && !isClassicFinalBoss && someUncaughtSpeciesOnField) ||
(isFullFreshStart && !isClassicFinalBoss) ||
(isEndless && !isEndlessMinorBoss)
) {
// Uncatchable paradox mons in classic and endless
this.queueShowText("battle:noPokeballForce");
} else if (
(isClassic && isClassicFinalBoss && missingMultipleStarters) ||
(isFullFreshStart && isClassicFinalBoss) ||
(isEndless && isEndlessMinorBoss) ||
isDaily
) {
// Uncatchable final boss in classic, endless and daily
this.queueShowText("battle:noPokeballForceFinalBoss");
} else {
return true;
}
} else if (battleType === BattleType.TRAINER) {
this.queueShowText("battle:noPokeballTrainer");
} else if (currentBattle.isBattleMysteryEncounter() && !currentBattle.mysteryEncounter!.catchAllowed) {
@ -420,14 +437,18 @@ export class CommandPhase extends FieldPhase {
.getEnemyField()
.filter(p => p.isActive(true))
.map(p => p.getBattlerIndex());
if (!this.checkCanUseBall()) {
return false;
}
if (targets.length > 1) {
this.queueShowText("battle:noPokeballMulti");
return false;
}
if (!this.checkCanUseBall()) {
return false;
}
const isChallengeActive = globalScene.gameMode.hasAnyChallenges();
const isFinalBoss = globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex);
const numBallTypes = 5;
if (cursor < numBallTypes) {
@ -436,12 +457,23 @@ export class CommandPhase extends FieldPhase {
targetPokemon?.isBoss() &&
targetPokemon?.bossSegmentIndex >= 1 &&
// TODO: Decouple this hardcoded exception for wonder guard and just check the target...
!targetPokemon?.hasAbility(AbilityId.WONDER_GUARD, false, true) &&
cursor < PokeballType.MASTER_BALL
!targetPokemon?.hasAbility(AbilityId.WONDER_GUARD, false, true)
) {
// When facing the final boss, it must be weakened unless a Master Ball is used AND no challenges are active.
// The message is customized for the final boss.
if (
isFinalBoss &&
(cursor < PokeballType.MASTER_BALL || (cursor === PokeballType.MASTER_BALL && isChallengeActive))
) {
this.queueShowText("battle:noPokeballForceFinalBossCatchable");
return false;
}
// When facing any other boss, Master Ball can always be used, and we use the standard message.
if (cursor < PokeballType.MASTER_BALL) {
this.queueShowText("battle:noPokeballStrong");
return false;
}
}
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: Command.BALL,

299
test/field/catching.test.ts Normal file
View File

@ -0,0 +1,299 @@
import { pokerogueApi } from "#api/pokerogue-api";
import { BattleType } from "#enums/battle-type";
import { BiomeId } from "#enums/biome-id";
import { Challenges } from "#enums/challenges";
import { GameModes } from "#enums/game-modes";
import { MoveId } from "#enums/move-id";
import { PokeballType } from "#enums/pokeball";
import { SpeciesId } from "#enums/species-id";
import { TrainerType } from "#enums/trainer-type";
import { GameManager } from "#test/test-utils/game-manager";
import { mockI18next } from "#test/test-utils/test-utils";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Helper function to run tests on cactching mons
*
* @remarks
* - Starts a run on the desired game mode, then attempts to throw a ball
* - If still in the command phase (meaning the ball did not catch) uses a move to proceed
* - If expecting success, checks that party length has increased by 1
* - Otherwise, checks that {@link i18next} has been called on the requested error key
*
* @param game - The {@link GameManager} instance
* @param ball - The {@link PokeballType} to be used for the catch attempt
* @param expectedResult - Either "success" if the enemy should be caught, or the expected locales error key
* @param mode - One of "classic", "daily", or "challenge"; defaults to "classic".
*/
async function runPokeballTest(
game: GameManager,
ball: PokeballType,
expectedResult: string,
mode: "classic" | "daily" | "challenge" = "classic",
) {
if (mode === "classic") {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
} else if (mode === "daily") {
// Have to do it this way because daily run is weird...
await game.runToFinalBossEncounter([SpeciesId.MAGIKARP], GameModes.DAILY);
} else if (mode === "challenge") {
await game.challengeMode.startBattle([SpeciesId.MAGIKARP]);
}
const partyLength = game.scene.getPlayerParty().length;
game.scene.pokeballCounts[ball] = 1;
const tSpy = mockI18next();
game.doThrowPokeball(ball);
// If still in the command phase due to ball failing, use a move to go on
if (game.isCurrentPhase("CommandPhase")) {
game.move.select(MoveId.SPLASH);
}
await game.toEndOfTurn();
if (expectedResult === "success") {
// Check that a mon has been caught by noticing that party length has increased
expect(game.scene.getPlayerParty()).toHaveLength(partyLength + 1);
} else {
expect(tSpy).toHaveBeenCalledWith(expectedResult);
}
}
describe("Throwing balls in classic", () => {
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
.startingWave(199)
.startingBiome(BiomeId.END)
.battleStyle("single")
.moveset([MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH])
.startingLevel(9999);
});
it("throwing ball at two mons", async () => {
game.override.startingWave(21).startingBiome(BiomeId.TOWN);
game.override.battleStyle("double");
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballMulti");
});
it("throwing ball in end biome", async () => {
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce");
});
it("throwing ball at two mons in end biome", async () => {
game.override.battleStyle("double");
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce");
});
it("throwing ball at two previously caught mon in end biome", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
await runPokeballTest(game, PokeballType.MASTER_BALL, "success");
});
it("throwing ball at two mons in end biome", async () => {
game.override.battleStyle("double");
await game.importData("./test/test-utils/saves/everything.prsv");
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballMulti");
});
it("throwing ball at final boss", async () => {
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss");
});
it("throwing rogue ball at final boss with full dex", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.ROGUE_BALL, "battle:noPokeballForceFinalBossCatchable");
});
it("throwing master ball at final boss with full dex", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.MASTER_BALL, "success");
});
});
describe("Throwing balls in fresh start challenge", () => {
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.FRESH_START, 2, 1);
game.override
.startingWave(199)
.startingBiome(BiomeId.END)
.battleStyle("single")
.moveset([MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH])
.startingLevel(9999);
});
// Tests should give the same result as a normal classic run, except for the last one
it("throwing ball in end biome", async () => {
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce", "challenge");
});
it("throwing ball at previously caught mon in end biome", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
await runPokeballTest(game, PokeballType.MASTER_BALL, "success", "challenge");
});
it("throwing ball at final boss", async () => {
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss", "challenge");
});
it("throwing rogue ball at final boss with full dex", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.ROGUE_BALL, "battle:noPokeballForceFinalBossCatchable", "challenge");
});
// If a challenge is active, even if the dex is complete we still need to weaken the final boss to master ball it
it("throwing ball at final boss with full dex", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBossCatchable", "challenge");
});
});
describe("Throwing balls in full fresh start challenge", () => {
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.FRESH_START, 1, 1);
game.override
.startingWave(199)
.startingBiome(BiomeId.END)
.battleStyle("single")
.moveset([MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH])
.startingLevel(9999);
});
// Paradox mons and final boss can NEVER be caught in the full fresh start challenge
it("throwing ball at previously caught mon in end biome", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce", "challenge");
});
it("throwing ball at final boss with full dex", async () => {
await game.importData("./test/test-utils/saves/everything.prsv");
game.override.startingWave(200);
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss", "challenge");
});
});
describe("Throwing balls in daily run", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("test-seed");
game.override
.startingWave(50)
.startingBiome(BiomeId.END)
.battleStyle("single")
.moveset([MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH])
.startingLevel(9999);
});
it("throwing ball at daily run boss", async () => {
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss", "daily");
});
});
describe("Throwing balls at trainers", () => {
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
.battleType(BattleType.TRAINER)
.randomTrainer({ trainerType: TrainerType.ACE_TRAINER })
.moveset([MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH])
.startingLevel(9999);
});
it("throwing ball at a trainer", async () => {
game.override.startingWave(21);
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballTrainer");
});
it("throwing ball at a trainer in a double battle", async () => {
game.override.startingWave(21).randomTrainer({ trainerType: TrainerType.TWINS });
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballTrainer");
});
it("throwing ball at a trainer in the end biome", async () => {
game.override.startingWave(195).startingBiome(BiomeId.END);
await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballTrainer");
});
});