diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0878ece2f01..895bc94b34c 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1933,19 +1933,21 @@ export class AddSubstituteAttr extends MoveEffectAttr { * @see {@linkcode apply} */ export class HealAttr extends MoveEffectAttr { - /** The percentage of {@linkcode Stat.HP} to heal */ - private healRatio: number; - /** Should an animation be shown? */ - private showAnim: boolean; - - constructor(healRatio?: number, showAnim?: boolean, selfTarget?: boolean) { - super(selfTarget === undefined || selfTarget); - - this.healRatio = healRatio || 1; - this.showAnim = !!showAnim; + constructor( + /** The percentage of {@linkcode Stat.HP} to heal; default `1` */ + protected healRatio = 1, + /** Whether to display a healing animation upon healing the target; default `false` */ + private showAnim = false, + selfTarget = true + ) { + super(selfTarget); } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!super.apply(user, target, move, args)) { + return false; + } + this.addHealPhase(this.selfTarget ? user : target, this.healRatio); return true; } @@ -1954,15 +1956,73 @@ export class HealAttr extends MoveEffectAttr { * Creates a new {@linkcode PokemonHealPhase}. * This heals the target and shows the appropriate message. */ - addHealPhase(target: Pokemon, healRatio: number) { + protected addHealPhase(target: Pokemon, healRatio: number) { globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), toDmgValue(target.getMaxHp() * healRatio), i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(target) }), true, !this.showAnim); } - getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { + override getTargetBenefitScore(user: Pokemon, target: Pokemon, _move: Move): number { const score = ((1 - (this.selfTarget ? user : target).getHpRatio()) * 20) - this.healRatio * 10; return Math.round(score / (1 - this.healRatio / 2)); } + + // TODO: Change post move failure rework + override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean { + if (!super.canApply(user, target, _move, _args)) { + return false; + } + + const healedPokemon = this.selfTarget ? user : target; + if (healedPokemon.isFullHp()) { + globalScene.phaseManager.queueMessage(i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(healedPokemon), + })) + return false; + } + return true; + } +} + +/** + * Attribute for moves with variable healing amounts. + * Heals the target by an amount depending on the return value of {@linkcode healFunc}. + * + * Used for {@linkcode MoveId.MOONLIGHT}, {@linkcode MoveId.SHORE_UP}, {@linkcode MoveId.JUNGLE_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, + ) { + super(1, showAnim, 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 canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean { + return user.getAlly() !== target || super.canApply(user, target, _move, _args); + } } /** @@ -2145,112 +2205,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. @@ -4354,36 +4308,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; @@ -8029,6 +7953,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; @@ -8240,15 +8211,12 @@ const MoveAttrs = Object.freeze({ SacrificialAttrOnHit, HalfSacrificialAttr, AddSubstituteAttr, - HealAttr, PartyStatusCureAttr, FlameBurstAttr, SacrificialFullRestoreAttr, IgnoreWeatherTypeDebuffAttr, - WeatherHealAttr, - PlantHealAttr, - SandHealAttr, - BoostHealAttr, + HealAttr, + VariableHealAttr, HealOnAllyAttr, HitHealAttr, IncrementMovePriorityAttr, @@ -8313,7 +8281,6 @@ const MoveAttrs = Object.freeze({ PresentPowerAttr, WaterShurikenPowerAttr, SpitUpPowerAttr, - SwallowHealAttr, MultiHitPowerIncrementAttr, LastMoveDoublePowerAttr, CombinedPledgePowerAttr, @@ -9164,13 +9131,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), @@ -9227,12 +9194,12 @@ export function initMoves() { .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 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) .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) @@ -10490,7 +10457,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()), @@ -10510,7 +10477,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.terrain?.terrainType === 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), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 0a8e8469115..d0fe5eabe45 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1610,6 +1610,7 @@ export default 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; } diff --git a/test/items/leftovers.test.ts b/test/items/leftovers.test.ts index 21319d2c9d7..99d2a16ff75 100644 --- a/test/items/leftovers.test.ts +++ b/test/items/leftovers.test.ts @@ -1,5 +1,4 @@ import { DamageAnimPhase } from "#app/phases/damage-anim-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; @@ -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 cd0a138e96c..0a9b6d9cf24 100644 --- a/test/moves/pollen_puff.test.ts +++ b/test/moves/pollen_puff.test.ts @@ -5,8 +5,11 @@ import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { MoveResult } from "#enums/move-result"; +import { getPokemonNameWithAffix } from "#app/messages"; +import i18next from "i18next"; -describe("Moves - Pollen Puff", () => { +describe("Move - Pollen Puff", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -23,42 +26,80 @@ 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.hp).toBeLessThan(karp1.getMaxHp()); + expect(omantye.hp).toBeCloseTo(0.5 * omantye.getMaxHp() + 1, 1); + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + }); - await game.phaseInterceptor.to("BerryPhase"); + it.todo("should display message & count as failed when hitting 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.hp).toBe(omantye.getMaxHp()); + expect(bulbasaur.getLastXMoves()[0].result).toBe(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.hp).toBeLessThanOrEqual(0.5 * omantye.getMaxHp() + 1); + expect( + game.phaseInterceptor.log.filter(l => l === "PokemonHealPhase"), + game.phaseInterceptor.log.join("\n"), + ).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]); - const target = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.POLLEN_PUFF); - - await game.phaseInterceptor.to("BerryPhase"); + game.move.use(MoveId.POLLEN_PUFF); + await game.toEndOfTurn(); + const target = game.field.getEnemyPokemon(); 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..0a6daaa209c --- /dev/null +++ b/test/moves/recovery-moves.test.ts @@ -0,0 +1,145 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { getEnumValues, toReadableString } from "#app/utils/common"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { WeatherType } from "#enums/weather-type"; +import GameManager from "#test/testUtils/gameManager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Recovery 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.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + 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: "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", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(move); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.5, 1); + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + }); + + it("should display message without adding phase if used at full HP", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + game.move.use(move); + await game.toEndOfTurn(); + + const blissey = game.field.getPlayerPokemon(); + expect(blissey.hp).toBe(blissey.getMaxHp()); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(blissey), + }), + ); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + }); + }); + + 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) as WeatherType[]) + .filter( + wt => ![WeatherType.SUNNY, WeatherType.HARSH_SUN, WeatherType.NONE, WeatherType.STRONG_WINDS].includes(wt), + ) + .map(wt => ({ + name: toReadableString(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); + }); + }); +}); diff --git a/test/moves/roost.test.ts b/test/moves/roost.test.ts index 3707e0ead45..0b65d36f82a 100644 --- a/test/moves/roost.test.ts +++ b/test/moves/roost.test.ts @@ -7,7 +7,7 @@ import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { TurnEndPhase } from "#app/phases/turn-end-phase"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, it } from "vitest"; describe("Moves - Roost", () => { let phaserGame: Phaser.Game; @@ -47,6 +47,9 @@ describe("Moves - Roost", () => { * 6. Non flying types using roost (such as dunsparce) are already grounded, so this move will only heal and have no other effects. */ + // TODO: This test currently is meaningless since we don't fail ineffective moves + it.todo("should still ground the user without failing when used at full HP"); + test("Non flying type uses roost -> no type change, took damage", async () => { await game.classicMode.startBattle([SpeciesId.DUNSPARCE]); const playerPokemon = game.scene.getPlayerPokemon()!; diff --git a/test/moves/spit_up.test.ts b/test/moves/spit_up.test.ts deleted file mode 100644 index 00cbe2fd0ad..00000000000 --- a/test/moves/spit_up.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Stat } from "#enums/stat"; -import { StockpilingTag } from "#app/data/battler-tags"; -import { allMoves } from "#app/data/data-lists"; -import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { MoveResult } from "#enums/move-result"; -import GameManager from "#test/testUtils/gameManager"; -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import type Move from "#app/data/moves/move"; -import { SpeciesId } from "#enums/species-id"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { MovePhase } from "#app/phases/move-phase"; -import { TurnInitPhase } from "#app/phases/turn-init-phase"; - -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.scene.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.scene.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.scene.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.scene.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.scene.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.scene.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.scene.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.scene.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.scene.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/swallow-spit-up.test.ts b/test/moves/swallow-spit-up.test.ts new file mode 100644 index 00000000000..4c73d23e766 --- /dev/null +++ b/test/moves/swallow-spit-up.test.ts @@ -0,0 +1,266 @@ +import { Stat } from "#enums/stat"; +import { StockpilingTag } from "#app/data/battler-tags"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { MoveResult } from "#enums/move-result"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { BattlerIndex } from "#enums/battler-index"; +import { allMoves } from "#app/data/data-lists"; +import { getPokemonNameWithAffix } from "#app/messages"; +import i18next from "i18next"; + +describe("Swallow & Spit Up", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + describe("Moves - Swallow", () => { + 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); + }); + + 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.getHpRatio()).toBeCloseTo(healPercent / 100, 1); + expect(swalot.getTag(StockpilingTag)).toBeUndefined(); + }, + ); + + it("should fail without Stockpile stacks", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const player = game.field.getPlayerPokemon(); + player.hp = 1; + + const stockpilingTag = player.getTag(StockpilingTag)!; + expect(stockpilingTag).toBeUndefined(); + + game.move.use(MoveId.SWALLOW); + await game.toEndOfTurn(); + + expect(player.getLastXMoves()[0]).toMatchObject({ + move: MoveId.SWALLOW, + result: MoveResult.FAIL, + }); + }); + + // TODO: Does this consume stacks or not? + it.todo("should fail and display message at full HP, consuming stacks", async () => { + await game.classicMode.startBattle([SpeciesId.SWALOT]); + + const swalot = game.field.getPlayerPokemon(); + swalot.addTag(BattlerTagType.STOCKPILING); + const stockpilingTag = swalot.getTag(StockpilingTag)!; + expect(stockpilingTag).toBeDefined(); + + game.move.use(MoveId.SWALLOW); + await game.toEndOfTurn(); + + expect(swalot.getLastXMoves()[0]).toMatchObject({ + move: MoveId.SWALLOW, + result: MoveResult.FAIL, + }); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(swalot), + }), + ); + expect(stockpilingTag).toBeDefined(); + }); + }); + + describe("Moves - Spit Up", () => { + let spitUpSpy: MockInstance; + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.RATTATA) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyLevel(2000) + .enemyMoveset(MoveId.SPLASH) + .ability(AbilityId.BALL_FETCH); + + 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(); + + const stockpilingTag = player.getTag(StockpilingTag)!; + expect(stockpilingTag).toBeUndefined(); + + game.move.use(MoveId.SPIT_UP); + await game.toEndOfTurn(); + + expect(player.getLastXMoves()[0]).toMatchObject({ + move: MoveId.SPIT_UP, + result: MoveResult.FAIL, + }); + }); + }); + + describe("Stockpile stack removal", () => { + 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); + }); + + it("should undo stat boosts when losing stacks", async () => { + await game.classicMode.startBattle([SpeciesId.ABOMASNOW]); + + const player = game.field.getPlayerPokemon(); + player.hp = 1; + + game.move.use(MoveId.STOCKPILE); + await game.toNextTurn(); + + const stockpilingTag = player.getTag(StockpilingTag)!; + expect(stockpilingTag).toBeDefined(); + expect(player.getStatStage(Stat.DEF)).toBe(1); + expect(player.getStatStage(Stat.SPDEF)).toBe(1); + + // remove the prior stat boosts 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.getStatStage(Stat.DEF)).toBe(0); + expect(player.getStatStage(Stat.SPDEF)).toBe(-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.getStatStage(Stat.DEF)).toBe(1); + expect(player.getStatStage(Stat.SPDEF)).toBe(1); + expect(player.hasAbility(AbilityId.SIMPLE)).toBe(true); + + game.move.use(MoveId.SPIT_UP); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + + // should have fallen by 2 stages from Simple + expect(player.getStatStage(Stat.DEF)).toBe(-1); + expect(player.getStatStage(Stat.SPDEF)).toBe(-1); + }); + + it("should invert stat drops when gaining Contrary", async () => { + game.override.enemyAbility(AbilityId.CONTRARY); + 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.getStatStage(Stat.DEF)).toBe(1); + expect(player.getStatStage(Stat.SPDEF)).toBe(1); + expect(player.hasAbility(AbilityId.CONTRARY)).toBe(true); + + game.move.use(MoveId.SPIT_UP); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + + // should have risen 1 stage from Contrary + expect(player.getStatStage(Stat.DEF)).toBe(2); + expect(player.getStatStage(Stat.SPDEF)).toBe(2); + }); + }); +}); diff --git a/test/moves/swallow.test.ts b/test/moves/swallow.test.ts deleted file mode 100644 index c511682011f..00000000000 --- a/test/moves/swallow.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Stat } from "#enums/stat"; -import { StockpilingTag } from "#app/data/battler-tags"; -import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { MoveResult } from "#enums/move-result"; -import { MovePhase } from "#app/phases/move-phase"; -import { TurnInitPhase } from "#app/phases/turn-init-phase"; -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import GameManager from "#test/testUtils/gameManager"; -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.scene.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.scene.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.scene.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.scene.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.scene.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.scene.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/testUtils/phaseInterceptor.ts b/test/testUtils/phaseInterceptor.ts index 9d046fc85ba..e1033f87b15 100644 --- a/test/testUtils/phaseInterceptor.ts +++ b/test/testUtils/phaseInterceptor.ts @@ -64,6 +64,7 @@ import { PostGameOverPhase } from "#app/phases/post-game-over-phase"; import { RevivalBlessingPhase } from "#app/phases/revival-blessing-phase"; import type { PhaseClass, PhaseString } from "#app/@types/phase-types"; +import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; export interface PromptHandler { phaseTarget?: string; @@ -143,6 +144,7 @@ export default class PhaseInterceptor { [AttemptRunPhase, this.startPhase], [SelectBiomePhase, this.startPhase], [MysteryEncounterPhase, this.startPhase], + [PokemonHealPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase], [MysteryEncounterBattlePhase, this.startPhase], [MysteryEncounterRewardsPhase, this.startPhase],