From 95dbfe69a06727b532139e1a89b8655c73a735b5 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 5 Aug 2025 13:54:10 -0400 Subject: [PATCH] Added heal pulse tests and clarified rounding --- src/data/moves/move.ts | 33 +++-- src/phases/pokemon-heal-phase.ts | 4 +- test/moves/recovery-moves.test.ts | 229 ++++++++++++++++++++---------- 3 files changed, 171 insertions(+), 95 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0b4e8291026..d415504fdd3 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1940,9 +1940,8 @@ export class AddSubstituteAttr extends MoveEffectAttr { } /** - * Heals the user or target by {@linkcode healRatio} depending on the value of {@linkcode selfTarget} - * @extends MoveEffectAttr - * @see {@linkcode apply} + * Attribute to implement healing moves, such as {@linkcode MoveId.RECOVER} or {@linkcode MoveId.HEAL_PULSE}. + * Heals the user or target of the move by a fixed amount relative to their maximum HP. */ export class HealAttr extends MoveEffectAttr { /** The percentage of {@linkcode Stat.HP} to heal; default `1` */ @@ -1952,7 +1951,8 @@ export class HealAttr extends MoveEffectAttr { /** * Whether the move should fail if the target is at full HP. - * @todo Remove post move failure rework + * @defaultValue `true` + * @todo Remove post move failure rework - this solely exists to prevent Lunar Blessing and co. from failing */ private failOnFullHp = true; @@ -1964,8 +1964,8 @@ export class HealAttr extends MoveEffectAttr { ) { super(selfTarget); this.healRatio = healRatio; - this.showAnim = showAnim - this.failOnFullHp = failOnFullHp + this.showAnim = showAnim; + this.failOnFullHp = failOnFullHp; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -1973,7 +1973,7 @@ export class HealAttr extends MoveEffectAttr { return false; } - this.addHealPhase(this.selfTarget ? user : target, this.healRatio); + this.addHealPhase(this.selfTarget ? user : target); return true; } @@ -1981,11 +1981,12 @@ export class HealAttr extends MoveEffectAttr { * Creates a new {@linkcode PokemonHealPhase}. * This heals the target and shows the appropriate message. */ - protected addHealPhase(target: Pokemon, healRatio: number) { - globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), - toDmgValue(target.getMaxHp() * healRatio), + protected addHealPhase(healedPokemon: Pokemon) { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", healedPokemon.getBattlerIndex(), + // Healing moves round half UP hp healed + Math.round(healedPokemon.getMaxHp() * this.healRatio), { - message: i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(target) }), + message: i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(healedPokemon) }), showFullHpMessage: true, skipAnim: !this.showAnim, } @@ -2002,12 +2003,10 @@ export class HealAttr extends MoveEffectAttr { } override getFailedText(user: Pokemon, target: Pokemon): string | undefined { - const healedPokemon = (this.selfTarget ? user : target); - if (healedPokemon.isFullHp()) { - return i18next.t("battle:hpIsFull", { - pokemonName: getPokemonNameWithAffix(healedPokemon), - }) - } + const healedPokemon = this.selfTarget ? user : target; + return i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(healedPokemon), + }) } } diff --git a/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index bf7fb3e9453..d0c8cda3ced 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -179,7 +179,9 @@ export class PokemonHealPhase extends CommonAnimPhase { /** * Calculate the amount of HP to be healed during this Phase. * @returns The updated healing amount, rounded down and capped at the Pokemon's maximum HP. - * @todo Prevent double rounding from callers + * @remarks + * The effect of Healing Charms are rounded down for parity with the closest mainline counterpart + * (Big Root). */ private getHealAmount(): number { if (this.revive) { diff --git a/test/moves/recovery-moves.test.ts b/test/moves/recovery-moves.test.ts index 70caa0568c6..e55b43dba8f 100644 --- a/test/moves/recovery-moves.test.ts +++ b/test/moves/recovery-moves.test.ts @@ -3,6 +3,7 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; import { WeatherType } from "#enums/weather-type"; import { GameManager } from "#test/test-utils/game-manager"; import { getEnumValues } from "#utils/enums"; @@ -11,7 +12,7 @@ import i18next from "i18next"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -describe("Moves - Recovery Moves - ", () => { +describe("Moves - ", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -31,118 +32,192 @@ describe("Moves - Recovery Moves - ", () => { .ability(AbilityId.MAGIC_GUARD) // prevents passive weather damage .battleStyle("single") .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) + .enemySpecies(SpeciesId.CHANSEY) .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 () => { + describe("Self-Healing Moves -", () => { + describe.each<{ name: string; move: MoveId }>([ + { name: "Recover", move: MoveId.RECOVER }, + { name: "Soft-Boiled", move: MoveId.SOFT_BOILED }, + { name: "Milk Drink", move: MoveId.MILK_DRINK }, + { name: "Slack Off", move: MoveId.SLACK_OFF }, + { name: "Heal Order", move: MoveId.HEAL_ORDER }, + { name: "Roost", move: MoveId.ROOST }, + { name: "Weather-based Healing Moves", move: MoveId.SYNTHESIS }, + { name: "Shore Up", move: MoveId.SHORE_UP }, + ])("$name", ({ move }) => { + it("should heal 50% of the user's maximum HP, rounded half up", async () => { + // NB: Shore Up and co. round down in mainline, but we keep them the same as others for consistency's sake + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const chansey = game.field.getEnemyPokemon(); + chansey.hp = 1; + chansey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251 + + game.move.use(move); + await game.toEndOfTurn(); + + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(chansey) }), + ); + expect(chansey).toHaveHp(252); // 251 + 1 + }); + + it("should fail if user is at full HP", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + game.move.use(move); + await game.toEndOfTurn(); + + const blissey = game.field.getPlayerPokemon(); + const chansey = game.field.getEnemyPokemon(); + expect(chansey).toHaveFullHp(); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:hpIsFull", { + pokemonName: getPokemonNameWithAffix(blissey), + }), + ); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL }); + }); + }); + + describe("Weather-based Healing Moves", () => { + it.each([ + { name: "Harsh Sunlight", weather: WeatherType.SUNNY }, + { name: "Extremely Harsh Sunlight", weather: WeatherType.HARSH_SUN }, + ])("should heal 66% of the user's maximum HP under $name", async ({ weather }) => { + game.override.weather(weather); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.MOONLIGHT); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1); + }); + + const nonSunWTs = getEnumValues(WeatherType) + .filter( + wt => ![WeatherType.SUNNY, WeatherType.HARSH_SUN, WeatherType.NONE, WeatherType.STRONG_WINDS].includes(wt), + ) + .map(wt => ({ + name: toTitleCase(WeatherType[wt]), + weather: wt, + })); + + it.each(nonSunWTs)("should heal 25% of the user's maximum HP under $name", async ({ weather }) => { + game.override.weather(weather); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.MOONLIGHT); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.25, 1); + }); + + it("should heal 50% of the user's maximum HP under strong winds", async () => { + game.override.ability(AbilityId.DELTA_STREAM); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.MOONLIGHT); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.5, 1); + }); + }); + + describe("Shore Up", () => { + it("should heal 66% of the user's maximum HP in a sandstorm", async () => { + game.override.weather(WeatherType.SANDSTORM); + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + blissey.hp = 1; + + game.move.use(MoveId.SHORE_UP); + await game.toEndOfTurn(); + + expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1); + }); + }); + }); + + describe.each([ + { + name: "Heal Pulse", + move: MoveId.HEAL_PULSE, + percent: 75, + ability: AbilityId.MEGA_LAUNCHER, + condText: "user has Mega Launcher", + }, + { + name: "Floral Healing", + move: MoveId.FLORAL_HEALING, + percent: 66, + ability: AbilityId.GRASSY_SURGE, + condText: "Grassy Terrain is active", + }, + ])("Target-Healing Moves - $name", ({ move, percent, ability }) => { + it("should heal 50% of the target's maximum HP, rounded half up", async () => { await game.classicMode.startBattle([SpeciesId.BLISSEY]); const blissey = game.field.getPlayerPokemon(); blissey.hp = 1; + blissey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251 game.move.use(move); await game.toEndOfTurn(); - expect(blissey.getHpRatio()).toBeCloseTo(0.5, 1); expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(blissey) }), + ); + expect(blissey).toHaveHp(252); // 251 + 1 }); - it("should fail if used at full HP", async () => { + it("should fail if target is at full HP", async () => { await game.classicMode.startBattle([SpeciesId.BLISSEY]); game.move.use(move); await game.toEndOfTurn(); const blissey = game.field.getPlayerPokemon(); - expect(blissey.hp).toBe(blissey.getMaxHp()); + expect(blissey).toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("battle:hpIsFull", { pokemonName: getPokemonNameWithAffix(blissey), }), ); expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); - expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - }); - }); - - describe("Weather-based Healing Moves", () => { - it.each([ - { name: "Harsh Sunlight", weather: WeatherType.SUNNY }, - { name: "Extremely Harsh Sunlight", weather: WeatherType.HARSH_SUN }, - ])("should heal 66% of the user's maximum HP under $name", async ({ weather }) => { - game.override.weather(weather); - await game.classicMode.startBattle([SpeciesId.BLISSEY]); - - const blissey = game.field.getPlayerPokemon(); - blissey.hp = 1; - - game.move.use(MoveId.MOONLIGHT); - await game.toEndOfTurn(); - - expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1); + expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL }); }); - const nonSunWTs = getEnumValues(WeatherType) - .filter( - wt => ![WeatherType.SUNNY, WeatherType.HARSH_SUN, WeatherType.NONE, WeatherType.STRONG_WINDS].includes(wt), - ) - .map(wt => ({ - name: toTitleCase(WeatherType[wt]), - weather: wt, - })); - - it.each(nonSunWTs)("should heal 25% of the user's maximum HP under $name", async ({ weather }) => { - game.override.weather(weather); + it("should heal $percent% of the target's maximum HP if $condText", async () => { + // prevents passive turn heal from grassy terrain + game.override.ability(ability).enemyAbility(AbilityId.LEVITATE); await game.classicMode.startBattle([SpeciesId.BLISSEY]); - const blissey = game.field.getPlayerPokemon(); - blissey.hp = 1; + const chansey = game.field.getEnemyPokemon(); + chansey.hp = 1; - game.move.use(MoveId.MOONLIGHT); + game.move.use(move); 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); + expect(chansey).toHaveHp(Math.round((percent * chansey.getMaxHp()) / 100) + 1); }); }); });