From b1e7ae43a1540226504f395f3e0b16c4daf166a2 Mon Sep 17 00:00:00 2001 From: Corrade <49605314+Corrade@users.noreply.github.com> Date: Mon, 15 Jul 2024 09:16:15 +1000 Subject: [PATCH] [Bug] Prevent fixed-damage and OHKO moves from being modified by damage-reducing abilities (#2703) * ReceivedMoveDamageMultiplierAbAttr patch: WIP refactored damage calculation, reordered ReceivedMoveDamageMultiplierAbAttr to avoid issues with fixed damage and OHKO moves, stubbed unit tests for dragon rage (fixed damage) and fissure (OHKO) * ReceivedMoveDamageMultiplierAbAttr patch: commented concerns regarding EnemyDamageBooster/ReducerModifier for others' reference in WIP branch * ReceivedMoveDamageMultiplierAbAttr patch: reordered ReceivedMoveDamageMultiplierAbAttr and EnemyDamageBooster/ReducerModifier to not trigger for fixed damage and OHKO moves, completed relevant tests for dragon rage and fissure * ReceivedMoveDamageMultiplierAbAttr patch: removed newline * ReceivedMoveDamageMultiplierAbAttr patch: in the unit test, extracted hard-coded Dragon Rage damage to a variable * ReceivedMoveDamageMultiplierAbAttr patch: naming consistency * ReceivedMoveDamageMultiplierAbAttr patch: replaced awaiting DamagePhase with TurnEndPhase as the former assumes damage will be done * ReceivedMoveDamageMultiplierAbAttr patch: removed redundant overrides in Fissure tests * ReceivedMoveDamageMultiplierAbAttr patch: tests: refactored crit removal, removed berries, fixed bug associated with Porygon sometimes getting Trace and copying the opponent's ability, which would override the manual ability override * Fixed unit tests * Added a comment and cleaned up an existing one --- src/field/pokemon.ts | 39 ++++++--- src/test/moves/dragon_rage.test.ts | 132 +++++++++++++++++++++++++++++ src/test/moves/fissure.test.ts | 63 ++++++++++++++ 3 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 src/test/moves/dragon_rage.test.ts create mode 100644 src/test/moves/fissure.test.ts diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 6cf3c2ece3f..d1eb152db80 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1869,9 +1869,17 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, numTargets, new Utils.IntegerHolder(0), twoStrikeMultiplier); if (!isTypeImmune) { - damage.value = Math.ceil( - ((((2 * source.level / 5 + 2) * power * sourceAtk.value / targetDef.value) / 50) + 2) - * stabMultiplier.value * typeMultiplier.value * arenaAttackTypeMultiplier.value * screenMultiplier.value * twoStrikeMultiplier.value * ((this.scene.randBattleSeedInt(16) + 85) / 100) * criticalMultiplier.value); + const levelMultiplier = (2 * source.level / 5 + 2); + const randomMultiplier = ((this.scene.randBattleSeedInt(16) + 85) / 100); + damage.value = Math.ceil((((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2) + * stabMultiplier.value + * typeMultiplier.value + * arenaAttackTypeMultiplier.value + * screenMultiplier.value + * twoStrikeMultiplier.value + * criticalMultiplier.value + * randomMultiplier); + if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) { if (!move.hasAttr(BypassBurnDamageReductionAttr)) { const burnDamageReductionCancelled = new Utils.BooleanHolder(false); @@ -1913,9 +1921,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (!typeMultiplier.value) { result = move.id === Moves.SHEER_COLD ? HitResult.IMMUNE : HitResult.NO_EFFECT; } else { - const oneHitKo = new Utils.BooleanHolder(false); - applyMoveAttrs(OneHitKOAttr, source, this, move, oneHitKo); - if (oneHitKo.value) { + const isOneHitKo = new Utils.BooleanHolder(false); + applyMoveAttrs(OneHitKOAttr, source, this, move, isOneHitKo); + if (isOneHitKo.value) { result = HitResult.ONE_HIT_KO; isCritical = false; damage.value = this.hp; @@ -1929,24 +1937,27 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } } - if (!fixedDamage.value) { + const isOneHitKo = result === HitResult.ONE_HIT_KO; + + if (!fixedDamage.value && !isOneHitKo) { if (!source.isPlayer()) { this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage); } if (!this.isPlayer()) { this.scene.applyModifiers(EnemyDamageReducerModifier, false, damage); } + + applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, damage); } + // This attribute may modify damage arbitrarily, so be careful about changing its order of application. applyMoveAttrs(ModifiedDamageAttr, source, this, move, damage); - applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, damage); console.log("damage", damage.value, move.name, power, sourceAtk, targetDef); // In case of fatal damage, this tag would have gotten cleared before we could lapse it. const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); - const oneHitKo = result === HitResult.ONE_HIT_KO; if (damage.value) { if (this.getHpRatio() === 1) { applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, damage); @@ -1955,10 +1966,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * We explicitly require to ignore the faint phase here, as we want to show the messages - * about the critical hit and the super effective/not very effective messages before the faint phase. - */ - damage.value = this.damageAndUpdate(damage.value, result as DamageResult, isCritical, oneHitKo, oneHitKo, true); + * We explicitly require to ignore the faint phase here, as we want to show the messages + * about the critical hit and the super effective/not very effective messages before the faint phase. + */ + damage.value = this.damageAndUpdate(damage.value, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true); this.turnData.damageTaken += damage.value; if (isCritical) { this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit")); @@ -2000,7 +2011,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } if (this.isFainted()) { - this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), oneHitKo)); + this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); this.resetSummonData(); } diff --git a/src/test/moves/dragon_rage.test.ts b/src/test/moves/dragon_rage.test.ts new file mode 100644 index 00000000000..51ea9a67728 --- /dev/null +++ b/src/test/moves/dragon_rage.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import overrides from "#app/overrides"; +import { TurnEndPhase } from "#app/phases"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { Species } from "#app/enums/species.js"; +import { Type } from "#app/data/type"; +import { BattleStat } from "#app/data/battle-stat"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import { modifierTypes } from "#app/modifier/modifier-type"; + +describe("Moves - Dragon Rage", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let partyPokemon: PlayerPokemon; + let enemyPokemon: EnemyPokemon; + + const dragonRageDamage = 40; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "NEVER_CRIT_OVERRIDE", "get").mockReturnValue(true); + + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.DRAGON_RAGE]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BALL_FETCH); + vi.spyOn(overrides, "PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BALL_FETCH); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BALL_FETCH); + vi.spyOn(overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BALL_FETCH); + vi.spyOn(overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(100); + + await game.startBattle(); + + partyPokemon = game.scene.getParty()[0]; + enemyPokemon = game.scene.getEnemyPokemon(); + + // remove berries + game.scene.removePartyMemberModifiers(0); + game.scene.clearEnemyHeldItemModifiers(); + }); + + it("ignores weaknesses", async () => { + vi.spyOn(enemyPokemon, "getTypes").mockReturnValue([Type.DRAGON]); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); + + it("ignores resistances", async () => { + vi.spyOn(enemyPokemon, "getTypes").mockReturnValue([Type.STEEL]); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); + + it("ignores stat changes", async () => { + partyPokemon.summonData.battleStats[BattleStat.SPATK] = 2; + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); + + it("ignores stab", async () => { + vi.spyOn(partyPokemon, "getTypes").mockReturnValue([Type.DRAGON]); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); + + it("ignores criticals", async () => { + partyPokemon.removeTag(BattlerTagType.NO_CRIT); + partyPokemon.addTag(BattlerTagType.ALWAYS_CRIT, 99); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); + + it("ignores damage modification from abilities such as ice scales", async () => { + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.ICE_SCALES); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); + + it("ignores multi hit", async () => { + game.scene.addModifier(modifierTypes.MULTI_LENS().newModifier(partyPokemon), false); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase); + const damageDealt = enemyPokemon.getMaxHp() - enemyPokemon.hp; + + expect(damageDealt).toBe(dragonRageDamage); + }); +}); diff --git a/src/test/moves/fissure.test.ts b/src/test/moves/fissure.test.ts new file mode 100644 index 00000000000..6d0dc70fec4 --- /dev/null +++ b/src/test/moves/fissure.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import overrides from "#app/overrides"; +import { DamagePhase } from "#app/phases"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { Species } from "#app/enums/species.js"; +import { EnemyPokemon } from "#app/field/pokemon"; + +describe("Moves - Fissure", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + //let partyPokemon: PlayerPokemon; + let enemyPokemon: EnemyPokemon; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "NEVER_CRIT_OVERRIDE", "get").mockReturnValue(true); + + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.FISSURE]); + vi.spyOn(overrides, "PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BALL_FETCH); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BALL_FETCH); + vi.spyOn(overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(100); + + await game.startBattle(); + + //partyPokemon = game.scene.getParty()[0]; + enemyPokemon = game.scene.getEnemyPokemon(); + + // remove berries + game.scene.removePartyMemberModifiers(0); + game.scene.clearEnemyHeldItemModifiers(); + }); + + it("ignores damage modification from abilities such as fur coat", async () => { + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NO_GUARD); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.FUR_COAT); + + game.doAttack(getMovePosition(game.scene, 0, Moves.FISSURE)); + await game.phaseInterceptor.to(DamagePhase, true); + + expect(enemyPokemon.isFainted()).toBe(true); + }); +});