From f8c8605710689f0f11ec380e24847786a59174fb Mon Sep 17 00:00:00 2001 From: Zach Day Date: Thu, 13 Jun 2024 14:23:13 -0400 Subject: [PATCH] [Move] Fix multi-hit moves activating type-immunity abilities multiple times (#2165) * Force multi-hit moves to hit once if they are against an immune type * Add test for multi-hit attacks against immune types * Document MultiHitAttr * Tiny change * Wording fix * Use shortcut methods in unit tests * Fix leftover modifications from METRONOME testing * Reorganize SAP SIPPER tests * Fix extra imports --- src/data/move.ts | 104 +++++++++++++++++--------- src/test/abilities/sap_sipper.test.ts | 81 +++++++++++++------- 2 files changed, 123 insertions(+), 62 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index 3979314568b..0c9fb7fc9b2 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1540,6 +1540,14 @@ export class IncrementMovePriorityAttr extends MoveAttr { } } +/** + * Attribute used for attack moves that hit multiple times per use, e.g. Bullet Seed. + * + * Applied at the beginning of {@linkcode MoveEffectPhase}. + * + * @extends MoveAttr + * @see {@linkcode apply} + */ export class MultiHitAttr extends MoveAttr { private multiHitType: MultiHitType; @@ -1549,43 +1557,28 @@ export class MultiHitAttr extends MoveAttr { this.multiHitType = multiHitType !== undefined ? multiHitType : MultiHitType._2_TO_5; } + /** + * Set the hit count of an attack based on this attribute instance's {@linkcode MultiHitType}. + * If the target has an immunity to this attack's types, the hit count will always be 1. + * + * @param user {@linkcode Pokemon} that used the attack + * @param target {@linkcode Pokemon} targeted by the attack + * @param move {@linkcode Move} being used + * @param args [0] {@linkcode Utils.IntegerHolder} storing the hit count of the attack + * @returns True + */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { let hitTimes: integer; - const hitType = new Utils.IntegerHolder(this.multiHitType); - applyMoveAttrs(ChangeMultiHitTypeAttr, user, target, move, hitType); - switch (hitType.value) { - case MultiHitType._2_TO_5: - { - const rand = user.randSeedInt(16); - const hitValue = new Utils.IntegerHolder(rand); - applyAbAttrs(MaxMultiHitAbAttr, user, null, hitValue); - if (hitValue.value >= 10) { - hitTimes = 2; - } else if (hitValue.value >= 4) { - hitTimes = 3; - } else if (hitValue.value >= 2) { - hitTimes = 4; - } else { - hitTimes = 5; - } - } - break; - case MultiHitType._2: - hitTimes = 2; - break; - case MultiHitType._3: - hitTimes = 3; - break; - case MultiHitType._10: - hitTimes = 10; - break; - case MultiHitType.BEAT_UP: - const party = user.isPlayer() ? user.scene.getParty() : user.scene.getEnemyParty(); - // No status means the ally pokemon can contribute to Beat Up - hitTimes = party.reduce((total, pokemon) => { - return total + (pokemon.id === user.id ? 1 : pokemon?.status && pokemon.status.effect !== StatusEffect.NONE ? 0 : 1); - }, 0); + + if (target.getAttackMoveEffectiveness(user, new PokemonMove(move.id)) === 0) { + // If there is a type immunity, the attack will stop no matter what + hitTimes = 1; + } else { + const hitType = new Utils.IntegerHolder(this.multiHitType); + applyMoveAttrs(ChangeMultiHitTypeAttr, user, target, move, hitType); + hitTimes = this.getHitCount(user, target); } + (args[0] as Utils.IntegerHolder).value = hitTimes; return true; } @@ -1593,6 +1586,49 @@ export class MultiHitAttr extends MoveAttr { getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { return -5; } + + /** + * Calculate the number of hits that an attack should have given this attribute's + * {@linkcode MultiHitType}. + * + * @param user {@linkcode Pokemon} using the attack + * @param target {@linkcode Pokemon} targeted by the attack + * @returns The number of hits this attack should deal + */ + getHitCount(user: Pokemon, target: Pokemon): integer { + switch (this.multiHitType) { + case MultiHitType._2_TO_5: + { + const rand = user.randSeedInt(16); + const hitValue = new Utils.IntegerHolder(rand); + applyAbAttrs(MaxMultiHitAbAttr, user, null, hitValue); + if (hitValue.value >= 10) { + return 2; + } else if (hitValue.value >= 4) { + return 3; + } else if (hitValue.value >= 2) { + return 4; + } else { + return 5; + } + } + case MultiHitType._2: + return 2; + break; + case MultiHitType._3: + return 3; + break; + case MultiHitType._10: + return 10; + break; + case MultiHitType.BEAT_UP: + const party = user.isPlayer() ? user.scene.getParty() : user.scene.getEnemyParty(); + // No status means the ally pokemon can contribute to Beat Up + return party.reduce((total, pokemon) => { + return total + (pokemon.id === user.id ? 1 : pokemon?.status && pokemon.status.effect !== StatusEffect.NONE ? 0 : 1); + }, 0); + } + } } export class ChangeMultiHitTypeAttr extends MoveAttr { diff --git a/src/test/abilities/sap_sipper.test.ts b/src/test/abilities/sap_sipper.test.ts index e2b3099f28f..7725806555a 100644 --- a/src/test/abilities/sap_sipper.test.ts +++ b/src/test/abilities/sap_sipper.test.ts @@ -4,13 +4,10 @@ import GameManager from "#app/test/utils/gameManager"; import * as overrides from "#app/overrides"; import {Species} from "#app/data/enums/species"; import { - CommandPhase, - EnemyCommandPhase, MoveEndPhase, TurnEndPhase, + MoveEndPhase, TurnEndPhase, } from "#app/phases"; -import {Mode} from "#app/ui/ui"; import {Moves} from "#app/data/enums/moves"; import {getMovePosition} from "#app/test/utils/gameManagerUtils"; -import {Command} from "#app/ui/command-ui-handler"; import { Abilities } from "#app/data/enums/abilities.js"; import { BattleStat } from "#app/data/battle-stat.js"; import { TerrainType } from "#app/data/terrain.js"; @@ -50,15 +47,9 @@ describe("Abilities - Sap Sipper", () => { const startingOppHp = game.scene.currentBattle.enemyParty[0].hp; - game.onNextPrompt("CommandPhase", Mode.COMMAND, () => { - game.scene.ui.setMode(Mode.FIGHT, (game.scene.getCurrentPhase() as CommandPhase).getFieldIndex()); - }); - game.onNextPrompt("CommandPhase", Mode.FIGHT, () => { - const movePosition = getMovePosition(game.scene, 0, moveToUse); - (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, movePosition, false); - }); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); - await game.phaseInterceptor.runFrom(EnemyCommandPhase).to(TurnEndPhase); + await game.phaseInterceptor.to(TurnEndPhase); expect(startingOppHp - game.scene.getEnemyParty()[0].hp).toBe(0); expect(game.scene.getEnemyParty()[0].summonData.battleStats[BattleStat.ATK]).toBe(1); @@ -75,15 +66,9 @@ describe("Abilities - Sap Sipper", () => { await game.startBattle(); - game.onNextPrompt("CommandPhase", Mode.COMMAND, () => { - game.scene.ui.setMode(Mode.FIGHT, (game.scene.getCurrentPhase() as CommandPhase).getFieldIndex()); - }); - game.onNextPrompt("CommandPhase", Mode.FIGHT, () => { - const movePosition = getMovePosition(game.scene, 0, moveToUse); - (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, movePosition, false); - }); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); - await game.phaseInterceptor.runFrom(EnemyCommandPhase).to(TurnEndPhase); + await game.phaseInterceptor.to(TurnEndPhase); expect(game.scene.getEnemyParty()[0].status).toBeUndefined(); expect(game.scene.getEnemyParty()[0].summonData.battleStats[BattleStat.ATK]).toBe(1); @@ -100,21 +85,36 @@ describe("Abilities - Sap Sipper", () => { await game.startBattle(); - game.onNextPrompt("CommandPhase", Mode.COMMAND, () => { - game.scene.ui.setMode(Mode.FIGHT, (game.scene.getCurrentPhase() as CommandPhase).getFieldIndex()); - }); - game.onNextPrompt("CommandPhase", Mode.FIGHT, () => { - const movePosition = getMovePosition(game.scene, 0, moveToUse); - (game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, movePosition, false); - }); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); - await game.phaseInterceptor.runFrom(EnemyCommandPhase).to(TurnEndPhase); + await game.phaseInterceptor.to(TurnEndPhase); expect(game.scene.arena.terrain).toBeDefined(); expect(game.scene.arena.terrain.terrainType).toBe(TerrainType.GRASSY); expect(game.scene.getEnemyParty()[0].summonData.battleStats[BattleStat.ATK]).toBe(0); }); + it("activate once against multi-hit grass attacks", async() => { + const moveToUse = Moves.BULLET_SEED; + const enemyAbility = Abilities.SAP_SIPPER; + + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + const startingOppHp = game.scene.currentBattle.enemyParty[0].hp; + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(startingOppHp - game.scene.getEnemyParty()[0].hp).toBe(0); + expect(game.scene.getEnemyParty()[0].summonData.battleStats[BattleStat.ATK]).toBe(1); + }); + it("do not activate against status moves that target the user", async() => { const moveToUse = Moves.SPIKY_SHIELD; const ability = Abilities.SAP_SIPPER; @@ -138,4 +138,29 @@ describe("Abilities - Sap Sipper", () => { expect(game.scene.getParty()[0].summonData.battleStats[BattleStat.ATK]).toBe(0); expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); }); + + /* + // TODO Add METRONOME outcome override + // To run this testcase, manually modify the METRONOME move to always give SAP_SIPPER, then uncomment + it("activate once against multi-hit grass attacks (metronome)", async() => { + const moveToUse = Moves.METRONOME; + const enemyAbility = Abilities.SAP_SIPPER; + + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + const startingOppHp = game.scene.currentBattle.enemyParty[0].hp; + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(startingOppHp - game.scene.getEnemyParty()[0].hp).toBe(0); + expect(game.scene.getEnemyParty()[0].summonData.battleStats[BattleStat.ATK]).toBe(1); + }); + */ });