[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
This commit is contained in:
Zach Day 2024-06-13 14:23:13 -04:00 committed by GitHub
parent 7b0ec0faf2
commit f8c8605710
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 62 deletions

View File

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

View File

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