From 7755f798fd0f88f5fffc2451e558882ecf27cd93 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Sun, 1 Sep 2024 18:21:11 -0700 Subject: [PATCH] [Ability] Implement Tera Shell (#3856) * Implement Tera Shell * Apply suggestions from code review Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/ability.ts Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> * Add comments and fixed damage condition to `applyPreDefend` * Fix speed tie breaking things in tera shell test * Change deprecated `startBattle` calls --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> --- src/data/ability.ts | 47 ++++++++++- src/field/pokemon.ts | 7 +- src/locales/en/ability-trigger.json | 1 + src/test/abilities/tera_shell.test.ts | 111 ++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/test/abilities/tera_shell.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index 3d5b32f4ce8..e40a88a04b2 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -8,7 +8,7 @@ import { Weather, WeatherType } from "./weather"; import { BattlerTag, GroundedTag, GulpMissileTag, SemiInvulnerableTag } from "./battler-tags"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { Gender } from "./gender"; -import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr } from "./move"; +import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { Stat, getStatName } from "./pokemon-stat"; import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; @@ -475,6 +475,47 @@ export class NonSuperEffectiveImmunityAbAttr extends TypeImmunityAbAttr { } } +/** + * Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Tera_Shell_(Ability) | Tera Shell} + * When the source is at full HP, incoming attacks will have a maximum 0.5x type effectiveness multiplier. + * @extends PreDefendAbAttr + */ +export class FullHpResistTypeAbAttr extends PreDefendAbAttr { + /** + * Reduces a type multiplier to 0.5 if the source is at full HP. + * @param pokemon {@linkcode Pokemon} the Pokemon with this ability + * @param passive n/a + * @param simulated n/a (this doesn't change game state) + * @param attacker n/a + * @param move {@linkcode Move} the move being used on the source + * @param cancelled n/a + * @param args `[0]` a container for the move's current type effectiveness multiplier + * @returns `true` if the move's effectiveness is reduced; `false` otherwise + */ + applyPreDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move | null, cancelled: Utils.BooleanHolder | null, args: any[]): boolean | Promise { + const typeMultiplier = args[0]; + if (!(typeMultiplier && typeMultiplier instanceof Utils.NumberHolder)) { + return false; + } + + if (move && move.hasAttr(FixedDamageAttr)) { + return false; + } + + if (pokemon.isFullHp() && typeMultiplier.value > 0.5) { + typeMultiplier.value = 0.5; + return true; + } + return false; + } + + getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string { + return i18next.t("abilityTriggers:fullHpResistType", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) + }); + } +} + export class PostDefendAbAttr extends AbAttr { applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean | Promise { return false; @@ -5761,10 +5802,10 @@ export function initAbilities() { .attr(NoTransformAbilityAbAttr) .attr(NoFusionAbilityAbAttr), new Ability(Abilities.TERA_SHELL, 9) + .attr(FullHpResistTypeAbAttr) .attr(UncopiableAbilityAbAttr) .attr(UnswappableAbilityAbAttr) - .ignorable() - .unimplemented(), + .ignorable(), new Ability(Abilities.TERAFORM_ZERO, 9) .attr(UncopiableAbilityAbAttr) .attr(UnswappableAbilityAbAttr) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 9fcbdd8dc6c..457ff874095 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -22,7 +22,7 @@ import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoo import { WeatherType } from "../data/weather"; import { TempBattleStat } from "../data/temp-battle-stat"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag"; -import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AddSecondStrikeAbAttr, IgnoreOpponentEvasionAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr } from "../data/ability"; +import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AddSecondStrikeAbAttr, IgnoreOpponentEvasionAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr } from "../data/ability"; import PokemonData from "../system/pokemon-data"; import { BattlerIndex } from "../battle"; import { Mode } from "../ui/ui"; @@ -1276,6 +1276,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } } + // Apply Tera Shell's effect to attacks after all immunities are accounted for + if (!ignoreAbility && move.category !== MoveCategory.STATUS) { + applyPreDefendAbAttrs(FullHpResistTypeAbAttr, this, source, move, cancelledHolder, simulated, typeMultiplier); + } + return (!cancelledHolder.value ? typeMultiplier.value : 0) as TypeDamageMultiplier; } diff --git a/src/locales/en/ability-trigger.json b/src/locales/en/ability-trigger.json index 307ab70b85c..4f1d4dac766 100644 --- a/src/locales/en/ability-trigger.json +++ b/src/locales/en/ability-trigger.json @@ -12,6 +12,7 @@ "blockItemTheft": "{{pokemonNameWithAffix}}'s {{abilityName}}\nprevents item theft!", "typeImmunityHeal": "{{pokemonNameWithAffix}}'s {{abilityName}}\nrestored its HP a little!", "nonSuperEffectiveImmunity": "{{pokemonNameWithAffix}} avoided damage\nwith {{abilityName}}!", + "fullHpResistType": "{{pokemonNameWithAffix}} made its shell gleam!\nIt's distorting type matchups!", "moveImmunity": "It doesn't affect {{pokemonNameWithAffix}}!", "reverseDrain": "{{pokemonNameWithAffix}} sucked up the liquid ooze!", "postDefendTypeChange": "{{pokemonNameWithAffix}}'s {{abilityName}}\nmade it the {{typeName}} type!", diff --git a/src/test/abilities/tera_shell.test.ts b/src/test/abilities/tera_shell.test.ts new file mode 100644 index 00000000000..f9cb2935619 --- /dev/null +++ b/src/test/abilities/tera_shell.test.ts @@ -0,0 +1,111 @@ +import { Abilities } from "#app/enums/abilities"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import { HitResult } from "#app/field/pokemon.js"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const TIMEOUT = 10 * 1000; // 10 second timeout + +describe("Abilities - Tera Shell", () => { + 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 + .battleType("single") + .ability(Abilities.TERA_SHELL) + .moveset([Moves.SPLASH]) + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.INSOMNIA) + .enemyMoveset(Array(4).fill(Moves.MACH_PUNCH)) + .startingLevel(100) + .enemyLevel(100); + }); + + it( + "should change the effectiveness of non-resisted attacks when the source is at full HP", + async () => { + await game.classicMode.startBattle([Species.SNORLAX]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + vi.spyOn(playerPokemon, "getMoveEffectiveness"); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.getMoveEffectiveness).toHaveLastReturnedWith(0.5); + + await game.toNextTurn(); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.getMoveEffectiveness).toHaveLastReturnedWith(2); + }, TIMEOUT + ); + + it( + "should not override type immunities", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.SHADOW_SNEAK)); + + await game.classicMode.startBattle([Species.SNORLAX]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + vi.spyOn(playerPokemon, "getMoveEffectiveness"); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.getMoveEffectiveness).toHaveLastReturnedWith(0); + }, TIMEOUT + ); + + it( + "should not override type multipliers less than 0.5x", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.QUICK_ATTACK)); + + await game.classicMode.startBattle([Species.AGGRON]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + vi.spyOn(playerPokemon, "getMoveEffectiveness"); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.getMoveEffectiveness).toHaveLastReturnedWith(0.25); + }, TIMEOUT + ); + + it( + "should not affect the effectiveness of fixed-damage moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.DRAGON_RAGE)); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + vi.spyOn(playerPokemon, "apply"); + + game.move.select(Moves.SPLASH); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(playerPokemon.apply).toHaveLastReturnedWith(HitResult.EFFECTIVE); + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp() - 40); + }, TIMEOUT + ); +});