diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 72da3f1ed6f..c5d02644078 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3961,6 +3961,66 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return baseDamage; } + + /** Determine the STAB multiplier for a move used against this pokemon. + * + * @param source - The attacking {@linkcode Pokemon} + * @param move - The {@linkcode Move} used in the attack + * @param ignoreSourceAbility - If `true`, ignores the attacking Pokemon's ability effects + * @param simulated - If `true`, suppresses changes to game state during the calculation + * + * @returns The STAB multiplier for the move used against this Pokemon + */ + calculateStabMultiplier(source: Pokemon, move: Move, ignoreSourceAbility: boolean, simulated: boolean): number { + // If the move has the Typeless attribute, it doesn't get STAB (e.g. struggle) + if (move.hasAttr(TypelessAttr)) { + return 1; + } + const sourceTypes = source.getTypes(); + const sourceTeraType = source.getTeraType(); + const moveType = source.getMoveType(move); + const matchesSourceType = sourceTypes.includes(source.getMoveType(move)); + const stabMultiplier = new Utils.NumberHolder(1); + if (matchesSourceType && moveType !== PokemonType.STELLAR) { + stabMultiplier.value += 0.5; + } + + if (!ignoreSourceAbility) { + applyAbAttrs(StabBoostAbAttr, source, null, simulated, stabMultiplier); + } + + applyMoveAttrs( + CombinedPledgeStabBoostAttr, + source, + this, + move, + stabMultiplier, + ); + + if ( + source.isTerastallized && + sourceTeraType === moveType && + moveType !== PokemonType.STELLAR + ) { + stabMultiplier.value += 0.5; + } + + if ( + source.isTerastallized && + source.getTeraType() === PokemonType.STELLAR && + (!source.stellarTypesBoosted.includes(moveType) || + source.hasSpecies(Species.TERAPAGOS)) + ) { + if (matchesSourceType) { + stabMultiplier.value += 0.5; + } else { + stabMultiplier.value += 0.2; + } + } + + return Math.min(stabMultiplier.value, 2.25); + } + /** * Calculates the damage of an attack made by another Pokemon against this Pokemon * @param source {@linkcode Pokemon} the attacking Pokemon @@ -4143,70 +4203,30 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { ? 1 : this.randSeedIntRange(85, 100) / 100; - const sourceTypes = source.getTypes(); - const sourceTeraType = source.getTeraType(); - const matchesSourceType = sourceTypes.includes(moveType); + /** A damage multiplier for when the attack is of the attacker's type and/or Tera type. */ - const stabMultiplier = new Utils.NumberHolder(1); - if (matchesSourceType && moveType !== PokemonType.STELLAR) { - stabMultiplier.value += 0.5; - } - if (!ignoreSourceAbility) { - applyAbAttrs(StabBoostAbAttr, source, null, simulated, stabMultiplier); - } - - applyMoveAttrs( - CombinedPledgeStabBoostAttr, - source, - this, - move, - stabMultiplier, - ); - - if ( - source.isTerastallized && - sourceTeraType === moveType && - moveType !== PokemonType.STELLAR - ) { - stabMultiplier.value += 0.5; - } - - if ( - source.isTerastallized && - source.getTeraType() === PokemonType.STELLAR && - (!source.stellarTypesBoosted.includes(moveType) || - source.hasSpecies(Species.TERAPAGOS)) - ) { - if (matchesSourceType) { - stabMultiplier.value += 0.5; - } else { - stabMultiplier.value += 0.2; - } - } - - stabMultiplier.value = Math.min(stabMultiplier.value, 2.25); + const stabMultiplier = this.calculateStabMultiplier(source, move, ignoreSourceAbility, simulated); /** Halves damage if the attacker is using a physical attack while burned */ - const burnMultiplier = new Utils.NumberHolder(1); + let burnMultiplier = 1; if ( isPhysical && source.status && - source.status.effect === StatusEffect.BURN + source.status.effect === StatusEffect.BURN && + !move.hasAttr(BypassBurnDamageReductionAttr) ) { - if (!move.hasAttr(BypassBurnDamageReductionAttr)) { - const burnDamageReductionCancelled = new Utils.BooleanHolder(false); - if (!ignoreSourceAbility) { - applyAbAttrs( - BypassBurnDamageReductionAbAttr, - source, - burnDamageReductionCancelled, - simulated, - ); - } - if (!burnDamageReductionCancelled.value) { - burnMultiplier.value = 0.5; - } + const burnDamageReductionCancelled = new Utils.BooleanHolder(false); + if (!ignoreSourceAbility) { + applyAbAttrs( + BypassBurnDamageReductionAbAttr, + source, + burnDamageReductionCancelled, + simulated, + ); + } + if (!burnDamageReductionCancelled.value) { + burnMultiplier = 0.5; } } @@ -4257,9 +4277,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { glaiveRushMultiplier.value * criticalMultiplier.value * randomMultiplier * - stabMultiplier.value * + stabMultiplier * typeMultiplier * - burnMultiplier.value * + burnMultiplier * screenMultiplier.value * hitsTagMultiplier.value * mistyTerrainMultiplier, diff --git a/test/moves/struggle.test.ts b/test/moves/struggle.test.ts new file mode 100644 index 00000000000..6b566df9d54 --- /dev/null +++ b/test/moves/struggle.test.ts @@ -0,0 +1,65 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Struggle", () => { + 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 + .moveset([Moves.SPLASH]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should not have its power boosted by adaptability or stab", async () => { + game.override.moveset([Moves.STRUGGLE]).ability(Abilities.ADAPTABILITY); + await game.classicMode.startBattle([Species.RATTATA]); + + const enemy = game.scene.getEnemyPokemon()!; + game.move.select(Moves.STRUGGLE); + + const stabSpy = vi.spyOn(enemy, "calculateStabMultiplier"); + + await game.phaseInterceptor.to("BerryPhase"); + + expect(stabSpy).toHaveReturnedWith(1); + + stabSpy.mockRestore(); + }); + + it("should ignore type effectiveness", async () => { + game.override.moveset([Moves.STRUGGLE]); + await game.classicMode.startBattle([Species.GASTLY]); + + const enemy = game.scene.getEnemyPokemon()!; + game.move.select(Moves.STRUGGLE); + + const moveEffectivenessSpy = vi.spyOn(enemy, "getMoveEffectiveness"); + + await game.phaseInterceptor.to("BerryPhase"); + + expect(moveEffectivenessSpy).toHaveReturnedWith(1); + + moveEffectivenessSpy.mockRestore(); + }); +});