diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 120d1d413c4..22a2a466a86 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3276,13 +3276,11 @@ export class IntimidateImmunityAbAttr extends AbAttr { export class PostIntimidateStatStageChangeAbAttr extends AbAttr { private stats: BattleStat[]; private stages: number; - private overwrites: boolean; - constructor(stats: BattleStat[], stages: number, overwrites?: boolean) { + constructor(stats: BattleStat[], stages: number) { super(true); this.stats = stats; this.stages = stages; - this.overwrites = !!overwrites; } override apply( @@ -3293,7 +3291,7 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr { _args: any[], ): void { if (!simulated) { - globalScene.phaseManager.pushNew( + globalScene.phaseManager.unshiftNew( "StatStageChangePhase", pokemon.getBattlerIndex(), false, @@ -3301,7 +3299,6 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr { this.stages, ); } - cancelled.value = this.overwrites; } } @@ -3512,7 +3509,6 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr { const cancelled = new BooleanHolder(false); if (this.intimidate) { applyAbAttrs("IntimidateImmunityAbAttr", opponent, cancelled, simulated); - applyAbAttrs("PostIntimidateStatStageChangeAbAttr", opponent, cancelled, simulated); if (opponent.getTag(BattlerTagType.SUBSTITUTE)) { cancelled.value = true; @@ -3527,6 +3523,7 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr { this.stages, ); } + applyAbAttrs("PostIntimidateStatStageChangeAbAttr", opponent, cancelled, simulated); } } } @@ -8886,7 +8883,8 @@ export function initAbilities() { .attr(PostSummonStatStageChangeOnArenaAbAttr, ArenaTagType.TAILWIND) .ignorable(), new Ability(AbilityId.GUARD_DOG, 9) - .attr(PostIntimidateStatStageChangeAbAttr, [ Stat.ATK ], 1, true) + .attr(PostIntimidateStatStageChangeAbAttr, [ Stat.ATK ], 1) + .attr(IntimidateImmunityAbAttr) .attr(ForceSwitchOutImmunityAbAttr) .ignorable(), new Ability(AbilityId.ROCKY_PAYLOAD, 9) diff --git a/test/abilities/rattled.test.ts b/test/abilities/rattled.test.ts new file mode 100644 index 00000000000..301e102511f --- /dev/null +++ b/test/abilities/rattled.test.ts @@ -0,0 +1,125 @@ +import { MoveId } from "#enums/move-id"; +import { AbilityId } from "#enums/ability-id"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { BattleType } from "#enums/battle-type"; +import { getStatKey, getStatStageChangeDescriptionKey, Stat } from "#enums/stat"; +import { BattlerIndex } from "#enums/battler-index"; +import i18next from "i18next"; +import { getPokemonNameWithAffix } from "#app/messages"; + +describe("Abilities - Rattled", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.RATTLED) + .battleType(BattleType.TRAINER) + .disableCrits() + .battleStyle("single") + .enemySpecies(SpeciesId.DUSKULL) + .enemyAbility(AbilityId.INTIMIDATE) + .enemyPassiveAbility(AbilityId.NO_GUARD); + }); + + it.each<{ type: string; move: MoveId }>([ + { type: "Bug", move: MoveId.TWINEEDLE }, + { type: "Ghost", move: MoveId.ASTONISH }, + { type: "Dark", move: MoveId.BEAT_UP }, + ])("should raise the user's Speed by 1 stage for each hit of a $type-type move", async ({ move }) => { + game.override.enemyAbility(AbilityId.BALL_FETCH); + await game.classicMode.startBattle([SpeciesId.GIMMIGHOUL]); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(move); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + game.phaseInterceptor.clearLogs(); + + await game.phaseInterceptor.to("MoveEffectPhase"); + const enemyHits = game.field.getEnemyPokemon().turnData.hitCount; + await game.phaseInterceptor.to("MoveEndPhase"); + + // Rattled should've raised speed once per hit, displaying a separate message each time + const gimmighoul = game.field.getPlayerPokemon(); + expect(gimmighoul.getStatStage(Stat.SPD)).toBe(enemyHits); + expect(game.phaseInterceptor.log.filter(p => p === "ShowAbilityPhase")).toHaveLength(enemyHits); + expect(game.phaseInterceptor.log.filter(p => p === "StatStageChangePhase")).toHaveLength(enemyHits); + const statChangeText = i18next.t(getStatStageChangeDescriptionKey(1, true), { + pokemonNameWithAffix: getPokemonNameWithAffix(gimmighoul), + stats: i18next.t(getStatKey(Stat.SPD)), + count: 1, + }); + expect(game.textInterceptor.logs.filter(t => t === statChangeText)).toHaveLength(enemyHits); + }); + + it.each<{ type: string; move: MoveId }>([ + { type: "Bug", move: MoveId.POWDER }, + { type: "Ghost", move: MoveId.CONFUSE_RAY }, + { type: "Dark", move: MoveId.TAUNT }, + ])("should not trigger from $type-type status moves", async ({ move }) => { + game.override.enemyAbility(AbilityId.BALL_FETCH); + await game.classicMode.startBattle([SpeciesId.GIMMIGHOUL]); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(move); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("MoveEndPhase"); + + const gimmighoul = game.field.getPlayerPokemon(); + expect(gimmighoul.getStatStage(Stat.SPD)).toBe(0); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + expect(game.phaseInterceptor.log).not.toContain("StatStageChangePhase"); + }); + + it("should activate after Intimidate attack drop on initial send out", async () => { + // `runToSummon` used instead of `startBattle` to avoid skipping past initial "post send out" effects + await game.classicMode.runToSummon([SpeciesId.GIMMIGHOUL]); + + // Intimidate + await game.phaseInterceptor.to("StatStageChangePhase"); + + const playerPokemon = game.field.getPlayerPokemon(); + expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(playerPokemon.getStatStage(Stat.SPD)).toBe(0); + game.phaseInterceptor.clearLogs(); + + // Rattled + await game.phaseInterceptor.to("StatStageChangePhase"); + + expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(playerPokemon.getStatStage(Stat.SPD)).toBe(1); + // Nothing but show/hide ability phases should be visible + for (const log of game.phaseInterceptor.log) { + expect(log).toBeOneOf(["ShowAbilityPhase", "HideAbilityPhase", "StatStageChangePhase", "MessagePhase"]); + } + }); + + it("should activate after Intimidate from enemy switch", async () => { + await game.classicMode.startBattle([SpeciesId.GIMMIGHOUL, SpeciesId.BULBASAUR]); + + game.move.use(MoveId.SPLASH); + game.forceEnemyToSwitch(); + await game.phaseInterceptor.to("StatStageChangePhase"); + + const playerPokemon = game.field.getPlayerPokemon(); + expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-2); + expect(playerPokemon.getStatStage(Stat.SPD)).toBe(1); + + await game.phaseInterceptor.to("StatStageChangePhase"); + expect(playerPokemon.getStatStage(Stat.SPD)).toBe(2); + }); +});