From 89536fafda9ae70d7bdcd774645f63cb356e59bb Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 5 Aug 2025 16:48:39 -0400 Subject: [PATCH] Added `MoveHealBoostAbAttr` + implemented Healing Pulse boost --- src/data/abilities/ability.ts | 55 +++++++++++++++++++++++++++++-- src/data/moves/move.ts | 24 ++++++++++---- src/phases/pokemon-heal-phase.ts | 4 +-- test/moves/recovery-moves.test.ts | 44 ++++++++++++------------- 4 files changed, 94 insertions(+), 33 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index d21219ffb4e..2342807a085 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1541,6 +1541,51 @@ export abstract class PreAttackAbAttr extends AbAttr { private declare readonly _: never; } +export interface MoveHealBoostAbAttrParams extends AugmentMoveInteractionAbAttrParams { + /** The base amount of HP being healed, as a fraction of the recipient's maximum HP. */ + healRatio: NumberHolder; +} + +/** + * Ability attribute to boost the healing potency of the user's moves. + * Used by {@linkcode AbilityId.MEGA_LAUNCHER} to implement Heal Pulse boosting. + */ +export class MoveHealBoostAbAttr extends AbAttr { + /** + * The amount to boost the healing by, as a multiplier of the base amount. + */ + private healMulti: number; + /** + * A lambda function determining whether to boost the heal amount. + * The ability will not be applied if this evaluates to `false`. + */ + // TODO: Use a `MoveConditionFunc` maybe? + private boostCondition: (user: Pokemon, target: Pokemon, move: Move) => boolean; + + constructor( + boostCondition: (user: Pokemon, target: Pokemon, move: Move) => boolean, + healMulti: number, + showAbility = false, + ) { + super(showAbility); + + if (healMulti === 1) { + throw new Error("Calling `MoveHealBoostAbAttr` with a multiplier of 1 is useless!"); + } + + this.healMulti = healMulti; + this.boostCondition = boostCondition; + } + + override canApply({ pokemon: user, opponent: target, move }: MoveHealBoostAbAttrParams): boolean { + return this.boostCondition?.(user, target, move) ?? true; + } + + override apply({ healRatio }: MoveHealBoostAbAttrParams): void { + healRatio.value *= this.healMulti; + } +} + export interface ModifyMoveEffectChanceAbAttrParams extends AbAttrBaseParams { /** The move being used by the attacker */ move: Move; @@ -1682,7 +1727,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { */ override canApply({ pokemon, opponent: target, move }: MoveTypeChangeAbAttrParams): boolean { return ( - (!this.condition || this.condition(pokemon, target, move)) && + (this.condition?.(pokemon, target, move) ?? true) && !noAbilityTypeOverrideMoves.has(move.id) && !( pokemon.isTerastallized && @@ -6558,6 +6603,7 @@ const AbilityAttrs = Object.freeze({ PostDefendMoveDisableAbAttr, PostStatStageChangeStatStageChangeAbAttr, PreAttackAbAttr, + MoveHealBoostAbAttr, MoveEffectChanceMultiplierAbAttr, IgnoreMoveEffectsAbAttr, VariableMovePowerAbAttr, @@ -7333,7 +7379,9 @@ export function initAbilities() { .attr(PostSummonUserFieldRemoveStatusEffectAbAttr, StatusEffect.SLEEP) .attr(UserFieldBattlerTagImmunityAbAttr, BattlerTagType.DROWSY) .ignorable() - .partial(), // Mold Breaker ally should not be affected by Sweet Veil + // Mold Breaker ally should not be affected by Sweet Veil + // TODO: Review this + .partial(), new Ability(AbilityId.STANCE_CHANGE, 6) .attr(NoFusionAbilityAbAttr) .uncopiable() @@ -7342,7 +7390,8 @@ export function initAbilities() { new Ability(AbilityId.GALE_WINGS, 6) .attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && pokemon.getMoveType(move) === PokemonType.FLYING, 1), new Ability(AbilityId.MEGA_LAUNCHER, 6) - .attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5), + .attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5) + .attr(MoveHealBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5), new Ability(AbilityId.GRASS_PELT, 6) .conditionalAttr(getTerrainCondition(TerrainType.GRASSY), StatMultiplierAbAttr, Stat.DEF, 1.5) .ignorable(), diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index d415504fdd3..ab284ba792f 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1940,7 +1940,7 @@ export class AddSubstituteAttr extends MoveEffectAttr { } /** - * Attribute to implement healing moves, such as {@linkcode MoveId.RECOVER} or {@linkcode MoveId.HEAL_PULSE}. + * Attribute to implement healing moves, such as {@linkcode MoveId.RECOVER} or {@linkcode MoveId.SOFT_BOILED}. * Heals the user or target of the move by a fixed amount relative to their maximum HP. */ export class HealAttr extends MoveEffectAttr { @@ -1968,11 +1968,22 @@ export class HealAttr extends MoveEffectAttr { this.failOnFullHp = failOnFullHp; } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (!super.apply(user, target, move, args)) { return false; } + // Apply any boosts to healing amounts (i.e. Heal Pulse + Mega Launcher). + const hp = new NumberHolder(this.healRatio) + applyAbAttrs("MoveHealBoostAbAttr", { + pokemon: user, + opponent: target, + move, + healRatio: hp + }) + this.healRatio = hp.value; + + this.addHealPhase(this.selfTarget ? user : target); return true; } @@ -1983,7 +1994,8 @@ export class HealAttr extends MoveEffectAttr { */ protected addHealPhase(healedPokemon: Pokemon) { globalScene.phaseManager.unshiftNew("PokemonHealPhase", healedPokemon.getBattlerIndex(), - // Healing moves round half UP hp healed + // Healing moves round half UP the hp healed + // (unlike most other sources which round down) Math.round(healedPokemon.getMaxHp() * this.healRatio), { message: i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(healedPokemon) }), @@ -2012,7 +2024,7 @@ 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}. + * Heals the user/target by an amount depending on the return value of {@linkcode healFunc}. * * Used for: * - {@linkcode MoveId.MOONLIGHT} and variants @@ -2023,7 +2035,7 @@ export class HealAttr extends MoveEffectAttr { export class VariableHealAttr extends HealAttr { constructor( /** A function yielding the amount of HP to heal. */ - private healFunc: (user: Pokemon, target: Pokemon, _move: Move) => number, + private healFunc: (user: Pokemon, target: Pokemon, move: Move) => number, showAnim = false, selfTarget = true, ) { @@ -10571,7 +10583,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(VariableHealAttr, () => globalScene.arena.getTerrainType() === 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/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index d0c8cda3ced..cbb5b3d6d9e 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -178,10 +178,10 @@ 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. + * @returns The updated healing amount post-modifications, capped at the Pokemon's maximum HP. * @remarks * The effect of Healing Charms are rounded down for parity with the closest mainline counterpart - * (Big Root). + * (i.e. 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 e55b43dba8f..ad39a86212d 100644 --- a/test/moves/recovery-moves.test.ts +++ b/test/moves/recovery-moves.test.ts @@ -51,32 +51,30 @@ describe("Moves - ", () => { { 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 + 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(game.phaseInterceptor.log).toContain("PokemonHealPhase"); expect(game.textInterceptor.logs).toContain( - i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(chansey) }), + i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(blissey) }), ); - expect(chansey).toHaveHp(252); // 251 + 1 + expect(blissey).toHaveHp(252); // 251 + 1 }); - it("should fail if user is at full HP", async () => { + it("should fail if the 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(blissey).toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("battle:hpIsFull", { pokemonName: getPokemonNameWithAffix(blissey), @@ -160,53 +158,55 @@ describe("Moves - ", () => { { name: "Heal Pulse", move: MoveId.HEAL_PULSE, - percent: 75, + percent: 3 / 4, ability: AbilityId.MEGA_LAUNCHER, condText: "user has Mega Launcher", }, { name: "Floral Healing", move: MoveId.FLORAL_HEALING, - percent: 66, + percent: 2 / 3, ability: AbilityId.GRASSY_SURGE, condText: "Grassy Terrain is active", }, - ])("Target-Healing Moves - $name", ({ move, percent, ability }) => { + ])("Target-Healing Moves - $name", ({ move, percent, ability, condText }) => { it("should heal 50% of the target'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 blissey = game.field.getPlayerPokemon(); - blissey.hp = 1; - blissey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251 + 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(blissey) }), + i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(chansey) }), ); - expect(blissey).toHaveHp(252); // 251 + 1 + expect(chansey).toHaveHp(252); // 251 + 1 }); - it("should fail if target is at full HP", async () => { + it("should fail if the 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).toHaveFullHp(); + const chansey = game.field.getEnemyPokemon(); + expect(chansey).toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("battle:hpIsFull", { - pokemonName: getPokemonNameWithAffix(blissey), + pokemonName: getPokemonNameWithAffix(chansey), }), ); expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL }); }); - it("should heal $percent% of the target's maximum HP if $condText", async () => { + it(`should heal ${(percent * 100).toPrecision(2)}% 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]); @@ -217,7 +217,7 @@ describe("Moves - ", () => { game.move.use(move); await game.toEndOfTurn(); - expect(chansey).toHaveHp(Math.round((percent * chansey.getMaxHp()) / 100) + 1); + expect(chansey).toHaveHp(Math.round(percent * chansey.getMaxHp()) + 1); }); }); });