diff --git a/src/data/ability.ts b/src/data/ability.ts index b1f0d2b197c..0aa4c948f75 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1623,6 +1623,68 @@ export class PostDefendStealHeldItemAbAttr extends PostDefendAbAttr { } } +/** + * Base class for defining all {@linkcode Ability} Attributes after a status effect has been set. + * @see {@linkcode applyPostSetStatus()}. + */ +export class PostSetStatusAbAttr extends AbAttr { + /** + * Does nothing after a status condition is set. + * @param pokemon {@linkcode Pokemon} that status condition was set on. + * @param sourcePokemon {@linkcode Pokemon} that that set the status condition. Is null if status was not set by a Pokemon. + * @param passive Whether this ability is a passive. + * @param effect {@linkcode StatusEffect} that was set. + * @param args Set of unique arguments needed by this attribute. + * @returns true if application of the ability succeeds. + */ + applyPostSetStatus( + pokemon: Pokemon, + sourcePokemon: Pokemon = null, + passive: boolean, + effect: StatusEffect, + args: any[]) : boolean | Promise { + return false; + } +} + +/** + * If another Pokemon burns, paralyzes, poisons, or badly poisons this Pokemon, + * that Pokemon receives the same non-volatile status condition as part of this + * ability attribute. For Synchronize ability. + */ +export class SynchronizeStatusAbAttr extends PostSetStatusAbAttr { + /** + * If the StatusEffect that was set is Burn, Paralysis, Poison, or Toxic, and the status + * was set by a source Pokemon, set the source Pokemon's status to the same StatusEffect. + * @param pokemon {@linkcode Pokemon} that status condition was set on. + * @param sourcePokemon {@linkcode Pokemon} that that set the status condition. Is null if status was not set by a Pokemon. + * @param passive Whether this ability is a passive. + * @param effect {@linkcode StatusEffect} that was set. + * @param args Set of unique arguments needed by this attribute. + * @returns true if application of the ability succeeds. + */ + applyPostSetStatus( + pokemon: Pokemon, + sourcePokemon: Pokemon = null, + passive: boolean, + effect: StatusEffect, + args: any[]): boolean { + // Synchronizable statuses + const syncStatuses = new Set([ + StatusEffect.BURN, + StatusEffect.PARALYSIS, + StatusEffect.POISON, + StatusEffect.TOXIC + ]); + + if (sourcePokemon && syncStatuses.has(effect)) { + return sourcePokemon.trySetStatus(effect, true); + } + + return false; + } +} + export class PostVictoryAbAttr extends AbAttr { applyPostVictory(pokemon: Pokemon, passive: boolean, args: any[]): boolean | Promise { return false; @@ -4012,6 +4074,11 @@ export function applyPostMoveUsedAbAttrs(attrType: Constructor(attrType, pokemon, (attr, passive) => attr.applyPostMoveUsed(pokemon, move, source, targets, args), args); } +export function applyPostSetStatusAbAttrs(attrType: Constructor, + pokemon: Pokemon, effect: StatusEffect, sourcePokemon?: Pokemon, ...args: any[]): Promise { + return applyAbAttrsInternal(attrType, pokemon, (attr, passive) => attr.applyPostSetStatus(pokemon, sourcePokemon, passive, effect, args), args); +} + export function applyBattleStatMultiplierAbAttrs(attrType: Constructor, pokemon: Pokemon, battleStat: BattleStat, statValue: Utils.NumberHolder, ...args: any[]): Promise { return applyAbAttrsInternal(attrType, pokemon, (attr, passive) => attr.applyBattleStat(pokemon, passive, battleStat, statValue, args), args); @@ -4239,7 +4306,8 @@ export function initAbilities() { .attr(EffectSporeAbAttr), new Ability(Abilities.SYNCHRONIZE, 3) .attr(SyncEncounterNatureAbAttr) - .unimplemented(), + .attr(SynchronizeStatusAbAttr) + .partial(), // interaction with psycho shift needs work new Ability(Abilities.CLEAR_BODY, 3) .attr(ProtectStatAbAttr) .ignorable(), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 3ee19920ae9..37f7616b511 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -23,7 +23,7 @@ import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HelpingHandTag import { WeatherType } from "../data/weather"; import { TempBattleStat } from "../data/temp-battle-stat"; import { ArenaTagSide, WeakenMoveScreenTag, WeakenMoveTypeTag } from "../data/arena-tag"; -import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, MoveTypeChangeAttr, PreApplyBattlerTagAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, VariableMovePowerAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AllyMoveCategoryPowerBoostAbAttr, FieldMoveTypePowerBoostAbAttr, AddSecondStrikeAbAttr } from "../data/ability"; +import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, MoveTypeChangeAttr, PreApplyBattlerTagAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, VariableMovePowerAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AllyMoveCategoryPowerBoostAbAttr, FieldMoveTypePowerBoostAbAttr, AddSecondStrikeAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs } from "../data/ability"; import PokemonData from "../system/pokemon-data"; import { BattlerIndex } from "../battle"; import { Mode } from "../ui/ui"; @@ -2586,6 +2586,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (effect !== StatusEffect.FAINT) { this.scene.triggerPokemonFormChange(this, SpeciesFormChangeStatusEffectTrigger, true); + applyPostSetStatusAbAttrs(PostSetStatusAbAttr, this, effect, sourcePokemon); } return true; diff --git a/src/test/abilities/synchronize.test.ts b/src/test/abilities/synchronize.test.ts new file mode 100644 index 00000000000..302312f1f6a --- /dev/null +++ b/src/test/abilities/synchronize.test.ts @@ -0,0 +1,212 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import * as overrides from "#app/overrides"; +import { + MoveEffectPhase, + TurnEndPhase, +} from "#app/phases"; +import {getMovePosition} from "#app/test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { StatusEffect } from "#app/data/status-effect.js"; + +describe("Abilities - Synchronize", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(overrides, "STATUS_OVERRIDE", "get").mockReturnValue(StatusEffect.NONE); + vi.spyOn(overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(StatusEffect.NONE); + // Opponent mocks + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.ALAKAZAM); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.PSYCHIC, Moves.CALM_MIND, Moves.FOCUS_BLAST, Moves.RECOVER]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.SYNCHRONIZE); + }, 20000); + + it("does not trigger when no status is applied by opponent Pokemon", async () => { + // Arrange + const moveToUse = Moves.HEADBUTT; + + // Starter mocks + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.ZIGZAGOON); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.PICKUP); + + // Act + await game.startBattle(); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status).toBe(undefined); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + }, 20000); + + it("sets the status of the source pokemon to Paralysis when paralyzed by it", async () => { + // Arrange + const moveToUse = Moves.THUNDER_WAVE; + + // Starter mocks + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.TOGEKISS); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.SERENE_GRACE); + + // Act + await game.startBattle(); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS); + expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }, 20000); + + it("sets the status of the source pokemon to Burned when burn is applied by it", async () => { + // Arrange + const moveToUse = Moves.WILL_O_WISP; + + // Starter mocks + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.EEVEE); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.GUTS); + + // Act + await game.startBattle(); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.BURN); + expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.BURN); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }, 20000); + + it("sets the status of the source pokemon to Poisoned when poison is applied by it", async () => { + // Arrange + const moveToUse = Moves.POISON_POWDER; + + // Starter mocks + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BRELOOM); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.TECHNICIAN); + + // Act + await game.startBattle(); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.POISON); + expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.POISON); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }, 20000); + + it("sets the status of the source pokemon to Toxic when toxic is applied by it", async () => { + // Arrange + const moveToUse = Moves.TOXIC; + + // Starter mocks + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.QUAGSIRE); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.DAMP); + + // Act + await game.startBattle(); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.TOXIC); + expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.TOXIC); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }, 20000); + + it("does not trigger when Pokemon is statused to non Burn, Paralysis, Poison, or Toxic", async () => { + // Arrange + const moveToUse = Moves.SPORE; + + // Starter mocks + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BRELOOM); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.POISON_HEAL); + + // Act + await game.startBattle(); + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status?.effect).toBe(undefined); + expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.SLEEP); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + }, 20000); + + it("does not trigger when Pokemon is statused by Toxic Spikes", async () => { + // Arrange + const moveToUse = Moves.SPLASH; + + // Starter mocks + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.SYNCHRONIZE); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BRELOOM); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TOXIC_SPIKES]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.TECHNICIAN); + + // Act + // Turn 1 - Opponent uses spikes, trainer uses splash + // Turn 2 - Opponent uses splash, trainer sends out Alakazam. Alakazam is toxic-ed but Synchronize should not proc + await game.startBattle([Species.MAGIKARP, Species.ALAKAZAM]); + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); // use splash + + await game.toNextTurn(); + game.doSwitchPokemon(1); + game.doAttack(0); + + await game.phaseInterceptor.to(TurnEndPhase); + + // Assert + expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.POISON); + expect(game.scene.getEnemyParty()[0].status?.effect).toBe(undefined); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + }, 20000); +});