diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 0aaf5b4b124..fa4ee2286ff 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1194,7 +1194,16 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr { } } -export class PostDefendCritStatStageChangeAbAttr extends PostDefendAbAttr { +/** + * Set stat stages when the user gets hit by a critical hit + * + * @privateremarks + * It is the responsibility of the caller to ensure that this ability attribute is only applied + * when the user has been hit by a critical hit; such an event is not checked here. + * + * @sealed + */ +export class PostReceiveCritStatStageChangeAbAttr extends AbAttr { private stat: BattleStat; private stages: number; @@ -1216,12 +1225,6 @@ export class PostDefendCritStatStageChangeAbAttr extends PostDefendAbAttr { ); } } - - override getCondition(): AbAttrCondition { - return (pokemon: Pokemon) => - pokemon.turnData.attacksReceived.length !== 0 && - pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1].critical; - } } export class PostDefendContactDamageAbAttr extends PostDefendAbAttr { @@ -6417,7 +6420,7 @@ const AbilityAttrs = Object.freeze({ PostDefendContactApplyStatusEffectAbAttr, EffectSporeAbAttr, PostDefendContactApplyTagChanceAbAttr, - PostDefendCritStatStageChangeAbAttr, + PostReceiveCritStatStageChangeAbAttr, PostDefendContactDamageAbAttr, PostDefendPerishSongAbAttr, PostDefendWeatherChangeAbAttr, @@ -6886,7 +6889,7 @@ export function initAbilities() { new Ability(AbilityId.GLUTTONY, 4) .attr(ReduceBerryUseThresholdAbAttr), new Ability(AbilityId.ANGER_POINT, 4) - .attr(PostDefendCritStatStageChangeAbAttr, Stat.ATK, 6), + .attr(PostReceiveCritStatStageChangeAbAttr, Stat.ATK, 12), new Ability(AbilityId.UNBURDEN, 4) .attr(PostItemLostApplyBattlerTagAbAttr, BattlerTagType.UNBURDEN) .bypassFaint() // Allows reviver seed to activate Unburden diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 0f53561f5f3..fb421d7a25f 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -432,9 +432,15 @@ export class MoveEffectPhase extends PokemonPhase { * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - {@linkcode Pokemon} the current target of this phase's invoked move * @param hitResult - The {@linkcode HitResult} of the attempted move + * @param wasCritical - `true` if the move was a critical hit */ - protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult): void { - applyAbAttrs("PostDefendAbAttr", { pokemon: target, opponent: user, move: this.move, hitResult }); + protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void { + const params = { pokemon: target, opponent: user, move: this.move, hitResult }; + applyAbAttrs("PostDefendAbAttr", params); + + if (wasCritical) { + applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params); + } target.lapseTags(BattlerTagLapseType.AFTER_HIT); } @@ -788,12 +794,12 @@ export class MoveEffectPhase extends PokemonPhase { this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); - const hitResult = this.applyMove(user, target, effectiveness); + const [hitResult, wasCritical] = this.applyMove(user, target, effectiveness); // Apply effects to the user (always) and the target (if not blocked by substitute). this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true); if (!this.move.hitsSubstitute(user, target)) { - this.applyOnTargetEffects(user, target, hitResult, firstTarget); + this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical); } if (this.lastHit) { globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); @@ -813,8 +819,9 @@ export class MoveEffectPhase extends PokemonPhase { * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} targeted by the move * @param effectiveness - The effectiveness of the move against the target + * @returns The {@linkcode HitResult} of the move against the target and a boolean indicating whether the target was crit */ - protected applyMoveDamage(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): HitResult { + protected applyMoveDamage(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] { const isCritical = target.getCriticalHitResult(user, this.move); /* @@ -845,7 +852,7 @@ export class MoveEffectPhase extends PokemonPhase { const isOneHitKo = result === HitResult.ONE_HIT_KO; if (!dmg) { - return result; + return [result, false]; } target.lapseTags(BattlerTagLapseType.HIT); @@ -873,7 +880,7 @@ export class MoveEffectPhase extends PokemonPhase { } if (damage <= 0) { - return result; + return [result, isCritical]; } if (user.isPlayer()) { @@ -902,7 +909,7 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage)); } - return result; + return [result, isCritical]; } /** @@ -956,17 +963,17 @@ export class MoveEffectPhase extends PokemonPhase { * @param target - The {@linkcode Pokemon} struck by the move * @param effectiveness - The effectiveness of the move against the target */ - protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): HitResult { + protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] { const moveCategory = user.getMoveCategory(target, this.move); if (moveCategory === MoveCategory.STATUS) { - return HitResult.STATUS; + return [HitResult.STATUS, false]; } const result = this.applyMoveDamage(user, target, effectiveness); if (user.turnData.hitsLeft === 1 || target.isFainted()) { - this.queueHitResultMessage(result); + this.queueHitResultMessage(result[0]); } if (target.isFainted()) { @@ -983,8 +990,15 @@ export class MoveEffectPhase extends PokemonPhase { * @param target - The {@linkcode Pokemon} targeted by the move * @param hitResult - The {@linkcode HitResult} obtained from applying the move * @param firstTarget - `true` if the target is the first Pokemon hit by the attack + * @param wasCritical - `true` if the move was a critical hit */ - protected applyOnTargetEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, firstTarget: boolean): void { + protected applyOnTargetEffects( + user: Pokemon, + target: Pokemon, + hitResult: HitResult, + firstTarget: boolean, + wasCritical = false, + ): void { /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ const dealsDamage = [ HitResult.EFFECTIVE, @@ -995,7 +1009,7 @@ export class MoveEffectPhase extends PokemonPhase { this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, false); this.applyHeldItemFlinchCheck(user, target, dealsDamage); - this.applyOnGetHitAbEffects(user, target, hitResult); + this.applyOnGetHitAbEffects(user, target, hitResult, wasCritical); applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult }); // We assume only enemy Pokemon are able to have the EnemyAttackStatusEffectChanceModifier from tokens diff --git a/test/abilities/anger-point.test.ts b/test/abilities/anger-point.test.ts new file mode 100644 index 00000000000..e6f3a94d12f --- /dev/null +++ b/test/abilities/anger-point.test.ts @@ -0,0 +1,78 @@ +import { PostReceiveCritStatStageChangeAbAttr } from "#app/data/abilities/ability"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Ability - Anger Point", () => { + 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 + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .enemyLevel(100); + }); + + it("should set the user's attack stage to +6 when hit by a critical hit", async () => { + game.override.enemyAbility(AbilityId.ANGER_POINT).moveset(MoveId.FALSE_SWIPE); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + const enemy = game.scene.getEnemyPokemon()!; + + // minimize the enemy's attack stage to ensure it is always set to +6 + enemy.setStatStage(Stat.ATK, -6); + vi.spyOn(enemy, "getCriticalHitResult").mockReturnValueOnce(true); + game.move.select(MoveId.FALSE_SWIPE); + await game.phaseInterceptor.to("BerryPhase"); + expect(enemy.getStatStage(Stat.ATK)).toBe(6); + }); + + it("should only proc once when a multi-hit move crits on the first hit", async () => { + game.override + .moveset(MoveId.BULLET_SEED) + .enemyLevel(50) + .enemyAbility(AbilityId.ANGER_POINT) + .ability(AbilityId.SKILL_LINK); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + const enemy = game.scene.getEnemyPokemon()!; + vi.spyOn(enemy, "getCriticalHitResult").mockReturnValueOnce(true); + const angerPointSpy = vi.spyOn(PostReceiveCritStatStageChangeAbAttr.prototype, "apply"); + game.move.select(MoveId.BULLET_SEED); + await game.phaseInterceptor.to("BerryPhase"); + expect(angerPointSpy).toHaveBeenCalledTimes(1); + }); + + it("should set a contrary user's attack stage to -6 when hit by a critical hit", async () => { + game.override + .enemyAbility(AbilityId.ANGER_POINT) + .enemyPassiveAbility(AbilityId.CONTRARY) + .enemyHasPassiveAbility(true) + .moveset(MoveId.FALSE_SWIPE); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + const enemy = game.scene.getEnemyPokemon()!; + vi.spyOn(enemy, "getCriticalHitResult").mockReturnValueOnce(true); + enemy.setStatStage(Stat.ATK, 6); + game.move.select(MoveId.FALSE_SWIPE); + await game.phaseInterceptor.to("BerryPhase"); + expect(enemy.getStatStage(Stat.ATK)).toBe(-6); + }); +});