diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 2f57df4a551..2342807a085 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -748,16 +748,12 @@ export class TypeImmunityHealAbAttr extends TypeImmunityAbAttr { const { pokemon, cancelled, simulated, passive } = params; if (!pokemon.isFullHp() && !simulated) { const abilityName = (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name; - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - pokemon.getBattlerIndex(), - toDmgValue(pokemon.getMaxHp() / 4), - i18next.t("abilityTriggers:typeImmunityHeal", { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", pokemon.getBattlerIndex(), pokemon.getMaxHp() / 4, { + message: i18next.t("abilityTriggers:typeImmunityHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName, }), - true, - ); + }); cancelled.value = true; // Suppresses "No Effect" message } } @@ -1545,6 +1541,51 @@ export abstract class PreAttackAbAttr extends AbAttr { private declare readonly _: never; } +export interface MoveHealBoostAbAttrParams extends AugmentMoveInteractionAbAttrParams { + /** The base amount of HP being healed, as a fraction of the recipient's maximum HP. */ + healRatio: NumberHolder; +} + +/** + * Ability attribute to boost the healing potency of the user's moves. + * Used by {@linkcode AbilityId.MEGA_LAUNCHER} to implement Heal Pulse boosting. + */ +export class MoveHealBoostAbAttr extends AbAttr { + /** + * The amount to boost the healing by, as a multiplier of the base amount. + */ + private healMulti: number; + /** + * A lambda function determining whether to boost the heal amount. + * The ability will not be applied if this evaluates to `false`. + */ + // TODO: Use a `MoveConditionFunc` maybe? + private boostCondition: (user: Pokemon, target: Pokemon, move: Move) => boolean; + + constructor( + boostCondition: (user: Pokemon, target: Pokemon, move: Move) => boolean, + healMulti: number, + showAbility = false, + ) { + super(showAbility); + + if (healMulti === 1) { + throw new Error("Calling `MoveHealBoostAbAttr` with a multiplier of 1 is useless!"); + } + + this.healMulti = healMulti; + this.boostCondition = boostCondition; + } + + override canApply({ pokemon: user, opponent: target, move }: MoveHealBoostAbAttrParams): boolean { + return this.boostCondition?.(user, target, move) ?? true; + } + + override apply({ healRatio }: MoveHealBoostAbAttrParams): void { + healRatio.value *= this.healMulti; + } +} + export interface ModifyMoveEffectChanceAbAttrParams extends AbAttrBaseParams { /** The move being used by the attacker */ move: Move; @@ -1686,7 +1727,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { */ override canApply({ pokemon, opponent: target, move }: MoveTypeChangeAbAttrParams): boolean { return ( - (!this.condition || this.condition(pokemon, target, move)) && + (this.condition?.(pokemon, target, move) ?? true) && !noAbilityTypeOverrideMoves.has(move.id) && !( pokemon.isTerastallized && @@ -2830,12 +2871,13 @@ export class PostSummonAllyHealAbAttr extends PostSummonAbAttr { "PokemonHealPhase", target.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / this.healRatio), - i18next.t("abilityTriggers:postSummonAllyHeal", { - pokemonNameWithAffix: getPokemonNameWithAffix(target), - pokemonName: pokemon.name, - }), - true, - !this.showAnim, + { + message: i18next.t("abilityTriggers:postSummonAllyHeal", { + pokemonNameWithAffix: getPokemonNameWithAffix(target), + pokemonName: pokemon.name, + }), + skipAnim: !this.showAnim, + }, ); } } @@ -4476,11 +4518,12 @@ export class PostWeatherLapseHealAbAttr extends PostWeatherLapseAbAttr { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / (16 / this.healFactor)), - i18next.t("abilityTriggers:postWeatherLapseHeal", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - abilityName, - }), - true, + { + message: i18next.t("abilityTriggers:postWeatherLapseHeal", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + abilityName, + }), + }, ); } } @@ -4595,8 +4638,12 @@ export class PostTurnStatusHealAbAttr extends PostTurnAbAttr { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 8), - i18next.t("abilityTriggers:poisonHeal", { pokemonName: getPokemonNameWithAffix(pokemon), abilityName }), - true, + { + message: i18next.t("abilityTriggers:poisonHeal", { + pokemonName: getPokemonNameWithAffix(pokemon), + abilityName, + }), + }, ); } } @@ -4843,11 +4890,12 @@ export class PostTurnHealAbAttr extends PostTurnAbAttr { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 16), - i18next.t("abilityTriggers:postTurnHeal", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - abilityName, - }), - true, + { + message: i18next.t("abilityTriggers:postTurnHeal", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + abilityName, + }), + }, ); } } @@ -5224,11 +5272,12 @@ export class HealFromBerryUseAbAttr extends AbAttr { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() * this.healPercent), - i18next.t("abilityTriggers:healFromBerryUse", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - abilityName, - }), - true, + { + message: i18next.t("abilityTriggers:healFromBerryUse", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + abilityName, + }), + }, ); } } @@ -6554,6 +6603,7 @@ const AbilityAttrs = Object.freeze({ PostDefendMoveDisableAbAttr, PostStatStageChangeStatStageChangeAbAttr, PreAttackAbAttr, + MoveHealBoostAbAttr, MoveEffectChanceMultiplierAbAttr, IgnoreMoveEffectsAbAttr, VariableMovePowerAbAttr, @@ -7329,7 +7379,9 @@ export function initAbilities() { .attr(PostSummonUserFieldRemoveStatusEffectAbAttr, StatusEffect.SLEEP) .attr(UserFieldBattlerTagImmunityAbAttr, BattlerTagType.DROWSY) .ignorable() - .partial(), // Mold Breaker ally should not be affected by Sweet Veil + // Mold Breaker ally should not be affected by Sweet Veil + // TODO: Review this + .partial(), new Ability(AbilityId.STANCE_CHANGE, 6) .attr(NoFusionAbilityAbAttr) .uncopiable() @@ -7338,7 +7390,8 @@ export function initAbilities() { new Ability(AbilityId.GALE_WINGS, 6) .attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && pokemon.getMoveType(move) === PokemonType.FLYING, 1), new Ability(AbilityId.MEGA_LAUNCHER, 6) - .attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5), + .attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5) + .attr(MoveHealBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5), new Ability(AbilityId.GRASS_PELT, 6) .conditionalAttr(getTerrainCondition(TerrainType.GRASSY), StatMultiplierAbAttr, Stat.DEF, 1.5) .ignorable(), diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 455beec6901..9e921e0f93b 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1076,18 +1076,16 @@ export class SeedTag extends SerializableBattlerTag { ); // Damage the target and restore our HP (or take damage in the case of liquid ooze) + // TODO: Liquid ooze should queue a damage anim phase directly const damage = pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT }); const reverseDrain = pokemon.hasAbilityWithAttr("ReverseDrainAbAttr", false); - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - source.getBattlerIndex(), - reverseDrain ? -damage : damage, - i18next.t(reverseDrain ? "battlerTags:seededLapseShed" : "battlerTags:seededLapse", { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", source.getBattlerIndex(), reverseDrain ? -damage : damage, { + message: i18next.t(reverseDrain ? "battlerTags:seededLapseShed" : "battlerTags:seededLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), - false, - true, - ); + showFullHpMessage: false, + skipAnim: true, + }); return true; } @@ -1382,10 +1380,11 @@ export class IngrainTag extends TrappedTag { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 16), - i18next.t("battlerTags:ingrainLapse", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - true, + { + message: i18next.t("battlerTags:ingrainLapse", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }), + }, ); } @@ -1455,11 +1454,12 @@ export class AquaRingTag extends SerializableBattlerTag { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 16), - i18next.t("battlerTags:aquaRingLapse", { - moveName: this.getMoveName(), - pokemonName: getPokemonNameWithAffix(pokemon), - }), - true, + { + message: i18next.t("battlerTags:aquaRingLapse", { + moveName: this.getMoveName(), + pokemonName: getPokemonNameWithAffix(pokemon), + }), + }, ); } @@ -2698,29 +2698,30 @@ export class StockpilingTag extends SerializableBattlerTag { * For each stat, an internal counter is incremented (by 1) if the stat was successfully changed. */ onAdd(pokemon: Pokemon): void { - if (this.stockpiledCount < 3) { - this.stockpiledCount++; - - globalScene.phaseManager.queueMessage( - i18next.t("battlerTags:stockpilingOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - stockpiledCount: this.stockpiledCount, - }), - ); - - // Attempt to increase DEF and SPDEF by one stage, keeping track of successful changes. - globalScene.phaseManager.unshiftNew( - "StatStageChangePhase", - pokemon.getBattlerIndex(), - true, - [Stat.SPDEF, Stat.DEF], - 1, - true, - false, - true, - this.onStatStagesChanged, - ); + if (this.stockpiledCount >= 3) { + return; } + this.stockpiledCount++; + + globalScene.phaseManager.queueMessage( + i18next.t("battlerTags:stockpilingOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + stockpiledCount: this.stockpiledCount, + }), + ); + + // Attempt to increase DEF and SPDEF by one stage, keeping track of successful changes. + globalScene.phaseManager.unshiftNew( + "StatStageChangePhase", + pokemon.getBattlerIndex(), + true, + [Stat.SPDEF, Stat.DEF], + 1, + true, + false, + true, + this.onStatStagesChanged, + ); } onOverlap(pokemon: Pokemon): void { diff --git a/src/data/berry.ts b/src/data/berry.ts index 61235b75e21..056bd7f7397 100644 --- a/src/data/berry.ts +++ b/src/data/berry.ts @@ -73,16 +73,12 @@ export function getBerryEffectFunc(berryType: BerryType): BerryEffectFunc { { const hpHealed = new NumberHolder(toDmgValue(consumer.getMaxHp() / 4)); applyAbAttrs("DoubleBerryEffectAbAttr", { pokemon: consumer, effectValue: hpHealed }); - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - consumer.getBattlerIndex(), - hpHealed.value, - i18next.t("battle:hpHealBerry", { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", consumer.getBattlerIndex(), hpHealed.value, { + message: i18next.t("battle:hpHealBerry", { pokemonNameWithAffix: getPokemonNameWithAffix(consumer), berryName: getBerryName(berryType), }), - true, - ); + }); } break; case BerryType.LUM: diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0dfbc78d7ae..7ef924d902f 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1940,25 +1940,51 @@ export class AddSubstituteAttr extends MoveEffectAttr { } /** - * Heals the user or target by {@linkcode healRatio} depending on the value of {@linkcode selfTarget} - * @extends MoveEffectAttr - * @see {@linkcode apply} + * Attribute to implement healing moves, such as {@linkcode MoveId.RECOVER} or {@linkcode MoveId.SOFT_BOILED}. + * Heals the user or target of the move by a fixed amount relative to their maximum HP. */ export class HealAttr extends MoveEffectAttr { - /** The percentage of {@linkcode Stat.HP} to heal */ - private healRatio: number; - /** Should an animation be shown? */ - private showAnim: boolean; + /** The percentage of {@linkcode Stat.HP} to heal; default `1` */ + protected healRatio = 1 + /** Whether to display a healing animation upon healing the target; default `false` */ + private showAnim = false - constructor(healRatio?: number, showAnim?: boolean, selfTarget?: boolean) { - super(selfTarget === undefined || selfTarget); + /** + * Whether the move should fail if the target is at full HP. + * @defaultValue `true` + * @todo Remove post move failure rework - this solely exists to prevent Lunar Blessing and co. from failing + */ + private failOnFullHp = true; - this.healRatio = healRatio || 1; - this.showAnim = !!showAnim; + constructor( + healRatio = 1, + showAnim = false, + selfTarget = true, + failOnFullHp = true + ) { + super(selfTarget); + this.healRatio = healRatio; + this.showAnim = showAnim; + this.failOnFullHp = failOnFullHp; } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - this.addHealPhase(this.selfTarget ? user : target, this.healRatio); + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!super.apply(user, target, move, args)) { + return false; + } + + // Apply any boosts to healing amounts (i.e. Heal Pulse + Mega Launcher). + const hp = new NumberHolder(this.healRatio) + applyAbAttrs("MoveHealBoostAbAttr", { + pokemon: user, + opponent: target, + move, + healRatio: hp + }) + this.healRatio = hp.value; + + + this.addHealPhase(this.selfTarget ? user : target); return true; } @@ -1966,15 +1992,81 @@ export class HealAttr extends MoveEffectAttr { * Creates a new {@linkcode PokemonHealPhase}. * This heals the target and shows the appropriate message. */ - 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); + protected addHealPhase(healedPokemon: Pokemon) { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", healedPokemon.getBattlerIndex(), + // Healing moves round half UP the hp healed + // (unlike most other sources which round down) + Math.round(healedPokemon.getMaxHp() * this.healRatio), + { + message: i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(healedPokemon) }), + showFullHpMessage: true, + skipAnim: !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)); } + + override getCondition(): MoveConditionFunc { + return (user, target) => !(this.failOnFullHp && (this.selfTarget ? user : target).isFullHp()); + } + + override getFailedText(user: Pokemon, target: Pokemon): string | undefined { + const healedPokemon = this.selfTarget ? user : target; + return i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(healedPokemon), + }) + } +} + +/** + * Attribute for moves with variable healing amounts. + * Heals the user/target by an amount depending on the return value of {@linkcode healFunc}. + * + * Used for: + * - {@linkcode MoveId.MOONLIGHT} and variants + * - {@linkcode MoveId.SHORE_UP} + * - {@linkcode MoveId.FLORAL_HEALING} + * - {@linkcode MoveId.SWALLOW} + */ +export class VariableHealAttr extends HealAttr { + constructor( + /** A function yielding the amount of HP to heal. */ + private healFunc: (user: Pokemon, target: Pokemon, move: Move) => number, + showAnim = false, + selfTarget = true, + failOnFullHp = true, + ) { + super(1, showAnim, selfTarget, selfTarget); + this.healFunc = healFunc; + } + + apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean { + this.healRatio = this.healFunc(user, target, move) + return super.apply(user, target, move, _args); + } +} + +/** + * Heals the target only if it is an ally. + * Used for {@linkcode MoveId.POLLEN_PUFF}. + */ +export class HealOnAllyAttr extends HealAttr { + 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 getCondition(): MoveConditionFunc { + return (user, target, _move) => user.getAlly() !== target || super.getCondition()(user, target, _move) + } } /** @@ -2101,17 +2193,19 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr { const pm = globalScene.phaseManager; pm.pushPhase( - pm.create("PokemonHealPhase", + pm.create( + "PokemonHealPhase", user.getBattlerIndex(), maxPartyMemberHp, - i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }), - true, - false, - false, - true, - false, - this.restorePP), - true); + { + message: i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }), + showFullHpMessage: false, + skipAnim: true, + healStatus: true, + fullRestorePP: this.restorePP, + } + ), + true); return true; } @@ -2157,112 +2251,6 @@ export class IgnoreWeatherTypeDebuffAttr extends MoveAttr { } } -export abstract class WeatherHealAttr extends HealAttr { - constructor() { - super(0.5); - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - let healRatio = 0.5; - if (!globalScene.arena.weather?.isEffectSuppressed()) { - const weatherType = globalScene.arena.weather?.weatherType || WeatherType.NONE; - healRatio = this.getWeatherHealRatio(weatherType); - } - this.addHealPhase(user, healRatio); - return true; - } - - abstract getWeatherHealRatio(weatherType: WeatherType): number; -} - -export class PlantHealAttr extends WeatherHealAttr { - getWeatherHealRatio(weatherType: WeatherType): number { - switch (weatherType) { - case WeatherType.SUNNY: - case WeatherType.HARSH_SUN: - return 2 / 3; - case WeatherType.RAIN: - case WeatherType.SANDSTORM: - case WeatherType.HAIL: - case WeatherType.SNOW: - case WeatherType.FOG: - case WeatherType.HEAVY_RAIN: - return 0.25; - default: - return 0.5; - } - } -} - -export class SandHealAttr extends WeatherHealAttr { - getWeatherHealRatio(weatherType: WeatherType): number { - switch (weatherType) { - case WeatherType.SANDSTORM: - return 2 / 3; - default: - return 0.5; - } - } -} - -/** - * Heals the target or the user by either {@linkcode normalHealRatio} or {@linkcode boostedHealRatio} - * depending on the evaluation of {@linkcode condition} - * @extends HealAttr - * @see {@linkcode apply} - */ -export class BoostHealAttr extends HealAttr { - /** Healing received when {@linkcode condition} is false */ - private normalHealRatio: number; - /** Healing received when {@linkcode condition} is true */ - private boostedHealRatio: number; - /** The lambda expression to check against when boosting the healing value */ - private condition?: MoveConditionFunc; - - constructor(normalHealRatio: number = 0.5, boostedHealRatio: number = 2 / 3, showAnim?: boolean, selfTarget?: boolean, condition?: MoveConditionFunc) { - super(normalHealRatio, showAnim, selfTarget); - this.normalHealRatio = normalHealRatio; - this.boostedHealRatio = boostedHealRatio; - this.condition = condition; - } - - /** - * @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 move was successful - */ - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const healRatio: number = (this.condition ? this.condition(user, target, move) : false) ? this.boostedHealRatio : this.normalHealRatio; - this.addHealPhase(target, healRatio); - return true; - } -} - -/** - * Heals the target only if it is the ally - * @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; - } -} - /** * Heals user as a side effect of a move that hits a target. * Healing is based on {@linkcode healRatio} * the amount of damage dealt or a stat of the target. @@ -2313,7 +2301,9 @@ export class HitHealAttr extends MoveEffectAttr { message = ""; } } - globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), healAmount, message, false, true); + globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), healAmount, + {message, showFullHpMessage: false, skipAnim: true} + ); return true; } @@ -4346,7 +4336,8 @@ export class PunishmentPowerAttr extends VariablePowerAttr { } export class PresentPowerAttr extends VariablePowerAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean { + const power = args[0] /** * If this move is multi-hit, and this attribute is applied to any hit * other than the first, this move cannot result in a heal. @@ -4355,17 +4346,21 @@ export class PresentPowerAttr extends VariablePowerAttr { const powerSeed = randSeedInt(firstHit ? 100 : 80); if (powerSeed <= 40) { - (args[0] as NumberHolder).value = 40; - } else if (40 < powerSeed && powerSeed <= 70) { - (args[0] as NumberHolder).value = 80; - } else if (70 < powerSeed && powerSeed <= 80) { - (args[0] as NumberHolder).value = 120; - } else if (80 < powerSeed && powerSeed <= 100) { - // If this move is multi-hit, disable all other hits + power.value = 40; + } else if (powerSeed <= 70) { + power.value = 80; + } else if (powerSeed <= 80) { + power.value = 120; + } else if (powerSeed <= 100) { + // Disable all other hits and heal the target for 25% max HP user.turnData.hitCount = 1; user.turnData.hitsLeft = 1; - globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), - toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) }), true); + globalScene.phaseManager.unshiftNew( + "PokemonHealPhase", + target.getBattlerIndex(), + toDmgValue(target.getMaxHp() / 4), + {message: i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) })} + ) } return true; @@ -4406,36 +4401,6 @@ export class SpitUpPowerAttr extends VariablePowerAttr { } } -/** - * Attribute used to apply Swallow's healing, which scales with Stockpile stacks. - * Does NOT remove stockpiled stacks. - */ -export class SwallowHealAttr extends HealAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const stockpilingTag = user.getTag(StockpilingTag); - - if (stockpilingTag && stockpilingTag.stockpiledCount > 0) { - const stockpiled = stockpilingTag.stockpiledCount; - let healRatio: number; - - if (stockpiled === 1) { - healRatio = 0.25; - } else if (stockpiled === 2) { - healRatio = 0.50; - } else { // stockpiled >= 3 - healRatio = 1.00; - } - - if (healRatio) { - this.addHealPhase(user, healRatio); - return true; - } - } - - return false; - } -} - const hasStockpileStacksCondition: MoveConditionFunc = (user) => { const hasStockpilingTag = user.getTag(StockpilingTag); return !!hasStockpilingTag && hasStockpilingTag.stockpiledCount > 0; @@ -5916,8 +5881,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; @@ -8089,6 +8054,53 @@ const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) = return message; }; +const sunnyHealRatioFunc = (): number => { + if (globalScene.arena.weather?.isEffectSuppressed()) { + return 1 / 2; + } + + switch (globalScene.arena.getWeatherType()) { + case WeatherType.SUNNY: + case WeatherType.HARSH_SUN: + return 2 / 3; + case WeatherType.RAIN: + case WeatherType.SANDSTORM: + case WeatherType.HAIL: + case WeatherType.SNOW: + case WeatherType.HEAVY_RAIN: + case WeatherType.FOG: + return 1 / 4; + case WeatherType.STRONG_WINDS: + default: + return 1 / 2; + } +} + +const shoreUpHealRatioFunc = (): number => { + if (globalScene.arena.weather?.isEffectSuppressed()) { + return 1 / 2; + } + + return globalScene.arena.getWeatherType() === WeatherType.SANDSTORM ? 2 / 3 : 1 / 2; +} + +const swallowHealFunc = (user: Pokemon): number => { + const tag = user.getTag(StockpilingTag); + if (!tag || tag.stockpiledCount <= 0) { + return 0; + } + + switch (tag.stockpiledCount) { + case 1: + return 0.25 + case 2: + return 0.5 + case 3: + default: // in case we ever get more stacks + return 1; + } +} + export class MoveCondition { protected func: MoveConditionFunc; @@ -8300,15 +8312,12 @@ const MoveAttrs = Object.freeze({ SacrificialAttrOnHit, HalfSacrificialAttr, AddSubstituteAttr, - HealAttr, PartyStatusCureAttr, FlameBurstAttr, SacrificialFullRestoreAttr, IgnoreWeatherTypeDebuffAttr, - WeatherHealAttr, - PlantHealAttr, - SandHealAttr, - BoostHealAttr, + HealAttr, + VariableHealAttr, HealOnAllyAttr, HitHealAttr, IncrementMovePriorityAttr, @@ -8372,7 +8381,6 @@ const MoveAttrs = Object.freeze({ PresentPowerAttr, WaterShurikenPowerAttr, SpitUpPowerAttr, - SwallowHealAttr, MultiHitPowerIncrementAttr, LastMoveDoublePowerAttr, CombinedPledgePowerAttr, @@ -9223,13 +9231,13 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK ], 1, true), new AttackMove(MoveId.VITAL_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 70, -1, 10, -1, -1, 2), new SelfStatusMove(MoveId.MORNING_SUN, PokemonType.NORMAL, -1, 5, -1, 0, 2) - .attr(PlantHealAttr) + .attr(VariableHealAttr, sunnyHealRatioFunc) .triageMove(), new SelfStatusMove(MoveId.SYNTHESIS, PokemonType.GRASS, -1, 5, -1, 0, 2) - .attr(PlantHealAttr) + .attr(VariableHealAttr, sunnyHealRatioFunc) .triageMove(), new SelfStatusMove(MoveId.MOONLIGHT, PokemonType.FAIRY, -1, 5, -1, 0, 2) - .attr(PlantHealAttr) + .attr(VariableHealAttr, sunnyHealRatioFunc) .triageMove(), new AttackMove(MoveId.HIDDEN_POWER, PokemonType.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 2) .attr(HiddenPowerTypeAttr), @@ -9286,15 +9294,15 @@ export function initMoves() { .partial(), // Does not lock the user, does not stop Pokemon from sleeping // Likely can make use of FrenzyAttr and an ArenaTag (just without the FrenzyMissFunc) new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3) - .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) + .condition(user => (user.getTag(BattlerTagType.STOCKPILING)?.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) + .attr(VariableHealAttr, swallowHealFunc, false, true, true) .condition(hasStockpileStacksCondition) - .attr(SwallowHealAttr) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true) .triageMove(), new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3) @@ -10556,7 +10564,7 @@ export function initMoves() { .unimplemented(), /* End Unused */ new SelfStatusMove(MoveId.SHORE_UP, PokemonType.GROUND, -1, 5, -1, 0, 7) - .attr(SandHealAttr) + .attr(VariableHealAttr, shoreUpHealRatioFunc) .triageMove(), new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7) .condition(new FirstMoveCondition()), @@ -10576,7 +10584,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPD ], -1, true) .punchingMove(), new StatusMove(MoveId.FLORAL_HEALING, PokemonType.FAIRY, -1, 10, -1, 0, 7) - .attr(BoostHealAttr, 0.5, 2 / 3, true, false, (user, target, move) => globalScene.arena.terrain?.terrainType === TerrainType.GRASSY) + .attr(VariableHealAttr, () => globalScene.arena.getTerrainType() === TerrainType.GRASSY ? 2 / 3 : 1 / 2, true, false) .triageMove() .reflectable(), new AttackMove(MoveId.HIGH_HORSEPOWER, PokemonType.GROUND, MoveCategory.PHYSICAL, 95, 95, 10, -1, 0, 7), @@ -11068,10 +11076,11 @@ export function initMoves() { .attr(HealStatusEffectAttr, false, StatusEffect.FREEZE) .attr(StatusEffectAttr, StatusEffect.BURN), new StatusMove(MoveId.JUNGLE_HEALING, PokemonType.GRASS, -1, 10, -1, 0, 8) - .attr(HealAttr, 0.25, true, false) + .attr(HealAttr, 0.25, true, false, false) .attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects()) .target(MoveTarget.USER_AND_ALLIES) - .triageMove(), + .triageMove() + .edgeCase(), // TODO: Review if jungle healing fails if HP cannot be restored and status cannot be cured new AttackMove(MoveId.WICKED_BLOW, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8) .attr(CritOnlyAttr) .punchingMove(), @@ -11173,10 +11182,11 @@ export function initMoves() { .windMove() .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(MoveId.LUNAR_BLESSING, PokemonType.PSYCHIC, -1, 5, -1, 0, 8) - .attr(HealAttr, 0.25, true, false) + .attr(HealAttr, 0.25, true, false, false) .attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects()) .target(MoveTarget.USER_AND_ALLIES) - .triageMove(), + .triageMove() + .edgeCase(), // TODO: Review if lunar blessing fails if HP cannot be restored and status cannot be cured new SelfStatusMove(MoveId.TAKE_HEART, PokemonType.PSYCHIC, -1, 10, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF ], 1, true) .attr(HealStatusEffectAttr, true, [ StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN, StatusEffect.SLEEP ]), diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 77ca6f0e9eb..2c5e270a65f 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -155,13 +155,12 @@ export class WishTag extends PositionalTag implements WishArgs { public override trigger(): void { // TODO: Rename this locales key - wish shows a message on REMOVAL, not addition - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:wishTagOnAdd", { + // TODO: What messages does Wish show when healing a Pokemon at full HP? + globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, { + message: i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: this.pokemonName, }), - ); - - globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false); + }); } public override shouldTrigger(): boolean { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d4f332d887c..2cdaeb79f18 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -24,11 +24,11 @@ import { NoCritTag, WeakenMoveScreenTag } from "#data/arena-tag"; import { AutotomizedTag, BattlerTag, + type BattlerTagTypeMap, CritBoostTag, EncoreTag, ExposedTag, GroundedTag, - type GrudgeTag, getBattlerTag, HighestStatBoostTag, MoveRestrictionBattlerTag, @@ -1640,6 +1640,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.getMaxHp() - this.hp; } + // TODO: Why does this default to `false`? getHpRatio(precise = false): number { return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; } @@ -4237,14 +4238,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return false; } - /**@overload */ - getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | undefined; - /** @overload */ - getTag(tagType: BattlerTagType.SUBSTITUTE): SubstituteTag | undefined; - - /** @overload */ - getTag(tagType: BattlerTagType): BattlerTag | undefined; + getTag(tagType: T): BattlerTagTypeMap[T] | undefined; /** @overload */ getTag(tagType: Constructor): T | undefined; diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index b31bee7fc69..ecacea47e33 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1674,11 +1674,12 @@ export class TurnHealModifier extends PokemonHeldItemModifier { "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 16) * this.stackCount, - i18next.t("modifier:turnHealApply", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - typeName: this.type.name, - }), - true, + { + message: i18next.t("modifier:turnHealApply", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + typeName: this.type.name, + }), + }, ); return true; } @@ -1766,16 +1767,16 @@ export class HitHealModifier extends PokemonHeldItemModifier { */ override apply(pokemon: Pokemon): boolean { if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) { - // TODO: this shouldn't be undefined AFAIK globalScene.phaseManager.unshiftNew( "PokemonHealPhase", pokemon.getBattlerIndex(), - toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount, - i18next.t("modifier:hitHealApply", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - typeName: this.type.name, - }), - true, + toDmgValue((pokemon.turnData.totalDamageDealt * this.stackCount) / 8), + { + message: i18next.t("modifier:hitHealApply", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + typeName: this.type.name, + }), + }, ); } @@ -1934,20 +1935,22 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier { */ override apply(pokemon: Pokemon): boolean { // Restore the Pokemon to half HP + // TODO: This should not use a phase to revive pokemon globalScene.phaseManager.unshiftNew( "PokemonHealPhase", pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 2), - i18next.t("modifier:pokemonInstantReviveApply", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - typeName: this.type.name, - }), - false, - false, - true, + { + message: i18next.t("modifier:pokemonInstantReviveApply", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + typeName: this.type.name, + }), + revive: true, + }, ); // Remove the Pokemon's FAINT status + // TODO: Remove call to `resetStatus` once StatusEffect.FAINT is canned pokemon.resetStatus(true, false, true, false); // Reapply Commander on the Pokemon's side of the field, if applicable @@ -3549,24 +3552,24 @@ export class EnemyTurnHealModifier extends EnemyPersistentModifier { * @returns `true` if the {@linkcode Pokemon} was healed */ override apply(enemyPokemon: Pokemon): boolean { - if (!enemyPokemon.isFullHp()) { - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - enemyPokemon.getBattlerIndex(), - Math.max(Math.floor(enemyPokemon.getMaxHp() / (100 / this.healPercent)) * this.stackCount, 1), - i18next.t("modifier:enemyTurnHealApply", { - pokemonNameWithAffix: getPokemonNameWithAffix(enemyPokemon), - }), - true, - false, - false, - false, - true, - ); - return true; + if (enemyPokemon.isFullHp()) { + return false; } - return false; + // Prevent healing to full from healing tokens + globalScene.phaseManager.unshiftNew( + "PokemonHealPhase", + enemyPokemon.getBattlerIndex(), + (enemyPokemon.getMaxHp() * this.stackCount * this.healPercent) / 100, + { + message: i18next.t("modifier:enemyTurnHealApply", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemyPokemon), + }), + preventFullHeal: true, + }, + ); + + return true; } getMaxStackCount(): number { diff --git a/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index fa6a3222466..cbb5b3d6d9e 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -1,39 +1,81 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { HealBlockTag } from "#data/battler-tags"; import { getStatusEffectHealText } from "#data/status-effect"; import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { HitResult } from "#enums/hit-result"; import { CommonAnim } from "#enums/move-anims-common"; -import { StatusEffect } from "#enums/status-effect"; import { HealingBoosterModifier } from "#modifiers/modifier"; import { CommonAnimPhase } from "#phases/common-anim-phase"; import { HealAchv } from "#system/achv"; -import { NumberHolder } from "#utils/common"; +import { NumberHolder, toDmgValue } from "#utils/common"; import i18next from "i18next"; export class PokemonHealPhase extends CommonAnimPhase { public readonly phaseName = "PokemonHealPhase"; + + /** The base amount of HP to heal. */ private hpHealed: number; - private message: string | null; + /** + * The message to display upon healing the target, or `undefined` to show no message. + * Will be overridden by the full HP message if {@linkcode showFullHpMessage} is set to `true` + */ + private message: string | undefined; + /** + * Whether to show a failure message upon healing a Pokemon already at full HP. + * @defaultValue `true` + */ private showFullHpMessage: boolean; + /** + * Whether to skip showing the healing animation. + * @defaultValue `false` + */ private skipAnim: boolean; + /** + * Whether to revive the affected Pokemon in addition to healing. + * Revives will not be affected by any Healing Charms. + * @todo Remove post modifier rework as revives will not be using phases to heal stuff + * @defaultValue `false` + */ private revive: boolean; + /** + * Whether to heal the affected Pokemon's status condition. + * @todo This should not be the healing phase's job + * @defaultValue `false` + */ private healStatus: boolean; + /** + * Whether to prevent fully healing affected Pokemon, leaving them 1 HP below full. + * @defaultValue `false` + */ private preventFullHeal: boolean; + /** + * Whether to fully restore PP upon healing. + * @todo This should not be the healing phase's job + * @defaultValue `false` + */ private fullRestorePP: boolean; constructor( battlerIndex: BattlerIndex, hpHealed: number, - message: string | null, - showFullHpMessage: boolean, - skipAnim = false, - revive = false, - healStatus = false, - preventFullHeal = false, - fullRestorePP = false, + { + message, + showFullHpMessage = true, + skipAnim = false, + revive = false, + healStatus = false, + preventFullHeal = false, + fullRestorePP = false, + }: { + message?: string; + showFullHpMessage?: boolean; + skipAnim?: boolean; + revive?: boolean; + healStatus?: boolean; + preventFullHeal?: boolean; + fullRestorePP?: boolean; + } = {}, ) { super(battlerIndex, undefined, CommonAnim.HEALTH_UP); @@ -47,89 +89,110 @@ export class PokemonHealPhase extends CommonAnimPhase { this.fullRestorePP = fullRestorePP; } - start() { - if (!this.skipAnim && (this.revive || this.getPokemon().hp) && !this.getPokemon().isFullHp()) { + override start() { + // Only play animation if not skipped and target is at full HP + if (!this.skipAnim && !this.getPokemon().isFullHp()) { super.start(); - } else { - this.end(); } + + this.heal(); + + super.end(); } - end() { + private heal() { const pokemon = this.getPokemon(); - if (!pokemon.isOnField() || (!this.revive && !pokemon.isActive())) { - return super.end(); + // Prevent healing off-field pokemon unless via revives + // TODO: Revival effects shouldn't use this phase + if (!this.revive && !pokemon.isActive(true)) { + super.end(); + return; } - const hasMessage = !!this.message; - const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0; - const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag; - let lastStatusEffect = StatusEffect.NONE; - + // Check for heal block, ending the phase early if healing was prevented + const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK); if (healBlock && this.hpHealed > 0) { globalScene.phaseManager.queueMessage(healBlock.onActivation(pokemon)); - this.message = null; - return super.end(); + super.end(); + return; } - if (healOrDamage) { - const hpRestoreMultiplier = new NumberHolder(1); - if (!this.revive) { - globalScene.applyModifiers(HealingBoosterModifier, this.player, hpRestoreMultiplier); - } - const healAmount = new NumberHolder(Math.floor(this.hpHealed * hpRestoreMultiplier.value)); - if (healAmount.value < 0) { - pokemon.damageAndUpdate(healAmount.value * -1, { result: HitResult.INDIRECT }); - healAmount.value = 0; - } - // Prevent healing to full if specified (in case of healing tokens so Sturdy doesn't cause a softlock) - if (this.preventFullHeal && pokemon.hp + healAmount.value >= pokemon.getMaxHp()) { - healAmount.value = pokemon.getMaxHp() - pokemon.hp - 1; - } - healAmount.value = pokemon.heal(healAmount.value); - if (healAmount.value) { - globalScene.damageNumberHandler.add(pokemon, healAmount.value, HitResult.HEAL); - } - if (pokemon.isPlayer()) { - globalScene.validateAchvs(HealAchv, healAmount); - if (healAmount.value > globalScene.gameData.gameStats.highestHeal) { - globalScene.gameData.gameStats.highestHeal = healAmount.value; - } - } - if (this.healStatus && !this.revive && pokemon.status) { - lastStatusEffect = pokemon.status.effect; - pokemon.resetStatus(); - } - if (this.fullRestorePP) { - for (const move of this.getPokemon().getMoveset()) { - if (move) { - move.ppUsed = 0; - } - } - } - pokemon.updateInfo().then(() => super.end()); - } else if (this.healStatus && !this.revive && pokemon.status) { - lastStatusEffect = pokemon.status.effect; + + this.doHealPokemon(); + + // Cure status as applicable + // TODO: This should not be the job of the healing phase + if (this.healStatus && pokemon.status) { + this.message = getStatusEffectHealText(pokemon.status.effect, getPokemonNameWithAffix(pokemon)); pokemon.resetStatus(); - pokemon.updateInfo().then(() => super.end()); - } else if (this.showFullHpMessage) { - this.message = i18next.t("battle:hpIsFull", { - pokemonName: getPokemonNameWithAffix(pokemon), + } + + // Restore PP. + // TODO: This should not be the job of the healing phase + if (this.fullRestorePP) { + pokemon.getMoveset().forEach(m => { + m.ppUsed = 0; }); } + // Show message, update info boxes and then wrap up. if (this.message) { globalScene.phaseManager.queueMessage(this.message); } + pokemon.updateInfo().then(() => super.end()); + } - if (this.healStatus && lastStatusEffect && !hasMessage) { - globalScene.phaseManager.queueMessage( - getStatusEffectHealText(lastStatusEffect, getPokemonNameWithAffix(pokemon)), - ); + /** + * Heal the Pokemon affected by this Phase. + */ + private doHealPokemon(): void { + const pokemon = this.getPokemon()!; + + // If we would heal the user past full HP, don't. + if (this.hpHealed > 0 && pokemon.isFullHp()) { + if (this.showFullHpMessage) { + this.message = i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(pokemon), + }); + } + return; } - if (!healOrDamage && !lastStatusEffect) { - super.end(); + const healAmount = this.getHealAmount(); + + if (healAmount < 0) { + // If Liquid Ooze is active, damage the user for the healing amount, then return. + // TODO: Consider refactoring liquid ooze to not use a heal phase to do damage + pokemon.damageAndUpdate(-healAmount, { result: HitResult.INDIRECT }); + return; + } + + // Heal the pokemon, then show damage numbers and validate achievements. + pokemon.heal(healAmount); + globalScene.damageNumberHandler.add(pokemon, healAmount, HitResult.HEAL); + if (pokemon.isPlayer()) { + globalScene.validateAchvs(HealAchv, healAmount); + globalScene.gameData.gameStats.highestHeal = Math.max(globalScene.gameData.gameStats.highestHeal, healAmount); } } + + /** + * Calculate the amount of HP to be healed during this Phase. + * @returns The updated healing amount post-modifications, capped at the Pokemon's maximum HP. + * @remarks + * The effect of Healing Charms are rounded down for parity with the closest mainline counterpart + * (i.e. Big Root). + */ + private getHealAmount(): number { + if (this.revive) { + return toDmgValue(this.hpHealed); + } + + // Apply the effect of healing charms for non-revival items before rounding down and capping at max HP + // (or 1 below max for healing tokens). + // Liquid Ooze damage (being negative) remains uncapped as normal. + const healMulti = new NumberHolder(1); + globalScene.applyModifiers(HealingBoosterModifier, this.player, healMulti); + return toDmgValue(Math.min(this.hpHealed * healMulti.value, this.getPokemon().getMaxHp() - +this.preventFullHeal)); + } } diff --git a/src/phases/quiet-form-change-phase.ts b/src/phases/quiet-form-change-phase.ts index ef53b16cc56..52eb128de45 100644 --- a/src/phases/quiet-form-change-phase.ts +++ b/src/phases/quiet-form-change-phase.ts @@ -154,16 +154,11 @@ export class QuietFormChangePhase extends BattlePhase { this.pokemon.findAndRemoveTags(t => t.tagType === BattlerTagType.AUTOTOMIZED); if (globalScene?.currentBattle.battleSpec === BattleSpec.FINAL_BOSS && this.pokemon.isEnemy()) { globalScene.playBgm(); - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - this.pokemon.getBattlerIndex(), - this.pokemon.getMaxHp(), - null, - false, - false, - false, - true, - ); + globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.pokemon.getBattlerIndex(), this.pokemon.getMaxHp(), { + showFullHpMessage: false, + healStatus: true, + fullRestorePP: true, + }); this.pokemon.findAndRemoveTags(() => true); this.pokemon.bossSegments = 5; this.pokemon.bossSegmentIndex = 4; diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index 463f26e73a2..1a994acca49 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -35,15 +35,11 @@ export class TurnEndPhase extends FieldPhase { globalScene.applyModifiers(TurnHealModifier, pokemon.isPlayer(), pokemon); if (globalScene.arena.terrain?.terrainType === TerrainType.GRASSY && pokemon.isGrounded()) { - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - pokemon.getBattlerIndex(), - Math.max(pokemon.getMaxHp() >> 4, 1), - i18next.t("battle:turnEndHpRestore", { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", pokemon.getBattlerIndex(), pokemon.getMaxHp() / 16, { + message: i18next.t("battle:turnEndHpRestore", { pokemonName: getPokemonNameWithAffix(pokemon), }), - true, - ); + }); } if (!pokemon.isPlayer()) { diff --git a/test/items/leftovers.test.ts b/test/items/leftovers.test.ts index 6ae4094799b..cbe1a186a5b 100644 --- a/test/items/leftovers.test.ts +++ b/test/items/leftovers.test.ts @@ -2,7 +2,6 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { DamageAnimPhase } from "#phases/damage-anim-phase"; -import { TurnEndPhase } from "#phases/turn-end-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -54,7 +53,7 @@ describe("Items - Leftovers", () => { const leadHpAfterDamage = leadPokemon.hp; // Check if leftovers heal us - await game.phaseInterceptor.to(TurnEndPhase); + await game.toNextTurn(); expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage); }); }); diff --git a/test/moves/pollen-puff.test.ts b/test/moves/pollen-puff.test.ts index 76732a39c43..7d1c700cfef 100644 --- a/test/moves/pollen-puff.test.ts +++ b/test/moves/pollen-puff.test.ts @@ -1,12 +1,15 @@ +import { getPokemonNameWithAffix } from "#app/messages"; import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; import { SpeciesId } from "#enums/species-id"; import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -describe("Moves - Pollen Puff", () => { +describe("Move - Pollen Puff", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -23,42 +26,77 @@ describe("Moves - Pollen Puff", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.POLLEN_PUFF]) .ability(AbilityId.BALL_FETCH) .battleStyle("single") .criticalHits(false) + .enemyLevel(100) .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.BALL_FETCH) .enemyMoveset(MoveId.SPLASH); }); - it("should not heal more than once when the user has a source of multi-hit", async () => { - game.override.battleStyle("double").moveset([MoveId.POLLEN_PUFF, MoveId.ENDURE]).ability(AbilityId.PARENTAL_BOND); + it("should damage an enemy when used, or heal an ally for 50% max HP", async () => { + game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND); await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]); - const [_, rightPokemon] = game.scene.getPlayerField(); + const [_, omantye, karp1] = game.scene.getField(); + omantye.hp = 1; - rightPokemon.damageAndUpdate(rightPokemon.hp - 1); + game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.toNextTurn(); - game.move.select(MoveId.POLLEN_PUFF, 0, BattlerIndex.PLAYER_2); - game.move.select(MoveId.ENDURE, 1); + expect(karp1).not.toHaveFullHp(); + expect(omantye).toHaveHp(omantye.getMaxHp() / 2 + 1); + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + }); - await game.phaseInterceptor.to("BerryPhase"); + it("should display message & count as failed when healing a full HP ally", async () => { + game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND); + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]); - // Pollen Puff heals with a ratio of 0.5, as long as Pollen Puff triggers only once the pokemon will always be <= (0.5 * Max HP) + 1 - expect(rightPokemon.hp).toBeLessThanOrEqual(0.5 * rightPokemon.getMaxHp() + 1); + const [bulbasaur, omantye] = game.scene.getPlayerField(); + + game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + // move failed without unshifting a phase + expect(omantye).toHaveFullHp(); + expect(bulbasaur).toHaveUsedMove({ move: MoveId.POLLEN_PUFF, result: MoveResult.FAIL }); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(omantye), + }), + ); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + }); + + it("should not heal more than once if the user has a source of multi-hit", async () => { + game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND); + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]); + + const [bulbasaur, omantye] = game.scene.getPlayerField(); + + omantye.hp = 1; + + game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + expect(bulbasaur.turnData.hitCount).toBe(1); + expect(omantye).toHaveHp(omantye.getMaxHp() / 2 + 1); + expect(game.phaseInterceptor.log.filter(l => l === "PokemonHealPhase")).toHaveLength(1); }); it("should damage an enemy multiple times when the user has a source of multi-hit", async () => { - game.override.moveset([MoveId.POLLEN_PUFF]).ability(AbilityId.PARENTAL_BOND).enemyLevel(100); + game.override.ability(AbilityId.PARENTAL_BOND); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + game.move.use(MoveId.POLLEN_PUFF); + await game.toEndOfTurn(); + const target = game.field.getEnemyPokemon(); - - game.move.select(MoveId.POLLEN_PUFF); - - await game.phaseInterceptor.to("BerryPhase"); - expect(target.battleData.hitCount).toBe(2); }); }); diff --git a/test/moves/recovery-moves.test.ts b/test/moves/recovery-moves.test.ts new file mode 100644 index 00000000000..ad39a86212d --- /dev/null +++ b/test/moves/recovery-moves.test.ts @@ -0,0 +1,223 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +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 { Stat } from "#enums/stat"; +import { WeatherType } from "#enums/weather-type"; +import { GameManager } from "#test/test-utils/game-manager"; +import { getEnumValues } from "#utils/enums"; +import { toTitleCase } from "#utils/strings"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - ", () => { + 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.MAGIC_GUARD) // prevents passive weather damage + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.CHANSEY) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + describe("Self-Healing Moves -", () => { + describe.each<{ name: string; move: MoveId }>([ + { name: "Recover", move: MoveId.RECOVER }, + { name: "Soft-Boiled", move: MoveId.SOFT_BOILED }, + { name: "Milk Drink", move: MoveId.MILK_DRINK }, + { name: "Slack Off", move: MoveId.SLACK_OFF }, + { name: "Heal Order", move: MoveId.HEAL_ORDER }, + { name: "Roost", move: MoveId.ROOST }, + { name: "Weather-based Healing Moves", move: MoveId.SYNTHESIS }, + { name: "Shore Up", move: MoveId.SHORE_UP }, + ])("$name", ({ move }) => { + it("should heal 50% of the user's maximum HP, rounded half up", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + blissey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251 + + game.move.use(move); + await game.toEndOfTurn(); + + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(blissey) }), + ); + expect(blissey).toHaveHp(252); // 251 + 1 + }); + + it("should fail if the user is at full HP", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + game.move.use(move); + await game.toEndOfTurn(); + + const blissey = game.field.getPlayerPokemon(); + expect(blissey).toHaveFullHp(); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(blissey), + }), + ); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL }); + }); + }); + + describe("Weather-based Healing Moves", () => { + it.each([ + { name: "Harsh Sunlight", weather: WeatherType.SUNNY }, + { name: "Extremely Harsh Sunlight", weather: WeatherType.HARSH_SUN }, + ])("should heal 66% of the user's maximum HP under $name", async ({ weather }) => { + game.override.weather(weather); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.MOONLIGHT); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1); + }); + + const nonSunWTs = getEnumValues(WeatherType) + .filter( + wt => ![WeatherType.SUNNY, WeatherType.HARSH_SUN, WeatherType.NONE, WeatherType.STRONG_WINDS].includes(wt), + ) + .map(wt => ({ + name: toTitleCase(WeatherType[wt]), + weather: wt, + })); + + it.each(nonSunWTs)("should heal 25% of the user's maximum HP under $name", async ({ weather }) => { + game.override.weather(weather); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.MOONLIGHT); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.25, 1); + }); + + it("should heal 50% of the user's maximum HP under strong winds", async () => { + game.override.ability(AbilityId.DELTA_STREAM); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.MOONLIGHT); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.5, 1); + }); + }); + + describe("Shore Up", () => { + it("should heal 66% of the user's maximum HP in a sandstorm", async () => { + game.override.weather(WeatherType.SANDSTORM); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.SHORE_UP); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1); + }); + }); + }); + + describe.each([ + { + name: "Heal Pulse", + move: MoveId.HEAL_PULSE, + percent: 3 / 4, + ability: AbilityId.MEGA_LAUNCHER, + condText: "user has Mega Launcher", + }, + { + name: "Floral Healing", + move: MoveId.FLORAL_HEALING, + percent: 2 / 3, + ability: AbilityId.GRASSY_SURGE, + condText: "Grassy Terrain is active", + }, + ])("Target-Healing Moves - $name", ({ move, percent, ability, condText }) => { + it("should heal 50% of the target's maximum HP, rounded half up", async () => { + // NB: Shore Up and co. round down in mainline, but we keep them the same as others for consistency's sake + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const chansey = game.field.getEnemyPokemon(); + chansey.hp = 1; + chansey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251 + + game.move.use(move); + await game.toEndOfTurn(); + + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(chansey) }), + ); + expect(chansey).toHaveHp(252); // 251 + 1 + }); + + it("should fail if the target is at full HP", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + game.move.use(move); + await game.toEndOfTurn(); + + const blissey = game.field.getPlayerPokemon(); + const chansey = game.field.getEnemyPokemon(); + expect(chansey).toHaveFullHp(); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(chansey), + }), + ); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL }); + }); + + it(`should heal ${(percent * 100).toPrecision(2)}% of the target's maximum HP if ${condText}`, async () => { + // prevents passive turn heal from grassy terrain + game.override.ability(ability).enemyAbility(AbilityId.LEVITATE); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const chansey = game.field.getEnemyPokemon(); + chansey.hp = 1; + + game.move.use(move); + await game.toEndOfTurn(); + + expect(chansey).toHaveHp(Math.round(percent * chansey.getMaxHp()) + 1); + }); + }); +}); diff --git a/test/moves/roost.test.ts b/test/moves/roost.test.ts index bb567a41cd0..53a5630ab5e 100644 --- a/test/moves/roost.test.ts +++ b/test/moves/roost.test.ts @@ -1,13 +1,12 @@ +import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; -import { MoveEffectPhase } from "#phases/move-effect-phase"; -import { TurnEndPhase } from "#phases/turn-end-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Roost", () => { let phaserGame: Phaser.Game; @@ -27,219 +26,105 @@ describe("Moves - Roost", () => { game = new GameManager(phaserGame); game.override .battleStyle("single") - .enemySpecies(SpeciesId.RELICANTH) + .enemySpecies(SpeciesId.SHUCKLE) + .ability(AbilityId.BALL_FETCH) .startingLevel(100) .enemyLevel(100) - .enemyMoveset(MoveId.EARTHQUAKE) - .moveset([MoveId.ROOST, MoveId.BURN_UP, MoveId.DOUBLE_SHOCK]); + .enemyMoveset(MoveId.SPLASH); }); - /** - * Roost's behavior should be defined as: - * The pokemon loses its flying type for a turn. If the pokemon was ungroundd solely due to being a flying type, it will be grounded until end of turn. - * 1. Pure Flying type pokemon -> become normal type until end of turn - * 2. Dual Flying/X type pokemon -> become type X until end of turn - * 3. Pokemon that use burn up into roost (ex. Moltres) -> become flying due to burn up, then typeless until end of turn after using roost - * 4. If a pokemon is afflicted with Forest's Curse or Trick or treat, dual type pokemon will become 3 type pokemon after the flying type is regained - * Pure flying types become (Grass or Ghost) and then back to flying/ (Grass or Ghost), - * and pokemon post Burn up become () - * 5. If a pokemon is also ungrounded due to other reasons (such as levitate), it will stay ungrounded post roost, despite not being flying type. - * 6. Non flying types using roost (such as dunsparce) are already grounded, so this move will only heal and have no other effects. - */ - - test("Non flying type uses roost -> no type change, took damage", async () => { - await game.classicMode.startBattle([SpeciesId.DUNSPARCE]); - const playerPokemon = game.field.getPlayerPokemon(); - const playerPokemonStartingHP = playerPokemon.hp; - game.move.select(MoveId.ROOST); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); - - // Should only be normal type, and NOT flying type - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); - - await game.phaseInterceptor.to(TurnEndPhase); - - // Lose HP, still normal type - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP); - expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); - }); - - test("Pure flying type -> becomes normal after roost and takes damage from ground moves -> regains flying", async () => { - await game.classicMode.startBattle([SpeciesId.TORNADUS]); - const playerPokemon = game.field.getPlayerPokemon(); - const playerPokemonStartingHP = playerPokemon.hp; - game.move.select(MoveId.ROOST); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); - - // Should only be normal type, and NOT flying type - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeTruthy(); - expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeFalsy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); - - await game.phaseInterceptor.to(TurnEndPhase); - - // Should have lost HP and is now back to being pure flying - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP); - expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeFalsy(); - expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); - }); - - test("Dual X/flying type -> becomes type X after roost and takes damage from ground moves -> regains flying", async () => { + it("should remove the user's Flying type until end of turn", async () => { await game.classicMode.startBattle([SpeciesId.HAWLUCHA]); - const playerPokemon = game.field.getPlayerPokemon(); - const playerPokemonStartingHP = playerPokemon.hp; - game.move.select(MoveId.ROOST); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); - // Should only be pure fighting type and grounded - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.FIGHTING).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); + const hawlucha = game.field.getPlayerPokemon(); + hawlucha.hp = 1; - await game.phaseInterceptor.to(TurnEndPhase); + game.move.use(MoveId.ROOST); + await game.phaseInterceptor.to("MoveEffectPhase"); - // Should have lost HP and is now back to being fighting/flying - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP); - expect(playerPokemonTypes[0] === PokemonType.FIGHTING).toBeTruthy(); - expect(playerPokemonTypes[1] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); + // Should lose flying type temporarily + expect(hawlucha).toHaveBattlerTag(BattlerTagType.ROOSTED); + expect(hawlucha).toHaveTypes([PokemonType.FIGHTING]); + expect(hawlucha.isGrounded()).toBe(true); + + await game.toEndOfTurn(); + + // Should have changed back to fighting/flying + expect(hawlucha).toHaveTypes([PokemonType.FIGHTING, PokemonType.FLYING]); + expect(hawlucha.isGrounded()).toBe(false); }); - test("Pokemon with levitate after using roost should lose flying type but still be unaffected by ground moves", async () => { - game.override.starterForms({ [SpeciesId.ROTOM]: 4 }); - await game.classicMode.startBattle([SpeciesId.ROTOM]); - const playerPokemon = game.field.getPlayerPokemon(); - const playerPokemonStartingHP = playerPokemon.hp; - game.move.select(MoveId.ROOST); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); + it("should preserve types of non-Flying type Pokemon", async () => { + await game.classicMode.startBattle([SpeciesId.MEW]); - // Should only be pure eletric type and grounded - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.ELECTRIC).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); + const mew = game.field.getPlayerPokemon(); + mew.hp = 1; - await game.phaseInterceptor.to(TurnEndPhase); + game.move.use(MoveId.ROOST); + await game.toEndOfTurn(false); - // Should have lost HP and is now back to being electric/flying - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.hp).toBe(playerPokemonStartingHP); - expect(playerPokemonTypes[0] === PokemonType.ELECTRIC).toBeTruthy(); - expect(playerPokemonTypes[1] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); + // Should remain psychic type + expect(mew).toHaveTypes([PokemonType.PSYCHIC]); + expect(mew.isGrounded()).toBe(true); }); - test("A fire/flying type that uses burn up, then roost should be typeless until end of turn", async () => { - await game.classicMode.startBattle([SpeciesId.MOLTRES]); - const playerPokemon = game.field.getPlayerPokemon(); - const playerPokemonStartingHP = playerPokemon.hp; - game.move.select(MoveId.BURN_UP); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); + it("should not remove the user's Tera Type", async () => { + await game.classicMode.startBattle([SpeciesId.PIDGEOT]); - // Should only be pure flying type after burn up - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); + const pidgeot = game.field.getPlayerPokemon(); + pidgeot.hp = 1; + pidgeot.teraType = PokemonType.FLYING; - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.ROOST); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); + game.move.use(MoveId.ROOST, BattlerIndex.PLAYER, undefined, true); + await game.toEndOfTurn(false); - // Should only be typeless type after roost and is grounded - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.getTag(BattlerTagType.ROOSTED)).toBeDefined(); - expect(playerPokemonTypes[0] === PokemonType.UNKNOWN).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); - - await game.phaseInterceptor.to(TurnEndPhase); - - // Should go back to being pure flying and have taken damage from earthquake, and is ungrounded again - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP); - expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); + // Should remain flying type + expect(pidgeot).toHaveTypes([PokemonType.FLYING], { args: [true] }); + expect(pidgeot.isGrounded()).toBe(false); }); - test("An electric/flying type that uses double shock, then roost should be typeless until end of turn", async () => { - game.override.enemySpecies(SpeciesId.ZEKROM); - await game.classicMode.startBattle([SpeciesId.ZAPDOS]); - const playerPokemon = game.field.getPlayerPokemon(); - const playerPokemonStartingHP = playerPokemon.hp; - game.move.select(MoveId.DOUBLE_SHOCK); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); + it("should convert pure Flying types into normal types", async () => { + await game.classicMode.startBattle([SpeciesId.TORNADUS]); - // Should only be pure flying type after burn up - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); + const tornadus = game.field.getPlayerPokemon(); + tornadus.hp = 1; - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.ROOST); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to(MoveEffectPhase); + game.move.use(MoveId.ROOST); + await game.toEndOfTurn(false); - // Should only be typeless type after roost and is grounded - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.getTag(BattlerTagType.ROOSTED)).toBeDefined(); - expect(playerPokemonTypes[0] === PokemonType.UNKNOWN).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); - - await game.phaseInterceptor.to(TurnEndPhase); - - // Should go back to being pure flying and have taken damage from earthquake, and is ungrounded again - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP); - expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); + // Should only be normal type, and NOT flying type + expect(tornadus).toHaveTypes([PokemonType.NORMAL]); + expect(tornadus.isGrounded()).toBe(true); }); - test("Dual Type Pokemon afflicted with Forests Curse/Trick or Treat and post roost will become dual type and then become 3 type at end of turn", async () => { - game.override.enemyMoveset([ - MoveId.TRICK_OR_TREAT, - MoveId.TRICK_OR_TREAT, - MoveId.TRICK_OR_TREAT, - MoveId.TRICK_OR_TREAT, - ]); - await game.classicMode.startBattle([SpeciesId.MOLTRES]); - const playerPokemon = game.field.getPlayerPokemon(); - game.move.select(MoveId.ROOST); - await game.phaseInterceptor.to(MoveEffectPhase); + it.each<{ name: string; move: MoveId; species: SpeciesId }>([ + { name: "Burn Up", move: MoveId.BURN_UP, species: SpeciesId.MOLTRES }, + { name: "Double Shock", move: MoveId.DOUBLE_SHOCK, species: SpeciesId.ZAPDOS }, + ])("should render user typeless when roosting after using $name", async ({ move, species }) => { + await game.classicMode.startBattle([species]); - let playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes[0] === PokemonType.FIRE).toBeTruthy(); - expect(playerPokemonTypes.length === 1).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeTruthy(); + const player = game.field.getPlayerPokemon(); + player.hp = 1; - await game.phaseInterceptor.to(TurnEndPhase); + game.move.use(move); + await game.toNextTurn(); - // Should be fire/flying/ghost - playerPokemonTypes = playerPokemon.getTypes(); - expect(playerPokemonTypes.filter(type => type === PokemonType.FLYING)).toHaveLength(1); - expect(playerPokemonTypes.filter(type => type === PokemonType.FIRE)).toHaveLength(1); - expect(playerPokemonTypes.filter(type => type === PokemonType.GHOST)).toHaveLength(1); - expect(playerPokemonTypes.length === 3).toBeTruthy(); - expect(playerPokemon.isGrounded()).toBeFalsy(); + // Should be pure flying type + expect(player).toHaveTypes([PokemonType.FLYING]); + expect(player.isGrounded()).toBe(false); + + game.move.use(MoveId.ROOST); + await game.phaseInterceptor.to("MoveEffectPhase"); + + // Should be typeless + expect(player).toHaveBattlerTag(BattlerTagType.ROOSTED); + expect(player).toHaveTypes([PokemonType.UNKNOWN]); + expect(player.isGrounded()).toBe(true); + + await game.toEndOfTurn(); + + // Should go back to being pure flying + expect(player).toHaveTypes([PokemonType.FLYING]); + expect(player.isGrounded()).toBe(false); }); }); diff --git a/test/moves/spit-up.test.ts b/test/moves/spit-up.test.ts deleted file mode 100644 index 8b110b0ea04..00000000000 --- a/test/moves/spit-up.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { StockpilingTag } from "#data/battler-tags"; -import { allMoves } from "#data/data-lists"; -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 type { Move } from "#moves/move"; -import { MovePhase } from "#phases/move-phase"; -import { TurnInitPhase } from "#phases/turn-init-phase"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - Spit Up", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - let spitUp: Move; - - beforeAll(() => { - phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - spitUp = allMoves[MoveId.SPIT_UP]; - game = new GameManager(phaserGame); - - game.override - .battleStyle("single") - .enemySpecies(SpeciesId.RATTATA) - .enemyMoveset(MoveId.SPLASH) - .enemyAbility(AbilityId.NONE) - .enemyLevel(2000) - .moveset(MoveId.SPIT_UP) - .ability(AbilityId.NONE); - - vi.spyOn(spitUp, "calculateBattlePower"); - }); - - describe("consumes all stockpile stacks to deal damage (scaling with stacks)", () => { - it("1 stack -> 100 power", async () => { - const stacksToSetup = 1; - const expectedPower = 100; - - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); - - game.move.select(MoveId.SPIT_UP); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce(); - expect(spitUp.calculateBattlePower).toHaveReturnedWith(expectedPower); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - - it("2 stacks -> 200 power", async () => { - const stacksToSetup = 2; - const expectedPower = 200; - - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); - - game.move.select(MoveId.SPIT_UP); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce(); - expect(spitUp.calculateBattlePower).toHaveReturnedWith(expectedPower); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - - it("3 stacks -> 300 power", async () => { - const stacksToSetup = 3; - const expectedPower = 300; - - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - pokemon.addTag(BattlerTagType.STOCKPILING); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); - - game.move.select(MoveId.SPIT_UP); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce(); - expect(spitUp.calculateBattlePower).toHaveReturnedWith(expectedPower); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - }); - - it("fails without stacks", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeUndefined(); - - game.move.select(MoveId.SPIT_UP); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ - move: MoveId.SPIT_UP, - result: MoveResult.FAIL, - targets: [game.field.getEnemyPokemon().getBattlerIndex()], - }); - - expect(spitUp.calculateBattlePower).not.toHaveBeenCalled(); - }); - - describe("restores stat boosts granted by stacks", () => { - it("decreases stats based on stored values (both boosts equal)", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - - game.move.select(MoveId.SPIT_UP); - await game.phaseInterceptor.to(MovePhase); - - expect(pokemon.getStatStage(Stat.DEF)).toBe(1); - expect(pokemon.getStatStage(Stat.SPDEF)).toBe(1); - - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ - move: MoveId.SPIT_UP, - result: MoveResult.SUCCESS, - targets: [game.field.getEnemyPokemon().getBattlerIndex()], - }); - - expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce(); - - expect(pokemon.getStatStage(Stat.DEF)).toBe(0); - expect(pokemon.getStatStage(Stat.SPDEF)).toBe(0); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - - it("decreases stats based on stored values (different boosts)", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - - // for the sake of simplicity (and because other tests cover the setup), set boost amounts directly - stockpilingTag.statChangeCounts = { - [Stat.DEF]: -1, - [Stat.SPDEF]: 2, - }; - - game.move.select(MoveId.SPIT_UP); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ - move: MoveId.SPIT_UP, - result: MoveResult.SUCCESS, - targets: [game.field.getEnemyPokemon().getBattlerIndex()], - }); - - expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce(); - - expect(pokemon.getStatStage(Stat.DEF)).toBe(1); - expect(pokemon.getStatStage(Stat.SPDEF)).toBe(-2); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - }); -}); diff --git a/test/moves/stockpile.test.ts b/test/moves/stockpile.test.ts index 2da1285ace2..2cae948a6c5 100644 --- a/test/moves/stockpile.test.ts +++ b/test/moves/stockpile.test.ts @@ -1,109 +1,110 @@ -import { StockpilingTag } from "#data/battler-tags"; 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 { TurnInitPhase } from "#phases/turn-init-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Stockpile", () => { - describe("integration tests", () => { - let phaserGame: Phaser.Game; - let game: GameManager; + let phaserGame: Phaser.Game; + let game: GameManager; - beforeAll(() => { - phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); - }); + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); - beforeEach(() => { - game = new GameManager(phaserGame); + beforeEach(() => { + game = new GameManager(phaserGame); - game.override - .battleStyle("single") - .enemySpecies(SpeciesId.RATTATA) - .enemyMoveset(MoveId.SPLASH) - .enemyAbility(AbilityId.NONE) - .startingLevel(2000) - .moveset([MoveId.STOCKPILE, MoveId.SPLASH]) - .ability(AbilityId.NONE); - }); + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.RATTATA) + .enemyMoveset(MoveId.SPLASH) + .enemyAbility(AbilityId.BALL_FETCH) + .startingLevel(2000) + .ability(AbilityId.BALL_FETCH); + }); - it("gains a stockpile stack and raises user's DEF and SPDEF stat stages by 1 on each use, fails at max stacks (3)", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + it("should gain a stockpile stack and raise DEF and SPDEF when used, up to 3 times", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - const user = game.field.getPlayerPokemon(); + const user = game.field.getPlayerPokemon(); - // Unfortunately, Stockpile stacks are not directly queryable (i.e. there is no pokemon.getStockpileStacks()), - // we just have to know that they're implemented as a BattlerTag. + expect(user).toHaveStatStage(Stat.DEF, 0); + expect(user).toHaveStatStage(Stat.SPDEF, 0); - expect(user.getTag(StockpilingTag)).toBeUndefined(); - expect(user.getStatStage(Stat.DEF)).toBe(0); - expect(user.getStatStage(Stat.SPDEF)).toBe(0); + // use Stockpile thrice + for (let i = 0; i < 3; i++) { + game.move.use(MoveId.STOCKPILE); + await game.toNextTurn(); - // use Stockpile four times - for (let i = 0; i < 4; i++) { - game.move.select(MoveId.STOCKPILE); - await game.toNextTurn(); - - const stockpilingTag = user.getTag(StockpilingTag)!; - - if (i < 3) { - // first three uses should behave normally - expect(user.getStatStage(Stat.DEF)).toBe(i + 1); - expect(user.getStatStage(Stat.SPDEF)).toBe(i + 1); - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(i + 1); - } else { - // fourth should have failed - expect(user.getStatStage(Stat.DEF)).toBe(3); - expect(user.getStatStage(Stat.SPDEF)).toBe(3); - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(3); - expect(user.getMoveHistory().at(-1)).toMatchObject({ - result: MoveResult.FAIL, - move: MoveId.STOCKPILE, - targets: [user.getBattlerIndex()], - }); - } - } - }); - - it("gains a stockpile stack even if user's DEF and SPDEF stat stages are at +6", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const user = game.field.getPlayerPokemon(); - - user.setStatStage(Stat.DEF, 6); - user.setStatStage(Stat.SPDEF, 6); - - expect(user.getTag(StockpilingTag)).toBeUndefined(); - expect(user.getStatStage(Stat.DEF)).toBe(6); - expect(user.getStatStage(Stat.SPDEF)).toBe(6); - - game.move.select(MoveId.STOCKPILE); - await game.phaseInterceptor.to(TurnInitPhase); - - const stockpilingTag = user.getTag(StockpilingTag)!; + const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!; expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(1); - expect(user.getStatStage(Stat.DEF)).toBe(6); - expect(user.getStatStage(Stat.SPDEF)).toBe(6); + expect(stockpilingTag.stockpiledCount).toBe(i + 1); + expect(user).toHaveStatStage(Stat.DEF, i + 1); + expect(user).toHaveStatStage(Stat.SPDEF, i + 1); + } + }); - game.move.select(MoveId.STOCKPILE); - await game.phaseInterceptor.to(TurnInitPhase); + it("should fail when used at max stacks", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - const stockpilingTagAgain = user.getTag(StockpilingTag)!; - expect(stockpilingTagAgain).toBeDefined(); - expect(stockpilingTagAgain.stockpiledCount).toBe(2); - expect(user.getStatStage(Stat.DEF)).toBe(6); - expect(user.getStatStage(Stat.SPDEF)).toBe(6); + const user = game.field.getPlayerPokemon(); + + user.addTag(BattlerTagType.STOCKPILING); + user.addTag(BattlerTagType.STOCKPILING); + user.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!; + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(3); + + game.move.use(MoveId.STOCKPILE); + await game.toNextTurn(); + + // should have failed + expect(user).toHaveStatStage(Stat.DEF, 3); + expect(user).toHaveStatStage(Stat.SPDEF, 3); + expect(stockpilingTag.stockpiledCount).toBe(3); + expect(user).toHaveUsedMove({ + move: MoveId.STOCKPILE, + result: MoveResult.FAIL, }); }); + + it("gains a stockpile stack even if user's DEF and SPDEF stat stages are at +6", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const user = game.field.getPlayerPokemon(); + + user.setStatStage(Stat.DEF, 6); + user.setStatStage(Stat.SPDEF, 6); + + expect(user).not.toHaveBattlerTag(BattlerTagType.STOCKPILING); + + game.move.use(MoveId.STOCKPILE); + await game.toNextTurn(); + + const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!; + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(1); + expect(user).toHaveStatStage(Stat.DEF, 6); + expect(user).toHaveStatStage(Stat.SPDEF, 6); + + game.move.use(MoveId.STOCKPILE); + await game.toNextTurn(); + + const stockpilingTagAgain = user.getTag(BattlerTagType.STOCKPILING)!; + expect(stockpilingTagAgain).toBeDefined(); + expect(stockpilingTagAgain.stockpiledCount).toBe(2); + expect(user).toHaveStatStage(Stat.DEF, 6); + expect(user).toHaveStatStage(Stat.SPDEF, 6); + }); }); diff --git a/test/moves/swallow-spit-up.test.ts b/test/moves/swallow-spit-up.test.ts new file mode 100644 index 00000000000..0324c13aa3e --- /dev/null +++ b/test/moves/swallow-spit-up.test.ts @@ -0,0 +1,232 @@ +import { StockpilingTag } from "#app/data/battler-tags"; +import { allMoves } from "#app/data/data-lists"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +describe("Moves - Swallow & Spit Up - ", () => { + 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") + .enemySpecies(SpeciesId.RATTATA) + .enemyMoveset(MoveId.SPLASH) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyLevel(100) + .startingLevel(100) + .ability(AbilityId.BALL_FETCH); + }); + + describe("Swallow", () => { + it.each<{ stackCount: number; healPercent: number }>([ + { stackCount: 1, healPercent: 25 }, + { stackCount: 2, healPercent: 50 }, + { stackCount: 3, healPercent: 100 }, + ])( + "should heal the user by $healPercent% max HP when consuming $stackCount stockpile stacks", + async ({ stackCount, healPercent }) => { + await game.classicMode.startBattle([SpeciesId.SWALOT]); + + const swalot = game.field.getPlayerPokemon(); + swalot.hp = 1; + + for (let i = 0; i < stackCount; i++) { + swalot.addTag(BattlerTagType.STOCKPILING); + } + + const stockpilingTag = swalot.getTag(StockpilingTag)!; + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stackCount); + + game.move.use(MoveId.SWALLOW); + await game.toEndOfTurn(); + + expect(swalot).toHaveHp((swalot.getMaxHp() * healPercent) / 100 + 1); + expect(swalot).not.toHaveBattlerTag(BattlerTagType.STOCKPILING); + }, + ); + + it("should fail without Stockpile stacks", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const player = game.field.getPlayerPokemon(); + player.hp = 1; + + expect(player).not.toHaveBattlerTag(BattlerTagType.STOCKPILING); + + game.move.use(MoveId.SWALLOW); + await game.toEndOfTurn(); + + expect(player).toHaveUsedMove({ + move: MoveId.SWALLOW, + result: MoveResult.FAIL, + }); + }); + + it("should count as a success and consume stacks despite displaying message at full HP", async () => { + await game.classicMode.startBattle([SpeciesId.SWALOT]); + + const swalot = game.field.getPlayerPokemon(); + swalot.addTag(BattlerTagType.STOCKPILING); + expect(swalot).toHaveBattlerTag(BattlerTagType.STOCKPILING); + + game.move.use(MoveId.SWALLOW); + await game.toEndOfTurn(); + + // Swallow counted as a "success" as its other effect (removing Stockpile) _did_ work + expect(swalot).toHaveUsedMove({ + move: MoveId.SWALLOW, + result: MoveResult.SUCCESS, + }); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(swalot), + }), + ); + expect(swalot).not.toHaveBattlerTag(BattlerTagType.STOCKPILING); + }); + }); + + describe("Spit Up", () => { + let spitUpSpy: MockInstance; + + beforeEach(() => { + spitUpSpy = vi.spyOn(allMoves[MoveId.SPIT_UP], "calculateBattlePower"); + }); + + it.each<{ stackCount: number; power: number }>([ + { stackCount: 1, power: 100 }, + { stackCount: 2, power: 200 }, + { stackCount: 3, power: 300 }, + ])("should have $power base power when consuming $stackCount stockpile stacks", async ({ stackCount, power }) => { + await game.classicMode.startBattle([SpeciesId.SWALOT]); + + const swalot = game.field.getPlayerPokemon(); + + for (let i = 0; i < stackCount; i++) { + swalot.addTag(BattlerTagType.STOCKPILING); + } + + const stockpilingTag = swalot.getTag(StockpilingTag)!; + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stackCount); + + game.move.use(MoveId.SPIT_UP); + await game.toEndOfTurn(); + + expect(spitUpSpy).toHaveReturnedWith(power); + expect(swalot.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("should fail without Stockpile stacks", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const player = game.field.getPlayerPokemon(); + + expect(player).not.toHaveBattlerTag(BattlerTagType.STOCKPILING); + + game.move.use(MoveId.SPIT_UP); + await game.toEndOfTurn(); + + expect(player).toHaveUsedMove({ + move: MoveId.SPIT_UP, + result: MoveResult.FAIL, + }); + }); + }); + + describe("Stockpile stack removal", () => { + it("should undo stat boosts when losing stacks", async () => { + await game.classicMode.startBattle([SpeciesId.SWALOT]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.STOCKPILE); + await game.toNextTurn(); + + expect(player).toHaveBattlerTag(BattlerTagType.STOCKPILING); + expect(player).toHaveStatStage(Stat.DEF, 1); + expect(player).toHaveStatStage(Stat.SPDEF, 1); + + // remove the prior stat boost phases from the log + game.phaseInterceptor.clearLogs(); + + game.move.use(MoveId.SWALLOW); + await game.move.forceEnemyMove(MoveId.ACID_SPRAY); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(player).toHaveStatStage(Stat.DEF, 0); + expect(player).toHaveStatStage(Stat.SPDEF, -2); // +1 --> -1 --> -2 + expect(game.phaseInterceptor.log.filter(l => l === "StatStageChangePhase")).toHaveLength(3); + }); + + it("should double stat drops when gaining Simple", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.STOCKPILE); + await game.move.forceEnemyMove(MoveId.SIMPLE_BEAM); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expect(player).toHaveStatStage(Stat.DEF, 1); + expect(player).toHaveStatStage(Stat.SPDEF, 1); + expect(player.hasAbility(AbilityId.SIMPLE)).toBe(true); + + game.move.use(MoveId.SWALLOW); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + + // should have fallen by 2 stages from Simple + expect(player).toHaveStatStage(Stat.DEF, -1); + expect(player).toHaveStatStage(Stat.SPDEF, -1); + }); + + it("should invert stat drops when gaining Contrary", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.STOCKPILE); + await game.move.forceEnemyMove(MoveId.ENTRAINMENT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(player).toHaveStatStage(Stat.DEF, 1); + expect(player).toHaveStatStage(Stat.SPDEF, 1); + expect(player).toHaveAbilityApplied(AbilityId.CONTRARY); + + game.move.use(MoveId.SPIT_UP); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + + // should have risen 1 stage from Contrary + expect(player).toHaveStatStage(Stat.DEF, 2); + expect(player).toHaveStatStage(Stat.SPDEF, 2); + }); + }); +}); diff --git a/test/moves/swallow.test.ts b/test/moves/swallow.test.ts deleted file mode 100644 index f896a4c9c77..00000000000 --- a/test/moves/swallow.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { StockpilingTag } from "#data/battler-tags"; -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 { MovePhase } from "#phases/move-phase"; -import { TurnInitPhase } from "#phases/turn-init-phase"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - Swallow", () => { - 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") - .enemySpecies(SpeciesId.RATTATA) - .enemyMoveset(MoveId.SPLASH) - .enemyAbility(AbilityId.NONE) - .enemyLevel(2000) - .moveset(MoveId.SWALLOW) - .ability(AbilityId.NONE); - }); - - describe("consumes all stockpile stacks to heal (scaling with stacks)", () => { - it("1 stack -> 25% heal", async () => { - const stacksToSetup = 1; - const expectedHeal = 25; - - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100); - pokemon["hp"] = 1; - - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); - - vi.spyOn(pokemon, "heal"); - - game.move.select(MoveId.SWALLOW); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.heal).toHaveBeenCalledOnce(); - expect(pokemon.heal).toHaveReturnedWith(expectedHeal); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - - it("2 stacks -> 50% heal", async () => { - const stacksToSetup = 2; - const expectedHeal = 50; - - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100); - pokemon["hp"] = 1; - - pokemon.addTag(BattlerTagType.STOCKPILING); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); - - vi.spyOn(pokemon, "heal"); - - game.move.select(MoveId.SWALLOW); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.heal).toHaveBeenCalledOnce(); - expect(pokemon.heal).toHaveReturnedWith(expectedHeal); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - - it("3 stacks -> 100% heal", async () => { - const stacksToSetup = 3; - const expectedHeal = 100; - - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100); - pokemon["hp"] = 0.0001; - - pokemon.addTag(BattlerTagType.STOCKPILING); - pokemon.addTag(BattlerTagType.STOCKPILING); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); - - vi.spyOn(pokemon, "heal"); - - game.move.select(MoveId.SWALLOW); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.heal).toHaveBeenCalledOnce(); - expect(pokemon.heal).toHaveReturnedWith(expect.closeTo(expectedHeal)); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - }); - - it("fails without stacks", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeUndefined(); - - game.move.select(MoveId.SWALLOW); - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ - move: MoveId.SWALLOW, - result: MoveResult.FAIL, - targets: [pokemon.getBattlerIndex()], - }); - }); - - describe("restores stat stage boosts granted by stacks", () => { - it("decreases stats based on stored values (both boosts equal)", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - - game.move.select(MoveId.SWALLOW); - await game.phaseInterceptor.to(MovePhase); - - expect(pokemon.getStatStage(Stat.DEF)).toBe(1); - expect(pokemon.getStatStage(Stat.SPDEF)).toBe(1); - - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ - move: MoveId.SWALLOW, - result: MoveResult.SUCCESS, - targets: [pokemon.getBattlerIndex()], - }); - - expect(pokemon.getStatStage(Stat.DEF)).toBe(0); - expect(pokemon.getStatStage(Stat.SPDEF)).toBe(0); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - - it("lower stat stages based on stored values (different boosts)", async () => { - await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); - - const pokemon = game.field.getPlayerPokemon(); - pokemon.addTag(BattlerTagType.STOCKPILING); - - const stockpilingTag = pokemon.getTag(StockpilingTag)!; - expect(stockpilingTag).toBeDefined(); - - // for the sake of simplicity (and because other tests cover the setup), set boost amounts directly - stockpilingTag.statChangeCounts = { - [Stat.DEF]: -1, - [Stat.SPDEF]: 2, - }; - - game.move.select(MoveId.SWALLOW); - - await game.phaseInterceptor.to(TurnInitPhase); - - expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ - move: MoveId.SWALLOW, - result: MoveResult.SUCCESS, - targets: [pokemon.getBattlerIndex()], - }); - - expect(pokemon.getStatStage(Stat.DEF)).toBe(1); - expect(pokemon.getStatStage(Stat.SPDEF)).toBe(-2); - - expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); - }); - }); -}); diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index f952557bb69..3385f7de1a8 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -371,9 +371,12 @@ export class GameManager { console.log("==================[New Turn]=================="); } - /** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */ - async toEndOfTurn() { - await this.phaseInterceptor.to("TurnEndPhase"); + /** + * Transition to the {@linkcode TurnEndPhase | end of the current turn}. + * @param endTurn - Whether to run the TurnEndPhase or not; default `true` + */ + async toEndOfTurn(endTurn = true) { + await this.phaseInterceptor.to("TurnEndPhase", endTurn); console.log("==================[End of Turn]=================="); } diff --git a/test/test-utils/phase-interceptor.ts b/test/test-utils/phase-interceptor.ts index 50de7e9f047..dacd0f3fdc6 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -36,6 +36,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase"; import { NextEncounterPhase } from "#phases/next-encounter-phase"; import { PartyExpPhase } from "#phases/party-exp-phase"; import { PartyHealPhase } from "#phases/party-heal-phase"; +import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PostGameOverPhase } from "#phases/post-game-over-phase"; @@ -146,6 +147,7 @@ export class PhaseInterceptor { [PositionalTagPhase, this.startPhase], [PokemonTransformPhase, this.startPhase], [MysteryEncounterPhase, this.startPhase], + [PokemonHealPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase], [MysteryEncounterBattlePhase, this.startPhase], [MysteryEncounterRewardsPhase, this.startPhase],