From be79a0b2dbb89f375147ae6cf885df473d0dc5d9 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Fri, 28 Mar 2025 20:49:18 -0500 Subject: [PATCH] Fully implement Flower Veil Signed-off-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> --- src/data/ability.ts | 99 ++++++++++++++++++++++----- src/data/arena-tag.ts | 1 + src/data/battler-tags.ts | 2 + src/field/pokemon.ts | 1 + src/phases/stat-stage-change-phase.ts | 21 ++++++ test/abilities/flower_veil.test.ts | 94 +++++++++++++++++++------ 6 files changed, 180 insertions(+), 38 deletions(-) diff --git a/src/data/ability.ts b/src/data/ability.ts index d8658c39bb5..6a644dbac47 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -3204,6 +3204,7 @@ export class ConfusionOnStatusEffectAbAttr extends PostAttackAbAttr { } export class PreSetStatusAbAttr extends AbAttr { + /** Return whether the ability attribute can be applied */ canApplyPreSetStatus( pokemon: Pokemon, passive: boolean, @@ -3228,7 +3229,7 @@ export class PreSetStatusAbAttr extends AbAttr { * Provides immunity to status effects to specified targets. */ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr { - private immuneEffects: StatusEffect[]; + protected immuneEffects: StatusEffect[]; /** * @param immuneEffects - The status effects to which the Pokémon is immune. @@ -3239,6 +3240,7 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr { this.immuneEffects = immuneEffects; } + /** Determine whether the */ override canApplyPreSetStatus(pokemon: Pokemon, passive: boolean, simulated: boolean, effect: StatusEffect, cancelled: Utils.BooleanHolder, args: any[]): boolean { return effect !== StatusEffect.FAINT && this.immuneEffects.length < 1 || this.immuneEffects.includes(effect); } @@ -3290,14 +3292,25 @@ export class UserFieldStatusEffectImmunityAbAttr extends PreSetStatusEffectImmun * */ export class ConditionalUserFieldStatusEffectImmunityAbAttr extends UserFieldStatusEffectImmunityAbAttr { + /** + * The condition for the field immunity to be applied. + * @param target The target of the status effect + * @param source The source of the status effect + */ protected condition: (target: Pokemon, source: Pokemon | null) => boolean; - override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { - if (cancelled.value || !this.condition(pokemon, args[1] as Pokemon)) { - return false; - } - - return super.apply(pokemon, passive, simulated, cancelled, args); + /** + * Evaluate the condition to determine if the {@linkcode ConditionalUserFieldStatusEffectImmunityAbAttr} can be applied. + * @param pokemon The pokemon with the ability + * @param passive unused + * @param simulated Whether the ability is being simulated + * @param effect The status effect being applied + * @param cancelled Holds whether the status effect was cancelled by a prior effect + * @param args `Args[0]` is the target of the status effect, `Args[1]` is the source. + * @returns + */ + override canApplyPreSetStatus(pokemon: Pokemon, passive: boolean, simulated: boolean, effect: StatusEffect, cancelled: Utils.BooleanHolder, args: [Pokemon, Pokemon | null, ...any]): boolean { + return (!cancelled.value && effect !== StatusEffect.FAINT && this.immuneEffects.length < 1 || this.immuneEffects.includes(effect)) && this.condition(pokemon, args[1]); } constructor(condition: (target: Pokemon, source: Pokemon | null) => boolean, ...immuneEffects: StatusEffect[]) { @@ -3307,6 +3320,55 @@ export class ConditionalUserFieldStatusEffectImmunityAbAttr extends UserFieldSta } } +/** + * Conditionally provides immunity to stat drop effects to the user's field. + * + * Used by {@linkcode Abilities.FLOWER_VEIL | Flower Veil}. + */ +export class ConditionalUserFieldProtectStatAbAttr extends PreStatStageChangeAbAttr { + /** {@linkcode BattleStat} to protect or `undefined` if **all** {@linkcode BattleStat} are protected */ + protected protectedStat?: BattleStat; + + /** If the method evaluates to true, the stat will be protected. */ + protected condition: (target: Pokemon) => boolean; + + constructor(condition: (target: Pokemon) => boolean, protectedStat?: BattleStat) { + super(); + this.condition = condition; + } + + /** + * Determine whether the {@linkcode ConditionalUserFieldProtectStatAbAttr} can be applied. + * @param pokemon The pokemon with the ability + * @param passive unused + * @param simulated Unused + * @param stat The stat being affected + * @param cancelled Holds whether the stat change was already prevented. + * @param args Args[0] is the target pokemon of the stat change. + * @returns + */ + override canApplyPreStatStageChange(pokemon: Pokemon, passive: boolean, simulated: boolean, stat: BattleStat, cancelled: Utils.BooleanHolder, args: [Pokemon, ...any]): boolean { + const target = args[0]; + if (!target) { + return false; + } + return !cancelled.value && (Utils.isNullOrUndefined(this.protectedStat) || stat === this.protectedStat) && this.condition(target); + } + + /** + * Apply the {@linkcode ConditionalUserFieldStatusEffectImmunityAbAttr} to an interaction + * @param _pokemon The pokemon the stat change is affecting (unused) + * @param _passive unused + * @param _simulated unused + * @param stat The stat being affected + * @param cancelled Will be set to true if the stat change is prevented + * @param _args unused + */ + override applyPreStatStageChange(_pokemon: Pokemon, _passive: boolean, _simulated: boolean, _stat: BattleStat, cancelled: Utils.BooleanHolder, _args: any[]): void { + cancelled.value = true; + } +} + export class PreApplyBattlerTagAbAttr extends AbAttr { canApplyPreApplyBattlerTag( @@ -3377,7 +3439,7 @@ export class UserFieldBattlerTagImmunityAbAttr extends PreApplyBattlerTagImmunit export class ConditionalUserFieldBattlerTagImmunityAbAttr extends UserFieldBattlerTagImmunityAbAttr { private condition: (target: Pokemon, source: Pokemon | null) => boolean; - override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { + override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]) { if (cancelled.value || !this.condition(pokemon, args[1] as Pokemon)) { return false; } @@ -5900,19 +5962,20 @@ export function applyPreLeaveFieldAbAttrs( ); } -export function applyPreStatStageChangeAbAttrs( - attrType: Constructor, +export function applyPreStatStageChangeAbAttrs ( + attrType: Constructor, pokemon: Pokemon | null, stat: BattleStat, cancelled: Utils.BooleanHolder, simulated = false, ...args: any[] ): void { - applyAbAttrsInternal( + applyAbAttrsInternal( attrType, pokemon, (attr, passive) => attr.applyPreStatStageChange(pokemon, passive, simulated, stat, cancelled, args), - (attr, passive) => attr.canApplyPreStatStageChange(pokemon, passive, simulated, stat, cancelled, args), args, + (attr, passive) => attr.canApplyPreStatStageChange(pokemon, passive, simulated, stat, cancelled, args), + args, simulated, ); } @@ -6715,17 +6778,19 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.FLOWER_VEIL, 6) .attr(ConditionalUserFieldStatusEffectImmunityAbAttr, (target: Pokemon, source: Pokemon | null) => { - return source ? target.getTypes().includes(Type.GRASS) && target.id !== source.id : false; + return source ? target.getTypes().includes(PokemonType.GRASS) && target.id !== source.id : false; }) .attr(ConditionalUserFieldBattlerTagImmunityAbAttr, (target: Pokemon, source: Pokemon | null) => { - return source ? target.getTypes().includes(Type.GRASS) && target.id !== source.id : false; + return target.getTypes().includes(PokemonType.GRASS); }, [ BattlerTagType.DROWSY ], - ) - .ignorable() - .unimplemented(), + .attr(ConditionalUserFieldProtectStatAbAttr, (target: Pokemon) => { + console.log(`target: ${target.name}`); + return target.getTypes().includes(PokemonType.GRASS); + }) + .ignorable(), new Ability(Abilities.CHEEK_POUCH, 6) .attr(HealFromBerryUseAbAttr, 1 / 3), new Ability(Abilities.PROTEAN, 6) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 8f1d6b09a73..203a00948e1 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -12,6 +12,7 @@ import { StatusEffect } from "#enums/status-effect"; import type { BattlerIndex } from "#app/battle"; import { BlockNonDirectDamageAbAttr, + ConditionalUserFieldProtectStatAbAttr, InfiltratorAbAttr, PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr, ProtectStatAbAttr, diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index b139faaeb88..c391c4010b8 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -5,6 +5,7 @@ import { BlockNonDirectDamageAbAttr, FlinchEffectAbAttr, ProtectStatAbAttr, + ConditionalUserFieldProtectStatAbAttr, ReverseDrainAbAttr, } from "#app/data/ability"; import { ChargeAnim, CommonAnim, CommonBattleAnim, MoveChargeAnim } from "#app/data/battle-anims"; @@ -3024,6 +3025,7 @@ export class MysteryEncounterPostSummonTag extends BattlerTag { if (lapseType === BattlerTagLapseType.CUSTOM) { const cancelled = new BooleanHolder(false); applyAbAttrs(ProtectStatAbAttr, pokemon, cancelled); + applyAbAttrs(ConditionalUserFieldProtectStatAbAttr, pokemon, cancelled, false, pokemon); if (!cancelled.value) { if (pokemon.mysteryEncounterBattleEffects) { pokemon.mysteryEncounterBattleEffects(pokemon); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 05ea2ef4889..571889ee14e 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4766,6 +4766,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { stubTag, cancelled, true, + this, ), ); diff --git a/src/phases/stat-stage-change-phase.ts b/src/phases/stat-stage-change-phase.ts index 71b50fa9dce..7ca5238c7f8 100644 --- a/src/phases/stat-stage-change-phase.ts +++ b/src/phases/stat-stage-change-phase.ts @@ -4,6 +4,7 @@ import { applyAbAttrs, applyPostStatStageChangeAbAttrs, applyPreStatStageChangeAbAttrs, + ConditionalUserFieldProtectStatAbAttr, PostStatStageChangeAbAttr, ProtectStatAbAttr, ReflectStatStageChangeAbAttr, @@ -22,6 +23,7 @@ import { PokemonPhase } from "./pokemon-phase"; import { Stat, type BattleStat, getStatKey, getStatStageChangeDescriptionKey } from "#enums/stat"; import { OctolockTag } from "#app/data/battler-tags"; import { ArenaTagType } from "#app/enums/arena-tag-type"; +import { pokemonFormChanges } from "#app/data/pokemon-forms"; export type StatStageChangeCallback = ( target: Pokemon | null, @@ -151,6 +153,25 @@ export class StatStageChangePhase extends PokemonPhase { if (!cancelled.value && !this.selfTarget && stages.value < 0) { applyPreStatStageChangeAbAttrs(ProtectStatAbAttr, pokemon, stat, cancelled, simulate); + applyPreStatStageChangeAbAttrs( + ConditionalUserFieldProtectStatAbAttr, + pokemon, + stat, + cancelled, + simulate, + pokemon, + ); + const ally = pokemon.getAlly(); + if (ally) { + applyPreStatStageChangeAbAttrs( + ConditionalUserFieldProtectStatAbAttr, + ally, + stat, + cancelled, + simulate, + pokemon, + ); + } /** Potential stat reflection due to Mirror Armor, does not apply to Octolock end of turn effect */ if ( diff --git a/test/abilities/flower_veil.test.ts b/test/abilities/flower_veil.test.ts index aa0b0e35e14..7f299b6fbb0 100644 --- a/test/abilities/flower_veil.test.ts +++ b/test/abilities/flower_veil.test.ts @@ -7,7 +7,10 @@ import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { allMoves } from "#app/data/moves/move"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { allAbilities } from "#app/data/ability"; describe("Abilities - Flower Veil", () => { let phaserGame: Phaser.Game; @@ -26,7 +29,7 @@ describe("Abilities - Flower Veil", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([ Moves.SPLASH ]) + .moveset([Moves.SPLASH]) .enemySpecies(Species.BULBASAUR) .ability(Abilities.FLOWER_VEIL) .battleType("single") @@ -37,36 +40,48 @@ describe("Abilities - Flower Veil", () => { }); it("should not prevent any source of self-inflicted status conditions", async () => { - game.override.enemyMoveset([ Moves.TACKLE, Moves.SPLASH ]) - .ability(Abilities.FLOWER_VEIL) - .moveset([ Moves.REST, Moves.SPLASH ]); - await game.classicMode.startBattle([ Species.BULBASAUR ]); + game.override + .enemyMoveset([Moves.TACKLE, Moves.SPLASH]) + .moveset([Moves.REST, Moves.SPLASH]) + .startingHeldItems([{ name: "FLAME_ORB" }]); + await game.classicMode.startBattle([Species.BULBASAUR]); const user = game.scene.getPlayerPokemon()!; game.move.select(Moves.REST); await game.forceEnemyMove(Moves.TACKLE); - await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); expect(user.status?.effect).toBe(StatusEffect.SLEEP); - game.scene.addModifier(modifierTypes.FLAME_ORB().newModifier(user)); - game.scene.updateModifiers(true); - // remove sleep status + // remove sleep status so we can get burn from the orb user.resetStatus(); game.move.select(Moves.SPLASH); await game.forceEnemyMove(Moves.SPLASH); await game.toNextTurn(); expect(user.status?.effect).toBe(StatusEffect.BURN); + }); - game.scene.addModifier(modifierTypes.TOXIC_ORB().newModifier(user)); - game.scene.updateModifiers(true); - user.resetStatus(); + it("should prevent drowsiness from yawn", async () => { + game.override.enemyMoveset([Moves.YAWN]).moveset([Moves.SPLASH]); + await game.classicMode.startBattle([Species.BULBASAUR]); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + const user = game.scene.getPlayerPokemon()!; + expect(user.getTag(BattlerTagType.DROWSY)).toBeOneOf([false, undefined, null]); + }); + it("should prevent status conditions from moves like Thunder Wave", async () => { + game.override.enemyMoveset([Moves.THUNDER_WAVE]).moveset([Moves.SPLASH]); + vi.spyOn(allMoves[Moves.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100); + await game.classicMode.startBattle([Species.BULBASAUR]); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.THUNDER_WAVE); + await game.toNextTurn(); + expect(game.scene.getPlayerPokemon()!.status).toBeUndefined(); }); it("should prevent the drops while retaining the boosts from spicy extract", async () => { - game.override.enemyMoveset([ Moves.SPICY_EXTRACT ]) - .moveset([ Moves.SPLASH ]); - await game.classicMode.startBattle([ Species.BULBASAUR ]); + game.override.enemyMoveset([Moves.SPICY_EXTRACT]).moveset([Moves.SPLASH]); + await game.classicMode.startBattle([Species.BULBASAUR]); const user = game.scene.getPlayerPokemon()!; game.move.select(Moves.SPLASH); await game.phaseInterceptor.to("BerryPhase"); @@ -75,12 +90,49 @@ describe("Abilities - Flower Veil", () => { }); it("should not prevent self-inflicted stat drops from moves like Close Combat", async () => { - game.override.moveset([ Moves.CLOSE_COMBAT ]); - await game.classicMode.startBattle([ Species.BULBASAUR ]); - const enemy = game.scene.getEnemyPokemon()!; + game.override.moveset([Moves.CLOSE_COMBAT]); + await game.classicMode.startBattle([Species.BULBASAUR]); + const userPokemon = game.scene.getPlayerPokemon()!; + console.log(userPokemon.name); game.move.select(Moves.CLOSE_COMBAT); await game.phaseInterceptor.to("BerryPhase"); - expect(enemy.getStatStage(Stat.ATK)).toBe(-1); - expect(enemy.getStatStage(Stat.DEF)).toBe(-1); + expect(userPokemon.getStatStage(Stat.DEF)).toBe(-1); + expect(userPokemon.getStatStage(Stat.SPDEF)).toBe(-1); + }); + + it("should not prevent status drops of pokemon that are not grass type", async () => { + game.override.enemyMoveset([Moves.GROWL]).moveset([Moves.SPLASH]); + await game.classicMode.startBattle([Species.SQUIRTLE]); + const user = game.scene.getPlayerPokemon()!; + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + expect(user.getStatStage(Stat.ATK)).toBe(-1); + }); + + it("should not prevent status drops of ally pokemon that are not grass type", async () => { + game.override.enemyMoveset([Moves.GROWL]).moveset([Moves.SPLASH]).battleType("double"); + await game.classicMode.startBattle([Species.MAGIKARP, Species.SQUIRTLE]); + const ally = game.scene.getPlayerField()[1]!; + + // Clear the ally ability to isolate what is being tested + vi.spyOn(ally, "getAbility").mockReturnValue(allAbilities[Abilities.BALL_FETCH]); + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + // Both enemies use growl. + expect(ally.getStatStage(Stat.ATK)).toBe(-2); + }); + + it("should prevent the status drops of ally grass type pokemon", async () => { + game.override.enemyMoveset([Moves.GROWL]).moveset([Moves.SPLASH]).battleType("double"); + await game.classicMode.startBattle([Species.SQUIRTLE, Species.BULBASAUR]); + const ally = game.scene.getPlayerField()[1]!; + + // Clear the ally ability to isolate the test + vi.spyOn(ally, "getAbility").mockReturnValue(allAbilities[Abilities.BALL_FETCH]); + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + expect(ally.getStatStage(Stat.ATK)).toBe(0); }); });