[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:
parent
7b0ec0faf2
commit
f8c8605710
104
src/data/move.ts
104
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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
*/
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue