From ab6d15ee8a31bfdeacf92ab9fc2624b31c7eb527 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:31:40 -0800 Subject: [PATCH] [Move] Improve Future Sight & Doom Desire (still partial) (#4545) * Fix behavior of Future Sight and Doom Desire Add test for Future Sight Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Prevent crash if Future Sight target is missing Co-authored-by: PigeonBar <56974298+PigeonBar@users.noreply.github.com> * Add `partial()` comments, update `DelayedAttackAttr` return --------- Co-authored-by: Gianluca Fuoco Co-authored-by: PigeonBar <56974298+PigeonBar@users.noreply.github.com> Co-authored-by: Tempoanon <163687446+Tempo-anon@users.noreply.github.com> --- src/data/arena-tag.ts | 9 ++--- src/data/move.ts | 24 ++++++++++--- src/phases/move-effect-phase.ts | 18 ++++++---- src/phases/move-phase.ts | 55 +++++++++++++++++++++++++++-- src/test/moves/future_sight.test.ts | 45 +++++++++++++++++++++++ 5 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 src/test/moves/future_sight.test.ts diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 3dd526676a0..73f8e16d573 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -780,13 +780,14 @@ class ToxicSpikesTag extends ArenaTrapTag { * Delays the attack's effect by a set amount of turns, usually 3 (including the turn the move is used), * and deals damage after the turn count is reached. */ -class DelayedAttackTag extends ArenaTag { +export class DelayedAttackTag extends ArenaTag { public targetIndex: BattlerIndex; - constructor(tagType: ArenaTagType, sourceMove: Moves | undefined, sourceId: number, targetIndex: BattlerIndex) { - super(tagType, 3, sourceMove, sourceId); + constructor(tagType: ArenaTagType, sourceMove: Moves | undefined, sourceId: number, targetIndex: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH) { + super(tagType, 3, sourceMove, sourceId, side); this.targetIndex = targetIndex; + this.side = side; } lapse(arena: Arena): boolean { @@ -1250,7 +1251,7 @@ export function getArenaTag(tagType: ArenaTagType, turnCount: number, sourceMove return new ToxicSpikesTag(sourceId, side); case ArenaTagType.FUTURE_SIGHT: case ArenaTagType.DOOM_DESIRE: - return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex!); // TODO:questionable bang + return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex!, side); // TODO:questionable bang case ArenaTagType.WISH: return new WishTag(turnCount, sourceId, side); case ArenaTagType.STEALTH_ROCK: diff --git a/src/data/move.ts b/src/data/move.ts index 6e350315e65..5ae37c3b092 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -2785,6 +2785,14 @@ export class OverrideMoveEffectAttr extends MoveAttr { } } +/** + * Attack Move that doesn't hit the turn it is played and doesn't allow for multiple + * uses on the same target. Examples are Future Sight or Doom Desire. + * @extends OverrideMoveEffectAttr + * @param tagType The {@linkcode ArenaTagType} that will be placed on the field when the move is used + * @param chargeAnim The {@linkcode ChargeAnim | Charging Animation} used for the move + * @param chargeText The text to display when the move is used + */ export class DelayedAttackAttr extends OverrideMoveEffectAttr { public tagType: ArenaTagType; public chargeAnim: ChargeAnim; @@ -2799,13 +2807,18 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { + // Edge case for the move applied on a pokemon that has fainted + if (!target) { + return Promise.resolve(true); + } + const side = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; return new Promise(resolve => { if (args.length < 2 || !args[1]) { new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => { (args[0] as Utils.BooleanHolder).value = true; user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER }); - user.scene.arena.addTag(this.tagType, 3, move.id, user.id, ArenaTagSide.BOTH, false, target.getBattlerIndex()); + user.scene.arena.addTag(this.tagType, 3, move.id, user.id, side, false, target.getBattlerIndex()); resolve(true); }); @@ -5534,7 +5547,8 @@ export class AddArenaTagAttr extends MoveEffectAttr { } if ((move.chance < 0 || move.chance === 100 || user.randSeedInt(100) < move.chance) && user.getLastXMoves(1)[0]?.result === MoveResult.SUCCESS) { - user.scene.arena.addTag(this.tagType, this.turnCount, move.id, user.id, (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY); + const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + user.scene.arena.addTag(this.tagType, this.turnCount, move.id, user.id, side); return true; } @@ -8297,7 +8311,8 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPDEF ], -1) .ballBombMove(), new AttackMove(Moves.FUTURE_SIGHT, Type.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 2) - .partial() // Complete buggy mess + .partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc + .ignoresProtect() .attr(DelayedAttackAttr, ArenaTagType.FUTURE_SIGHT, ChargeAnim.FUTURE_SIGHT_CHARGING, i18next.t("moveTriggers:foresawAnAttack", { pokemonName: "{USER}" })), new AttackMove(Moves.ROCK_SMASH, Type.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 15, 50, 0, 2) .attr(StatStageChangeAttr, [ Stat.DEF ], -1), @@ -8604,7 +8619,8 @@ export function initMoves() { .attr(ConfuseAttr) .pulseMove(), new AttackMove(Moves.DOOM_DESIRE, Type.STEEL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 3) - .partial() // Complete buggy mess + .partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc + .ignoresProtect() .attr(DelayedAttackAttr, ArenaTagType.DOOM_DESIRE, ChargeAnim.DOOM_DESIRE_CHARGING, i18next.t("moveTriggers:choseDoomDesireAsDestiny", { pokemonName: "{USER}" })), new AttackMove(Moves.PSYCHO_BOOST, Type.PSYCHIC, MoveCategory.SPECIAL, 140, 90, 5, -1, 0, 3) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true), diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 4c813a881d3..62ece3769b5 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -25,6 +25,7 @@ import { applyFilteredMoveAttrs, applyMoveAttrs, AttackMove, + DelayedAttackAttr, FixedDamageAttr, HitsTagAttr, MissEffectAttr, @@ -85,8 +86,13 @@ export class MoveEffectPhase extends PokemonPhase { /** All Pokemon targeted by this phase's invoked move */ const targets = this.getTargets(); - /** If the user was somehow removed from the field, end this phase */ - if (!user?.isOnField()) { + if (!user) { + return super.end(); + } + + const isDelayedAttack = this.move.getMove().hasAttr(DelayedAttackAttr); + /** If the user was somehow removed from the field and it's not a delayed attack, end this phase */ + if (!user.isOnField() && !isDelayedAttack) { return super.end(); } @@ -142,9 +148,9 @@ export class MoveEffectPhase extends PokemonPhase { const hasActiveTargets = targets.some(t => t.isActive(true)); /** Check if the target is immune via ability to the attacking move, and NOT in semi invulnerable state */ - const isImmune = targets[0].hasAbilityWithAttr(TypeImmunityAbAttr) - && (targets[0].getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) - && !targets[0].getTag(SemiInvulnerableTag); + const isImmune = targets[0]?.hasAbilityWithAttr(TypeImmunityAbAttr) + && (targets[0]?.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) + && !targets[0]?.getTag(SemiInvulnerableTag); /** * If no targets are left for the move to hit (FAIL), or the invoked move is single-target @@ -156,7 +162,7 @@ export class MoveEffectPhase extends PokemonPhase { if (hasActiveTargets) { this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" })); moveHistoryEntry.result = MoveResult.MISS; - applyMoveAttrs(MissEffectAttr, user, null, move); + applyMoveAttrs(MissEffectAttr, user, null, this.move.getMove()); } else { this.scene.queueMessage(i18next.t("battle:attackFailed")); moveHistoryEntry.result = MoveResult.FAIL; diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 5c6c339ffa5..b11a7d9b711 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,9 +1,31 @@ import { BattlerIndex } from "#app/battle"; import BattleScene from "#app/battle-scene"; -import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr, ReduceStatusEffectDurationAbAttr } from "#app/data/ability"; +import { + applyAbAttrs, + applyPostMoveUsedAbAttrs, + applyPreAttackAbAttrs, + BlockRedirectAbAttr, + IncreasePpAbAttr, + PokemonTypeChangeAbAttr, + PostMoveUsedAbAttr, + RedirectMoveAbAttr, + ReduceStatusEffectDurationAbAttr +} from "#app/data/ability"; +import { DelayedAttackTag } from "#app/data/arena-tag"; import { CommonAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags"; -import { allMoves, applyMoveAttrs, BypassRedirectAttr, BypassSleepAttr, CopyMoveAttr, frenzyMissFunc, HealStatusEffectAttr, MoveFlags, PreMoveMessageAttr } from "#app/data/move"; +import { + allMoves, + applyMoveAttrs, + BypassRedirectAttr, + BypassSleepAttr, + CopyMoveAttr, + DelayedAttackAttr, + frenzyMissFunc, + HealStatusEffectAttr, + MoveFlags, + PreMoveMessageAttr +} from "#app/data/move"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; import { Type } from "#app/data/type"; @@ -14,16 +36,17 @@ import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; import { BattlePhase } from "#app/phases/battle-phase"; import { CommonAnimPhase } from "#app/phases/common-anim-phase"; +import { MoveChargePhase } from "#app/phases/move-charge-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import { BooleanHolder, NumberHolder } from "#app/utils"; import { Abilities } from "#enums/abilities"; +import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { StatusEffect } from "#enums/status-effect"; import i18next from "i18next"; -import { MoveChargePhase } from "#app/phases/move-charge-phase"; export class MovePhase extends BattlePhase { protected _pokemon: Pokemon; @@ -227,6 +250,32 @@ export class MovePhase extends BattlePhase { // form changes happen even before we know that the move wll execute. this.scene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); + const isDelayedAttack = this.move.getMove().hasAttr(DelayedAttackAttr); + if (isDelayedAttack) { + // Check the player side arena if future sight is active + const futureSightTags = this.scene.arena.findTags(t => t.tagType === ArenaTagType.FUTURE_SIGHT); + const doomDesireTags = this.scene.arena.findTags(t => t.tagType === ArenaTagType.DOOM_DESIRE); + let fail = false; + const currentTargetIndex = targets[0].getBattlerIndex(); + for (const tag of futureSightTags) { + if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) { + fail = true; + break; + } + } + for (const tag of doomDesireTags) { + if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) { + fail = true; + break; + } + } + if (fail) { + this.showMoveText(); + this.showFailedText(); + return this.end(); + } + } + this.showMoveText(); if (moveQueue.length > 0) { diff --git a/src/test/moves/future_sight.test.ts b/src/test/moves/future_sight.test.ts new file mode 100644 index 00000000000..d0110a87202 --- /dev/null +++ b/src/test/moves/future_sight.test.ts @@ -0,0 +1,45 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Future Sight", () => { + 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 + .startingLevel(50) + .moveset([ Moves.FUTURE_SIGHT, Moves.SPLASH ]) + .battleType("single") + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.STURDY) + .enemyMoveset(Moves.SPLASH); + }); + + it("hits 2 turns after use, ignores user switch out", async () => { + await game.classicMode.startBattle([ Species.FEEBAS, Species.MILOTIC ]); + + game.move.select(Moves.FUTURE_SIGHT); + await game.toNextTurn(); + game.doSwitchPokemon(1); + await game.toNextTurn(); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false); + }); +});