From 87046712afc59aa19094f5e29a5cb6bbf2370512 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Sun, 22 Jun 2025 23:12:23 -0500 Subject: [PATCH] Made adjustments based on reviewer suggestions --- src/battle-scene.ts | 2 +- src/battle.ts | 5 ++- src/overrides.ts | 8 ++++- src/phases/attempt-run-phase.ts | 20 +++++------ src/phases/battle-phase.ts | 13 -------- src/phases/turn-start-phase.ts | 2 +- src/utils/run-utils.ts | 51 ++++++++++++----------------- test/abilities/honey_gather.test.ts | 3 ++ test/abilities/speed_boost.test.ts | 10 ++++-- test/escape-calculations.test.ts | 21 +++++------- 10 files changed, 61 insertions(+), 74 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index a23684cc408..e6bc8a802de 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -800,7 +800,7 @@ export default class BattleScene extends SceneBase { // TODO: Add `undefined` to return type /** * Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not. - * @param active - If true, returns only the PlayerPokemon that are currently active + * @param active - Whether to consider only active on-field pokemon ({@see {@linkcode Pokemon.isActiveZ} for more information}); default `false` * @returns array of {@linkcode PlayerPokemon} */ public getPlayerField(active = false): PlayerPokemon[] { diff --git a/src/battle.ts b/src/battle.ts index 5bc6cbdce1f..36e5148c0e3 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -94,7 +94,10 @@ export default class Battle { /** If the current battle is a Mystery Encounter, this will always be defined */ public mysteryEncounter?: MysteryEncounter; - /**Tracks whether the last run attempt in battle failed*/ + /** + * Tracker for whether the last run attempt failed. + * @defaultValue `false` + */ public failedRunAway = false; private rngCounter = 0; diff --git a/src/overrides.ts b/src/overrides.ts index b390b9fa70f..1bf541804db 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -118,7 +118,13 @@ class DefaultOverrides { * or `false` to force it to never trigger. */ readonly CONFUSION_ACTIVATION_OVERRIDE: boolean | null = null; - + /** + * If non-null, will override random flee attempts to always or never succeed by forcing {@linkcode calculateEscapeChance} to return 100% or 0%. + * Set to `null` to disable. + * + * Is overridden if either player Pokemon has {@linkcode AbilityId.RUN_AWAY | Run Away}. + */ + readonly RUN_SUCCESS_OVERRIDE: boolean | null = null; // ---------------- // PLAYER OVERRIDES // ---------------- diff --git a/src/phases/attempt-run-phase.ts b/src/phases/attempt-run-phase.ts index 4f2481968ae..bef80cc3860 100644 --- a/src/phases/attempt-run-phase.ts +++ b/src/phases/attempt-run-phase.ts @@ -1,38 +1,33 @@ import { applyAbAttrs, applyPreLeaveFieldAbAttrs } from "#app/data/abilities/apply-ab-attrs"; import { StatusEffect } from "#enums/status-effect"; import i18next from "i18next"; -import { NumberHolder, randSeedInt } from "#app/utils/common"; import { globalScene } from "#app/global-scene"; import { calculateEscapeChance } from "#app/utils/run-utils"; import { FieldPhase } from "./field-phase"; export class AttemptRunPhase extends FieldPhase { public readonly phaseName = "AttemptRunPhase"; - /** For testing purposes: this is to force the pokemon to fail and escape */ - public forceFailEscape = false; private getTeamRNG(range: number, min = 0) { - return globalScene.currentBattle ? globalScene.randBattleSeedInt(range, min) : randSeedInt(range, min); + return globalScene.randBattleSeedInt(range, min); } start() { super.start(); - //Increment escape attempts count on entry + // Increment escape attempts count on entry const currentAttempts = globalScene.currentBattle.escapeAttempts++; const activePlayerField = globalScene.getPlayerField(true); const enemyField = globalScene.getEnemyField(); const escapeRoll = this.getTeamRNG(100); - const escapeChance = new NumberHolder(0); - - calculateEscapeChance(activePlayerField, enemyField, escapeChance, currentAttempts); + const escapeChance = calculateEscapeChance(currentAttempts); activePlayerField.forEach(p => { - applyAbAttrs("RunSuccessAbAttr", p, null, false, escapeChance); + applyAbAttrs("RunSuccessAbAttr", p, null, false, { value: escapeChance }); }); - if (escapeRoll < escapeChance.value && !this.forceFailEscape) { + if (escapeRoll < escapeChance) { enemyField.forEach(enemyPokemon => applyPreLeaveFieldAbAttrs("PreLeaveFieldAbAttr", enemyPokemon)); globalScene.playSound("se/flee"); @@ -65,7 +60,10 @@ export class AttemptRunPhase extends FieldPhase { globalScene.phaseManager.pushNew("NewBattlePhase"); } else { - globalScene.currentBattle.failedRunAway = true; + activePlayerField.forEach(p => { + p.turnData.failedRunAway = true; + }); + globalScene.phaseManager.queueMessage(i18next.t("battle:runAwayCannotEscape"), null, true, 500); } diff --git a/src/phases/battle-phase.ts b/src/phases/battle-phase.ts index eb9827845c2..7cefd0369d9 100644 --- a/src/phases/battle-phase.ts +++ b/src/phases/battle-phase.ts @@ -3,19 +3,6 @@ import { TrainerSlot } from "#enums/trainer-slot"; import { Phase } from "#app/phase"; export abstract class BattlePhase extends Phase { - start() { - if (globalScene.currentBattle.failedRunAway) { - const activePlayerField = globalScene.getPlayerField(true); - - activePlayerField.forEach(p => { - p.turnData.failedRunAway = true; - }); - - //Reset flag for future run attempts - globalScene.currentBattle.failedRunAway = false; - } - } - showEnemyTrainer(trainerSlot: TrainerSlot = TrainerSlot.NONE): void { if (!globalScene.currentBattle.trainer) { console.warn("Enemy trainer is missing!"); diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index be36508c025..0bdb0548aef 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -208,7 +208,7 @@ export class TurnStartPhase extends FieldPhase { break; case Command.RUN: { - //Team Based action, no need for checking individual pokemon or whether its doubles + // Running (like ball throwing) is a team action taking up both Pokemon's turns. phaseManager.unshiftNew("AttemptRunPhase"); } break; diff --git a/src/utils/run-utils.ts b/src/utils/run-utils.ts index 4cb2782806d..d1c558eaa5f 100644 --- a/src/utils/run-utils.ts +++ b/src/utils/run-utils.ts @@ -1,32 +1,20 @@ -import type { PlayerPokemon, EnemyPokemon } from "#app/field/pokemon"; -import type { NumberHolder } from "#app/utils/common"; -import type Pokemon from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; import { Stat } from "#enums/stat"; - +import Overrides from "#app/overrides"; /** - * Calculates the chance for the player's team to successfully run away from battle. + * Calculate the chance for the player's team to successfully run away from battle. * - * @param playerField The player's currently active Pokémon - * @param enemyField The enemy team's Pokémon - * @param escapeChance A mutable NumberHolder to be assigned the final escape chance value - * @param escapeAttempts The number of previous escape attempts made in the battle + * @param escapeAttempts The number of prior failed escape attempts in the current battle + * @returns The final escape chance, as percentage out of 100. */ -export function calculateEscapeChance( - playerField: PlayerPokemon[], - enemyField: EnemyPokemon[], - escapeChance: NumberHolder, - escapeAttempts: number, -) { - /** Sum of the speed of all enemy pokemon on the field */ - const enemySpeed = enemyField.reduce( - (total: number, enemyPokemon: Pokemon) => total + enemyPokemon.getStat(Stat.SPD), - 0, - ); - /** Sum of the speed of all player pokemon on the field */ - const playerSpeed = playerField.reduce( - (total: number, playerPokemon: Pokemon) => total + playerPokemon.getStat(Stat.SPD), - 0, - ); +export function calculateEscapeChance(escapeAttempts: number): number { + const enemyField = globalScene.getEnemyField(); + const activePlayerField = globalScene.getPlayerField(true); + + // Cf https://bulbapedia.bulbagarden.net/wiki/Escape#Generation_V_onwards + // From gen 5 onwards, running takes the _base_ speed totals of both party sides. + const enemySpeed = enemyField.reduce((total, enemy) => total + enemy.getStat(Stat.SPD), 0); + const playerSpeed = activePlayerField.reduce((total, player) => total + player.getStat(Stat.SPD), 0); /* The way the escape chance works is by looking at the difference between your speed and the enemy field's average speed as a ratio. The higher this ratio, the higher your chance of success. * However, there is a cap for the ratio of your speed vs enemy speed which beyond that point, you won't gain any advantage. It also looks at how many times you've tried to escape. @@ -44,10 +32,8 @@ export function calculateEscapeChance( * From the above, we can calculate the below values */ - let isBoss = false; - for (let e = 0; e < enemyField.length; e++) { - isBoss = isBoss || enemyField[e].isBoss(); // this line checks if any of the enemy pokemon on the field are bosses; if so, the calculation for escaping is different - } + /** Whether at least 1 pokemon on the enemy field is a boss. */ + const isBoss = enemyField.some(e => e.isBoss()); /** The ratio between the speed of your active pokemon and the speed of the enemy field */ const speedRatio = playerSpeed / enemySpeed; @@ -62,8 +48,13 @@ export function calculateEscapeChance( /** Slope of the escape chance curve */ const escapeSlope = (maxChance - minChance) / speedCap; + // Check for override, guaranteeing or forbidding random flee attempts as applicable. + if (Overrides.RUN_SUCCESS_OVERRIDE !== null) { + return Overrides.RUN_SUCCESS_OVERRIDE ? 100 : 0; + } + // This will calculate the escape chance given all of the above and clamp it to the range of [`minChance`, `maxChance`] - escapeChance.value = Phaser.Math.Clamp( + return Phaser.Math.Clamp( Math.round(escapeSlope * speedRatio + minChance + escapeBonus * escapeAttempts), minChance, maxChance, diff --git a/test/abilities/honey_gather.test.ts b/test/abilities/honey_gather.test.ts index f8700f3e6f7..e10e85e1d8b 100644 --- a/test/abilities/honey_gather.test.ts +++ b/test/abilities/honey_gather.test.ts @@ -6,6 +6,7 @@ import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Overrides from "#app/overrides"; describe("Abilities - Honey Gather", () => { let phaserGame: Phaser.Game; @@ -63,6 +64,8 @@ describe("Abilities - Honey Gather", () => { // something weird is going on with the test framework, so this is required to prevent a crash const enemy = game.scene.getEnemyPokemon()!; vi.spyOn(enemy, "scene", "get").mockReturnValue(game.scene); + //Expects next wave so run must succeed + vi.spyOn(Overrides, "RUN_SUCCESS_OVERRIDE", "get").mockReturnValue(true); const commandPhase = game.scene.phaseManager.getCurrentPhase() as CommandPhase; commandPhase.handleCommand(Command.RUN, 0); diff --git a/test/abilities/speed_boost.test.ts b/test/abilities/speed_boost.test.ts index a4445d085f3..1e731c7d6b6 100644 --- a/test/abilities/speed_boost.test.ts +++ b/test/abilities/speed_boost.test.ts @@ -4,10 +4,11 @@ import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CommandPhase } from "#app/phases/command-phase"; import { Command } from "#enums/command"; import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; +import Overrides from "#app/overrides"; describe("Abilities - Speed Boost", () => { let phaserGame: Phaser.Game; @@ -96,12 +97,15 @@ describe("Abilities - Speed Boost", () => { }); it("should not trigger if pokemon fails to escape", async () => { + //Account for doubles, should not trigger on either pokemon + game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.SHUCKLE]); + vi.spyOn(Overrides, "RUN_SUCCESS_OVERRIDE", "get").mockReturnValue(false); + const commandPhase = game.scene.phaseManager.getCurrentPhase() as CommandPhase; commandPhase.handleCommand(Command.RUN, 0); - const runPhase = game.scene.phaseManager.getCurrentPhase() as AttemptRunPhase; - runPhase.forceFailEscape = true; + await game.phaseInterceptor.to(AttemptRunPhase); await game.toNextTurn(); diff --git a/test/escape-calculations.test.ts b/test/escape-calculations.test.ts index ac5b3eefb71..a1803f3fcd2 100644 --- a/test/escape-calculations.test.ts +++ b/test/escape-calculations.test.ts @@ -1,7 +1,6 @@ import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; import type { CommandPhase } from "#app/phases/command-phase"; import { Command } from "#enums/command"; -import { NumberHolder } from "#app/utils/common"; import { AbilityId } from "#enums/ability-id"; import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; @@ -45,7 +44,6 @@ describe("Escape chance calculations", () => { commandPhase.handleCommand(Command.RUN, 0); await game.phaseInterceptor.to(AttemptRunPhase, false); - const escapePercentage = new NumberHolder(0); // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping const escapeChances: { @@ -91,8 +89,8 @@ describe("Escape chance calculations", () => { 20, escapeChances[i].pokemonSpeedRatio * enemySpeed, ]); - calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts); - expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + const chance = calculateEscapeChance(game.scene.currentBattle.escapeAttempts); + expect(chance).toBe(escapeChances[i].expectedEscapeChance); } }); @@ -117,7 +115,6 @@ describe("Escape chance calculations", () => { commandPhase.handleCommand(Command.RUN, 0); await game.phaseInterceptor.to(AttemptRunPhase, false); - const escapePercentage = new NumberHolder(0); // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping const escapeChances: { @@ -171,9 +168,9 @@ describe("Escape chance calculations", () => { 20, escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5], ]); - calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts); + const chance = calculateEscapeChance(game.scene.currentBattle.escapeAttempts); // checks to make sure the escape values are the same - expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + expect(chance).toBe(escapeChances[i].expectedEscapeChance); // checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed expect(playerPokemon[0].stats[5] + playerPokemon[1].stats[5]).toBe( escapeChances[i].pokemonSpeedRatio * totalEnemySpeed, @@ -195,7 +192,6 @@ describe("Escape chance calculations", () => { commandPhase.handleCommand(Command.RUN, 0); await game.phaseInterceptor.to(AttemptRunPhase, false); - const escapePercentage = new NumberHolder(0); // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping const escapeChances: { @@ -254,8 +250,8 @@ describe("Escape chance calculations", () => { 20, escapeChances[i].pokemonSpeedRatio * enemySpeed, ]); - calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts); - expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + const chance = calculateEscapeChance(game.scene.currentBattle.escapeAttempts); + expect(chance).toBe(escapeChances[i].expectedEscapeChance); } }); @@ -280,7 +276,6 @@ describe("Escape chance calculations", () => { commandPhase.handleCommand(Command.RUN, 0); await game.phaseInterceptor.to(AttemptRunPhase, false); - const escapePercentage = new NumberHolder(0); // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping const escapeChances: { @@ -346,9 +341,9 @@ describe("Escape chance calculations", () => { 20, escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5], ]); - calculateEscapeChance(playerPokemon, enemyField, escapePercentage, game.scene.currentBattle.escapeAttempts); + const chance = calculateEscapeChance(game.scene.currentBattle.escapeAttempts); // checks to make sure the escape values are the same - expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + expect(chance).toBe(escapeChances[i].expectedEscapeChance); // checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed expect(playerPokemon[0].stats[5] + playerPokemon[1].stats[5]).toBe( escapeChances[i].pokemonSpeedRatio * totalEnemySpeed,