diff --git a/src/battle-scene.ts b/src/battle-scene.ts index b802466ee19..747a717a46a 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -836,7 +836,7 @@ export default class BattleScene extends SceneBase { /** * Returns an array of Pokemon on both sides of the battle - player first, then enemy. * Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type. - * @param activeOnly - Whether to consider only active pokemon; default `false` + * @param activeOnly - Whether to consider only active pokemon (see {@linkcode Pokemon.isActive()} for more info); default `false` * @returns An array of {@linkcode Pokemon}, as described above. */ public getField(activeOnly = false): Pokemon[] { diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 70195d6a152..065d9865929 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -11,7 +11,7 @@ import { coerceArray, } from "#app/utils/common"; import { getPokemonNameWithAffix } from "#app/messages"; -import { GroundedTag } from "#app/data/battler-tags"; +import { GroundedTag, SemiInvulnerableTag } from "#app/data/battler-tags"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { getNonVolatileStatusEffects, @@ -63,7 +63,7 @@ import type { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import type { BattleStat, EffectiveStat } from "#enums/stat"; import type { BerryType } from "#enums/berry-type"; import type { EnemyPokemon } from "#app/field/pokemon"; -import type { PokemonMove } from "../moves/pokemon-move"; +import { PokemonMove } from "../moves/pokemon-move"; import type Pokemon from "#app/field/pokemon"; import type { Weather } from "#app/data/weather"; import type { BattlerTag } from "#app/data/battler-tags"; @@ -78,7 +78,10 @@ import type { import type { BattlerIndex } from "#enums/battler-index"; import type Move from "#app/data/moves/move"; import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag"; +import type { HitCheckEntry } from "#app/phases/move-effect-phase"; +import { HitCheckResult } from "#enums/hit-check-result"; import type { Constructor } from "#app/utils/common"; +import { getMoveTargets } from "#app/data/moves/move-utils"; import type { Localizable } from "#app/@types/locales"; import { applyAbAttrs } from "./apply-ab-attrs"; @@ -3285,11 +3288,11 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr { private stages: number; private overwrites: boolean; - constructor(stats: BattleStat[], stages: number, overwrites?: boolean) { + constructor(stats: BattleStat[], stages: number, overwrites = false) { super(true); this.stats = stats; this.stages = stages; - this.overwrites = !!overwrites; + this.overwrites = overwrites; } override apply( @@ -3384,11 +3387,17 @@ export class PostSummonRemoveArenaTagAbAttr extends PostSummonAbAttr { export class PostSummonAddArenaTagAbAttr extends PostSummonAbAttr { private readonly tagType: ArenaTagType; private readonly turnCount: number; - private readonly side?: ArenaTagSide; - private readonly quiet?: boolean; + private readonly side: ArenaTagSide; + private readonly quiet: boolean; private sourceId: number; - constructor(showAbility: boolean, tagType: ArenaTagType, turnCount: number, side?: ArenaTagSide, quiet?: boolean) { + constructor( + showAbility: boolean, + tagType: ArenaTagType, + turnCount: number, + side: ArenaTagSide = ArenaTagSide.BOTH, + quiet = false, + ) { super(showAbility); this.tagType = tagType; this.turnCount = turnCount; @@ -3441,7 +3450,7 @@ export class PostSummonAddBattlerTagAbAttr extends PostSummonAbAttr { private tagType: BattlerTagType; private turnCount: number; - constructor(tagType: BattlerTagType, turnCount: number, showAbility?: boolean) { + constructor(tagType: BattlerTagType, turnCount: number, showAbility = true) { super(showAbility); this.tagType = tagType; @@ -3490,13 +3499,13 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr { private selfTarget: boolean; private intimidate: boolean; - constructor(stats: BattleStat[], stages: number, selfTarget?: boolean, intimidate?: boolean) { + constructor(stats: BattleStat[], stages: number, selfTarget = false, intimidate = false) { super(true); this.stats = stats; this.stages = stages; - this.selfTarget = !!selfTarget; - this.intimidate = !!intimidate; + this.selfTarget = selfTarget; + this.intimidate = intimidate; } override applyPostSummon(pokemon: Pokemon, _passive: boolean, simulated: boolean, _args: any[]): void { @@ -3539,6 +3548,10 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr { } } +/** + * Attribute to heal the user's allies upon entering battle. + * Used by {@linkcode AbilityId.HOSPITALITY}. + */ export class PostSummonAllyHealAbAttr extends PostSummonAbAttr { private healRatio: number; private showAnim: boolean; @@ -4856,7 +4869,7 @@ export class MultCritAbAttr extends AbAttr { export class ConditionalCritAbAttr extends AbAttr { private condition: PokemonAttackCondition; - constructor(condition: PokemonAttackCondition, _checkUser?: boolean) { + constructor(condition: PokemonAttackCondition) { super(false); this.condition = condition; @@ -5045,10 +5058,10 @@ export class BlockWeatherDamageAttr extends PreWeatherDamageAbAttr { export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr { public affectsImmutable: boolean; - constructor(affectsImmutable?: boolean) { + constructor(affectsImmutable = false) { super(true); - this.affectsImmutable = !!affectsImmutable; + this.affectsImmutable = affectsImmutable; } override canApplyPreWeatherEffect( @@ -5891,6 +5904,7 @@ export class PostTurnHurtIfSleepingAbAttr extends PostTurnAbAttr { !opp.switchOutStatus, ); } + /** * Deals damage to all sleeping opponents equal to 1/8 of their max hp (min 1) * @param pokemon {@linkcode Pokemon} with this ability @@ -5999,106 +6013,152 @@ export class PostBiomeChangeTerrainChangeAbAttr extends PostBiomeChangeAbAttr { } /** - * Triggers just after a move is used either by the opponent or the player - * @extends AbAttr + * Attribute to trigger effects after a move is used by either side of the field. */ export class PostMoveUsedAbAttr extends AbAttr { + /** + * Check whether this ability can be applied after a move is used. + * @param pokemon - The {@linkcode Pokemon} with the ability + * @param move - The {@linkcode Move} having been been used + * @param source - The {@linkcode Pokemon} who used the move + * @param targets - Array of {@linkcode BattlerIndex}es containing Pokemon targeted by move. + * @param hitChecks - Array of {@linkcode HitCheckEntry | HitCheckEntries} containing results of move usage + * @param simulated - Whether the ability call is simulated + * @param args - Extra arguments passed to the function. Handled by child classes. + * @returns Whether the ability can be successfully applied. + * By default, it requires: + * * The pokemon with the ability is not semi-invulnerable + * * The move was used by another pokemon + * * The move's effect was successfully applied against at least 1 target + */ canApplyPostMoveUsed( - _pokemon: Pokemon, - _move: PokemonMove, - _source: Pokemon, - _targets: BattlerIndex[], + pokemon: Pokemon, + _move: Move, + source: Pokemon, + targets: BattlerIndex[], + hitChecks: HitCheckEntry[], _simulated: boolean, _args: any[], ): boolean { - return true; + return ( + !pokemon.getTag(SemiInvulnerableTag) && + source.getBattlerIndex() !== pokemon.getBattlerIndex() && + targets.length > 0 && + hitChecks.some(hr => hr[0] === HitCheckResult.HIT) + ); } applyPostMoveUsed( _pokemon: Pokemon, - _move: PokemonMove, + _move: Move, _source: Pokemon, _targets: BattlerIndex[], + _hitChecks: HitCheckEntry[], _simulated: boolean, _args: any[], ): void {} } /** - * Triggers after a dance move is used either by the opponent or the player - * @extends PostMoveUsedAbAttr + * Triggers after a dance move is used either by the opponent or the player. */ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { - override canApplyPostMoveUsed( + /** + * Check whether this ability can be applied after a move is used. + * @param dancer - The {@linkcode Pokemon} with the ability + * @param move - The {@linkcode Move} having been used + * @param source - The {@linkcode Pokemon} who used the move + * @param targets - Array of {@linkcode BattlerIndex}es containing Pokemon targeted by move. + * @param hitResults - Array of {@linkcode HitCheckEntry | HitCheckEntries} containing results of move usage + * @param simulated - N/A + * @param args - N/A + * @returns `true` if the ability can be applied, `false` otherwise + * @see {@linkcode applyPostMoveUsed} + */ + canApplyPostMoveUsed( dancer: Pokemon, - _move: PokemonMove, + move: Move, source: Pokemon, - _targets: BattlerIndex[], - _simulated: boolean, - _args: any[], + targets: BattlerIndex[], + hitResults: HitCheckEntry[], + simulated: boolean, + args: any[], ): boolean { - // List of tags that prevent the Dancer from replicating the move - const forbiddenTags = [ - BattlerTagType.FLYING, - BattlerTagType.UNDERWATER, - BattlerTagType.UNDERGROUND, - BattlerTagType.HIDDEN, - ]; - // The move to replicate cannot come from the Dancer return ( - source.getBattlerIndex() !== dancer.getBattlerIndex() && - !dancer.summonData.tags.some(tag => forbiddenTags.includes(tag.tagType)) + super.canApplyPostMoveUsed(dancer, move, source, targets, hitResults, simulated, args) && + move.hasFlag(MoveFlags.DANCE_MOVE) ); } /** - * Resolves the Dancer ability by replicating the move used by the source of the dance - * either on the source itself or on the target of the dance - * @param dancer {@linkcode Pokemon} with Dancer ability - * @param move {@linkcode PokemonMove} Dancing move used by the source - * @param source {@linkcode Pokemon} that used the dancing move - * @param targets {@linkcode BattlerIndex}Targets of the dancing move - * @param _args N/A + * Resolves the {@linkcode AbilityId.DANCER | Dancer} ability by replicating other Pokemon's dance moves + * on either this Pokemon or the user of the move. + * @param dancer - The {@linkcode Pokemon} with Dancer + * @param move - The {@linkcode Move} having been used + * @param source - The {@linkcode Pokemon} who used the move + * @param targets - Array of {@linkcode BattlerIndex}es containing Pokemon targeted by original move + * @param _hitResults - N/A + * @param _simulated - Whether the ability call is simulated + * @param _args - N/A */ override applyPostMoveUsed( dancer: Pokemon, - move: PokemonMove, + move: Move, source: Pokemon, targets: BattlerIndex[], - simulated: boolean, + _hitResults: HitCheckEntry[], + _simulated: boolean, _args: any[], ): void { - if (!simulated) { - dancer.turnData.extraTurns++; - // If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance - if (move.getMove().is("AttackMove") || move.getMove().is("StatusMove")) { - const target = this.getTarget(dancer, source, targets); - globalScene.phaseManager.unshiftNew("MovePhase", dancer, target, move, MoveUseMode.INDIRECT); - } else if (move.getMove().is("SelfStatusMove")) { - // If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself - globalScene.phaseManager.unshiftNew( - "MovePhase", - dancer, - [dancer.getBattlerIndex()], - move, - MoveUseMode.INDIRECT, - ); - } - } + // increment extra turns var to ensure subsequent multi-hit moves don't bork + dancer.turnData.extraTurns++; + + globalScene.phaseManager.unshiftNew( + "MovePhase", + dancer, + this.getMoveTargets(dancer, source, move, targets), + new PokemonMove(move.id), + MoveUseMode.INDIRECT, + ); } /** - * Get the correct targets of Dancer ability + * Get the correct targets of Dancer ability. * - * @param dancer {@linkcode Pokemon} Pokemon with Dancer ability - * @param source {@linkcode Pokemon} Source of the dancing move - * @param targets {@linkcode BattlerIndex} Targets of the dancing move + * @param dancer - The {@linkcode Pokemon} with Dancer + * @param source - The {@linkcode Pokemon} that used the dancing move + * @param move - The {@linkcode Move} being used + * @param targets - An array of {@linkcode BattlerIndex}es containing original targets of copied move */ - getTarget(dancer: Pokemon, source: Pokemon, targets: BattlerIndex[]): BattlerIndex[] { - if (dancer.isPlayer()) { - return source.isPlayer() ? targets : [source.getBattlerIndex()]; + private getMoveTargets(dancer: Pokemon, source: Pokemon, move: Move, targets: BattlerIndex[]): BattlerIndex[] { + if (move.isMultiTarget()) { + return getMoveTargets(dancer, move.id).targets; } - return source.isPlayer() ? [source.getBattlerIndex()] : targets; + + // Self-targeted status moves (Swords Dance & co.) are always replicated on the user. + if (move.is("SelfStatusMove")) { + return [dancer.getBattlerIndex()]; + } + + // Attack moves are unleashed on the source of the dance UNLESS they are an ally attacking an enemy + // (in which case we retain the prior move's targets) + if (dancer.isPlayer() !== source.isPlayer() || targets.includes(dancer.getBattlerIndex())) { + targets = [source.getBattlerIndex()]; + } + + // Attempt to redirect to the prior target's partner if fainted and not our own ally. + const firstTarget = globalScene.getField()[targets[0]]; + const ally = firstTarget.getAlly(); + if ( + globalScene.currentBattle.double && + firstTarget.isFainted() && + firstTarget.isPlayer() !== dancer.isPlayer() && + ally?.isActive() + ) { + return [ally.getBattlerIndex()]; + } + + return targets; } } @@ -6200,7 +6260,7 @@ export class BypassBurnDamageReductionAbAttr extends AbAttr { /** * Causes Pokemon to take reduced damage from the {@linkcode StatusEffect.BURN | Burn} status - * @param multiplier Multiplied with the damage taken + * @param multiplier - Multiplier for burn damage taken */ export class ReduceBurnDamageAbAttr extends AbAttr { constructor(protected multiplier: number) { @@ -6208,7 +6268,7 @@ export class ReduceBurnDamageAbAttr extends AbAttr { } /** - * Applies the damage reduction + * Applies the burn damage reduction * @param _pokemon N/A * @param _passive N/A * @param _cancelled N/A @@ -6657,6 +6717,7 @@ export class RedirectTypeMoveAbAttr extends RedirectMoveAbAttr { } } +// TODO: Rework this - currently it's just an empty class used as a marker export class BlockRedirectAbAttr extends AbAttr {} /** @@ -6664,6 +6725,7 @@ export class BlockRedirectAbAttr extends AbAttr {} * @param statusEffect - The {@linkcode StatusEffect} to check for * @see {@linkcode apply} */ +// TODO: Make this take no args and affect the status (once public accessors are added) export class ReduceStatusEffectDurationAbAttr extends AbAttr { private statusEffect: StatusEffect; @@ -6730,6 +6792,7 @@ export class FlinchStatStageChangeAbAttr extends FlinchEffectAbAttr { } } +// TODO: Move PP increasing property from `move-phase` into the ability class export class IncreasePpAbAttr extends AbAttr {} export class ForceSwitchOutImmunityAbAttr extends AbAttr { @@ -8671,8 +8734,8 @@ export function initAbilities() { .attr(PostDancingMoveAbAttr) /* Incorrect interations with: * Petal Dance (should not lock in or count down timer; currently does both) - * Flinches (due to tag being removed earlier) - * Failed/protected moves (should not trigger if original move is protected against) + * All status moves whose `apply` function unshifts a phase with eligibility checks + * (this includes stat stage moves as well as Teeter Dance and co.) due to moves being still considered "successful" */ .edgeCase(), new Ability(AbilityId.BATTERY, 7) diff --git a/src/data/abilities/apply-ab-attrs.ts b/src/data/abilities/apply-ab-attrs.ts index fdbd2652698..0abef9e2b47 100644 --- a/src/data/abilities/apply-ab-attrs.ts +++ b/src/data/abilities/apply-ab-attrs.ts @@ -1,6 +1,7 @@ import type { AbAttrApplyFunc, AbAttrMap, AbAttrString, AbAttrSuccessFunc } from "#app/@types/ability-types"; import type Pokemon from "#app/field/pokemon"; import { globalScene } from "#app/global-scene"; +import type { HitCheckEntry } from "#app/phases/move-effect-phase"; import type { BooleanHolder, NumberHolder } from "#app/utils/common"; import type { BattlerIndex } from "#enums/battler-index"; import type { HitResult } from "#enums/hit-result"; @@ -9,7 +10,6 @@ import type { StatusEffect } from "#enums/status-effect"; import type { WeatherType } from "#enums/weather-type"; import type { BattlerTag } from "../battler-tags"; import type Move from "../moves/move"; -import type { PokemonMove } from "../moves/pokemon-move"; import type { TerrainType } from "../terrain"; import type { Weather } from "../weather"; import type { @@ -79,8 +79,6 @@ function applySingleAbAttrs( continue; } - globalScene.phaseManager.setPhaseQueueSplice(); - if (attr.showAbility && !simulated) { globalScene.phaseManager.queueAbilityDisplay(pokemon, passive, true); abShown = true; @@ -120,7 +118,6 @@ function applyAbAttrsInternal( for (const passive of [false, true]) { if (pokemon) { applySingleAbAttrs(pokemon, passive, attrType, applyFunc, successFunc, args, gainedMidTurn, simulated, messages); - globalScene.phaseManager.clearPhaseQueueSplice(); } } } @@ -206,18 +203,20 @@ export function applyPostDefendAbAttrs( export function applyPostMoveUsedAbAttrs( attrType: AbAttrMap[K] extends PostMoveUsedAbAttr ? K : never, pokemon: Pokemon, - move: PokemonMove, + move: Move, source: Pokemon, targets: BattlerIndex[], + hitChecks: HitCheckEntry[], simulated = false, ...args: any[] ): void { applyAbAttrsInternal( attrType, pokemon, - (attr, _passive) => (attr as PostMoveUsedAbAttr).applyPostMoveUsed(pokemon, move, source, targets, simulated, args), (attr, _passive) => - (attr as PostMoveUsedAbAttr).canApplyPostMoveUsed(pokemon, move, source, targets, simulated, args), + (attr as PostMoveUsedAbAttr).applyPostMoveUsed(pokemon, move, source, targets, hitChecks, simulated, args), + (attr, _passive) => + (attr as PostMoveUsedAbAttr).canApplyPostMoveUsed(pokemon, move, source, targets, hitChecks, simulated, args), args, simulated, ); diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index be060b57e9c..60792af28e2 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -1387,11 +1387,11 @@ export class EncounterBattleAnim extends BattleAnim { public encounterAnim: EncounterAnim; public oppAnim: boolean; - constructor(encounterAnim: EncounterAnim, user: Pokemon, target?: Pokemon, oppAnim?: boolean) { - super(user, target ?? user, true); + constructor(encounterAnim: EncounterAnim, user: Pokemon, target: Pokemon = user, oppAnim = false) { + super(user, target, true); this.encounterAnim = encounterAnim; - this.oppAnim = oppAnim ?? false; + this.oppAnim = oppAnim; } getAnim(): AnimConfig | null { diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8405fd1dd4d..5daa20eadfe 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -34,6 +34,7 @@ import { isNullOrUndefined } from "#app/utils/common"; import { MoveUseMode } from "#enums/move-use-mode"; import { invalidEncoreMoves } from "./moves/invalid-moves"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; +import type { TurnMove } from "#app/field/pokemon"; /** * A {@linkcode BattlerTag} represents a semi-persistent effect that can be attached to a {@linkcode Pokemon}. @@ -153,21 +154,21 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag { } /** - * Gets whether this tag is restricting a move. + * Check if this tag is currently restricting a move's use. * - * @param move - {@linkcode MoveId} ID to check restriction for. - * @param user - The {@linkcode Pokemon} involved - * @returns `true` if the move is restricted by this tag, otherwise `false`. + * @param move - The {@linkcode MoveId} whose usability is being checked. + * @param user - The {@linkcode Pokemon} using the move. + * @returns Whether the given move is restricted by this tag. */ public abstract isMoveRestricted(move: MoveId, user?: Pokemon): boolean; /** - * Checks if this tag is restricting a move based on a user's decisions during the target selection phase - * - * @param {MoveId} _move {@linkcode MoveId} move ID to check restriction for - * @param {Pokemon} _user {@linkcode Pokemon} the user of the above move - * @param {Pokemon} _target {@linkcode Pokemon} the target of the above move - * @returns {boolean} `false` unless overridden by the child tag + * Check if this tag is restricting a move during target selection. + * Returns `false` by default unless overridden by a child class. + * @param _move - The {@linkcode MoveId} whose selectability is being checked. + * @param _user - The {@linkcode Pokemon} using the move. + * @param _target - The {@linkcode Pokemon} being targeted + * @returns Whether the given move should be unselectable when choosing targets. */ isMoveTargetRestricted(_move: MoveId, _user: Pokemon, _target: Pokemon): boolean { return false; @@ -315,9 +316,10 @@ export class DisabledTag extends MoveRestrictionBattlerTag { /** * @override - * @param {Pokemon} pokemon {@linkcode Pokemon} attempting to use the restricted move - * @param {MoveId} move {@linkcode MoveId} ID of the move being interrupted - * @returns {string} text to display when the move is interrupted + * Display the text that occurs when a move is interrupted via Disable. + * @param pokemon - The {@linkcode Pokemon} attempting to use the restricted move. + * @param move - The {@linkcode MoveId} of the move being interrupted. + * @returns The text to display when the given move is interrupted. */ override interruptedText(pokemon: Pokemon, move: MoveId): string { return i18next.t("battle:disableInterruptedMove", { @@ -1876,9 +1878,9 @@ export class TruantTag extends AbilityBattlerTag { return super.lapse(pokemon, lapseType); } - const lastMove = pokemon.getLastXMoves()[0]; + const lastMove: TurnMove | undefined = pokemon.getLastXMoves()[0]; - if (!lastMove) { + if (!lastMove?.move) { // Don't interrupt move if last move was `Moves.NONE` OR no prior move was found return true; } @@ -3419,7 +3421,8 @@ export class PsychoShiftTag extends BattlerTag { } /** - * Tag associated with the move Magic Coat. + * Tag associated with {@linkcode Moves.MAGIC_COAT | Magic Coat} that reflects certain status moves directed at the user. + * TODO: Move Reflection code out of `move-effect-phase` and into here */ export class MagicCoatTag extends BattlerTag { constructor() { diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f94c59bb463..f0e0a4f987c 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -181,9 +181,9 @@ export default abstract class Move implements Localizable { } /** - * Check if a move has an attribute that matches `attrType` - * @param attrType any attribute that extends {@linkcode MoveAttr} - * @returns true if the move has attribute `attrType` + * Check if a move has an attribute that matches `attrType`. + * @param attrType - The {@linkcode MoveAttrString} for the attribute to check. + * @returns Whether the move has an attribute that is or extends `attrType`. */ hasAttr(attrType: MoveAttrString): boolean { const targetAttr = MoveAttrs[attrType]; @@ -635,7 +635,9 @@ export default abstract class Move implements Localizable { target?: Pokemon; isFollowUp?: boolean; }): boolean { - // special cases below, eg: if the move flag is MAKES_CONTACT, and the user pokemon has an ability that ignores contact (like "Long Reach"), then overrides and move does not make contact + // Handle special cases + + // Abilities that ignores contact (Long Reach) and substitute blockages switch (flag) { case MoveFlags.MAKES_CONTACT: if (user.hasAbilityWithAttr("IgnoreContactAbAttr") || this.hitsSubstitute(user, target)) { @@ -643,15 +645,17 @@ export default abstract class Move implements Localizable { } break; case MoveFlags.IGNORE_ABILITIES: + // Check for ability based blockages if (user.hasAbilityWithAttr("MoveAbilityBypassAbAttr")) { const abilityEffectsIgnored = new BooleanHolder(false); applyAbAttrs("MoveAbilityBypassAbAttr", user, abilityEffectsIgnored, false, this); if (abilityEffectsIgnored.value) { return true; } - // Sunsteel strike, Moongeist beam, and photon geyser will not ignore abilities if invoked - // by another move, such as via metronome. } + + // Sunsteel strike, Moongeist beam, and photon geyser will not ignore abilities if invoked + // by another move, such as metronome/dancer. return this.hasFlag(MoveFlags.IGNORE_ABILITIES) && !isFollowUp; case MoveFlags.IGNORE_PROTECT: if (user.hasAbilityWithAttr("IgnoreProtectOnContactAbAttr") @@ -2466,6 +2470,7 @@ export class MultiHitAttr extends MoveAttr { break; case MultiHitType.BEAT_UP: // Estimate that half of the party can contribute to beat up. + // TODO: The AI should be able to check this manually? expectedHits = Math.max(1, partySize / 2); break; } diff --git a/src/enums/move-use-mode.ts b/src/enums/move-use-mode.ts index 31694ad4081..50b4461d76f 100644 --- a/src/enums/move-use-mode.ts +++ b/src/enums/move-use-mode.ts @@ -147,3 +147,22 @@ export function isIgnorePP(useMode: MoveUseMode): boolean { export function isReflected(useMode: MoveUseMode): boolean { return useMode === MoveUseMode.REFLECTED; } + +/** + * Check if a given {@linkcode MoveUseMode} is capable of being copied by {@linkcode PostDancingMoveAbAttr | Dancer}. + * @param useMode - The {@linkcode MoveUseMode} to check. + * @returns Whether {@linkcode useMode} is dancer copiable. + * @remarks + * This function is equivalent to the following truth table: + * + * | Use Type | Returns | + * |------------------------------------|---------| + * | {@linkcode MoveUseMode.NORMAL} | `true` | + * | {@linkcode MoveUseMode.IGNORE_PP} | `true` | + * | {@linkcode MoveUseMode.INDIRECT} | `false` | + * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | + * | {@linkcode MoveUseMode.REFLECTED} | `false` | + */ +export function isDancerCopiable(useMode: MoveUseMode): boolean { + return !([MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as MoveUseMode[]).includes(useMode) +} diff --git a/src/overrides.ts b/src/overrides.ts index b390b9fa70f..1aaa60775ff 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -1,4 +1,4 @@ -import { type PokeballCounts } from "#app/battle-scene"; +import type { PokeballCounts } from "#app/battle-scene"; import { EvolutionItem } from "#app/data/balance/pokemon-evolutions"; import { Gender } from "#app/data/gender"; import { FormChangeItem } from "#enums/form-change-item"; @@ -6,21 +6,21 @@ import { type ModifierOverride } from "#app/modifier/modifier-type"; import { Variant } from "#app/sprites/variant"; import { Unlockables } from "#enums/unlockables"; import { AbilityId } from "#enums/ability-id"; -import { BattleType } from "#enums/battle-type"; +import type { BattleType } from "#enums/battle-type"; import { BerryType } from "#enums/berry-type"; -import { BiomeId } from "#enums/biome-id"; -import { EggTier } from "#enums/egg-type"; -import { MoveId } from "#enums/move-id"; -import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; -import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import type { BiomeId } from "#enums/biome-id"; +import type { EggTier } from "#enums/egg-type"; +import type { MoveId } from "#enums/move-id"; +import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PokeballType } from "#enums/pokeball"; import { PokemonType } from "#enums/pokemon-type"; -import { SpeciesId } from "#enums/species-id"; +import type { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; -import { TimeOfDay } from "#enums/time-of-day"; -import { TrainerType } from "#enums/trainer-type"; -import { VariantTier } from "#enums/variant-tier"; +import type { TimeOfDay } from "#enums/time-of-day"; +import type { TrainerType } from "#enums/trainer-type"; +import type { VariantTier } from "#enums/variant-tier"; import { WeatherType } from "#enums/weather-type"; /** @@ -154,6 +154,10 @@ class DefaultOverrides { readonly ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; readonly PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; readonly HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; + /** + * If set, will be added to each newly caught/obtained player Pokemon. + * @remarks If this is set to {@linkcode StatusEffect.SLEEP}, it will always have a constant duration of 4 turns. + */ readonly STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; readonly GENDER_OVERRIDE: Gender | null = null; readonly MOVESET_OVERRIDE: MoveId | Array = []; diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 9390e6dd75d..31db27c1e6f 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -99,6 +99,7 @@ import { UnavailablePhase } from "#app/phases/unavailable-phase"; import { UnlockPhase } from "#app/phases/unlock-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; import { WeatherEffectPhase } from "#app/phases/weather-effect-phase"; +import { DancerPhase } from "#app/phases/dancer-phase"; /** * Manager for phases used by battle scene. @@ -126,6 +127,7 @@ const PHASES = Object.freeze({ CommandPhase, CommonAnimPhase, DamageAnimPhase, + DancerPhase, EggHatchPhase, EggLapsePhase, EggSummaryPhase, @@ -567,7 +569,6 @@ export class PhaseManager { */ public queueAbilityDisplay(pokemon: Pokemon, passive: boolean, show: boolean): void { this.unshiftPhase(show ? new ShowAbilityPhase(pokemon.getBattlerIndex(), passive) : new HideAbilityPhase()); - this.clearPhaseQueueSplice(); } /** diff --git a/src/phases/dancer-phase.ts b/src/phases/dancer-phase.ts new file mode 100644 index 00000000000..ee43ab11d20 --- /dev/null +++ b/src/phases/dancer-phase.ts @@ -0,0 +1,36 @@ +import type { BattlerIndex } from "#enums/battler-index"; +import { applyPostMoveUsedAbAttrs } from "#app/data/abilities/apply-ab-attrs"; +import type Move from "#app/data/moves/move"; +import { globalScene } from "#app/global-scene"; +import type { HitCheckEntry } from "#app/phases/move-effect-phase"; +import { PokemonPhase } from "#app/phases/pokemon-phase"; + +/** The phase where all on-field Pokemon trigger Dancer and Dancer-like effects. */ +export class DancerPhase extends PokemonPhase { + public override readonly phaseName: "DancerPhase"; + + constructor( + battlerIndex: BattlerIndex, + private targets: BattlerIndex[], + private move: Move, + private hitChecks: HitCheckEntry[], + ) { + super(battlerIndex); + } + + // TODO: Make iteration occur in speed order + override start(): void { + super.start(); + for (const pokemon of globalScene.getField(true)) { + applyPostMoveUsedAbAttrs( + "PostMoveUsedAbAttr", + pokemon, + this.move, + this.getPokemon(), + this.targets, + this.hitChecks, + ); + } + super.end(); + } +} diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index d7da1ab996c..7964f94834c 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -54,10 +54,9 @@ import { HitCheckResult } from "#enums/hit-check-result"; import type Move from "#app/data/moves/move"; import { isFieldTargeted } from "#app/data/moves/move-utils"; import { DamageAchv } from "#app/system/achv"; -import { isVirtual, isReflected, MoveUseMode } from "#enums/move-use-mode"; +import { isVirtual, isReflected, MoveUseMode, isDancerCopiable } from "#enums/move-use-mode"; export type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; - export class MoveEffectPhase extends PokemonPhase { public readonly phaseName = "MoveEffectPhase"; public move: Move; @@ -112,11 +111,11 @@ export class MoveEffectPhase extends PokemonPhase { * Compute targets and the results of hit checks of the invoked move against all targets, * organized by battler index. * - * **This is *not* a pure function**; it has the following side effects - * - `this.hitChecks` - The results of the hit checks against each target - * - `this.moveHistoryEntry` - Sets success or failure based on the hit check results - * - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the - * move was unsuccessful against all targets + * **This is *not* a pure function** and has the following side effects: + * - Sets `this.hitChecks` to the results of the hit checks against each target + * - Sets success/failure of `this.moveHistoryEntry` based on the hit check results + * - Sets `user.turnData.hitCount` and `user.turnData.hitsLeft` to 1 if the move + * was unsuccessful against all targets (effectively canceling it) * * @returns The targets of the invoked move * @see {@linkcode hitCheck} @@ -194,9 +193,9 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Apply the move to each of the resolved targets. + * Apply the move to each of its resolved targets. * @param targets - The resolved set of targets of the move - * @throws Error if there was an unexpected hit check result + * @throws - Error if there was an unexpected hit check result */ private applyToTargets(user: Pokemon, targets: Pokemon[]): void { let firstHit = true; @@ -368,6 +367,7 @@ export class MoveEffectPhase extends PokemonPhase { */ private postAnimCallback(user: Pokemon, targets: Pokemon[]) { // Add to the move history entry + // TODO: Once Truant is fixed to not check history, don't push an entry for reflected/indirect moves if (this.firstHit) { user.pushMoveHistory(this.moveHistoryEntry); applyExecutedMoveAbAttrs("ExecutedMoveAbAttr", user); @@ -428,6 +428,19 @@ export class MoveEffectPhase extends PokemonPhase { this.getTargets().forEach(target => { target.turnData.moveEffectiveness = null; }); + + // Dancer does not proc on other dancer moves, nor for either occurrence of a reflected move. + // (This blocks copying on the follow-up reflected use; the initial use gets blocked by hit checks) + if (isDancerCopiable(this.useMode)) { + globalScene.phaseManager.appendNewToPhase( + "MoveEndPhase", + "DancerPhase", + this.battlerIndex, + this.targets, + this.move, + this.hitChecks, + ); + } super.end(); } @@ -759,7 +772,7 @@ export class MoveEffectPhase extends PokemonPhase { user: Pokemon, target: Pokemon | null, firstTarget?: boolean | null, - selfTarget?: boolean, + selfTarget = false, ): void { applyFilteredMoveAttrs( (attr: MoveAttr) => diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 2e94b085948..c13f1da2533 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,6 +1,6 @@ import { BattlerIndex } from "#enums/battler-index"; import { globalScene } from "#app/global-scene"; -import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs } from "#app/data/abilities/apply-ab-attrs"; +import { applyAbAttrs, applyPreAttackAbAttrs } from "#app/data/abilities/apply-ab-attrs"; import type { DelayedAttackTag } from "#app/data/arena-tag"; import { CommonAnim } from "#enums/move-anims-common"; import { CenterOfAttentionTag } from "#app/data/battler-tags"; @@ -431,16 +431,6 @@ export class MovePhase extends BattlePhase { // Remove the user from its semi-invulnerable state (if applicable) this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); } - - // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). - // Note the MoveUseMode check here prevents an infinite Dancer loop. - const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const; - if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) { - // TODO: Fix in dancer PR to move to MEP for hit checks - globalScene.getField(true).forEach(pokemon => { - applyPostMoveUsedAbAttrs("PostMoveUsedAbAttr", pokemon, this.move, this.pokemon, this.targets); - }); - } } /** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */ diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 6219907fb68..2db2298fae7 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -18,16 +18,16 @@ export class TurnStartPhase extends FieldPhase { /** * This orders the active Pokemon on the field by speed into an BattlerIndex array and returns that array. * It also checks for Trick Room and reverses the array if it is present. - * @returns {@linkcode BattlerIndex[]} the battle indices of all pokemon on the field ordered by speed + * @returns An array of {@linkcode BattlerIndex}es containing all on-field pokemon sorted in speed order. */ getSpeedOrder(): BattlerIndex[] { const playerField = globalScene.getPlayerField().filter(p => p.isActive()) as Pokemon[]; const enemyField = globalScene.getEnemyField().filter(p => p.isActive()) as Pokemon[]; - // We shuffle the list before sorting so speed ties produce random results + // Shuffle the list before sorting so speed ties produce random results + // This is seeded with the current turn to prevent an inconsistency with variable turn order + // based on how long since you last reloaded let orderedTargets: Pokemon[] = playerField.concat(enemyField); - // We seed it with the current turn to prevent an inconsistency where it - // was varying based on how long since you last reloaded globalScene.executeWithSeedOffset( () => { orderedTargets = randSeedShuffle(orderedTargets); @@ -36,11 +36,11 @@ export class TurnStartPhase extends FieldPhase { globalScene.waveSeed, ); - // Next, a check for Trick Room is applied to determine sort order. + // Check for Trick Room and reverse sort order if active. + // Notably, Pokerogue does NOT have the "outspeed trick room" glitch at >1809 spd. const speedReversed = new BooleanHolder(false); globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); - // Adjust the sort function based on whether Trick Room is active. orderedTargets.sort((a: Pokemon, b: Pokemon) => { const aSpeed = a?.getEffectiveStat(Stat.SPD) ?? 0; const bSpeed = b?.getEffectiveStat(Stat.SPD) ?? 0; @@ -111,7 +111,8 @@ export class TurnStartPhase extends FieldPhase { } } - // If there is no difference between the move's calculated priorities, the game checks for differences in battlerBypassSpeed and returns the result. + // If there is no difference between the move's calculated priorities, + // check for differences in battlerBypassSpeed and returns the result. if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { return battlerBypassSpeed[a].value ? -1 : 1; } diff --git a/test/abilities/dancer.test.ts b/test/abilities/dancer.test.ts index 2a4a3c36bcc..43edd721511 100644 --- a/test/abilities/dancer.test.ts +++ b/test/abilities/dancer.test.ts @@ -1,13 +1,20 @@ import { BattlerIndex } from "#enums/battler-index"; +import type Pokemon from "#app/field/pokemon"; import { MoveResult } from "#enums/move-result"; -import type { MovePhase } from "#app/phases/move-phase"; +import { MovePhase } from "#app/phases/move-phase"; import { AbilityId } from "#enums/ability-id"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveUseMode } from "#enums/move-use-mode"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { toDmgValue } from "#app/utils/common"; +import { allMoves, allAbilities } from "#app/data/data-lists"; +import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; describe("Abilities - Dancer", () => { let phaserGame: Phaser.Game; @@ -25,119 +32,517 @@ describe("Abilities - Dancer", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("double").enemyAbility(AbilityId.BALL_FETCH); + game.override + .battleStyle("single") + .disableCrits() + .ability(AbilityId.DANCER) + .enemySpecies(SpeciesId.SHUCKLE) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .enemyLevel(100) + .startingLevel(100); }); - // Reference Link: https://bulbapedia.bulbagarden.net/wiki/Dancer_(Ability) + /** + * Check that the specified {@linkcode Pokemon} is using the specified move + * in the current {@linkcode MovePhase} against the specified targets. + */ + function checkCurrentMoveUser( + pokemon: Pokemon, + move: MoveId, + targets?: BattlerIndex[], + useMode: MoveUseMode = MoveUseMode.INDIRECT, + ) { + const currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; + expect(currentPhase).not.toBeNull(); + expect(currentPhase).toBeInstanceOf(MovePhase); + expect(currentPhase.pokemon).toBe(pokemon); + expect(currentPhase.move.moveId).toBe(move); + if (targets) { + expect(currentPhase.targets).toHaveLength(targets.length); + expect(currentPhase.targets).toEqual(expect.arrayContaining(targets)); + } + expect(currentPhase.useMode).toBe(useMode); + } - it("triggers when dance moves are used, doesn't consume extra PP", async () => { - game.override.enemyAbility(AbilityId.DANCER).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset(MoveId.VICTORY_DANCE); + async function toNextMove() { + await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MovePhase", false); + } + + // Reference Link: https://bulbapedia.bulbagarden.net/wiki/Dancer_(Ability). + + it("should copy dance moves without consuming extra PP", async () => { + game.override.enemyAbility(AbilityId.DANCER); + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const oricorio = game.field.getPlayerPokemon(); + const shuckle = game.field.getEnemyPokemon(); + + game.move.changeMoveset(oricorio, [MoveId.VICTORY_DANCE, MoveId.SWORDS_DANCE]); + game.move.changeMoveset(shuckle, [MoveId.VICTORY_DANCE, MoveId.SWORDS_DANCE]); + + game.move.select(MoveId.SWORDS_DANCE); + await game.move.selectEnemyMove(MoveId.VICTORY_DANCE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("BerryPhase"); + + // shpuldn't use PP if copied move is also in moveset + expect(oricorio.moveset.map(m => m.ppUsed)).toEqual([0, 1]); + expect(shuckle.moveset.map(m => m.ppUsed)).toEqual([1, 0]); + + // effects were applied correctly + expect(oricorio.getStatStage(Stat.ATK)).toBe(3); + expect(shuckle.getStatStage(Stat.ATK)).toBe(3); + + // moves showed up in history + expect(oricorio.getLastXMoves(-1)).toHaveLength(2); + expect(oricorio.getLastXMoves(-1)).toEqual([ + expect.objectContaining({ move: MoveId.VICTORY_DANCE, useMode: MoveUseMode.INDIRECT }), + expect.objectContaining({ move: MoveId.SWORDS_DANCE, useMode: MoveUseMode.NORMAL }), + ]); + expect(shuckle.getLastXMoves(-1)).toHaveLength(2); + expect(shuckle.getLastXMoves(-1)).toEqual([ + expect.objectContaining({ move: MoveId.VICTORY_DANCE, useMode: MoveUseMode.NORMAL }), + expect.objectContaining({ move: MoveId.SWORDS_DANCE, useMode: MoveUseMode.INDIRECT }), + ]); + }); + + it("should redirect copied move if ally target faints", async () => { + game.override.battleStyle("double").startingLevel(500); await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); const [oricorio, feebas] = game.scene.getPlayerField(); - game.move.changeMoveset(oricorio, [MoveId.SWORDS_DANCE, MoveId.VICTORY_DANCE, MoveId.SPLASH]); - game.move.changeMoveset(feebas, [MoveId.SWORDS_DANCE, MoveId.SPLASH]); - game.move.select(MoveId.SPLASH); - game.move.select(MoveId.SWORDS_DANCE, 1); - await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]); - await game.phaseInterceptor.to("MovePhase"); // feebas uses swords dance - await game.phaseInterceptor.to("MovePhase", false); // oricorio copies swords dance + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); - let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; - expect(currentPhase.pokemon).toBe(oricorio); - expect(currentPhase.move.moveId).toBe(MoveId.SWORDS_DANCE); + await game.phaseInterceptor.to("MovePhase", false); // feebas rev dance + checkCurrentMoveUser(feebas, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY_2], MoveUseMode.NORMAL); + await toNextMove(); - await game.phaseInterceptor.to("MoveEndPhase"); // end oricorio's move - await game.phaseInterceptor.to("MovePhase"); // magikarp 1 copies swords dance - await game.phaseInterceptor.to("MovePhase"); // magikarp 2 copies swords dance - await game.phaseInterceptor.to("MovePhase"); // magikarp (left) uses victory dance - await game.phaseInterceptor.to("MovePhase", false); // oricorio copies magikarp's victory dance + // attack should redirect + const [shuckle1, shuckle2] = game.scene.getEnemyField(); + expect(shuckle2.isFainted()).toBe(true); + checkCurrentMoveUser(oricorio, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY]); - currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; - expect(currentPhase.pokemon).toBe(oricorio); - expect(currentPhase.move.moveId).toBe(MoveId.VICTORY_DANCE); + await game.toEndOfTurn(); - await game.phaseInterceptor.to("BerryPhase"); // finish the turn - - // doesn't use PP if copied move is also in moveset - expect(oricorio.moveset[0]?.ppUsed).toBe(0); - expect(oricorio.moveset[1]?.ppUsed).toBe(0); + expect(shuckle1.isFainted()).toBe(true); }); - // TODO: Enable after Dancer rework to not push to move history - it.todo("should not count as the last move used for mirror move/instruct", async () => { - game.override - .moveset([MoveId.FIERY_DANCE, MoveId.REVELATION_DANCE]) - .enemyMoveset([MoveId.INSTRUCT, MoveId.MIRROR_MOVE, MoveId.SPLASH]) - .enemySpecies(SpeciesId.SHUCKLE) - .enemyLevel(10); - await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); - - const [oricorio] = game.scene.getPlayerField(); - const [, shuckle2] = game.scene.getEnemyField(); - - game.move.select(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); - game.move.select(MoveId.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); - await game.move.selectEnemyMove(MoveId.INSTRUCT, BattlerIndex.PLAYER); - await game.move.selectEnemyMove(MoveId.MIRROR_MOVE, BattlerIndex.PLAYER); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("MovePhase"); // Oricorio rev dance - await game.phaseInterceptor.to("MovePhase"); // Feebas fiery dance - await game.phaseInterceptor.to("MovePhase"); // Oricorio fiery dance (from dancer) - await game.phaseInterceptor.to("MoveEndPhase", false); - // dancer copied move doesn't appear in move history - expect(oricorio.getLastXMoves(-1)[0].move).toBe(MoveId.REVELATION_DANCE); - - await game.phaseInterceptor.to("MovePhase"); // shuckle 2 mirror moves oricorio - await game.phaseInterceptor.to("MovePhase"); // calls instructed rev dance - let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; - expect(currentPhase.pokemon).toBe(shuckle2); - expect(currentPhase.move.moveId).toBe(MoveId.REVELATION_DANCE); - - await game.phaseInterceptor.to("MovePhase"); // shuckle 1 instructs oricorio - await game.phaseInterceptor.to("MovePhase"); - currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; - expect(currentPhase.pokemon).toBe(oricorio); - expect(currentPhase.move.moveId).toBe(MoveId.REVELATION_DANCE); - }); - - it("should not break subsequent last hit only moves", async () => { - game.override.battleStyle("single"); - await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); - - const [oricorio, feebas] = game.scene.getPlayerParty(); - - game.move.use(MoveId.BATON_PASS); - game.doSelectPartyPokemon(1); - await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.field.getPlayerPokemon()).toBe(feebas); - expect(feebas.getStatStage(Stat.ATK)).toBe(2); - expect(oricorio.isOnField()).toBe(false); - expect(oricorio.visible).toBe(false); - }); - - it("should not trigger while flinched", async () => { - game.override.battleStyle("double").moveset(MoveId.SPLASH).enemyMoveset([MoveId.SWORDS_DANCE, MoveId.FAKE_OUT]); + it("should redirect copied move if source enemy faints", async () => { + game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.ORICORIO]); - const oricorio = game.scene.getPlayerPokemon()!; - expect(oricorio).toBeDefined(); + const oricorio = game.field.getPlayerPokemon(); + const [shuckle1, shuckle2] = game.scene.getEnemyField(); + shuckle1.hp = 1; + vi.spyOn(shuckle2, "getAbility").mockReturnValue(allAbilities[AbilityId.ROUGH_SKIN]); - // get faked out and copy swords dance - game.move.select(MoveId.SPLASH); + // Enemy 1 hits enemy 2 and gets pwned + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.AQUA_STEP, BattlerIndex.ENEMY_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER]); + + await game.phaseInterceptor.to("MovePhase"); // shuckle aqua steps its ally and kills itself + await toNextMove(); // Oricorio copies + expect(shuckle1.isFainted()).toBe(true); + + // attack should redirect to other shuckle + checkCurrentMoveUser(oricorio, MoveId.AQUA_STEP, [BattlerIndex.ENEMY_2]); + + await game.toEndOfTurn(); + expect(oricorio.isFullHp()).toBe(false); + }); + + it("should target correctly in double battles", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); + + const [oricorio, feebas] = game.scene.getPlayerField(); + + // oricorio feather dances feebas, everyone else dances like crazy + game.move.use(MoveId.FEATHER_DANCE, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + game.move.use(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.move.forceEnemyMove(MoveId.FIERY_DANCE, BattlerIndex.PLAYER); await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); - await game.move.forceEnemyMove(MoveId.FAKE_OUT, BattlerIndex.PLAYER); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); - expect(oricorio.getLastXMoves(-1)[0]).toMatchObject({ - move: MoveId.NONE, - result: MoveResult.FAIL, - }); + await game.phaseInterceptor.to("MovePhase", false); // oricorio feather dance + checkCurrentMoveUser(oricorio, MoveId.FEATHER_DANCE, [BattlerIndex.PLAYER_2], MoveUseMode.NORMAL); + await toNextMove(); // feebas copies feather dance against oricorio + checkCurrentMoveUser(feebas, MoveId.FEATHER_DANCE, [BattlerIndex.PLAYER]); + + await toNextMove(); // feebas uses rev dance on shuckle #2 + checkCurrentMoveUser(feebas, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY_2], MoveUseMode.NORMAL); + await toNextMove(); // oricorio copies rev dance against same target + checkCurrentMoveUser(oricorio, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY_2]); + + await toNextMove(); // shuckle 1 uses fiery dance + await toNextMove(); // oricorio copies fiery dance against it + checkCurrentMoveUser(oricorio, MoveId.FIERY_DANCE, [BattlerIndex.ENEMY]); + await toNextMove(); // feebas copies fiery dance + checkCurrentMoveUser(feebas, MoveId.FIERY_DANCE, [BattlerIndex.ENEMY]); + + await toNextMove(); // shuckle 2 uses swords dance + await toNextMove(); // oricorio copies swords dance + checkCurrentMoveUser(oricorio, MoveId.SWORDS_DANCE, [BattlerIndex.PLAYER]); + await toNextMove(); // feebas copies swords dance + checkCurrentMoveUser(feebas, MoveId.SWORDS_DANCE, [BattlerIndex.PLAYER_2]); + + await game.toEndOfTurn(); + }); + + it("should display ability flyouts right before move use", async () => { + game.override.battleStyle("double").enemyAbility(AbilityId.DANCER); + await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); + + // TODO: uncomment once dynamic spd order added + // game.scene + // .getField() + // .forEach((pkmn, i) => pkmn.setStat(Stat.SPD, 5 - i)); + + game.move.use(MoveId.SWORDS_DANCE, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("DancerPhase", false); + game.phaseInterceptor.clearLogs(); + await game.toEndOfTurn(); + + const showAbPhases: number[] = []; + const hideAbPhases: number[] = []; + const movePhases: number[] = []; + const moveEndPhases: number[] = []; + + const log = game.phaseInterceptor.log; + for (const [index, phase] of log.entries()) { + switch (phase) { + case "ShowAbilityPhase": + showAbPhases.push(index); + break; + case "HideAbilityPhase": + hideAbPhases.push(index); + break; + case "MovePhase": + movePhases.push(index); + break; + case "MoveEndPhase": + case "DancerPhase": // also count the initial dancer phase (which occurs right after the initial usage anyhow) + moveEndPhases.push(index); + break; + } + } + + expect(showAbPhases).toHaveLength(3); + expect(hideAbPhases).toHaveLength(3); + + // Each dancer's ShowAbilityPhase must be immediately preceded by a MoveEndPhase and HideAbilityPhase, + // and followed by a MovePhase. + // We do not check the move phases directly as other pokemon may have moved themselves. + for (const i of showAbPhases) { + expect(moveEndPhases).toContain(i - 2); + expect(hideAbPhases).toContain(i - 1); + expect(movePhases).toContain(i + 1); + } + }); + + // TODO: Enable once abilities start proccing in speed order + it.todo("should respect speed order during doubles", async () => { + game.override + .battleStyle("double") + .enemyAbility(AbilityId.DANCER) + .moveset([MoveId.QUIVER_DANCE, MoveId.SPLASH]) + .enemyMoveset(MoveId.SPLASH); + await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); + + // Set the mons in reverse speed order - P1, P2, E1, E2 + // Used in place of `setTurnOrder` as the latter only applies for turn start phase + game.scene.getField().forEach((pkmn, i) => pkmn.setStat(Stat.SPD, 5 - i)); + const orderSpy = vi.spyOn(MovePhase.prototype, "start"); + const showAbSpy = vi.spyOn(ShowAbilityPhase.prototype, "start"); + + game.move.select(MoveId.QUIVER_DANCE, BattlerIndex.PLAYER); + game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + const [oricorio, feebas, shuckle1, shuckle2] = game.scene.getField(); + + const expectedOrder = [ + // Oricorio quiver dance, then copies + oricorio, + feebas, + shuckle1, + shuckle2, + ]; + + const order = (orderSpy.mock.contexts as MovePhase[]).map(mp => mp.pokemon); + const abOrder = (showAbSpy.mock.contexts as ShowAbilityPhase[]).map(sap => sap.getPokemon()); + expect(order).toEqual(expectedOrder); + expect(abOrder).toEqual(expectedOrder); + }); + + // TODO: Currently this is bugged as counter moves don't work at all + it.todo("should count as last attack recieved for counter moves", async () => { + game.override.battleStyle("double"); + + await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.SNIVY]); + + const [oricorio, _snivy] = game.scene.getPlayerField(); + const [shuckle1, shuckle2] = game.scene.getEnemyField(); + + const enemyDmgSpy = vi.spyOn(shuckle2, "damageAndUpdate"); + + // snivy attacks enemy 2, prompting oricorio to do the same + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.killPokemon(shuckle1); + await game.move.forceEnemyMove(MoveId.METAL_BURST); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2]); + + // ORDER: + // oricorio splash + // shuckle 1 splash + // snivy rev dance vs shuckle 2 + // oricorio copies rev dance vs shuckle 2 + // shuckle 2 metal burst vs Oricorio + + await game.toEndOfTurn(); + expect(shuckle2.getLastXMoves(-1)[0].move).toBe(MoveId.METAL_BURST); + expect(shuckle2.getLastXMoves(-1)[0].targets).toEqual([BattlerIndex.PLAYER]); + + expect(enemyDmgSpy).toHaveBeenCalledTimes(2); + const lastDmgTaken = enemyDmgSpy.mock.lastCall?.[0]!; + expect(oricorio.getInverseHp()).toBe(toDmgValue(lastDmgTaken * 1.5)); + }); + + it.each<{ name: string; move?: MoveId; enemyMove: MoveId }>([ + { name: "protected moves", enemyMove: MoveId.FIERY_DANCE, move: MoveId.PROTECT }, + { name: "missed moves", enemyMove: MoveId.AQUA_STEP }, + { name: "ineffective moves", enemyMove: MoveId.REVELATION_DANCE }, // ground type + // TODO: These currently don't work as the moves are still considered "successful" + // if all targets are unaffected + // { name: "failed Teeter Dance", enemyMove: Moves.TEETER_DANCE }, + // { name: "capped stat-boosting moves", enemyMove: Moves.FEATHER_DANCE }, + // { name: "capped stat-lowering moves", enemyMove: Moves.QUIVER_DANCE }, + ])("should not trigger on $name", async ({ move = MoveId.SPLASH, enemyMove }) => { + game.override.enemySpecies(SpeciesId.GROUDON); + // force aqua step to whiff + vi.spyOn(allMoves[MoveId.AQUA_STEP], "accuracy", "get").mockReturnValue(0); + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const oricorio = game.field.getPlayerPokemon(); + + oricorio.setStatStage(Stat.ATK, -6); + oricorio.setStatStage(Stat.SPATK, +6); + oricorio.setStatStage(Stat.SPDEF, +6); + oricorio.setStatStage(Stat.SPD, +6); + oricorio.addTag(BattlerTagType.CONFUSED, 12, MoveId.CONFUSE_RAY, game.field.getEnemyPokemon().id); + + game.move.use(move); + await game.move.forceEnemyMove(enemyMove); + await game.toNextTurn(); + + expect(oricorio.getLastXMoves(-1)).toEqual([ + expect.objectContaining({ move, result: MoveResult.SUCCESS, useMode: MoveUseMode.NORMAL }), + ]); + expect(oricorio.waveData.abilityRevealed).toBe(false); + }); + + it("should trigger confusion self-damage, even when protected against", async () => { + game.override.confusionActivation(false); // disable confusion unless forced by mocks + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const oricorio = game.field.getPlayerPokemon(); + + // get confused + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.CONFUSE_RAY); + await game.toNextTurn(); + + expect(oricorio.getTag(BattlerTagType.CONFUSED)).toBeDefined(); + + // Protect, then copy swords dance + game.move.use(MoveId.PROTECT); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); + + await game.phaseInterceptor.to("MovePhase"); // protect + await game.phaseInterceptor.to("MoveEndPhase"); // Swords dance + await game.move.forceConfusionActivation(true); // force confusion proc during swords dance copy + await game.toEndOfTurn(); + + // took damage from confusion instead of using move; player remains confused + expect(oricorio.hp).toBeLessThan(oricorio.getMaxHp()); + expect(oricorio.getTag(BattlerTagType.CONFUSED)).toBeDefined(); + expect(game.field.getEnemyPokemon()?.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + }); + + it("should respect full paralysis", async () => { + game.override.statusEffect(StatusEffect.PARALYSIS).statusActivation(true); + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const oricorio = game.field.getPlayerPokemon(); + expect(oricorio.status).not.toBeNull(); + + // attempt to copy swords dance and get para'd + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expect(oricorio.getLastXMoves(-1)[0]).toMatchObject({ move: MoveId.NONE }); + expect(oricorio.status).toBeTruthy(); expect(oricorio.getStatStage(Stat.ATK)).toBe(0); }); + + it.each<{ name: string; status: StatusEffect }>([ + { name: "Sleep", status: StatusEffect.SLEEP }, + { name: "Freeze", status: StatusEffect.FREEZE }, + ])("should cure $name when copying move", async ({ status }) => { + game.override.statusEffect(status).statusActivation(true); + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const oricorio = game.field.getPlayerPokemon(); + expect(oricorio.status?.effect).toBe(status); + + game.move.use(MoveId.ACROBATICS); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + + await game.phaseInterceptor.to("MoveEffectPhase"); // enemy SD + await game.phaseInterceptor.to("MovePhase"); // player dancer attempt (curing happens inside MP) + expect(oricorio.status).toBeNull(); + + await game.toEndOfTurn(); + expect(oricorio.getStatStage(Stat.ATK)).toBe(2); + expect(game.field.getEnemyPokemon().isFullHp()).toBe(false); + }); + + // TODO: This more or less requires an overhaul of Frenzy moves + it.todo("should not lock user into Petal Dance or reduce its duration", async () => { + game.override.enemyMoveset(MoveId.PETAL_DANCE); + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + // Mock RNG to make frenzy always last for max duration + const oricorio = game.field.getPlayerPokemon(); + vi.spyOn(oricorio, "randBattleSeedIntRange").mockImplementation((_, max) => max); + + game.move.changeMoveset(oricorio, [MoveId.SPLASH, MoveId.PETAL_DANCE]); + + const shuckle = game.field.getEnemyPokemon(); + + // Enemy uses petal dance and we copy + game.move.select(MoveId.SPLASH); + await game.toEndOfTurn(); + + // used petal dance without being locked into move + expect(oricorio.getLastXMoves(-1)[0]).toMatchObject({ move: MoveId.PETAL_DANCE, useMode: MoveUseMode.INDIRECT }); + expect(oricorio.getMoveQueue()).toHaveLength(0); + expect(oricorio.getTag(BattlerTagType.FRENZY)).toBeUndefined(); + expect(shuckle.turnData.attacksReceived).toHaveLength(1); + await game.toNextTurn(); + + // Use petal dance ourselves and copy enemy one + game.move.select(MoveId.PETAL_DANCE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("MoveEndPhase"); + const prevQueueLength = oricorio.getMoveQueue().length; + await game.toEndOfTurn(); + + // locked into Petal Dance for 2 more turns (not 1) + expect(oricorio.getMoveQueue()).toHaveLength(prevQueueLength); + expect(oricorio.getTag(BattlerTagType.FRENZY)).toBeDefined(); + expect(oricorio.getMoveset().find(m => m.moveId === MoveId.PETAL_DANCE)?.ppUsed).toBe(1); + }); + + it("should lapse Truant and respect its disables", async () => { + game.override.passiveAbility(AbilityId.TRUANT).moveset(MoveId.SPLASH).enemyMoveset(MoveId.SWORDS_DANCE); + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const oricorio = game.field.getPlayerPokemon(); + + // turn 1: Splash --> truanted Dancer SD + game.move.select(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expect(oricorio.getLastXMoves(2)).toEqual([ + expect.objectContaining({ + move: MoveId.NONE, + result: MoveResult.FAIL, + useMode: MoveUseMode.INDIRECT, + }), + expect.objectContaining({ + move: MoveId.SPLASH, + result: MoveResult.SUCCESS, + useMode: MoveUseMode.NORMAL, + }), + ]); + expect(oricorio.getStatStage(Stat.ATK)).toBe(0); + + // Turn 2: Dancer SD --> truanted Splash + game.move.select(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(oricorio.getLastXMoves(2)).toEqual([ + expect.objectContaining({ + move: MoveId.NONE, + result: MoveResult.FAIL, + useMode: MoveUseMode.NORMAL, + }), + expect.objectContaining({ + move: MoveId.SWORDS_DANCE, + result: MoveResult.SUCCESS, + useMode: MoveUseMode.INDIRECT, + }), + ]); + expect(oricorio.getStatStage(Stat.ATK)).toBe(2); + }); + + it.each<{ name: string; move: MoveId }>([ + { name: "Mirror Move", move: MoveId.MIRROR_MOVE }, + { name: "Copycat", move: MoveId.COPYCAT }, + ])("should not count as last move used for $name", async ({ move }) => { + await game.classicMode.startBattle([SpeciesId.ORICORIO]); + + const shuckle = game.field.getEnemyPokemon(); + + // select splash first so we have a clear indicator of what move got copied + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(move); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(shuckle.getLastXMoves()[0]).toMatchObject({ move: MoveId.SPLASH, useMode: MoveUseMode.FOLLOW_UP }); + }); + + it("should not count as the last move used for Instruct", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.INSTRUCT, BattlerIndex.PLAYER); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2, BattlerIndex.ENEMY]); + + await game.phaseInterceptor.to("MovePhase"); // Oricorio uses splash + + await game.phaseInterceptor.to("MovePhase"); // Feebas uses fiery dance + await game.phaseInterceptor.to("MovePhase"); // Oricorio copies fiery dance + + await game.phaseInterceptor.to("MovePhase"); // shuckle 2 instructs oricorio + await toNextMove(); // instructed move used + + checkCurrentMoveUser(game.field.getPlayerPokemon(), MoveId.SPLASH, [BattlerIndex.PLAYER], MoveUseMode.NORMAL); + }); + + (""); }); diff --git a/test/testUtils/helpers/classicModeHelper.ts b/test/testUtils/helpers/classicModeHelper.ts index eff97483777..d19ed8fb287 100644 --- a/test/testUtils/helpers/classicModeHelper.ts +++ b/test/testUtils/helpers/classicModeHelper.ts @@ -91,6 +91,6 @@ export class ClassicModeHelper extends GameManagerHelper { } await this.game.phaseInterceptor.to(CommandPhase); - console.log("==================[New Turn]=================="); + console.log("==================[New Battle]=================="); } } diff --git a/test/testUtils/helpers/overridesHelper.ts b/test/testUtils/helpers/overridesHelper.ts index 3bf0fbbda47..d717dcc69a1 100644 --- a/test/testUtils/helpers/overridesHelper.ts +++ b/test/testUtils/helpers/overridesHelper.ts @@ -209,9 +209,11 @@ export class OverridesHelper extends GameManagerHelper { } /** - * Override the player pokemon's {@linkcode StatusEffect | status-effect} - * @param statusEffect - The {@linkcode StatusEffect | status-effect} to set - * @returns + * Override the player pokemon's {@linkcode StatusEffect | status effect}. + * Will be applied to each newly spawned pokemon on battle start. + * @param statusEffect - The {@linkcode StatusEffect | status effect} to set + * @returns `this` + * @remarks If this is set to {@linkcode StatusEffect.SLEEP}, it will always have a constant duration of 4 turns. */ public statusEffect(statusEffect: StatusEffect): this { vi.spyOn(Overrides, "STATUS_OVERRIDE", "get").mockReturnValue(statusEffect);