From 4f733796c5394c8060f8736b065d5929162384f6 Mon Sep 17 00:00:00 2001 From: Mumble <171087428+frutescens@users.noreply.github.com> Date: Tue, 5 Nov 2024 18:32:07 -0800 Subject: [PATCH] [Move] Implement Grudge (#4794) * some work * slay dnr * Fixed up move mechanics * bahhh * yawn * updated lapse type to correctness * Test + documentation * yattt * Remove some redundant code * Apply suggestions from code review Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com> * Update battler-tags.ts * Fix `PokemonAnimPhase` --------- Co-authored-by: frutescens Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com> --- src/data/battler-tags.ts | 41 +++++++++++++++ src/data/move.ts | 2 +- src/enums/battler-tag-type.ts | 3 +- src/field/pokemon.ts | 19 +++---- src/phases/faint-phase.ts | 18 +++++-- src/phases/pokemon-anim-phase.ts | 5 +- src/test/moves/grudge.test.ts | 90 ++++++++++++++++++++++++++++++++ 7 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 src/test/moves/grudge.test.ts diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 2012c4330cc..473974048c9 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2827,6 +2827,45 @@ export class PowerTrickTag extends BattlerTag { } } +/** + * Tag associated with the move Grudge. + * If this tag is active when the bearer faints from an opponent's move, the tag reduces that move's PP to 0. + * Otherwise, it lapses when the bearer makes another move. + */ +export class GrudgeTag extends BattlerTag { + constructor() { + super(BattlerTagType.GRUDGE, [ BattlerTagLapseType.CUSTOM, BattlerTagLapseType.PRE_MOVE ], 1, Moves.GRUDGE); + } + + onAdd(pokemon: Pokemon) { + super.onAdd(pokemon); + pokemon.scene.queueMessage(i18next.t("battlerTags:grudgeOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + } + + /** + * Activates Grudge's special effect on the attacking Pokemon and lapses the tag. + * @param pokemon + * @param lapseType + * @param sourcePokemon {@linkcode Pokemon} the source of the move that fainted the tag's bearer + * @returns `false` if Grudge activates its effect or lapses + */ + override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean { + if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) { + if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) { + const lastMove = pokemon.turnData.attacksReceived[0]; + const lastMoveData = sourcePokemon.getMoveset().find(m => m?.moveId === lastMove.move); + if (lastMoveData && lastMove.move !== Moves.STRUGGLE) { + lastMoveData.ppUsed = lastMoveData.getMovePp(); + pokemon.scene.queueMessage(i18next.t("battlerTags:grudgeLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: lastMoveData.getName() })); + } + } + return false; + } else { + return super.lapse(pokemon, lapseType); + } + } +} + /** * Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID. * @param sourceId - The ID of the pokemon adding the tag @@ -3008,6 +3047,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new TelekinesisTag(sourceMove); case BattlerTagType.POWER_TRICK: return new PowerTrickTag(sourceMove, sourceId); + case BattlerTagType.GRUDGE: + return new GrudgeTag(); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index c97749e0357..fb09d822a1d 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8489,7 +8489,7 @@ export function initMoves() { .attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN) .condition((user, target, move) => !!user.status && (user.status.effect === StatusEffect.PARALYSIS || user.status.effect === StatusEffect.POISON || user.status.effect === StatusEffect.TOXIC || user.status.effect === StatusEffect.BURN)), new SelfStatusMove(Moves.GRUDGE, Type.GHOST, -1, 5, -1, 0, 3) - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.GRUDGE, true, undefined, 1), new SelfStatusMove(Moves.SNATCH, Type.DARK, -1, 10, -1, 4, 3) .unimplemented(), new AttackMove(Moves.SECRET_POWER, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 20, 30, 0, 3) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index c53157c68d4..660a47e0d68 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -89,5 +89,6 @@ export enum BattlerTagType { SYRUP_BOMB = "SYRUP_BOMB", ELECTRIFIED = "ELECTRIFIED", TELEKINESIS = "TELEKINESIS", - COMMANDED = "COMMANDED" + COMMANDED = "COMMANDED", + GRUDGE = "GRUDGE" } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 77881d36c56..25a771c9281 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -7,7 +7,7 @@ import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, Varia import { default as PokemonSpecies, PokemonSpeciesForm, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER, getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters"; import { starterPassiveAbilities } from "#app/data/balance/passives"; -import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils"; +import { Constructor, isNullOrUndefined, randSeedInt, type nil } from "#app/utils"; import * as Utils from "#app/utils"; import { Type, TypeDamageMultiplier, getTypeDamageMultiplier, getTypeRgb } from "#app/data/type"; import { getLevelTotalExp } from "#app/data/exp"; @@ -2841,6 +2841,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { // In case of fatal damage, this tag would have gotten cleared before we could lapse it. const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); + const grudgeTag = this.getTag(BattlerTagType.GRUDGE); const isOneHitKo = result === HitResult.ONE_HIT_KO; @@ -2912,12 +2913,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (this.isFainted()) { // set splice index here, so future scene queues happen before FaintedPhase this.scene.setPhaseQueueSplice(); - if (!isNullOrUndefined(destinyTag) && dmg) { - // Destiny Bond will activate during FaintPhase - this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo, destinyTag, source)); - } else { - this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); - } + this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo, destinyTag, grudgeTag, source)); + this.destroySubstitute(); this.lapseTag(BattlerTagType.COMMANDED); this.resetSummonData(); @@ -3051,19 +3048,19 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** @overload */ - getTag(tagType: BattlerTagType): BattlerTag | null; + getTag(tagType: BattlerTagType): BattlerTag | nil; /** @overload */ - getTag(tagType: Constructor): T | null; + getTag(tagType: Constructor): T | nil; - getTag(tagType: BattlerTagType | Constructor): BattlerTag | null { + getTag(tagType: BattlerTagType | Constructor): BattlerTag | nil { if (!this.summonData) { return null; } return (tagType instanceof Function ? this.summonData.tags.find(t => t instanceof tagType) : this.summonData.tags.find(t => t.tagType === tagType) - )!; // TODO: is this bang correct? + ); } findTag(tagFilter: ((tag: BattlerTag) => boolean)) { diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 3e90233a38c..d66c5b66144 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -1,7 +1,7 @@ import { BattlerIndex, BattleType } from "#app/battle"; import BattleScene from "#app/battle-scene"; import { applyPostFaintAbAttrs, applyPostKnockOutAbAttrs, applyPostVictoryAbAttrs, PostFaintAbAttr, PostKnockOutAbAttr, PostVictoryAbAttr } from "#app/data/ability"; -import { BattlerTagLapseType, DestinyBondTag } from "#app/data/battler-tags"; +import { BattlerTagLapseType, DestinyBondTag, GrudgeTag } from "#app/data/battler-tags"; import { battleSpecDialogue } from "#app/data/dialogue"; import { allMoves, PostVictoryStatStageChangeAttr } from "#app/data/move"; import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; @@ -31,18 +31,24 @@ export class FaintPhase extends PokemonPhase { /** * Destiny Bond tag belonging to the currently fainting Pokemon, if applicable */ - private destinyTag?: DestinyBondTag; + private destinyTag?: DestinyBondTag | null; /** - * The source Pokemon that dealt fatal damage and should get KO'd by Destiny Bond, if applicable + * Grudge tag belonging to the currently fainting Pokemon, if applicable + */ + private grudgeTag?: GrudgeTag | null; + + /** + * The source Pokemon that dealt fatal damage */ private source?: Pokemon; - constructor(scene: BattleScene, battlerIndex: BattlerIndex, preventEndure: boolean = false, destinyTag?: DestinyBondTag, source?: Pokemon) { + constructor(scene: BattleScene, battlerIndex: BattlerIndex, preventEndure: boolean = false, destinyTag?: DestinyBondTag | null, grudgeTag?: GrudgeTag | null, source?: Pokemon) { super(scene, battlerIndex); this.preventEndure = preventEndure; this.destinyTag = destinyTag; + this.grudgeTag = grudgeTag; this.source = source; } @@ -53,6 +59,10 @@ export class FaintPhase extends PokemonPhase { this.destinyTag.lapse(this.source, BattlerTagLapseType.CUSTOM); } + if (!isNullOrUndefined(this.grudgeTag) && !isNullOrUndefined(this.source)) { + this.grudgeTag.lapse(this.getPokemon(), BattlerTagLapseType.CUSTOM, this.source); + } + if (!this.preventEndure) { const instantReviveModifier = this.scene.applyModifier(PokemonInstantReviveModifier, this.player, this.getPokemon()) as PokemonInstantReviveModifier; diff --git a/src/phases/pokemon-anim-phase.ts b/src/phases/pokemon-anim-phase.ts index e9e1129f158..ad0be34af7d 100644 --- a/src/phases/pokemon-anim-phase.ts +++ b/src/phases/pokemon-anim-phase.ts @@ -1,8 +1,9 @@ import BattleScene from "#app/battle-scene"; import { SubstituteTag } from "#app/data/battler-tags"; -import { PokemonAnimType } from "#enums/pokemon-anim-type"; import Pokemon from "#app/field/pokemon"; import { BattlePhase } from "#app/phases/battle-phase"; +import { isNullOrUndefined } from "#app/utils"; +import { PokemonAnimType } from "#enums/pokemon-anim-type"; import { Species } from "#enums/species"; @@ -51,7 +52,7 @@ export class PokemonAnimPhase extends BattlePhase { private doSubstituteAddAnim(): void { const substitute = this.pokemon.getTag(SubstituteTag); - if (substitute === null) { + if (isNullOrUndefined(substitute)) { return this.end(); } diff --git a/src/test/moves/grudge.test.ts b/src/test/moves/grudge.test.ts new file mode 100644 index 00000000000..340808929ab --- /dev/null +++ b/src/test/moves/grudge.test.ts @@ -0,0 +1,90 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { BattlerIndex } from "#app/battle"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Grudge", () => { + 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 + .moveset([ Moves.EMBER, Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.SHEDINJA) + .enemyAbility(Abilities.WONDER_GUARD) + .enemyMoveset([ Moves.GRUDGE, Moves.SPLASH ]); + }); + + it("should reduce the PP of the Pokemon's move to 0 when the user has fainted", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + const playerPokemon = game.scene.getPlayerPokemon(); + game.move.select(Moves.EMBER); + await game.forceEnemyMove(Moves.GRUDGE); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("BerryPhase"); + + const playerMove = playerPokemon?.getMoveset().find(m => m?.moveId === Moves.EMBER); + + expect(playerMove?.getPpRatio()).toBe(0); + }); + + it("should remain in effect until the user's next move", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + const playerPokemon = game.scene.getPlayerPokemon(); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.GRUDGE); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.toNextTurn(); + + game.move.select(Moves.EMBER); + await game.forceEnemyMove(Moves.SPLASH); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to("BerryPhase"); + + const playerMove = playerPokemon?.getMoveset().find(m => m?.moveId === Moves.EMBER); + + expect(playerMove?.getPpRatio()).toBe(0); + }); + + it("should not reduce the opponent's PP if the user dies to weather/indirect damage", async () => { + // Opponent will be reduced to 1 HP by False Swipe, then faint to Sandstorm + game.override + .moveset([ Moves.FALSE_SWIPE ]) + .startingLevel(100) + .ability(Abilities.SAND_STREAM) + .enemySpecies(Species.RATTATA); + await game.classicMode.startBattle([ Species.GEODUDE ]); + + const enemyPokemon = game.scene.getEnemyPokemon(); + const playerPokemon = game.scene.getPlayerPokemon(); + + game.move.select(Moves.FALSE_SWIPE); + await game.forceEnemyMove(Moves.GRUDGE); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(enemyPokemon?.isFainted()).toBe(true); + + const playerMove = playerPokemon?.getMoveset().find(m => m?.moveId === Moves.FALSE_SWIPE); + expect(playerMove?.getPpRatio()).toBeGreaterThan(0); + }); +});