diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 4ebd4f8d4d5..8f43eb65f39 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1951,49 +1951,6 @@ export class HealAttr extends MoveEffectAttr { } } -/** - * 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); - } -} - /** * Attribute to put the user to sleep for a fixed duration, fully heal them and cure their status. * Used for {@linkcode MoveId.REST}. @@ -2203,6 +2160,111 @@ 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.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. @@ -4311,6 +4373,40 @@ 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 { + constructor() { + super(1) + } + + 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; @@ -7946,53 +8042,6 @@ 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; @@ -8203,12 +8252,15 @@ const MoveAttrs = Object.freeze({ SacrificialAttrOnHit, HalfSacrificialAttr, AddSubstituteAttr, + HealAttr, PartyStatusCureAttr, FlameBurstAttr, SacrificialFullRestoreAttr, IgnoreWeatherTypeDebuffAttr, - HealAttr, - VariableHealAttr, + WeatherHealAttr, + PlantHealAttr, + SandHealAttr, + BoostHealAttr, HealOnAllyAttr, HitHealAttr, IncrementMovePriorityAttr, @@ -8273,6 +8325,7 @@ const MoveAttrs = Object.freeze({ PresentPowerAttr, WaterShurikenPowerAttr, SpitUpPowerAttr, + SwallowHealAttr, MultiHitPowerIncrementAttr, LastMoveDoublePowerAttr, CombinedPledgePowerAttr, @@ -9118,13 +9171,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(VariableHealAttr, sunnyHealRatioFunc) + .attr(PlantHealAttr) .triageMove(), new SelfStatusMove(MoveId.SYNTHESIS, PokemonType.GRASS, -1, 5, -1, 0, 2) - .attr(VariableHealAttr, sunnyHealRatioFunc) + .attr(PlantHealAttr) .triageMove(), new SelfStatusMove(MoveId.MOONLIGHT, PokemonType.FAIRY, -1, 5, -1, 0, 2) - .attr(VariableHealAttr, sunnyHealRatioFunc) + .attr(PlantHealAttr) .triageMove(), new AttackMove(MoveId.HIDDEN_POWER, PokemonType.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 2) .attr(HiddenPowerTypeAttr), @@ -9181,12 +9234,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) - .attr(SpitUpPowerAttr, 100) .condition(hasStockpileStacksCondition) + .attr(SpitUpPowerAttr, 100) .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) @@ -10432,7 +10485,7 @@ export function initMoves() { .unimplemented(), /* End Unused */ new SelfStatusMove(MoveId.SHORE_UP, PokemonType.GROUND, -1, 5, -1, 0, 7) - .attr(VariableHealAttr, shoreUpHealRatioFunc) + .attr(SandHealAttr) .triageMove(), new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7) .condition(new FirstMoveCondition()), @@ -10452,7 +10505,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPD ], -1, true) .punchingMove(), new StatusMove(MoveId.FLORAL_HEALING, PokemonType.FAIRY, -1, 10, -1, 0, 7) - .attr(VariableHealAttr, () => globalScene.arena.terrain?.terrainType === TerrainType.GRASSY ? 2 / 3 : 1 / 2, true, false) + .attr(BoostHealAttr, 0.5, 2 / 3, true, false, (user, target, move) => globalScene.arena.terrain?.terrainType === TerrainType.GRASSY) .triageMove() .reflectable(), new AttackMove(MoveId.HIGH_HORSEPOWER, PokemonType.GROUND, MoveCategory.PHYSICAL, 95, 95, 10, -1, 0, 7), diff --git a/test/moves/pollen_puff.test.ts b/test/moves/pollen_puff.test.ts deleted file mode 100644 index d5866323e79..00000000000 --- a/test/moves/pollen_puff.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { BattlerIndex } from "#enums/battler-index"; -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 } from "vitest"; -import { MoveResult } from "#enums/move-result"; -import { getPokemonNameWithAffix } from "#app/messages"; -import i18next from "i18next"; - -describe("Moves - Pollen Puff", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - game = new GameManager(phaserGame); - game.override - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .disableCrits() - .enemyLevel(100) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - 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 [_, omantye, karp1] = game.scene.getField(); - omantye.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.toEndOfTurn(); - - expect(karp1.hp).toBeLessThan(karp1.getMaxHp()); - expect(omantye.hp).toBeCloseTo(0.5 * omantye.getMaxHp() + 1, 1); - expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); - }); - - it("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]); - - 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")).toHaveLength(1); - }); - - it("should damage an enemy multiple times when the user has a source of multi-hit", async () => { - game.override.ability(AbilityId.PARENTAL_BOND); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.POLLEN_PUFF); - await game.toEndOfTurn(); - - const target = game.scene.getEnemyPokemon()!; - expect(target.battleData.hitCount).toBe(2); - }); -}); diff --git a/test/moves/recovery-moves.test.ts b/test/moves/recovery-moves.test.ts deleted file mode 100644 index 89c97437367..00000000000 --- a/test/moves/recovery-moves.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -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.67, 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); - }); - }); -});