From e7a1bbf3ea3af5b83dbfb0d286ec621287d41c4e Mon Sep 17 00:00:00 2001 From: PieonFire Date: Tue, 5 Aug 2025 12:16:23 +0200 Subject: [PATCH] Update ability and move logic to use `getMoveCategory` instead of `category` --- .ls-lint.yml | 1 + src/data/abilities/ability.ts | 87 ++++++++----- src/data/moves/move.ts | 212 ++++++++++++++++---------------- src/field/pokemon.ts | 2 +- test/abilities/fur-coat.test.ts | 124 +++++++++++++++++++ 5 files changed, 292 insertions(+), 134 deletions(-) create mode 100644 test/abilities/fur-coat.test.ts diff --git a/.ls-lint.yml b/.ls-lint.yml index 22f08f72938..e98b2b876c3 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -26,3 +26,4 @@ ignore: - .git - public - dist + - .idea diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 2f57df4a551..1ef76af50de 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1982,7 +1982,12 @@ export class AllyMoveCategoryPowerBoostAbAttr extends FieldMovePowerBoostAbAttr * @param powerMultiplier - The multiplier to apply to the move's power. */ constructor(boostedCategories: MoveCategory[], powerMultiplier: number) { - super((_pokemon, _defender, move) => boostedCategories.includes(move.category), powerMultiplier); + super((_pokemon, _defender, move) => { + if (_pokemon === null) { + return false; + } + return boostedCategories.includes(_pokemon.getMoveCategory(_defender, move)); + }, powerMultiplier); } } @@ -2114,7 +2119,13 @@ export abstract class PostAttackAbAttr extends AbAttr { /** The default `attackCondition` requires that the selected move is a damaging move */ constructor( - attackCondition: PokemonAttackCondition = (_user, _target, move) => move.category !== MoveCategory.STATUS, + attackCondition: PokemonAttackCondition = (_user, _target, move) => { + if (!_user) { + return false; + } + + return _user.getMoveCategory(_target, move) !== MoveCategory.STATUS; + }, showAbility = true, ) { super(showAbility); @@ -6933,7 +6944,12 @@ export function initAbilities() { .attr(PostSummonAddBattlerTagAbAttr, BattlerTagType.TRUANT, 1, false), new Ability(AbilityId.HUSTLE, 3) .attr(StatMultiplierAbAttr, Stat.ATK, 1.5) - .attr(StatMultiplierAbAttr, Stat.ACC, 0.8, (_user, _target, move) => move.category === MoveCategory.PHYSICAL), + .attr(StatMultiplierAbAttr, Stat.ACC, 0.8, (_user, _target, move) => { + if (_user === null) { + return false + } + return _user.getMoveCategory(_target, move) === MoveCategory.PHYSICAL + }), new Ability(AbilityId.CUTE_CHARM, 3) .attr(PostDefendContactApplyTagChanceAbAttr, 30, BattlerTagType.INFATUATED), new Ability(AbilityId.PLUS, 3) @@ -7165,8 +7181,8 @@ export function initAbilities() { .attr(AlliedFieldDamageReductionAbAttr, 0.75) .ignorable(), new Ability(AbilityId.WEAK_ARMOR, 5) - .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, Stat.DEF, -1) - .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, Stat.SPD, 2), + .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) === MoveCategory.PHYSICAL, Stat.DEF, -1) + .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) === MoveCategory.PHYSICAL, Stat.SPD, 2), new Ability(AbilityId.HEAVY_METAL, 5) .attr(WeightMultiplierAbAttr, 2) .ignorable(), @@ -7177,9 +7193,15 @@ export function initAbilities() { .attr(ReceivedMoveDamageMultiplierAbAttr, (target, _user, _move) => target.isFullHp(), 0.5) .ignorable(), new Ability(AbilityId.TOXIC_BOOST, 5) - .attr(MovePowerBoostAbAttr, (user, _target, move) => move.category === MoveCategory.PHYSICAL && (user?.status?.effect === StatusEffect.POISON || user?.status?.effect === StatusEffect.TOXIC), 1.5), + .attr(MovePowerBoostAbAttr, (user, _target, move) => { + if (user===null) { return false; } + return user.getMoveCategory(_target, move) === MoveCategory.PHYSICAL && (user?.status?.effect === StatusEffect.POISON || user?.status?.effect === StatusEffect.TOXIC) + }, 1.5), new Ability(AbilityId.FLARE_BOOST, 5) - .attr(MovePowerBoostAbAttr, (user, _target, move) => move.category === MoveCategory.SPECIAL && user?.status?.effect === StatusEffect.BURN, 1.5), + .attr(MovePowerBoostAbAttr, (user, _target, move) => { + if (user===null) { return false; } + return user.getMoveCategory(_target, move) === MoveCategory.SPECIAL && user?.status?.effect === StatusEffect.BURN + }, 1.5), new Ability(AbilityId.HARVEST, 5) .attr( PostTurnRestoreBerryAbAttr, @@ -7240,11 +7262,11 @@ export function initAbilities() { new Ability(AbilityId.MOXIE, 5) .attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1), new Ability(AbilityId.JUSTIFIED, 5) - .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.DARK && move.category !== MoveCategory.STATUS, Stat.ATK, 1), + .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.DARK && user.getMoveCategory(_target, move) !== MoveCategory.STATUS, Stat.ATK, 1), new Ability(AbilityId.RATTLED, 5) .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => { const moveType = user.getMoveType(move); - return move.category !== MoveCategory.STATUS + return user.getMoveCategory(_target, move) !== MoveCategory.STATUS && (moveType === PokemonType.DARK || moveType === PokemonType.BUG || moveType === PokemonType.GHOST); }, Stat.SPD, 1) .attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1), @@ -7258,7 +7280,7 @@ export function initAbilities() { .attr(TypeImmunityStatStageChangeAbAttr, PokemonType.GRASS, Stat.ATK, 1) .ignorable(), new Ability(AbilityId.PRANKSTER, 5) - .attr(ChangeMovePriorityAbAttr, (_pokemon, move: Move) => move.category === MoveCategory.STATUS, 1), + .attr(ChangeMovePriorityAbAttr, (_pokemon, move: Move) => _pokemon.getMoveCategory(_pokemon, move) === MoveCategory.STATUS, 1), new Ability(AbilityId.SAND_FORCE, 5) .attr(MoveTypePowerBoostAbAttr, PokemonType.ROCK, 1.3) .attr(MoveTypePowerBoostAbAttr, PokemonType.GROUND, 1.3) @@ -7311,8 +7333,15 @@ export function initAbilities() { // TODO: needs testing on interaction with weather blockage .edgeCase(), new Ability(AbilityId.FUR_COAT, 6) - .attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, 0.5) + .attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => { + const isPhysicalMove = _user.getMoveCategory(_target, move) === MoveCategory.PHYSICAL + // This is for a theoretical move where Fur Coat shouldn't apply, in case it's implemented later + const moveIsPhysicalAndActuallyHitsDef = isPhysicalMove && !move.hasAttr("VariableDefAttr") + const moveIsSpecialButHitsPhysicalInstead = move.hasAttr("DefDefAttr") + return moveIsPhysicalAndActuallyHitsDef || moveIsSpecialButHitsPhysicalInstead + }, 0.5) .ignorable(), + new Ability(AbilityId.MAGICIAN, 6) .attr(PostAttackStealHeldItemAbAttr), new Ability(AbilityId.BULLETPROOF, 6) @@ -7382,7 +7411,7 @@ export function initAbilities() { .attr(PreLeaveFieldClearWeatherAbAttr) .bypassFaint(), new Ability(AbilityId.STAMINA, 7) - .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), + .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS, Stat.DEF, 1), new Ability(AbilityId.WIMP_OUT, 7) .attr(PostDamageForceSwitchAbAttr) .edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode @@ -7390,7 +7419,7 @@ export function initAbilities() { .attr(PostDamageForceSwitchAbAttr) .edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode new Ability(AbilityId.WATER_COMPACTION, 7) - .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), + .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && user.getMoveCategory(_target, move) !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(AbilityId.MERCILESS, 7) .attr(ConditionalCritAbAttr, (_user, target, _move) => target?.status?.effect === StatusEffect.TOXIC || target?.status?.effect === StatusEffect.POISON), new Ability(AbilityId.SHIELDS_DOWN, 7, -1) @@ -7419,7 +7448,7 @@ export function initAbilities() { new Ability(AbilityId.STEELWORKER, 7) .attr(MoveTypePowerBoostAbAttr, PokemonType.STEEL), new Ability(AbilityId.BERSERK, 7) - .attr(PostDefendHpGatedStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, 0.5, [ Stat.SPATK ], 1) + .attr(PostDefendHpGatedStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS, 0.5, [ Stat.SPATK ], 1) .condition(getSheerForceHitDisableAbCondition()), new Ability(AbilityId.SLUSH_RUSH, 7) .attr(StatMultiplierAbAttr, Stat.SPD, 2) @@ -7573,7 +7602,7 @@ export function initAbilities() { .attr(FetchBallAbAttr) .condition(getOncePerBattleCondition(AbilityId.BALL_FETCH)), new Ability(AbilityId.COTTON_DOWN, 8) - .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.SPD, -1, false, true) + .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS, Stat.SPD, -1, false, true) .bypassFaint(), new Ability(AbilityId.PROPELLER_TAIL, 8) .attr(BlockRedirectAbAttr), @@ -7598,7 +7627,7 @@ export function initAbilities() { new Ability(AbilityId.STEAM_ENGINE, 8) .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => { const moveType = user.getMoveType(move); - return move.category !== MoveCategory.STATUS + return user.getMoveCategory(_target, move) !== MoveCategory.STATUS && (moveType === PokemonType.FIRE || moveType === PokemonType.WATER); }, Stat.SPD, 6), new Ability(AbilityId.PUNK_ROCK, 8) @@ -7606,10 +7635,10 @@ export function initAbilities() { .attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => move.hasFlag(MoveFlags.SOUND_BASED), 0.5) .ignorable(), new Ability(AbilityId.SAND_SPIT, 8) - .attr(PostDefendWeatherChangeAbAttr, WeatherType.SANDSTORM, (_target, _user, move) => move.category !== MoveCategory.STATUS) + .attr(PostDefendWeatherChangeAbAttr, WeatherType.SANDSTORM, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS) .bypassFaint(), new Ability(AbilityId.ICE_SCALES, 8) - .attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => move.category === MoveCategory.SPECIAL, 0.5) + .attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) === MoveCategory.SPECIAL, 0.5) .ignorable(), new Ability(AbilityId.RIPEN, 8) .attr(DoubleBerryEffectAbAttr), @@ -7623,7 +7652,7 @@ export function initAbilities() { // When weather changes to HAIL or SNOW while pokemon is fielded, add BattlerTagType.ICE_FACE .attr(PostWeatherChangeAddBattlerTagAttr, BattlerTagType.ICE_FACE, 0, WeatherType.HAIL, WeatherType.SNOW) .attr(FormBlockDamageAbAttr, - (target, _user, move) => move.category === MoveCategory.PHYSICAL && !!target.getTag(BattlerTagType.ICE_FACE), 0, BattlerTagType.ICE_FACE, + (target, _user, move) => _user.getMoveCategory(target, move) === MoveCategory.PHYSICAL && !!target.getTag(BattlerTagType.ICE_FACE), 0, BattlerTagType.ICE_FACE, (pokemon, abilityName) => i18next.t("abilityTriggers:iceFaceAvoidedDamage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName: abilityName })) .attr(PostBattleInitFormChangeAbAttr, () => 0) .uncopiable() @@ -7703,13 +7732,13 @@ export function initAbilities() { .attr(PostDefendTerrainChangeAbAttr, TerrainType.GRASSY) .bypassFaint(), new Ability(AbilityId.THERMAL_EXCHANGE, 9) - .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.FIRE && move.category !== MoveCategory.STATUS, Stat.ATK, 1) + .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.FIRE && user.getMoveCategory(_target, move) !== MoveCategory.STATUS, Stat.ATK, 1) .attr(StatusEffectImmunityAbAttr, StatusEffect.BURN) .attr(PostSummonHealStatusAbAttr, StatusEffect.BURN) .ignorable(), new Ability(AbilityId.ANGER_SHELL, 9) - .attr(PostDefendHpGatedStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, 0.5, [ Stat.ATK, Stat.SPATK, Stat.SPD ], 1) - .attr(PostDefendHpGatedStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, 0.5, [ Stat.DEF, Stat.SPDEF ], -1) + .attr(PostDefendHpGatedStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS, 0.5, [ Stat.ATK, Stat.SPATK, Stat.SPD ], 1) + .attr(PostDefendHpGatedStatStageChangeAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS, 0.5, [ Stat.DEF, Stat.SPDEF ], -1) .condition(getSheerForceHitDisableAbCondition()), new Ability(AbilityId.PURIFYING_SALT, 9) .attr(StatusEffectImmunityAbAttr) @@ -7719,7 +7748,7 @@ export function initAbilities() { .attr(TypeImmunityStatStageChangeAbAttr, PokemonType.FIRE, Stat.DEF, 2) .ignorable(), new Ability(AbilityId.WIND_RIDER, 9) - .attr(MoveImmunityStatStageChangeAbAttr, (pokemon, attacker, move) => pokemon !== attacker && move.hasFlag(MoveFlags.WIND_MOVE) && move.category !== MoveCategory.STATUS, Stat.ATK, 1) + .attr(MoveImmunityStatStageChangeAbAttr, (pokemon, attacker, move) => pokemon !== attacker && move.hasFlag(MoveFlags.WIND_MOVE) && pokemon.getMoveCategory(attacker, move) !== MoveCategory.STATUS, Stat.ATK, 1) .attr(PostSummonStatStageChangeOnArenaAbAttr, ArenaTagType.TAILWIND) .ignorable(), new Ability(AbilityId.GUARD_DOG, 9) @@ -7746,7 +7775,7 @@ export function initAbilities() { .unreplaceable() .edgeCase(), // Encore, Frenzy, and other non-`TURN_END` tags don't lapse correctly on the commanding Pokemon. new Ability(AbilityId.ELECTROMORPHOSIS, 9) - .attr(PostDefendApplyBattlerTagAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, BattlerTagType.CHARGED), + .attr(PostDefendApplyBattlerTagAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) !== MoveCategory.STATUS, BattlerTagType.CHARGED), new Ability(AbilityId.PROTOSYNTHESIS, 9, -2) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN), PostSummonAddBattlerTagAbAttr, BattlerTagType.PROTOSYNTHESIS, 0, true) .attr(PostWeatherChangeAddBattlerTagAttr, BattlerTagType.PROTOSYNTHESIS, 0, WeatherType.SUNNY, WeatherType.HARSH_SUN) @@ -7760,7 +7789,7 @@ export function initAbilities() { new Ability(AbilityId.GOOD_AS_GOLD, 9) .attr(MoveImmunityAbAttr, (pokemon, attacker, move) => pokemon !== attacker - && move.category === MoveCategory.STATUS + && pokemon.getMoveCategory(attacker, move) === MoveCategory.STATUS && ![ MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES, MoveTarget.USER_SIDE ].includes(move.moveTarget) ) .edgeCase() // Heal Bell should not cure the status of a Pokemon with Good As Gold @@ -7800,7 +7829,7 @@ export function initAbilities() { new Ability(AbilityId.COSTAR, 9, -2) .attr(PostSummonCopyAllyStatsAbAttr), new Ability(AbilityId.TOXIC_DEBRIS, 9) - .attr(PostDefendApplyArenaTrapTagAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, ArenaTagType.TOXIC_SPIKES) + .attr(PostDefendApplyArenaTrapTagAbAttr, (_target, _user, move) => _user.getMoveCategory(_target, move) === MoveCategory.PHYSICAL, ArenaTagType.TOXIC_SPIKES) .bypassFaint(), new Ability(AbilityId.ARMOR_TAIL, 9) .attr(FieldPriorityMoveImmunityAbAttr) @@ -7809,9 +7838,9 @@ export function initAbilities() { .attr(TypeImmunityHealAbAttr, PokemonType.GROUND) .ignorable(), new Ability(AbilityId.MYCELIUM_MIGHT, 9) - .attr(ChangeMovePriorityAbAttr, (_pokemon, move) => move.category === MoveCategory.STATUS, -0.2) - .attr(PreventBypassSpeedChanceAbAttr, (_pokemon, move) => move.category === MoveCategory.STATUS) - .attr(MoveAbilityBypassAbAttr, (_pokemon, move: Move) => move.category === MoveCategory.STATUS), + .attr(ChangeMovePriorityAbAttr, (_pokemon, move) => _pokemon.getMoveCategory(_pokemon, move) === MoveCategory.STATUS, -0.2) + .attr(PreventBypassSpeedChanceAbAttr, (_pokemon, move) => _pokemon.getMoveCategory(_pokemon, move) === MoveCategory.STATUS) + .attr(MoveAbilityBypassAbAttr, (_pokemon, move: Move) => _pokemon.getMoveCategory(_pokemon, move) === MoveCategory.STATUS), new Ability(AbilityId.MINDS_EYE, 9) .attr(IgnoreTypeImmunityAbAttr, PokemonType.GHOST, [ PokemonType.NORMAL, PokemonType.FIGHTING ]) .attr(ProtectStatAbAttr, Stat.ACC) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0dfbc78d7ae..ea9adc90b2e 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1,97 +1,101 @@ -import { AbAttrParamsWithCancel, PreAttackModifyPowerAbAttrParams } from "#abilities/ability"; +import {AbAttrParamsWithCancel, PreAttackModifyPowerAbAttrParams} from "#abilities/ability"; +import {applyAbAttrs} from "#abilities/apply-ab-attrs"; +import {loggedInUser} from "#app/account"; +import type {GameMode} from "#app/game-mode"; +import {globalScene} from "#app/global-scene"; +import {getPokemonNameWithAffix} from "#app/messages"; +import type {ArenaTrapTag} from "#data/arena-tag"; +import {WeakenMoveTypeTag} from "#data/arena-tag"; +import {MoveChargeAnim} from "#data/battle-anims"; import { - applyAbAttrs -} from "#abilities/apply-ab-attrs"; -import { loggedInUser } from "#app/account"; -import type { GameMode } from "#app/game-mode"; -import { globalScene } from "#app/global-scene"; -import { getPokemonNameWithAffix } from "#app/messages"; -import type { ArenaTrapTag } from "#data/arena-tag"; -import { WeakenMoveTypeTag } from "#data/arena-tag"; -import { MoveChargeAnim } from "#data/battle-anims"; -import { - CommandedTag, - EncoreTag, - GulpMissileTag, - HelpingHandTag, - SemiInvulnerableTag, - ShellTrapTag, - StockpilingTag, - SubstituteTag, - TrappedTag, - TypeBoostTag, + CommandedTag, + EncoreTag, + GulpMissileTag, + HelpingHandTag, + SemiInvulnerableTag, + ShellTrapTag, + StockpilingTag, + SubstituteTag, + TrappedTag, + TypeBoostTag, } from "#data/battler-tags"; -import { getBerryEffectFunc } from "#data/berry"; -import { applyChallenges } from "#data/challenge"; -import { allAbilities, allMoves } from "#data/data-lists"; -import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; -import { DelayedAttackTag } from "#data/positional-tags/positional-tag"; +import {getBerryEffectFunc} from "#data/berry"; +import {applyChallenges} from "#data/challenge"; +import {allAbilities, allMoves} from "#data/data-lists"; +import {SpeciesFormChangeRevertWeatherFormTrigger} from "#data/form-change-triggers"; +import {DelayedAttackTag} from "#data/positional-tags/positional-tag"; +import {getNonVolatileStatusEffects, getStatusEffectHealText, isNonVolatileStatusEffect,} from "#data/status-effect"; +import {TerrainType} from "#data/terrain"; +import {getTypeDamageMultiplier} from "#data/type"; +import {AbilityId} from "#enums/ability-id"; +import {ArenaTagSide} from "#enums/arena-tag-side"; +import {ArenaTagType} from "#enums/arena-tag-type"; +import {BattleType} from "#enums/battle-type"; +import type {BattlerIndex} from "#enums/battler-index"; +import {BattlerTagType} from "#enums/battler-tag-type"; +import {BiomeId} from "#enums/biome-id"; +import {ChallengeType} from "#enums/challenge-type"; +import {Command} from "#enums/command"; +import {FieldPosition} from "#enums/field-position"; +import {HitResult} from "#enums/hit-result"; +import {ModifierPoolType} from "#enums/modifier-pool-type"; +import {ChargeAnim} from "#enums/move-anims-common"; +import {MoveId} from "#enums/move-id"; +import {MoveResult} from "#enums/move-result"; +import {isVirtual, MoveUseMode} from "#enums/move-use-mode"; +import {MoveCategory} from "#enums/move-category"; +import {MoveEffectTrigger} from "#enums/move-effect-trigger"; +import {MoveFlags} from "#enums/move-flags"; +import {MoveTarget} from "#enums/move-target"; +import {MultiHitType} from "#enums/multi-hit-type"; +import {PokemonType} from "#enums/pokemon-type"; +import {PositionalTagType} from "#enums/positional-tag-type"; +import {SpeciesId} from "#enums/species-id"; +import {BATTLE_STATS, type BattleStat, type EffectiveStat, getStatKey, Stat,} from "#enums/stat"; +import {StatusEffect} from "#enums/status-effect"; +import {SwitchType} from "#enums/switch-type"; +import {WeatherType} from "#enums/weather-type"; +import {MoveUsedEvent} from "#events/battle-scene"; +import type {EnemyPokemon, Pokemon} from "#field/pokemon"; import { - getNonVolatileStatusEffects, - getStatusEffectHealText, - isNonVolatileStatusEffect, -} from "#data/status-effect"; -import { TerrainType } from "#data/terrain"; -import { getTypeDamageMultiplier } from "#data/type"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattleType } from "#enums/battle-type"; -import type { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { BiomeId } from "#enums/biome-id"; -import { ChallengeType } from "#enums/challenge-type"; -import { Command } from "#enums/command"; -import { FieldPosition } from "#enums/field-position"; -import { HitResult } from "#enums/hit-result"; -import { ModifierPoolType } from "#enums/modifier-pool-type"; -import { ChargeAnim } from "#enums/move-anims-common"; -import { MoveId } from "#enums/move-id"; -import { MoveResult } from "#enums/move-result"; -import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; -import { MoveCategory } from "#enums/move-category"; -import { MoveEffectTrigger } from "#enums/move-effect-trigger"; -import { MoveFlags } from "#enums/move-flags"; -import { MoveTarget } from "#enums/move-target"; -import { MultiHitType } from "#enums/multi-hit-type"; -import { PokemonType } from "#enums/pokemon-type"; -import { PositionalTagType } from "#enums/positional-tag-type"; -import { SpeciesId } from "#enums/species-id"; -import { - BATTLE_STATS, - type BattleStat, - type EffectiveStat, - getStatKey, - Stat, -} from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import { SwitchType } from "#enums/switch-type"; -import { WeatherType } from "#enums/weather-type"; -import { MoveUsedEvent } from "#events/battle-scene"; -import type { EnemyPokemon, Pokemon } from "#field/pokemon"; -import { - AttackTypeBoosterModifier, - BerryModifier, - PokemonHeldItemModifier, - PokemonMoveAccuracyBoosterModifier, - PokemonMultiHitModifier, - PreserveBerryModifier, + AttackTypeBoosterModifier, + BerryModifier, + PokemonHeldItemModifier, + PokemonMoveAccuracyBoosterModifier, + PokemonMultiHitModifier, + PreserveBerryModifier, } from "#modifiers/modifier"; -import { applyMoveAttrs } from "#moves/apply-attrs"; -import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; -import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; -import { PokemonMove } from "#moves/pokemon-move"; -import { MoveEndPhase } from "#phases/move-end-phase"; -import { MovePhase } from "#phases/move-phase"; -import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; -import { SwitchSummonPhase } from "#phases/switch-summon-phase"; -import type { AttackMoveResult } from "#types/attack-move-result"; -import type { Localizable } from "#types/locales"; -import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types"; -import type { TurnMove } from "#types/turn-move"; -import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; -import { getEnumValues } from "#utils/enums"; -import { toTitleCase } from "#utils/strings"; +import {applyMoveAttrs} from "#moves/apply-attrs"; +import { + invalidAssistMoves, + invalidCopycatMoves, + invalidMetronomeMoves, + invalidMirrorMoveMoves, + invalidSketchMoves, + invalidSleepTalkMoves +} from "#moves/invalid-moves"; +import {frenzyMissFunc, getMoveTargets} from "#moves/move-utils"; +import {PokemonMove} from "#moves/pokemon-move"; +import {MoveEndPhase} from "#phases/move-end-phase"; +import {MovePhase} from "#phases/move-phase"; +import {PokemonHealPhase} from "#phases/pokemon-heal-phase"; +import {SwitchSummonPhase} from "#phases/switch-summon-phase"; +import type {AttackMoveResult} from "#types/attack-move-result"; +import type {Localizable} from "#types/locales"; +import type {ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString} from "#types/move-types"; +import type {TurnMove} from "#types/turn-move"; +import { + BooleanHolder, + type Constructor, + isNullOrUndefined, + NumberHolder, + randSeedFloat, + randSeedInt, + randSeedItem, + toDmgValue +} from "#utils/common"; +import {getEnumValues} from "#utils/enums"; +import {toTitleCase} from "#utils/strings"; import i18next from "i18next"; /** @@ -1626,7 +1630,7 @@ export class MatchHpAttr extends FixedDamageAttr { }*/ } -type MoveFilter = (move: Move) => boolean; +type MoveFilter = (user: Pokemon, target: Pokemon, move: Move) => boolean; export class CounterDamageAttr extends FixedDamageAttr { private moveFilter: MoveFilter; @@ -1640,14 +1644,14 @@ export class CounterDamageAttr extends FixedDamageAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const damage = user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).reduce((total: number, ar: AttackMoveResult) => total + ar.damage, 0); + const damage = user.turnData.attacksReceived.filter(ar => this.moveFilter(user, target, allMoves[ar.move])).reduce((total: number, ar: AttackMoveResult) => total + ar.damage, 0); (args[0] as NumberHolder).value = toDmgValue(damage * this.multiplier); return true; } getCondition(): MoveConditionFunc { - return (user, target, move) => !!user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).length; + return (user, target, move) => !!user.turnData.attacksReceived.filter(ar => this.moveFilter(user, target, allMoves[ar.move])).length; } } @@ -2534,10 +2538,10 @@ export class StatusEffectAttr extends MoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const statusCheck = moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance; - const quiet = move.category !== MoveCategory.STATUS; + const quiet = user.getMoveCategory(target, move) !== MoveCategory.STATUS; if (statusCheck) { const pokemon = this.selfTarget ? user : target; - if (user !== target && move.category === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, false, user, true)) { + if (user !== target && user.getMoveCategory(target, move) === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, false, user, true)) { return false; } if (((!pokemon.status || this.overrideStatus) || (pokemon.status.effect === this.effect && moveChance < 0)) @@ -5882,7 +5886,7 @@ export class ConfuseAttr extends AddBattlerTagAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (!this.selfTarget && target.isSafeguarded(user)) { - if (move.category === MoveCategory.STATUS) { + if (user.getMoveCategory(target, move) === MoveCategory.STATUS) { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:safeguard", { targetName: getPokemonNameWithAffix(target) })); } return false; @@ -5916,8 +5920,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; @@ -6498,7 +6502,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } getCondition(): MoveConditionFunc { - return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); + return (user, target, move) => (user.getMoveCategory(target, move) !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); } getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined { @@ -6555,7 +6559,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return !this.isBatonPass() && globalScene.currentBattle.waveIndex % 10 !== 0 // Don't allow wild mons to flee with U-turn et al. - && !(this.selfSwitch && MoveCategory.STATUS !== move.category); + && !(this.selfSwitch && MoveCategory.STATUS !== user.getMoveCategory(target, move)); } const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); @@ -8685,7 +8689,7 @@ export function initMoves() { new AttackMove(MoveId.LOW_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(WeightPowerAttr), new AttackMove(MoveId.COUNTER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, -5, 1) - .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.PHYSICAL, 2) + .attr(CounterDamageAttr, (user: Pokemon, target: Pokemon, move: Move) => user.getMoveCategory(target, move) === MoveCategory.PHYSICAL, 2) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.SEISMIC_TOSS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(LevelDamageAttr), @@ -9250,7 +9254,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.DEF ], -1) .bitingMove(), new AttackMove(MoveId.MIRROR_COAT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 20, -1, -5, 2) - .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2) + .attr(CounterDamageAttr, (user: Pokemon, target: Pokemon, move: Move) => user.getMoveCategory(target, move) === MoveCategory.SPECIAL, 2) .target(MoveTarget.ATTACKER), new StatusMove(MoveId.PSYCH_UP, PokemonType.NORMAL, -1, 10, -1, 0, 2) .ignoresSubstitute() @@ -9661,7 +9665,7 @@ export function initMoves() { .attr(AcupressureStatStageChangeAttr) .target(MoveTarget.USER_OR_NEAR_ALLY), new AttackMove(MoveId.METAL_BURST, PokemonType.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 4) - .attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5) + .attr(CounterDamageAttr, (user: Pokemon, target: Pokemon, move: Move) => (user.getMoveCategory(target, move) === MoveCategory.PHYSICAL || user.getMoveCategory(target, move) === MoveCategory.SPECIAL), 1.5) .redirectCounter() .makesContact(false) .target(MoveTarget.ATTACKER), @@ -11441,7 +11445,7 @@ export function initMoves() { return !turnMove.length || turnMove[0].move !== move.id || turnMove[0].result !== MoveResult.SUCCESS; }), // TODO Add Instruct/Encore interaction new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9) - .attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5) + .attr(CounterDamageAttr, (user: Pokemon, target: Pokemon, move: Move) => (user.getMoveCategory(target, move) === MoveCategory.PHYSICAL || user.getMoveCategory(target, move) === MoveCategory.SPECIAL), 1.5) .redirectCounter() .target(MoveTarget.ATTACKER), new AttackMove(MoveId.AQUA_CUTTER, PokemonType.WATER, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 9) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d4f332d887c..4813de1e52d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1403,7 +1403,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param move - The {@linkcode Move} being used * @returns The given move's final category */ - getMoveCategory(target: Pokemon, move: Move): MoveCategory { + getMoveCategory(target: Pokemon | null, move: Move): MoveCategory { const moveCategory = new NumberHolder(move.category); applyMoveAttrs("VariableMoveCategoryAttr", this, target, move, moveCategory); return moveCategory.value; diff --git a/test/abilities/fur-coat.test.ts b/test/abilities/fur-coat.test.ts new file mode 100644 index 00000000000..d881450f922 --- /dev/null +++ b/test/abilities/fur-coat.test.ts @@ -0,0 +1,124 @@ +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Ability - Fur Coat", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .ability(AbilityId.BALL_FETCH) + .startingLevel(50) + .enemySpecies(SpeciesId.CHANSEY) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyPassiveAbility(AbilityId.NONE) + .enemyMoveset([MoveId.SPLASH]) + .enemyLevel(50) + .criticalHits(false); + }); + + function damageAfterShouldBeAboutHalfOfDamageBefore(damageAfter: number, damageBefore: number) { + const halfDamage = damageBefore * 0.5; + const difference = Math.abs(damageAfter - halfDamage); + // Can't use `toBeCloseTo` because they're exactly 0.5 apart, and we still get rounding errors + expect(difference).toBeLessThan(0.6); + } + + it("should reduce damage from a physical move after gaining Fur Coat", async () => { + await game.classicMode.startBattle([SpeciesId.PIKACHU]); + const enemy = game.field.getEnemyPokemon(); + // Use Tackle before Fur Coat + game.move.use(MoveId.TACKLE); + await game.toEndOfTurn(); + const damageBefore = enemy.getMaxHp() - enemy.hp; + // Give Fur Coat + game.field.mockAbility(enemy, AbilityId.FUR_COAT); + enemy.hp = enemy.getMaxHp(); + // Use Tackle after Fur Coat + game.move.use(MoveId.TACKLE); + await game.toEndOfTurn(); + const damageAfter = enemy.getMaxHp() - enemy.hp; + damageAfterShouldBeAboutHalfOfDamageBefore(damageAfter, damageBefore); + }); + + it("should not reduce damage from a special move", async () => { + await game.classicMode.startBattle([SpeciesId.PIKACHU]); + const enemy = game.field.getEnemyPokemon(); + // Use Scald before Fur Coat + game.move.use(MoveId.SCALD); + await game.toEndOfTurn(); + const damageBefore = enemy.getMaxHp() - enemy.hp; + // Give Fur Coat + game.field.mockAbility(enemy, AbilityId.FUR_COAT); + enemy.hp = enemy.getMaxHp(); + // Use Scald after Fur Coat + game.move.use(MoveId.SCALD); + await game.toEndOfTurn(); + const damageAfter = enemy.getMaxHp() - enemy.hp; + expect(damageAfter).toBe(damageBefore); + }); + + it.each([ + { moveName: "Psyshock", moveId: MoveId.PSYSHOCK }, + { + moveName: "Psystrike", + moveId: MoveId.PSYSTRIKE, + }, + { + moveName: "Secret Sword", + moveId: MoveId.SECRET_SWORD, + }, + ])("should reduce damage from $moveName after gaining Fur Coat", async ({ moveId }) => { + await game.classicMode.startBattle([SpeciesId.PIKACHU]); + const enemy = game.field.getEnemyPokemon(); + + game.move.use(moveId); + await game.toEndOfTurn(); + + const attackDamageBefore = enemy.getMaxHp() - enemy.hp; + + game.field.mockAbility(enemy, AbilityId.FUR_COAT); + enemy.hp = enemy.getMaxHp(); + + game.move.use(moveId); + await game.toEndOfTurn(); + + const attackDamageAfter = enemy.getMaxHp() - enemy.hp; + damageAfterShouldBeAboutHalfOfDamageBefore(attackDamageAfter, attackDamageBefore); + }); + + it("should reduce damage from Shell Side Arm", async () => { + await game.classicMode.startBattle([SpeciesId.HERACROSS]); + game.override.enemyAbility(AbilityId.IMMUNITY); + const enemy = game.field.getEnemyPokemon(); + + // Use Shell Side Arm before Fur Coat + game.move.use(MoveId.SHELL_SIDE_ARM); + await game.toEndOfTurn(); + const damageBefore = enemy.getMaxHp() - enemy.hp; + // Give Fur Coat + game.field.mockAbility(enemy, AbilityId.FUR_COAT); + enemy.hp = enemy.getMaxHp(); + // Use Shell Side Arm after Fur Coat + game.move.use(MoveId.SHELL_SIDE_ARM); + await game.toEndOfTurn(); + const damageAfter = enemy.getMaxHp() - enemy.hp; + damageAfterShouldBeAboutHalfOfDamageBefore(damageAfter, damageBefore); + }); +});