diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f94c59bb463..65d5231831e 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1946,15 +1946,74 @@ 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 { + const healRatio = this.healFunc(user, target, move) + this.addHealPhase(target, healRatio); + return true; + } +} + +/** + * 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); + } } /** @@ -2137,112 +2196,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. @@ -4345,36 +4298,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; @@ -8020,6 +7943,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; @@ -8231,15 +8201,12 @@ const MoveAttrs = Object.freeze({ SacrificialAttrOnHit, HalfSacrificialAttr, AddSubstituteAttr, - HealAttr, PartyStatusCureAttr, FlameBurstAttr, SacrificialFullRestoreAttr, IgnoreWeatherTypeDebuffAttr, - WeatherHealAttr, - PlantHealAttr, - SandHealAttr, - BoostHealAttr, + HealAttr, + VariableHealAttr, HealOnAllyAttr, HitHealAttr, IncrementMovePriorityAttr, @@ -8304,7 +8271,6 @@ const MoveAttrs = Object.freeze({ PresentPowerAttr, WaterShurikenPowerAttr, SpitUpPowerAttr, - SwallowHealAttr, MultiHitPowerIncrementAttr, LastMoveDoublePowerAttr, CombinedPledgePowerAttr, @@ -9155,13 +9121,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), @@ -9218,12 +9184,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) @@ -10481,7 +10447,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()), @@ -10501,7 +10467,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/test/moves/recovery-moves.test.ts b/test/moves/recovery-moves.test.ts new file mode 100644 index 00000000000..92a481f7a7e --- /dev/null +++ b/test/moves/recovery-moves.test.ts @@ -0,0 +1,175 @@ +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, + }); + }); + + 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 }, + ])("Normal Recovery Moves - $name", ({ move }) => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .disableCrits() + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + 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 and fail 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", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .disableCrits() + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it.each([ + { name: "Harsh Sunlight", ability: AbilityId.DROUGHT }, + { name: "Extremely Harsh Sunlight", ability: AbilityId.DESOLATE_LAND }, + ])("should heal 66% of the user's maximum HP under $name", async ({ ability }) => { + game.override.passiveAbility(ability); + 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.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", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .disableCrits() + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it("should heal 66% of the user's maximum HP under sandstorm", async () => { + game.override.ability(AbilityId.SAND_STREAM); + 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/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..80b817723f5 --- /dev/null +++ b/test/moves/swallow-spit-up.test.ts @@ -0,0 +1,264 @@ +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; + + player.addTag(BattlerTagType.STOCKPILING); + 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(); - }); - }); -});