diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 2f57df4a551..b139fcaea0d 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1238,7 +1238,7 @@ export class PostDefendContactApplyStatusEffectAbAttr extends PostDefendAbAttr { // TODO: Probably want to check against simulated here const effect = this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randBattleSeedInt(this.effects.length)]; - attacker.trySetStatus(effect, true, pokemon); + attacker.trySetStatus(effect, pokemon); } } @@ -2230,7 +2230,7 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr { apply({ pokemon, opponent }: PostMoveInteractionAbAttrParams): void { const effect = this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randBattleSeedInt(this.effects.length)]; - opponent.trySetStatus(effect, true, pokemon); + opponent.trySetStatus(effect, pokemon); } } @@ -2385,7 +2385,7 @@ export class SynchronizeStatusAbAttr extends PostSetStatusAbAttr { */ override apply({ simulated, effect, sourcePokemon, pokemon }: PostSetStatusAbAttrParams): void { if (!simulated && sourcePokemon) { - sourcePokemon.trySetStatus(effect, true, pokemon); + sourcePokemon.trySetStatus(effect, pokemon); } } } @@ -3661,7 +3661,8 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr { protected immuneEffects: StatusEffect[]; /** - * @param immuneEffects - The status effects to which the Pokémon is immune. + * @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application. + * If none are provided, will block **all** status effects regardless of type. */ constructor(...immuneEffects: StatusEffect[]) { super(); @@ -3670,7 +3671,7 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr { } override canApply({ effect }: PreSetStatusAbAttrParams): boolean { - return (effect !== StatusEffect.FAINT && this.immuneEffects.length < 1) || this.immuneEffects.includes(effect); + return (this.immuneEffects.length === 0 && effect !== StatusEffect.FAINT) || this.immuneEffects.includes(effect); } /** @@ -3722,6 +3723,11 @@ export interface UserFieldStatusEffectImmunityAbAttrParams extends AbAttrBasePar */ export class UserFieldStatusEffectImmunityAbAttr extends AbAttr { protected immuneEffects: StatusEffect[]; + + /** + * @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application. + * If none are provided, will block **all** status effects regardless of type. + */ constructor(...immuneEffects: StatusEffect[]) { super(); @@ -3730,7 +3736,7 @@ export class UserFieldStatusEffectImmunityAbAttr extends AbAttr { override canApply({ effect, cancelled }: UserFieldStatusEffectImmunityAbAttrParams): boolean { return ( - (!cancelled.value && effect !== StatusEffect.FAINT && this.immuneEffects.length < 1) || + (!cancelled.value && this.immuneEffects.length === 0 && effect !== StatusEffect.FAINT) || this.immuneEffects.includes(effect) ); } @@ -3756,6 +3762,10 @@ export class ConditionalUserFieldStatusEffectImmunityAbAttr extends UserFieldSta */ private condition: (target: Pokemon, source: Pokemon | null) => boolean; + /** + * @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application. + * If none are provided, will block **all** status effects regardless of type. + */ constructor(condition: (target: Pokemon, source: Pokemon | null) => boolean, ...immuneEffects: StatusEffect[]) { super(...immuneEffects); @@ -7481,8 +7491,7 @@ export function initAbilities() { .unsuppressable() .bypassFaint(), new Ability(AbilityId.CORROSION, 7) - .attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ PokemonType.STEEL, PokemonType.POISON ]) - .edgeCase(), // Should poison itself with toxic orb. + .attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ PokemonType.STEEL, PokemonType.POISON ]), new Ability(AbilityId.COMATOSE, 7) .attr(StatusEffectImmunityAbAttr, ...getNonVolatileStatusEffects()) .attr(BattlerTagImmunityAbAttr, BattlerTagType.DROWSY) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 15c2cde1d58..0802917dc25 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -890,32 +890,31 @@ class ToxicSpikesTag extends ArenaTrapTag { } override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { - if (pokemon.isGrounded()) { - if (simulated) { - return true; - } - if (pokemon.isOfType(PokemonType.POISON)) { - this.#neutralized = true; - if (globalScene.arena.removeTag(this.tagType)) { - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - moveName: this.getMoveName(), - }), - ); - return true; - } - } else if (!pokemon.status) { - const toxic = this.layers > 1; - if ( - pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName()) - ) { - return true; - } - } + if (!pokemon.isGrounded()) { + return false; } - return false; + if (simulated) { + return true; + } + + if (pokemon.isOfType(PokemonType.POISON)) { + // Neutralize the tag and remove it from the field. + // Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon + this.#neutralized = true; + globalScene.arena.removeTagOnSide(this.tagType, this.side); + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + moveName: this.getMoveName(), + }), + ); + return true; + } + + // Attempt to poison the target, suppressing any immunity messages that arise. + const effect = this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC; + return pokemon.trySetStatus(effect, null, undefined, this.getMoveName(), false, true); } getMatchupScoreMultiplier(pokemon: Pokemon): number { diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 455beec6901..e2392aee092 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -563,7 +563,7 @@ export class BeakBlastChargingTag extends BattlerTag { target: pokemon, }) ) { - phaseData.attacker.trySetStatus(StatusEffect.BURN, true, pokemon); + phaseData.attacker.trySetStatus(StatusEffect.BURN, pokemon); } return true; } @@ -1509,7 +1509,7 @@ export class DrowsyTag extends SerializableBattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { if (!super.lapse(pokemon, lapseType)) { - pokemon.trySetStatus(StatusEffect.SLEEP, true); + pokemon.trySetStatus(StatusEffect.SLEEP); return false; } @@ -1859,7 +1859,7 @@ export class ContactSetStatusProtectedTag extends DamageProtectedTag { * @param user - The pokemon that is being attacked and has the tag */ override onContact(attacker: Pokemon, user: Pokemon): void { - attacker.trySetStatus(this.#statusEffect, true, user); + attacker.trySetStatus(this.#statusEffect, user); } } @@ -2799,7 +2799,7 @@ export class GulpMissileTag extends SerializableBattlerTag { if (this.tagType === BattlerTagType.GULP_MISSILE_ARROKUDA) { globalScene.phaseManager.unshiftNew("StatStageChangePhase", attacker.getBattlerIndex(), false, [Stat.DEF], -1); } else { - attacker.trySetStatus(StatusEffect.PARALYSIS, true, pokemon); + attacker.trySetStatus(StatusEffect.PARALYSIS, pokemon); } } return false; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0dfbc78d7ae..fdc2bdd7ef4 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -89,7 +89,7 @@ import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; -import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; +import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { toTitleCase } from "#utils/strings"; import i18next from "i18next"; @@ -1184,8 +1184,9 @@ export abstract class MoveAttr { } /** - * @virtual - * @returns the {@linkcode MoveCondition} or {@linkcode MoveConditionFunc} for this {@linkcode Move} + * Return this `MoveAttr`'s associated {@linkcode MoveCondition} or {@linkcode MoveConditionFunc}. + * The specified condition will be added to all {@linkcode Move}s with this attribute, + * and moves **will fail upon use** if _at least 1_ of their attached conditions returns `false`. */ getCondition(): MoveCondition | MoveConditionFunc | null { return null; @@ -1298,15 +1299,21 @@ export class MoveEffectAttr extends MoveAttr { } /** - * Determines whether the {@linkcode Move}'s effects are valid to {@linkcode apply} - * @virtual - * @param user {@linkcode Pokemon} using the move - * @param target {@linkcode Pokemon} target of the move - * @param move {@linkcode Move} with this attribute - * @param args Set of unique arguments needed by this attribute - * @returns true if basic application of the ability attribute should be possible + * Determine whether this {@linkcode MoveAttr}'s effects are able to {@linkcode apply | be applied} to the target. + * + * Will **NOT** cause the move to fail upon returning `false` (unlike {@linkcode getCondition}; + * merely that the effect for this attribute will be nullified. + * @param user - The {@linkcode Pokemon} using the move + * @param target - The {@linkcode Pokemon} being targeted by the move, or {@linkcode user} if the move is + * {@linkcode selfTarget | self-targeting} + * @param move - The {@linkcode Move} being used + * @param _args - Set of unique arguments needed by this attribute + * @returns `true` if basic application of this `MoveAttr`s effects should be possible */ - canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) { + // TODO: Decouple this check from the `apply` step + // TODO: Make non-damaging moves fail by default if none of their attributes can apply + canApply(user: Pokemon, target: Pokemon, move: Move, _args?: any[]) { + // TODO: These checks seem redundant return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp) && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })); @@ -1945,19 +1952,17 @@ export class AddSubstituteAttr extends MoveEffectAttr { * @see {@linkcode apply} */ export class HealAttr extends MoveEffectAttr { - /** The percentage of {@linkcode Stat.HP} to heal */ - private healRatio: number; - /** Should an animation be shown? */ - private showAnim: boolean; - - constructor(healRatio?: number, showAnim?: boolean, selfTarget?: boolean) { - super(selfTarget === undefined || selfTarget); - - this.healRatio = healRatio || 1; - this.showAnim = !!showAnim; + constructor( + /** The percentage of {@linkcode Stat.HP} to heal. */ + private healRatio: number, + /** Whether to display a healing animation when healing the target; default `false` */ + private showAnim = false, + selfTarget = true + ) { + super(selfTarget); } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { this.addHealPhase(this.selfTarget ? user : target, this.healRatio); return true; } @@ -1966,15 +1971,65 @@ export class HealAttr extends MoveEffectAttr { * Creates a new {@linkcode PokemonHealPhase}. * This heals the target and shows the appropriate message. */ - addHealPhase(target: Pokemon, healRatio: number) { + protected addHealPhase(target: Pokemon, healRatio: number) { globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), toDmgValue(target.getMaxHp() * healRatio), i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(target) }), true, !this.showAnim); } - getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { + override getTargetBenefitScore(user: Pokemon, target: Pokemon, _move: Move): number { const score = ((1 - (this.selfTarget ? user : target).getHpRatio()) * 20) - this.healRatio * 10; return Math.round(score / (1 - this.healRatio / 2)); } + + // TODO: Change to fail move + override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean { + if (!super.canApply(user, target, _move, _args)) { + return false; + } + + const healedPokemon = this.selfTarget ? user : target; + if (healedPokemon.isFullHp()) { + globalScene.phaseManager.queueMessage(i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(healedPokemon), + })) + return false; + } + return true; + } +} + +/** + * Attribute to put the user to sleep for a fixed duration, fully heal them and cure their status. + * Used for {@linkcode MoveId.REST}. + */ +export class RestAttr extends HealAttr { + private duration: number; + + constructor(duration: number) { + super(1, true); + this.duration = duration; + } + + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const wasSet = user.trySetStatus(StatusEffect.SLEEP, user, this.duration, null, true, true, + i18next.t("moveTriggers:restBecameHealthy", { + pokemonName: getPokemonNameWithAffix(user), + })); + return wasSet && super.apply(user, target, move, args); + } + + override addHealPhase(user: Pokemon): void { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), user.getMaxHp(), null) + } + + // TODO: change after HealAttr is changed to fail move + override getCondition(): MoveConditionFunc { + return (user, target, move) => + super.canApply(user, target, move, []) + // Intentionally suppress messages here as we display generic fail msg + // TODO: This might have order-of-operation jank + && user.canSetStatus(StatusEffect.SLEEP, true, true, user) + } } /** @@ -2246,20 +2301,9 @@ export class BoostHealAttr extends HealAttr { * @see {@linkcode apply} */ export class HealOnAllyAttr extends HealAttr { - /** - * @param user {@linkcode Pokemon} using the move - * @param target {@linkcode Pokemon} target of the move - * @param move {@linkcode Move} with this attribute - * @param args N/A - * @returns true if the function succeeds - */ - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (user.getAlly() === target) { - super.apply(user, target, move, args); - return true; - } - - return false; + override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean { + // Don't trigger if not targeting an ally + return target === user.getAlly() && super.canApply(user, target, _move, _args); } } @@ -2270,6 +2314,7 @@ export class HealOnAllyAttr extends HealAttr { * @see {@linkcode apply} * @see {@linkcode getUserBenefitScore} */ +// TODO: Make Strength Sap its own attribute that extends off of this one export class HitHealAttr extends MoveEffectAttr { private healRatio: number; private healStat: EffectiveStat | null; @@ -2520,49 +2565,50 @@ export class WaterShurikenMultiHitTypeAttr extends ChangeMultiHitTypeAttr { export class StatusEffectAttr extends MoveEffectAttr { public effect: StatusEffect; - public turnsRemaining?: number; - public overrideStatus: boolean = false; - constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) { + constructor(effect: StatusEffect, selfTarget = false) { super(selfTarget); this.effect = effect; - this.turnsRemaining = turnsRemaining; - this.overrideStatus = overrideStatus; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const statusCheck = moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance; + if (!statusCheck) { + return false; + } + + // non-status moves don't play sound effects for failures const quiet = move.category !== MoveCategory.STATUS; - if (statusCheck) { - const pokemon = this.selfTarget ? user : target; - if (user !== target && move.category === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, false, user, true)) { - return false; - } - if (((!pokemon.status || this.overrideStatus) || (pokemon.status.effect === this.effect && moveChance < 0)) - && pokemon.trySetStatus(this.effect, true, user, this.turnsRemaining, null, this.overrideStatus, quiet)) { - applyAbAttrs("ConfusionOnStatusEffectAbAttr", {pokemon: user, opponent: target, move, effect: this.effect}); - return true; - } + + if ( + target.trySetStatus(this.effect, user, undefined, null, false, quiet) + ) { + applyAbAttrs("ConfusionOnStatusEffectAbAttr", {pokemon: user, opponent: target, move, effect: this.effect}); + return true; } return false; } getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { const moveChance = this.getMoveChance(user, target, move, this.selfTarget, false); - const score = (moveChance < 0) ? -10 : Math.floor(moveChance * -0.1); + const score = moveChance < 0 ? -10 : Math.floor(moveChance * -0.1); const pokemon = this.selfTarget ? user : target; - return !pokemon.status && pokemon.canSetStatus(this.effect, true, false, user) ? score : 0; + return pokemon.canSetStatus(this.effect, true, false, user) ? score : 0; } } +/** + * Attribute to randomly apply one of several statuses to the target. + * Used for {@linkcode Moves.TRI_ATTACK} and {@linkcode Moves.DIRE_CLAW}. + */ export class MultiStatusEffectAttr extends StatusEffectAttr { public effects: StatusEffect[]; - constructor(effects: StatusEffect[], selfTarget?: boolean, turnsRemaining?: number, overrideStatus?: boolean) { - super(effects[0], selfTarget, turnsRemaining, overrideStatus); + constructor(effects: StatusEffect[], selfTarget?: boolean) { + super(effects[0], selfTarget); this.effects = effects; } @@ -2595,26 +2641,41 @@ export class PsychoShiftEffectAttr extends MoveEffectAttr { * @returns - Whether the effect was successfully applied to the target. */ apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { - const statusToApply: StatusEffect | undefined = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined); + const statusToApply = user.status?.effect ?? + (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE); - if (target.status || !statusToApply) { + // Bang is justified as condition func returns early if no status is found + if (!target.trySetStatus(statusToApply, user)) { return false; - } else { - const canSetStatus = target.canSetStatus(statusToApply, true, false, user); - const trySetStatus = canSetStatus ? target.trySetStatus(statusToApply, true, user) : false; + } - if (trySetStatus && user.status) { - // PsychoShiftTag is added to the user if move succeeds so that the user is healed of its status effect after its move - user.addTag(BattlerTagType.PSYCHO_SHIFT); + if (user.status) { + // Add tag to user to heal its status effect after the move ends (unless we have comatose); + // occurs after move use to ensure correct Synchronize timing + user.addTag(BattlerTagType.PSYCHO_SHIFT) + } + + return true; + } + + getCondition(): MoveConditionFunc { + return (user, target) => { + if (target.status?.effect) { + return false; } - return trySetStatus; + const statusToApply = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE); + return !!statusToApply && target.canSetStatus(statusToApply, false, false, user); } } getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { - const statusToApply = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined); - return !target.status && statusToApply && target.canSetStatus(statusToApply, true, false, user) ? -10 : 0; + const statusToApply = + user.status?.effect ?? + (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE); + + // TODO: Give this a positive user benefit score + return !target.status?.effect && statusToApply && target.canSetStatus(statusToApply, true, false, user) ? -10 : 0; } } @@ -2674,7 +2735,7 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { * Used for Incinerate and Knock Off. * Not Implemented Cases: (Same applies for Thief) * "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item." - * "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item."" + * "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item." */ export class RemoveHeldItemAttr extends MoveEffectAttr { @@ -2884,7 +2945,7 @@ export class HealStatusEffectAttr extends MoveEffectAttr { */ constructor(selfTarget: boolean, effects: StatusEffect | StatusEffect[]) { super(selfTarget, { lastHitOnly: true }); - this.effects = [ effects ].flat(1); + this.effects = coerceArray(effects) } /** @@ -4411,6 +4472,10 @@ export class SpitUpPowerAttr extends VariablePowerAttr { * Does NOT remove stockpiled stacks. */ export class SwallowHealAttr extends HealAttr { + constructor() { + super(1) + } + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const stockpilingTag = user.getTag(StockpilingTag); @@ -7917,7 +7982,7 @@ export class StatusIfBoostedAttr extends MoveEffectAttr { */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (target.turnData.statStagesIncreased) { - target.trySetStatus(this.effect, true, user); + target.trySetStatus(this.effect, user); } return true; } @@ -8064,11 +8129,11 @@ const failIfDampCondition: MoveConditionFunc = (user, target, move) => { return !cancelled.value; }; -const userSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(AbilityId.COMATOSE); +const userSleptOrComatoseCondition: MoveConditionFunc = (user) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(AbilityId.COMATOSE); -const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE); +const targetSleptOrComatoseCondition: MoveConditionFunc = (_user: Pokemon, target: Pokemon, _move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE); -const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined; +const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.findPhase(phase => phase.is("MovePhase")) !== undefined; const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => { const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); @@ -8949,9 +9014,7 @@ export function initMoves() { .attr(MultiHitAttr, MultiHitType._2) .makesContact(false), new SelfStatusMove(MoveId.REST, PokemonType.PSYCHIC, -1, 5, -1, 0, 1) - .attr(StatusEffectAttr, StatusEffect.SLEEP, true, 3, true) - .attr(HealAttr, 1, true) - .condition((user, target, move) => !user.isFullHp() && user.canSetStatus(StatusEffect.SLEEP, true, true, user)) + .attr(RestAttr, 3) .triageMove(), new AttackMove(MoveId.ROCK_SLIDE, PokemonType.ROCK, MoveCategory.PHYSICAL, 75, 90, 10, 30, 0, 1) .attr(FlinchAttr) @@ -9289,14 +9352,16 @@ export function initMoves() { .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3) - .condition(hasStockpileStacksCondition) .attr(SpitUpPowerAttr, 100) + .condition(hasStockpileStacksCondition) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true), new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3) - .condition(hasStockpileStacksCondition) .attr(SwallowHealAttr) + .condition(hasStockpileStacksCondition) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true) - .triageMove(), + .triageMove() + // TODO: Verify if using Swallow at full HP still consumes stacks or not + .edgeCase(), new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) .attr(StatusEffectAttr, StatusEffect.BURN) @@ -9682,14 +9747,8 @@ export function initMoves() { .unimplemented(), new StatusMove(MoveId.PSYCHO_SHIFT, PokemonType.PSYCHIC, 100, 10, -1, 0, 4) .attr(PsychoShiftEffectAttr) - .condition((user, target, move) => { - let statusToApply = user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined; - if (user.status?.effect && isNonVolatileStatusEffect(user.status.effect)) { - statusToApply = user.status.effect; - } - return !!statusToApply && target.canSetStatus(statusToApply, false, false, user); - } - ), + // TODO: Verify status applied if a statused pokemon obtains Comatose (via Transform) and uses Psycho Shift + .edgeCase(), new AttackMove(MoveId.TRUMP_CARD, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 5, -1, 0, 4) .makesContact() .attr(LessPPMorePowerAttr), diff --git a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts index 7d2583a00cb..d3882d78663 100644 --- a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts +++ b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts @@ -243,8 +243,9 @@ export const FieryFalloutEncounter: MysteryEncounter = MysteryEncounterBuilder.w if (burnable?.length > 0) { const roll = randSeedInt(burnable.length); const chosenPokemon = burnable[roll]; - if (chosenPokemon.trySetStatus(StatusEffect.BURN)) { + if (chosenPokemon.canSetStatus(StatusEffect.BURN, true)) { // Burn applied + chosenPokemon.doSetStatus(StatusEffect.BURN); encounter.setDialogueToken("burnedPokemon", chosenPokemon.getNameToRender()); encounter.setDialogueToken("abilityName", allAbilities[AbilityId.HEATPROOF].name); queueEncounterMessage(`${namespace}:option.2.target_burned`); diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index 19f06707257..279825b1227 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -307,7 +307,7 @@ export function getRandomSpeciesByStarterCost( */ export function koPlayerPokemon(pokemon: PlayerPokemon) { pokemon.hp = 0; - pokemon.trySetStatus(StatusEffect.FAINT); + pokemon.doSetStatus(StatusEffect.FAINT); pokemon.updateInfo(); queueEncounterMessage( i18next.t("battle:fainted", { diff --git a/src/enums/status-effect.ts b/src/enums/status-effect.ts index b79951f530a..3064dbe907f 100644 --- a/src/enums/status-effect.ts +++ b/src/enums/status-effect.ts @@ -1,3 +1,5 @@ +/** Enum representing all non-volatile status effects. */ +// TODO: Remove StatusEffect.FAINT export enum StatusEffect { NONE, POISON, diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d4f332d887c..a2100e622ec 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -234,6 +234,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { public ivs: number[]; public nature: Nature; public moveset: PokemonMove[]; + /** + * This Pokemon's current {@link https://m.bulbapedia.bulbagarden.net/wiki/Status_condition#Non-volatile_status | non-volatile status condition}, + * or `null` if none exist. + * @todo Make private + */ public status: Status | null; public friendship: number; public metLevel: number; @@ -4735,7 +4740,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param reason - The reason for the status application failure - * can be "overlap" (already has same status), "other" (generic fail message) * or a {@linkcode TerrainType} for terrain-based blockages. - * Defaults to "other". + * Default `"other"` */ queueStatusImmuneMessage( quiet: boolean, @@ -4764,15 +4769,20 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Checks if a status effect can be applied to the Pokemon. + * Check if a status effect can be applied to this {@linkcode Pokemon}. * - * @param effect The {@linkcode StatusEffect} whose applicability is being checked - * @param quiet Whether in-battle messages should trigger or not - * @param overrideStatus Whether the Pokemon's current status can be overriden - * @param sourcePokemon The Pokemon that is setting the status effect - * @param ignoreField Whether any field effects (weather, terrain, etc.) should be considered + * @param effect - The {@linkcode StatusEffect} whose applicability is being checked + * @param quiet - Whether to suppress in-battle messages for status checks; default `false` + * @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false` + * @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target, + * or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null` + * @param ignoreField - Whether to ignore field effects (weather, terrain, etc.) preventing status application; + * default `false` + * @returns Whether {@linkcode effect} can be applied to this Pokemon. */ - canSetStatus( + // TODO: Review and verify the message order precedence in mainline if multiple status-blocking effects are present at once + // TODO: Make argument order consistent with `trySetStatus` + public canSetStatus( effect: StatusEffect, quiet = false, overrideStatus = false, @@ -4780,6 +4790,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { ignoreField = false, ): boolean { if (effect !== StatusEffect.FAINT) { + // Status-overriding moves (i.e. Rest) fail if their respective status already exists; + // all other moves fail if the target already has _any_ status if (overrideStatus ? this.status?.effect === effect : this.status) { this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message return false; @@ -4792,73 +4804,62 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { const types = this.getTypes(true, true); + /* Whether the target is immune to the specific status being applied. */ + let isImmune = false; + /** The reason for a potential blockage; default "other" for type-based. */ + let reason: "other" | Exclude = "other"; + switch (effect) { case StatusEffect.POISON: - case StatusEffect.TOXIC: { - // Check if the Pokemon is immune to Poison/Toxic or if the source pokemon is canceling the immunity - const poisonImmunity = types.map(defType => { - // Check if the Pokemon is not immune to Poison/Toxic + case StatusEffect.TOXIC: + // Check for type based immunities and/or Corrosion from the applier. + isImmune = types.some(defType => { + // only 1 immunity needed to block if (defType !== PokemonType.POISON && defType !== PokemonType.STEEL) { return false; } - // Check if the source Pokemon has an ability that cancels the Poison/Toxic immunity + // No source (such as from Toxic Spikes) = blocked by default + if (!sourcePokemon) { + return true; + } + const cancelImmunity = new BooleanHolder(false); // TODO: Determine if we need to pass `quiet` as the value for simulated in this call - if (sourcePokemon) { - applyAbAttrs("IgnoreTypeStatusEffectImmunityAbAttr", { - pokemon: sourcePokemon, - cancelled: cancelImmunity, - statusEffect: effect, - defenderType: defType, - }); - if (cancelImmunity.value) { - return false; - } - } - - return true; + applyAbAttrs("IgnoreTypeStatusEffectImmunityAbAttr", { + pokemon: sourcePokemon, + cancelled: cancelImmunity, + statusEffect: effect, + defenderType: defType, + }); + return !cancelImmunity.value; }); - - if (this.isOfType(PokemonType.POISON) || this.isOfType(PokemonType.STEEL)) { - if (poisonImmunity.includes(true)) { - this.queueStatusImmuneMessage(quiet); - return false; - } - } break; - } case StatusEffect.PARALYSIS: - if (this.isOfType(PokemonType.ELECTRIC)) { - this.queueStatusImmuneMessage(quiet); - return false; - } + isImmune = this.isOfType(PokemonType.ELECTRIC); break; case StatusEffect.SLEEP: - if (this.isGrounded() && globalScene.arena.terrain?.terrainType === TerrainType.ELECTRIC) { - this.queueStatusImmuneMessage(quiet, TerrainType.ELECTRIC); - return false; - } + isImmune = this.isGrounded() && globalScene.arena.getTerrainType() === TerrainType.ELECTRIC; + reason = TerrainType.ELECTRIC; break; - case StatusEffect.FREEZE: - if ( + case StatusEffect.FREEZE: { + const weatherType = globalScene.arena.getWeatherType(); + isImmune = this.isOfType(PokemonType.ICE) || - (!ignoreField && - globalScene?.arena?.weather?.weatherType && - [WeatherType.SUNNY, WeatherType.HARSH_SUN].includes(globalScene.arena.weather.weatherType)) - ) { - this.queueStatusImmuneMessage(quiet); - return false; - } + (!ignoreField && (weatherType === WeatherType.SUNNY || weatherType === WeatherType.HARSH_SUN)); break; + } case StatusEffect.BURN: - if (this.isOfType(PokemonType.FIRE)) { - this.queueStatusImmuneMessage(quiet); - return false; - } + isImmune = this.isOfType(PokemonType.FIRE); break; } + if (isImmune) { + this.queueStatusImmuneMessage(quiet, reason); + return false; + } + + // Check for cancellations from self/ally abilities const cancelled = new BooleanHolder(false); applyAbAttrs("StatusEffectImmunityAbAttr", { pokemon: this, effect, cancelled, simulated: quiet }); if (cancelled.value) { @@ -4875,14 +4876,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { source: sourcePokemon, }); if (cancelled.value) { - break; + return false; } } - if (cancelled.value) { - return false; - } - + // Perform safeguard checks if (sourcePokemon && sourcePokemon !== this && this.isSafeguarded(sourcePokemon)) { if (!quiet) { globalScene.phaseManager.queueMessage( @@ -4895,18 +4893,36 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return true; } - trySetStatus( - effect?: StatusEffect, - asPhase = false, + /** + * Attempt to set this Pokemon's status to the specified condition. + * Enqueues a new `ObtainStatusEffectPhase` to trigger animations, etc. + * @param effect - The {@linkcode StatusEffect} to set + * @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target, + * or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null` + * @param sleepTurnsRemaining - The number of turns to set {@linkcode StatusEffect.SLEEP} for; + * defaults to a random number between 2 and 4 and is unused for non-Sleep statuses + * @param sourceText - The text to show for the source of the status effect, if any; default `null` + * @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false` + * @param quiet - Whether to suppress in-battle messages for status checks; default `true` + * @param overrideMessage - String containing text to be displayed upon status setting; defaults to normal key for status + * and is used exclusively for Rest + * @returns Whether the status effect phase was successfully created. + * @see {@linkcode doSetStatus} - alternate function that sets status immediately (albeit without condition checks). + */ + public trySetStatus( + effect: StatusEffect, sourcePokemon: Pokemon | null = null, - turnsRemaining = 0, + sleepTurnsRemaining?: number, sourceText: string | null = null, overrideStatus?: boolean, quiet = true, + overrideMessage?: string, ): boolean { + // TODO: This needs to propagate failure status for status moves if (!effect) { return false; } + if (!this.canSetStatus(effect, quiet, overrideStatus, sourcePokemon)) { return false; } @@ -4926,48 +4942,79 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } - if (asPhase) { - if (overrideStatus) { - this.resetStatus(false); - } - globalScene.phaseManager.unshiftNew( - "ObtainStatusEffectPhase", - this.getBattlerIndex(), - effect, - turnsRemaining, - sourceText, - sourcePokemon, - ); - return true; + if (overrideStatus) { + this.resetStatus(false); } - let sleepTurnsRemaining: NumberHolder; + globalScene.phaseManager.unshiftNew( + "ObtainStatusEffectPhase", + this.getBattlerIndex(), + effect, + sourcePokemon, + sleepTurnsRemaining, + sourceText, + overrideMessage, + ); + return true; + } + + /** + * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. + * @param effect - The {@linkcode StatusEffect} to set + * @remarks + * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. + */ + doSetStatus(effect: Exclude): void; + /** + * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. + * @param effect - {@linkcode StatusEffect.SLEEP} + * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 + * @remarks + * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. + */ + doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void; + /** + * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. + * @param effect - The {@linkcode StatusEffect} to set + * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 + * and is unused for all non-sleep Statuses + * @remarks + * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. + */ + doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void; + /** + * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. + * @param effect - The {@linkcode StatusEffect} to set + * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 + * and is unused for all non-sleep Statuses + * @remarks + * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. + * @todo Make this and all related fields private and change tests to use a field-based helper or similar + */ + doSetStatus( + effect: StatusEffect, + sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4), + ): void { if (effect === StatusEffect.SLEEP) { - sleepTurnsRemaining = new NumberHolder(this.randBattleSeedIntRange(2, 4)); - this.setFrameRate(4); - // If the user is invulnerable, lets remove their invulnerability when they fall asleep - const invulnerableTags = [ + // If the user is semi-invulnerable when put asleep (such as due to Yawm), + // remove their invulnerability and cancel the upcoming move from the queue + const invulnTagTypes = [ + BattlerTagType.FLYING, BattlerTagType.UNDERGROUND, BattlerTagType.UNDERWATER, BattlerTagType.HIDDEN, - BattlerTagType.FLYING, ]; - const tag = invulnerableTags.find(t => this.getTag(t)); - - if (tag) { - this.removeTag(tag); - this.getMoveQueue().pop(); + if (this.findTag(t => invulnTagTypes.includes(t.tagType))) { + this.findAndRemoveTags(t => invulnTagTypes.includes(t.tagType)); + this.getMoveQueue().shift(); } } - sleepTurnsRemaining = sleepTurnsRemaining!; // tell TS compiler it's defined - this.status = new Status(effect, 0, sleepTurnsRemaining?.value); - - return true; + this.status = new Status(effect, 0, sleepTurnsRemaining); } /** diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index b31bee7fc69..a4131bf2b12 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1733,12 +1733,12 @@ export class TurnStatusEffectModifier extends PokemonHeldItemModifier { } /** - * Tries to inflicts the holder with the associated {@linkcode StatusEffect}. - * @param pokemon {@linkcode Pokemon} that holds the held item + * Attempt to inflict the holder with the associated {@linkcode StatusEffect}. + * @param pokemon - The {@linkcode Pokemon} holding the item * @returns `true` if the status effect was applied successfully */ override apply(pokemon: Pokemon): boolean { - return pokemon.trySetStatus(this.effect, true, undefined, undefined, this.type.name); + return pokemon.trySetStatus(this.effect, pokemon, undefined, this.type.name); } getMaxHeldItemCount(_pokemon: Pokemon): number { @@ -3605,7 +3605,7 @@ export class EnemyAttackStatusEffectChanceModifier extends EnemyPersistentModifi */ override apply(enemyPokemon: Pokemon): boolean { if (randSeedFloat() <= this.chance * this.getStackCount()) { - return enemyPokemon.trySetStatus(this.effect, true); + return enemyPokemon.trySetStatus(this.effect); } return false; diff --git a/src/phases/attempt-capture-phase.ts b/src/phases/attempt-capture-phase.ts index fcddd23dd20..0f01bf2157f 100644 --- a/src/phases/attempt-capture-phase.ts +++ b/src/phases/attempt-capture-phase.ts @@ -265,7 +265,7 @@ export class AttemptCapturePhase extends PokemonPhase { const removePokemon = () => { globalScene.addFaintedEnemyScore(pokemon); pokemon.hp = 0; - pokemon.trySetStatus(StatusEffect.FAINT); + pokemon.doSetStatus(StatusEffect.FAINT); globalScene.clearEnemyHeldItemModifiers(); pokemon.leaveField(true, true, true); }; diff --git a/src/phases/attempt-run-phase.ts b/src/phases/attempt-run-phase.ts index a59667bdd4e..e8212a27243 100644 --- a/src/phases/attempt-run-phase.ts +++ b/src/phases/attempt-run-phase.ts @@ -45,7 +45,7 @@ export class AttemptRunPhase extends FieldPhase { enemyField.forEach(enemyPokemon => { enemyPokemon.hideInfo().then(() => enemyPokemon.destroy()); enemyPokemon.hp = 0; - enemyPokemon.trySetStatus(StatusEffect.FAINT); + enemyPokemon.doSetStatus(StatusEffect.FAINT); }); globalScene.phaseManager.pushNew("BattleEndPhase", false); diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index d1bd0ed0804..2d953043866 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -205,7 +205,7 @@ export class FaintPhase extends PokemonPhase { pokemon.lapseTags(BattlerTagLapseType.FAINT); pokemon.y -= 150; - pokemon.trySetStatus(StatusEffect.FAINT); + pokemon.doSetStatus(StatusEffect.FAINT); if (pokemon.isPlayer()) { globalScene.currentBattle.removeFaintedParticipant(pokemon as PlayerPokemon); } else { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index f88f9d0cad1..9a8e509e302 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -269,8 +269,8 @@ export class MovePhase extends BattlePhase { globalScene.phaseManager.queueMessage( getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)), ); - this.pokemon.resetStatus(); - this.pokemon.updateInfo(); + // cannot use `asPhase=true` as it will cause status to be reset _after_ move condition checks fire + this.pokemon.resetStatus(false, false, false, false); } } diff --git a/src/phases/obtain-status-effect-phase.ts b/src/phases/obtain-status-effect-phase.ts index fbadbed205b..4846130cf4d 100644 --- a/src/phases/obtain-status-effect-phase.ts +++ b/src/phases/obtain-status-effect-phase.ts @@ -3,71 +3,64 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { CommonBattleAnim } from "#data/battle-anims"; import { SpeciesFormChangeStatusEffectTrigger } from "#data/form-change-triggers"; -import { getStatusEffectObtainText, getStatusEffectOverlapText } from "#data/status-effect"; +import { getStatusEffectObtainText } from "#data/status-effect"; import type { BattlerIndex } from "#enums/battler-index"; import { CommonAnim } from "#enums/move-anims-common"; import { StatusEffect } from "#enums/status-effect"; import type { Pokemon } from "#field/pokemon"; import { PokemonPhase } from "#phases/pokemon-phase"; -import { isNullOrUndefined } from "#utils/common"; export class ObtainStatusEffectPhase extends PokemonPhase { public readonly phaseName = "ObtainStatusEffectPhase"; - private statusEffect?: StatusEffect; - private turnsRemaining?: number; - private sourceText?: string | null; - private sourcePokemon?: Pokemon | null; + /** + * @param battlerIndex - The {@linkcode BattlerIndex} of the Pokemon obtaining the status effect. + * @param statusEffect - The {@linkcode StatusEffect} being applied. + * @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target, + * or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null`. + * @param sleepTurnsRemaining - The number of turns to set {@linkcode StatusEffect.SLEEP} for; + * defaults to a random number between 2 and 4 and is unused for non-Sleep statuses. + * @param sourceText - The text to show for the source of the status effect, if any; default `null`. + * @param statusMessage - A string containing text to be displayed upon status setting; + * defaults to normal key for status if empty or omitted. + */ constructor( battlerIndex: BattlerIndex, - statusEffect?: StatusEffect, - turnsRemaining?: number, - sourceText?: string | null, - sourcePokemon?: Pokemon | null, + private statusEffect: StatusEffect, + private sourcePokemon: Pokemon | null = null, + private sleepTurnsRemaining?: number, + sourceText: string | null = null, // TODO: This should take `undefined` instead of `null` + private statusMessage = "", ) { super(battlerIndex); - this.statusEffect = statusEffect; - this.turnsRemaining = turnsRemaining; - this.sourceText = sourceText; - this.sourcePokemon = sourcePokemon; + this.statusMessage ||= getStatusEffectObtainText( + statusEffect, + getPokemonNameWithAffix(this.getPokemon()), + sourceText ?? undefined, + ); } start() { const pokemon = this.getPokemon(); - if (pokemon && !pokemon.status) { - if (pokemon.trySetStatus(this.statusEffect, false, this.sourcePokemon)) { - if (this.turnsRemaining) { - pokemon.status!.sleepTurnsRemaining = this.turnsRemaining; - } - pokemon.updateInfo(true); - new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(false, () => { - globalScene.phaseManager.queueMessage( - getStatusEffectObtainText( - this.statusEffect, - getPokemonNameWithAffix(pokemon), - this.sourceText ?? undefined, - ), - ); - if (!isNullOrUndefined(this.statusEffect) && this.statusEffect !== StatusEffect.FAINT) { - globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeStatusEffectTrigger, true); - // If mold breaker etc was used to set this status, it shouldn't apply to abilities activated afterwards - globalScene.arena.setIgnoreAbilities(false); - applyAbAttrs("PostSetStatusAbAttr", { - pokemon, - effect: this.statusEffect, - sourcePokemon: this.sourcePokemon ?? undefined, - }); - } - this.end(); + + pokemon.doSetStatus(this.statusEffect, this.sleepTurnsRemaining); + pokemon.updateInfo(true); + + new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect - 1), pokemon).play(false, () => { + globalScene.phaseManager.queueMessage(this.statusMessage); + if (this.statusEffect && this.statusEffect !== StatusEffect.FAINT) { + globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeStatusEffectTrigger, true); + // If the status was applied from a move, ensure abilities are not ignored for follow-up triggers. + // TODO: Ensure this isn't breaking any other phases unshifted afterwards + globalScene.arena.setIgnoreAbilities(false); + applyAbAttrs("PostSetStatusAbAttr", { + pokemon, + effect: this.statusEffect, + sourcePokemon: this.sourcePokemon ?? undefined, }); - return; } - } else if (pokemon.status?.effect === this.statusEffect) { - globalScene.phaseManager.queueMessage( - getStatusEffectOverlapText(this.statusEffect ?? StatusEffect.NONE, getPokemonNameWithAffix(pokemon)), - ); - } - this.end(); + this.end(); + }); } } diff --git a/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index fa6a3222466..02bb3a0b968 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -13,6 +13,7 @@ import { HealAchv } from "#system/achv"; import { NumberHolder } from "#utils/common"; import i18next from "i18next"; +// TODO: Refactor this - it has far too many arguments export class PokemonHealPhase extends CommonAnimPhase { public readonly phaseName = "PokemonHealPhase"; private hpHealed: number; @@ -28,7 +29,7 @@ export class PokemonHealPhase extends CommonAnimPhase { battlerIndex: BattlerIndex, hpHealed: number, message: string | null, - showFullHpMessage: boolean, + showFullHpMessage = true, skipAnim = false, revive = false, healStatus = false, @@ -72,6 +73,7 @@ export class PokemonHealPhase extends CommonAnimPhase { this.message = null; return super.end(); } + if (healOrDamage) { const hpRestoreMultiplier = new NumberHolder(1); if (!this.revive) { diff --git a/test/abilities/corrosion.test.ts b/test/abilities/corrosion.test.ts index 490a365394b..965c00290fe 100644 --- a/test/abilities/corrosion.test.ts +++ b/test/abilities/corrosion.test.ts @@ -1,6 +1,7 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -22,25 +23,66 @@ describe("Abilities - Corrosion", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.SPLASH]) .battleStyle("single") .criticalHits(false) .enemySpecies(SpeciesId.GRIMER) - .enemyAbility(AbilityId.CORROSION) - .enemyMoveset(MoveId.TOXIC); + .ability(AbilityId.CORROSION) + .enemyAbility(AbilityId.NO_GUARD) + .enemyMoveset(MoveId.SPLASH); }); - it("If a Poison- or Steel-type Pokémon with this Ability poisons a target with Synchronize, Synchronize does not gain the ability to poison Poison- or Steel-type Pokémon.", async () => { - game.override.ability(AbilityId.SYNCHRONIZE); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); + it.each<{ name: string; species: SpeciesId }>([ + { name: "Poison", species: SpeciesId.GRIMER }, + { name: "Steel", species: SpeciesId.KLINK }, + ])("should grant the user the ability to poison $name-type opponents", async ({ species }) => { + game.override.enemySpecies(species); + await game.classicMode.startBattle([SpeciesId.SALANDIT]); - const playerPokemon = game.scene.getPlayerPokemon(); - const enemyPokemon = game.scene.getEnemyPokemon(); - expect(playerPokemon!.status).toBeUndefined(); + const enemy = game.field.getEnemyPokemon(); + expect(enemy.status?.effect).toBeUndefined(); - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("BerryPhase"); - expect(playerPokemon!.status).toBeDefined(); - expect(enemyPokemon!.status).toBeUndefined(); + game.move.use(MoveId.POISON_GAS); + await game.toEndOfTurn(); + + expect(enemy.status?.effect).toBe(StatusEffect.POISON); + }); + + it("should not affect Toxic Spikes", async () => { + await game.classicMode.startBattle([SpeciesId.SALANDIT]); + + game.move.use(MoveId.TOXIC_SPIKES); + await game.doKillOpponents(); + await game.toNextWave(); + + const enemyPokemon = game.field.getEnemyPokemon(); + expect(enemyPokemon.status).toBeUndefined(); + }); + + it("should not affect an opponent's Synchronize ability", async () => { + game.override.enemyAbility(AbilityId.SYNCHRONIZE); + await game.classicMode.startBattle([SpeciesId.ARBOK]); + + const playerPokemon = game.field.getPlayerPokemon(); + const enemyPokemon = game.field.getEnemyPokemon(); + expect(enemyPokemon.status?.effect).toBeUndefined(); + + game.move.use(MoveId.TOXIC); + await game.toEndOfTurn(); + + expect(enemyPokemon.status?.effect).toBe(StatusEffect.TOXIC); + expect(playerPokemon.status?.effect).toBeUndefined(); + }); + + it("should affect the user's held Toxic Orb", async () => { + game.override.startingHeldItems([{ name: "TOXIC_ORB", count: 1 }]); + await game.classicMode.startBattle([SpeciesId.SALAZZLE]); + + const salazzle = game.field.getPlayerPokemon(); + expect(salazzle.status?.effect).toBeUndefined(); + + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + expect(salazzle.status?.effect).toBe(StatusEffect.TOXIC); }); }); diff --git a/test/abilities/healer.test.ts b/test/abilities/healer.test.ts index 52f47535bf4..43280ff8271 100644 --- a/test/abilities/healer.test.ts +++ b/test/abilities/healer.test.ts @@ -49,6 +49,7 @@ describe("Abilities - Healer", () => { const user = game.field.getPlayerPokemon(); // Only want one magikarp to have the ability vi.spyOn(user, "getAbility").mockReturnValue(allAbilities[AbilityId.HEALER]); + game.move.select(MoveId.SPLASH); // faint the ally game.move.select(MoveId.LUNAR_DANCE, 1); @@ -62,9 +63,10 @@ describe("Abilities - Healer", () => { it("should heal the status of an ally if the ally has a status", async () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); const [user, ally] = game.scene.getPlayerField(); + // Only want one magikarp to have the ability. vi.spyOn(user, "getAbility").mockReturnValue(allAbilities[AbilityId.HEALER]); - expect(ally.trySetStatus(StatusEffect.BURN)).toBe(true); + ally.doSetStatus(StatusEffect.BURN); game.move.select(MoveId.SPLASH); game.move.select(MoveId.SPLASH, 1); @@ -80,7 +82,7 @@ describe("Abilities - Healer", () => { const [user, ally] = game.scene.getPlayerField(); // Only want one magikarp to have the ability. vi.spyOn(user, "getAbility").mockReturnValue(allAbilities[AbilityId.HEALER]); - expect(ally.trySetStatus(StatusEffect.BURN)).toBe(true); + ally.doSetStatus(StatusEffect.BURN); game.move.select(MoveId.SPLASH); game.move.select(MoveId.SPLASH, 1); await game.phaseInterceptor.to("TurnEndPhase"); diff --git a/test/abilities/infiltrator.test.ts b/test/abilities/infiltrator.test.ts index a093fbbe6c6..24fb1b24540 100644 --- a/test/abilities/infiltrator.test.ts +++ b/test/abilities/infiltrator.test.ts @@ -79,9 +79,9 @@ describe("Abilities - Infiltrator", () => { game.scene.arena.addTag(ArenaTagType.SAFEGUARD, 1, MoveId.NONE, enemy.id, ArenaTagSide.ENEMY, true); - game.move.select(MoveId.SPORE); + game.move.use(MoveId.SPORE); + await game.toEndOfTurn(); - await game.phaseInterceptor.to("BerryPhase", false); expect(enemy.status?.effect).toBe(StatusEffect.SLEEP); expect(player.waveData.abilitiesApplied).toContain(AbilityId.INFILTRATOR); }); diff --git a/test/abilities/insomnia.test.ts b/test/abilities/insomnia.test.ts deleted file mode 100644 index 679220687b9..00000000000 --- a/test/abilities/insomnia.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Insomnia", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove sleep when gained", async () => { - game.override - .ability(AbilityId.INSOMNIA) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.SLEEP); - expect(enemy?.status?.effect).toBe(StatusEffect.SLEEP); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/limber.test.ts b/test/abilities/limber.test.ts deleted file mode 100644 index e65a54b545d..00000000000 --- a/test/abilities/limber.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Limber", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove paralysis when gained", async () => { - game.override - .ability(AbilityId.LIMBER) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.PARALYSIS); - expect(enemy?.status?.effect).toBe(StatusEffect.PARALYSIS); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/magma-armor.test.ts b/test/abilities/magma-armor.test.ts deleted file mode 100644 index 2e7176fdf96..00000000000 --- a/test/abilities/magma-armor.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Magma Armor", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove freeze when gained", async () => { - game.override - .ability(AbilityId.MAGMA_ARMOR) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.FREEZE); - expect(enemy?.status?.effect).toBe(StatusEffect.FREEZE); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/status-immunity-ab-attrs.test.ts b/test/abilities/status-immunity-ab-attrs.test.ts new file mode 100644 index 00000000000..7df621d5577 --- /dev/null +++ b/test/abilities/status-immunity-ab-attrs.test.ts @@ -0,0 +1,95 @@ +import { allMoves } from "#data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; +import { StatusEffectAttr } from "#moves/move"; +import { GameManager } from "#test/test-utils/game-manager"; +import { toTitleCase } from "#utils/strings"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe.each<{ name: string; ability: AbilityId; status: StatusEffect }>([ + { name: "Vital Spirit", ability: AbilityId.VITAL_SPIRIT, status: StatusEffect.SLEEP }, + { name: "Insomnia", ability: AbilityId.INSOMNIA, status: StatusEffect.SLEEP }, + { name: "Immunity", ability: AbilityId.IMMUNITY, status: StatusEffect.POISON }, + { name: "Magma Armor", ability: AbilityId.MAGMA_ARMOR, status: StatusEffect.FREEZE }, + { name: "Limber", ability: AbilityId.LIMBER, status: StatusEffect.PARALYSIS }, + { name: "Thermal Exchange", ability: AbilityId.THERMAL_EXCHANGE, status: StatusEffect.BURN }, + { name: "Water Veil", ability: AbilityId.WATER_VEIL, status: StatusEffect.BURN }, + { name: "Water Bubble", ability: AbilityId.WATER_BUBBLE, status: StatusEffect.BURN }, +])("Abilities - $name", ({ ability, status }) => { + 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 + .battleStyle("single") + .criticalHits(false) + .enemyLevel(100) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(ability) + .enemyMoveset(MoveId.SPLASH); + + // Mock Lumina Crash and Spore to be our status-inflicting moves of choice + vi.spyOn(allMoves[MoveId.LUMINA_CRASH], "attrs", "get").mockReturnValue([new StatusEffectAttr(status, false)]); + vi.spyOn(allMoves[MoveId.SPORE], "attrs", "get").mockReturnValue([new StatusEffectAttr(status, false)]); + }); + + const statusStr = toTitleCase(StatusEffect[status]); + + it(`should prevent application of ${statusStr} without failing damaging moves`, async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const karp = game.field.getEnemyPokemon(); + expect(karp.status?.effect).toBeUndefined(); + expect(karp.canSetStatus(status)).toBe(false); + + game.move.use(MoveId.LUMINA_CRASH); + await game.toEndOfTurn(); + + expect(karp.status?.effect).toBeUndefined(); + expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }); + + it(`should cure ${statusStr} upon being gained`, async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + feebas.doSetStatus(status); + expect(feebas.status?.effect).toBe(status); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SKILL_SWAP); + await game.toEndOfTurn(); + + expect(feebas.status?.effect).toBeUndefined(); + }); + + // TODO: This does not propagate failures currently + it.todo( + `should cause status moves inflicting ${statusStr} to count as failed if no other effects can be applied`, + async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.SPORE); + await game.toEndOfTurn(); + + const karp = game.field.getEnemyPokemon(); + expect(karp.status?.effect).toBeUndefined(); + expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }, + ); +}); diff --git a/test/abilities/thermal-exchange.test.ts b/test/abilities/thermal-exchange.test.ts deleted file mode 100644 index 193676ccc18..00000000000 --- a/test/abilities/thermal-exchange.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Thermal Exchange", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove burn when gained", async () => { - game.override - .ability(AbilityId.THERMAL_EXCHANGE) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.BURN); - expect(enemy?.status?.effect).toBe(StatusEffect.BURN); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/vital-spirit.test.ts b/test/abilities/vital-spirit.test.ts deleted file mode 100644 index e5d80a66a8e..00000000000 --- a/test/abilities/vital-spirit.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Vital Spirit", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove sleep when gained", async () => { - game.override - .ability(AbilityId.INSOMNIA) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.SLEEP); - expect(enemy?.status?.effect).toBe(StatusEffect.SLEEP); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/water-bubble.test.ts b/test/abilities/water-bubble.test.ts deleted file mode 100644 index 6be1ac51094..00000000000 --- a/test/abilities/water-bubble.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Water Bubble", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove burn when gained", async () => { - game.override - .ability(AbilityId.THERMAL_EXCHANGE) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.BURN); - expect(enemy?.status?.effect).toBe(StatusEffect.BURN); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/water-veil.test.ts b/test/abilities/water-veil.test.ts deleted file mode 100644 index 0c7068ae209..00000000000 --- a/test/abilities/water-veil.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Abilities - Water Veil", () => { - 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 - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should remove burn when gained", async () => { - game.override - .ability(AbilityId.THERMAL_EXCHANGE) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.BURN); - expect(enemy?.status?.effect).toBe(StatusEffect.BURN); - - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy?.status).toBeNull(); - }); -}); diff --git a/test/abilities/immunity.test.ts b/test/field/pokemon-funcs.test.ts similarity index 55% rename from test/abilities/immunity.test.ts rename to test/field/pokemon-funcs.test.ts index dccee93ac10..eea1f80192a 100644 --- a/test/abilities/immunity.test.ts +++ b/test/field/pokemon-funcs.test.ts @@ -6,7 +6,7 @@ import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -describe("Abilities - Immunity", () => { +describe("Spec - Pokemon Functions", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -23,29 +23,29 @@ describe("Abilities - Immunity", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) .battleStyle("single") + .startingLevel(100) .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.BALL_FETCH) + .ability(AbilityId.BALL_FETCH) .enemyMoveset(MoveId.SPLASH); }); - it("should remove poison when gained", async () => { - game.override - .ability(AbilityId.IMMUNITY) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset(MoveId.SKILL_SWAP) - .enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon(); - enemy?.trySetStatus(StatusEffect.POISON); - expect(enemy?.status?.effect).toBe(StatusEffect.POISON); + describe("doSetStatus", () => { + it("should change the Pokemon's status, ignoring feasibility checks", async () => { + await game.classicMode.startBattle([SpeciesId.ACCELGOR]); - game.move.select(MoveId.SKILL_SWAP); - await game.phaseInterceptor.to("BerryPhase"); + const player = game.field.getPlayerPokemon(); - expect(enemy?.status).toBeNull(); + expect(player.status?.effect).toBeUndefined(); + player.doSetStatus(StatusEffect.BURN); + expect(player.status?.effect).toBe(StatusEffect.BURN); + + expect(player.canSetStatus(StatusEffect.SLEEP)).toBe(false); + player.doSetStatus(StatusEffect.SLEEP, 5); + expect(player.status?.effect).toBe(StatusEffect.SLEEP); + expect(player.status?.sleepTurnsRemaining).toBe(5); + }); }); }); diff --git a/test/field/pokemon.test.ts b/test/field/pokemon.test.ts index 02058ad6cb1..4ee5819fc26 100644 --- a/test/field/pokemon.test.ts +++ b/test/field/pokemon.test.ts @@ -25,15 +25,6 @@ describe("Spec - Pokemon", () => { game = new GameManager(phaserGame); }); - it("should not crash when trying to set status of undefined", async () => { - await game.classicMode.runToSummon([SpeciesId.ABRA]); - - const pkm = game.field.getPlayerPokemon(); - expect(pkm).toBeDefined(); - - expect(pkm.trySetStatus(undefined)).toBe(false); - }); - describe("Add To Party", () => { let scene: BattleScene; diff --git a/test/moves/beat-up.test.ts b/test/moves/beat-up.test.ts index cfb3d35bed5..ff08b55cefa 100644 --- a/test/moves/beat-up.test.ts +++ b/test/moves/beat-up.test.ts @@ -73,7 +73,7 @@ describe("Moves - Beat Up", () => { const playerPokemon = game.field.getPlayerPokemon(); - game.scene.getPlayerParty()[1].trySetStatus(StatusEffect.BURN); + game.scene.getPlayerParty()[1].doSetStatus(StatusEffect.BURN); game.move.select(MoveId.BEAT_UP); diff --git a/test/moves/fusion-flare.test.ts b/test/moves/fusion-flare.test.ts index dd8ae11683d..e5c45c8fadb 100644 --- a/test/moves/fusion-flare.test.ts +++ b/test/moves/fusion-flare.test.ts @@ -44,7 +44,7 @@ describe("Moves - Fusion Flare", () => { await game.phaseInterceptor.to(TurnStartPhase, false); // Inflict freeze quietly and check if it was properly inflicted - partyMember.trySetStatus(StatusEffect.FREEZE, false); + partyMember.doSetStatus(StatusEffect.FREEZE); expect(partyMember.status!.effect).toBe(StatusEffect.FREEZE); await game.toNextTurn(); diff --git a/test/moves/rest.test.ts b/test/moves/rest.test.ts new file mode 100644 index 00000000000..9f29d468e4a --- /dev/null +++ b/test/moves/rest.test.ts @@ -0,0 +1,146 @@ +import { AbilityId } from "#enums/ability-id"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Move - Rest", () => { + 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.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.EKANS) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + }); + + it("should fully heal the user, cure its prior status and put it to sleep", async () => { + game.override.statusEffect(StatusEffect.POISON); + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const snorlax = game.field.getPlayerPokemon(); + snorlax.hp = 1; + expect(snorlax.status?.effect).toBe(StatusEffect.POISON); + + game.move.use(MoveId.REST); + await game.toEndOfTurn(); + + expect(snorlax.hp).toBe(snorlax.getMaxHp()); + expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP); + }); + + it("should always last 3 turns", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const snorlax = game.field.getPlayerPokemon(); + snorlax.hp = 1; + + // Cf https://bulbapedia.bulbagarden.net/wiki/Rest_(move): + // > The user is unable to use MoveId while asleep for 2 turns after the turn when Rest is used. + game.move.use(MoveId.REST); + await game.toNextTurn(); + + expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP); + expect(snorlax.status?.sleepTurnsRemaining).toBe(3); + + game.move.use(MoveId.SWORDS_DANCE); + await game.toNextTurn(); + expect(snorlax.status?.sleepTurnsRemaining).toBe(2); + + game.move.use(MoveId.SWORDS_DANCE); + await game.toNextTurn(); + expect(snorlax.status?.sleepTurnsRemaining).toBe(1); + + game.move.use(MoveId.SWORDS_DANCE); + await game.toNextTurn(); + expect(snorlax.status?.effect).toBeUndefined(); + expect(snorlax.getStatStage(Stat.ATK)).toBe(2); + }); + + it("should preserve non-volatile status conditions", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const snorlax = game.field.getPlayerPokemon(); + snorlax.hp = 1; + snorlax.addTag(BattlerTagType.CONFUSED, 999); + + game.move.use(MoveId.REST); + await game.toEndOfTurn(); + + expect(snorlax.getTag(BattlerTagType.CONFUSED)).toBeDefined(); + }); + + it.each<{ name: string; status?: StatusEffect; ability?: AbilityId; dmg?: number }>([ + { name: "is at full HP", dmg: 0 }, + { name: "is grounded on Electric Terrain", ability: AbilityId.ELECTRIC_SURGE }, + { name: "is grounded on Misty Terrain", ability: AbilityId.MISTY_SURGE }, + { name: "has Comatose", ability: AbilityId.COMATOSE }, + ])("should fail if the user $name", async ({ status = StatusEffect.NONE, ability = AbilityId.NONE, dmg = 1 }) => { + game.override.ability(ability).statusEffect(status); + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const snorlax = game.field.getPlayerPokemon(); + + snorlax.hp = snorlax.getMaxHp() - dmg; + + game.move.use(MoveId.REST); + await game.toEndOfTurn(); + + expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should fail if called while already asleep", async () => { + game.override.statusEffect(StatusEffect.SLEEP).moveset([MoveId.REST, MoveId.SLEEP_TALK]); + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const snorlax = game.field.getPlayerPokemon(); + snorlax.hp = 1; + + // Need to use sleep talk here since you normally can't move while asleep + game.move.select(MoveId.SLEEP_TALK); + await game.toEndOfTurn(); + + expect(snorlax.isFullHp()).toBe(false); + expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP); + expect(snorlax.getLastXMoves(-1).map(tm => tm.result)).toEqual([MoveResult.FAIL, MoveResult.SUCCESS]); + }); + + it("should succeed if called the same turn as the user wakes", async () => { + game.override.statusEffect(StatusEffect.SLEEP); + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const snorlax = game.field.getPlayerPokemon(); + snorlax.hp = 1; + + expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP); + snorlax.status!.sleepTurnsRemaining = 1; + + game.move.use(MoveId.REST); + await game.toNextTurn(); + + expect(snorlax.status!.effect).toBe(StatusEffect.SLEEP); + expect(snorlax.isFullHp()).toBe(true); + expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(snorlax.status!.sleepTurnsRemaining).toBeGreaterThan(1); + }); +}); diff --git a/test/moves/sleep-talk.test.ts b/test/moves/sleep-talk.test.ts index 9e8db2e3615..e8a1bf4cbcb 100644 --- a/test/moves/sleep-talk.test.ts +++ b/test/moves/sleep-talk.test.ts @@ -1,6 +1,7 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; @@ -31,13 +32,36 @@ describe("Moves - Sleep Talk", () => { .battleStyle("single") .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) + .enemyAbility(AbilityId.NO_GUARD) .enemyMoveset(MoveId.SPLASH) .enemyLevel(100); }); - it("should fail when the user is not asleep", async () => { - game.override.statusEffect(StatusEffect.NONE); + it("should call a random valid move if the user is asleep", async () => { + game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.SWORDS_DANCE]); // Dig and Fly are invalid moves, Swords Dance should always be called + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.select(MoveId.SLEEP_TALK); + await game.toNextTurn(); + + const feebas = game.field.getPlayerPokemon(); + expect(feebas.getStatStage(Stat.ATK)).toBe(2); + expect(feebas.getLastXMoves(2)).toEqual([ + expect.objectContaining({ + move: MoveId.SWORDS_DANCE, + result: MoveResult.SUCCESS, + useMode: MoveUseMode.FOLLOW_UP, + }), + expect.objectContaining({ + move: MoveId.SLEEP_TALK, + result: MoveResult.SUCCESS, + useMode: MoveUseMode.NORMAL, + }), + ]); + }); + + it("should fail if the user is not asleep", async () => { + game.override.statusEffect(StatusEffect.POISON); await game.classicMode.startBattle([SpeciesId.FEEBAS]); game.move.select(MoveId.SLEEP_TALK); @@ -45,6 +69,19 @@ describe("Moves - Sleep Talk", () => { expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); + it("should fail the turn the user wakes up from Sleep", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + expect(feebas.status?.effect).toBe(StatusEffect.SLEEP); + feebas.status!.sleepTurnsRemaining = 1; + + game.move.select(MoveId.SLEEP_TALK); + await game.toNextTurn(); + + expect(feebas).toHaveUsedMove({result: MoveResult.FAIL}); + }); + it("should fail if the user has no valid moves", async () => { game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.METRONOME, MoveId.SOLAR_BEAM]); await game.classicMode.startBattle([SpeciesId.FEEBAS]); @@ -54,22 +91,15 @@ describe("Moves - Sleep Talk", () => { expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); - it("should call a random valid move if the user is asleep", async () => { - game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.SWORDS_DANCE]); // Dig and Fly are invalid moves, Swords Dance should always be called + it("should apply secondary effects of the called move", async () => { + game.override.moveset([MoveId.SLEEP_TALK, MoveId.SCALE_SHOT]); await game.classicMode.startBattle([SpeciesId.FEEBAS]); game.move.select(MoveId.SLEEP_TALK); await game.toNextTurn(); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)); - }); - it("should apply secondary effects of a move", async () => { - game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.WOOD_HAMMER]); // Dig and Fly are invalid moves, Wood Hammer should always be called - await game.classicMode.startBattle(); - - game.move.select(MoveId.SLEEP_TALK); - await game.toNextTurn(); - - expect(game.field.getPlayerPokemon().isFullHp()).toBeFalsy(); // Wood Hammer recoil effect should be applied + const feebas = game.field.getPlayerPokemon(); + expect(feebas.getStatStage(Stat.SPD)).toBe(1); + expect(feebas.getStatStage(Stat.DEF)).toBe(-1); }); }); diff --git a/test/mystery-encounter/mystery-encounter-utils.test.ts b/test/mystery-encounter/mystery-encounter-utils.test.ts index adcc3111319..361c3f7480e 100644 --- a/test/mystery-encounter/mystery-encounter-utils.test.ts +++ b/test/mystery-encounter/mystery-encounter-utils.test.ts @@ -63,7 +63,7 @@ describe("Mystery Encounter Utils", () => { // Both pokemon fainted scene.getPlayerParty().forEach(p => { p.hp = 0; - p.trySetStatus(StatusEffect.FAINT); + p.doSetStatus(StatusEffect.FAINT); void p.updateInfo(); }); @@ -83,7 +83,7 @@ describe("Mystery Encounter Utils", () => { // Only faint 1st pokemon const party = scene.getPlayerParty(); party[0].hp = 0; - party[0].trySetStatus(StatusEffect.FAINT); + party[0].doSetStatus(StatusEffect.FAINT); await party[0].updateInfo(); // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) @@ -102,7 +102,7 @@ describe("Mystery Encounter Utils", () => { // Only faint 1st pokemon const party = scene.getPlayerParty(); party[0].hp = 0; - party[0].trySetStatus(StatusEffect.FAINT); + party[0].doSetStatus(StatusEffect.FAINT); await party[0].updateInfo(); // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) @@ -121,7 +121,7 @@ describe("Mystery Encounter Utils", () => { // Only faint 1st pokemon const party = scene.getPlayerParty(); party[0].hp = 0; - party[0].trySetStatus(StatusEffect.FAINT); + party[0].doSetStatus(StatusEffect.FAINT); await party[0].updateInfo(); // Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal) @@ -167,7 +167,7 @@ describe("Mystery Encounter Utils", () => { const party = scene.getPlayerParty(); party[0].level = 100; party[0].hp = 0; - party[0].trySetStatus(StatusEffect.FAINT); + party[0].doSetStatus(StatusEffect.FAINT); await party[0].updateInfo(); party[1].level = 10; @@ -206,7 +206,7 @@ describe("Mystery Encounter Utils", () => { const party = scene.getPlayerParty(); party[0].level = 10; party[0].hp = 0; - party[0].trySetStatus(StatusEffect.FAINT); + party[0].doSetStatus(StatusEffect.FAINT); await party[0].updateInfo(); party[1].level = 100;