Added MoveHealBoostAbAttr + implemented Healing Pulse boost

This commit is contained in:
Bertie690 2025-08-05 16:48:39 -04:00
parent 95dbfe69a0
commit 89536fafda
4 changed files with 94 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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