[Bug] Fix anger point procing on every hit if first hit in multi hit was a crit

https://github.com/pagefaultgames/pokerogue/pull/6067

* Fix anger point always procing on multi-hit

when first strike was a crit

* Fix comment spacing

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

* Rename PostDefendCritStatStageChangeAbAttr

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
Sirz Benjie 2025-07-07 19:38:18 -06:00 committed by GitHub
parent e82c788585
commit 115d63d0c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 117 additions and 22 deletions

View File

@ -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

View File

@ -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

View File

@ -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);
});
});