Added heal pulse tests and clarified rounding

This commit is contained in:
Bertie690 2025-08-05 13:54:10 -04:00
parent 90c9c71cd9
commit 95dbfe69a0
3 changed files with 171 additions and 95 deletions

View File

@ -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),
})
}
}

View File

@ -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) {

View File

@ -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);
});
});
});