diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index b46f109db9e..c82506b64d5 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -126,10 +126,10 @@ export default abstract class Move implements Localizable { /** * Check if the move is of the given subclass without requiring `instanceof`. - * + * * ⚠️ Does _not_ work for {@linkcode ChargingAttackMove} and {@linkcode ChargingSelfStatusMove} subclasses. For those, * use {@linkcode isChargingMove} instead. - * + * * @param moveKind - The string name of the move to check against * @returns Whether this move is of the provided type. */ @@ -1946,6 +1946,64 @@ export class HealAttr extends MoveEffectAttr { const score = ((1 - (this.selfTarget ? user : target).getHpRatio()) * 20) - this.healRatio * 10; return Math.round(score / (1 - this.healRatio / 2)); } + + override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean { + return !(this.selfTarget ? user : target).isFullHp(); + } + + override getFailedText(user: Pokemon, target: Pokemon, _move: Move): string | undefined { + const healedPokemon = this.selfTarget ? user : target; + return healedPokemon.isFullHp() + ? i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(healedPokemon), + }) + : undefined; + } +} + +/** + * 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); + } +} + } /** @@ -2128,112 +2186,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. @@ -4325,36 +4277,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; @@ -7990,6 +7912,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; @@ -8200,15 +8169,12 @@ const MoveAttrs = Object.freeze({ SacrificialAttrOnHit, HalfSacrificialAttr, AddSubstituteAttr, - HealAttr, PartyStatusCureAttr, FlameBurstAttr, SacrificialFullRestoreAttr, IgnoreWeatherTypeDebuffAttr, - WeatherHealAttr, - PlantHealAttr, - SandHealAttr, - BoostHealAttr, + HealAttr, + VariableHealAttr, HealOnAllyAttr, HitHealAttr, IncrementMovePriorityAttr, @@ -8273,7 +8239,6 @@ const MoveAttrs = Object.freeze({ PresentPowerAttr, WaterShurikenPowerAttr, SpitUpPowerAttr, - SwallowHealAttr, MultiHitPowerIncrementAttr, LastMoveDoublePowerAttr, CombinedPledgePowerAttr, @@ -9121,13 +9086,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), @@ -9184,12 +9149,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) @@ -10440,7 +10405,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()), @@ -10460,7 +10425,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..89c97437367 --- /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.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); + }); + }); +});