[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
This commit is contained in:
Corrade 2024-07-15 09:16:15 +10:00 committed by GitHub
parent a9a071bb4d
commit b1e7ae43a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 220 additions and 14 deletions

View File

@ -1869,9 +1869,17 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, numTargets, new Utils.IntegerHolder(0), twoStrikeMultiplier); applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, numTargets, new Utils.IntegerHolder(0), twoStrikeMultiplier);
if (!isTypeImmune) { if (!isTypeImmune) {
damage.value = Math.ceil( const levelMultiplier = (2 * source.level / 5 + 2);
((((2 * source.level / 5 + 2) * power * sourceAtk.value / targetDef.value) / 50) + 2) const randomMultiplier = ((this.scene.randBattleSeedInt(16) + 85) / 100);
* stabMultiplier.value * typeMultiplier.value * arenaAttackTypeMultiplier.value * screenMultiplier.value * twoStrikeMultiplier.value * ((this.scene.randBattleSeedInt(16) + 85) / 100) * criticalMultiplier.value); 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 (isPhysical && source.status && source.status.effect === StatusEffect.BURN) {
if (!move.hasAttr(BypassBurnDamageReductionAttr)) { if (!move.hasAttr(BypassBurnDamageReductionAttr)) {
const burnDamageReductionCancelled = new Utils.BooleanHolder(false); const burnDamageReductionCancelled = new Utils.BooleanHolder(false);
@ -1913,9 +1921,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (!typeMultiplier.value) { if (!typeMultiplier.value) {
result = move.id === Moves.SHEER_COLD ? HitResult.IMMUNE : HitResult.NO_EFFECT; result = move.id === Moves.SHEER_COLD ? HitResult.IMMUNE : HitResult.NO_EFFECT;
} else { } else {
const oneHitKo = new Utils.BooleanHolder(false); const isOneHitKo = new Utils.BooleanHolder(false);
applyMoveAttrs(OneHitKOAttr, source, this, move, oneHitKo); applyMoveAttrs(OneHitKOAttr, source, this, move, isOneHitKo);
if (oneHitKo.value) { if (isOneHitKo.value) {
result = HitResult.ONE_HIT_KO; result = HitResult.ONE_HIT_KO;
isCritical = false; isCritical = false;
damage.value = this.hp; 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()) { if (!source.isPlayer()) {
this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage); this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage);
} }
if (!this.isPlayer()) { if (!this.isPlayer()) {
this.scene.applyModifiers(EnemyDamageReducerModifier, false, damage); 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); applyMoveAttrs(ModifiedDamageAttr, source, this, move, damage);
applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, damage);
console.log("damage", damage.value, move.name, power, sourceAtk, targetDef); 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. // In case of fatal damage, this tag would have gotten cleared before we could lapse it.
const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND);
const oneHitKo = result === HitResult.ONE_HIT_KO;
if (damage.value) { if (damage.value) {
if (this.getHpRatio() === 1) { if (this.getHpRatio() === 1) {
applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, damage); 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 * 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. * 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); damage.value = this.damageAndUpdate(damage.value, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true);
this.turnData.damageTaken += damage.value; this.turnData.damageTaken += damage.value;
if (isCritical) { if (isCritical) {
this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit")); this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit"));
@ -2000,7 +2011,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
} }
if (this.isFainted()) { 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(); this.resetSummonData();
} }

View File

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

View File

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