diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 6a3ab753484..18a7d69f684 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -3157,9 +3157,13 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { // Display the move animation to foresee an attack globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user)); - globalScene.phaseManager.queueMessage(i18next.t(this.chargeText, - // uncomment if any new delayed moves actually use target in the move text. - {pokemonName: getPokemonNameWithAffix(user)/*, targetName: getPokemonNameWithAffix(target) */})) + globalScene.phaseManager.queueMessage( + i18next.t( + this.chargeText, + // uncomment if any new delayed moves actually use target in the move text. + {pokemonName: getPokemonNameWithAffix(user)/*, targetName: getPokemonNameWithAffix(target) */} + ) + ) user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode: useMode, turn: globalScene.currentBattle.turn}) @@ -3180,10 +3184,15 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { } } +/** Attribute to queue a {@linkcode WishTag} to activate in 2 turns. */ export class WishAttr extends MoveEffectAttr { apply(user: Pokemon, target: Pokemon, _move: Move): boolean { - globalScene.arena.positionalTagManager.addTag({tagType: PositionalTagType.WISH, sourceId: user.id, healHp: toDmgValue(user.getMaxHp() / 2), targetIndex: target.getBattlerIndex(), + globalScene.arena.positionalTagManager.addTag({ + tagType: PositionalTagType.WISH, + healHp: toDmgValue(user.getMaxHp() / 2), + targetIndex: target.getBattlerIndex(), turnCount: 2, + pokemonName: getPokemonNameWithAffix(user), }); return true; } diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 08728680f43..5c37e843c36 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -12,14 +12,11 @@ import type { Pokemon } from "#field/pokemon"; import i18next from "i18next"; /** - * Baseline arguments used to construct all {@linkcode PositionalTag}s. + * Baseline arguments used to construct all {@linkcode PositionalTag}s, + * the contents of which are serialized and used to construct new tags. \ * Does not contain the `tagType` parameter (which is used to select the proper class constructor to use). */ export interface PositionalTagBaseArgs { - /** - * The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created the effect. - */ - sourceId: number; /** * The number of turns remaining until activation. \ * Decremented by 1 at the end of each turn until reaching 0, at which point it will {@linkcode trigger} and be removed. @@ -42,12 +39,10 @@ export interface PositionalTagBaseArgs { export abstract class PositionalTag implements PositionalTagBaseArgs { public abstract readonly tagType: PositionalTagType; // These arguments have to be public to implement the interface, but are functionally private. - public sourceId: number; public turnCount: number; public targetIndex: BattlerIndex; - constructor({ sourceId, turnCount, targetIndex }: PositionalTagBaseArgs) { - this.sourceId = sourceId; + constructor({ turnCount, targetIndex }: PositionalTagBaseArgs) { this.turnCount = turnCount; this.targetIndex = targetIndex; } @@ -79,6 +74,10 @@ export abstract class PositionalTag implements PositionalTagBaseArgs { } interface DelayedAttackArgs extends PositionalTagBaseArgs { + /** + * The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect. + */ + sourceId: number; /** The {@linkcode MoveId} that created this attack. */ sourceMove: MoveId; } @@ -91,9 +90,11 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs { export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { public override readonly tagType = PositionalTagType.DELAYED_ATTACK; public sourceMove: MoveId; + public sourceId: number; constructor({ sourceId, turnCount, targetIndex, sourceMove }: DelayedAttackArgs) { - super({ sourceId, turnCount, targetIndex }); + super({ turnCount, targetIndex }); + this.sourceId = sourceId; this.sourceMove = sourceMove; } @@ -130,6 +131,10 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs interface WishArgs extends PositionalTagBaseArgs { /** The amount of {@linkcode Stat.HP | HP} to heal; set to 50% of the user's max HP during move usage. */ healHp: number; + /** + * The name of the {@linkcode Pokemon} having created the tag.. + */ + pokemonName: string; } /** @@ -138,17 +143,29 @@ interface WishArgs extends PositionalTagBaseArgs { export class WishTag extends PositionalTag implements WishArgs { public override readonly tagType = PositionalTagType.WISH; + readonly pokemonName: string; + public healHp: number; - constructor({ sourceId, turnCount, targetIndex, healHp }: WishArgs) { - super({ sourceId, turnCount, targetIndex }); + constructor({ turnCount, targetIndex, healHp, pokemonName }: WishArgs) { + super({ turnCount, targetIndex }); this.healHp = healHp; + this.pokemonName = pokemonName; } public trigger(): void { + // TODO: Rename this locales key - wish shows a message on REMOVAL, dumbass + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:wishTagOnAdd", { + pokemonName: this.pokemonName, + }), + ); + globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false); } public shouldDisappear(): boolean { + // Disappear if no target. + // The source need not exist at the time of activation (since all we need is a simple message) return !!this.getTarget(); } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index db3f0e8d9cb..05fa4a0f6d9 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -19,7 +19,7 @@ import { MoveFlags } from "#enums/MoveFlags"; import { MoveTarget } from "#enums/MoveTarget"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; -import { isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode"; +import { isReflected, MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import { @@ -244,22 +244,15 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.currentBattle.lastPlayerInvolved = this.fieldIndex; } - const isDelayedAttack = this.move.hasAttr("DelayedAttackAttr"); - /** If the user was somehow removed from the field and it's not a delayed attack, end this phase */ - if (!user.isOnField()) { - if (!isDelayedAttack) { - super.end(); - return; - } - if (!user.scene) { - /* - * This happens if the Pokemon that used the delayed attack gets caught and released - * on the turn the attack would have triggered. Having access to the global scene - * in the future may solve this entirely, so for now we just cancel the hit - */ - super.end(); - return; - } + if (!user.scene) { + /* + * This happens if the Pokemon that used the delayed attack gets caught and released + * on the turn the attack would have triggered. Having access to the global scene + * in the future may solve this entirely, so for now we just cancel the hit + */ + console.warn("User scene bye bye bye skibidi rizz"); + super.end(); + return; } const move = this.move; @@ -270,18 +263,12 @@ export class MoveEffectPhase extends PokemonPhase { */ const overridden = new BooleanHolder(false); + console.log(this.useMode); // Apply effects to override a move effect. // Assuming single target here works as this is (currently) // only used for Future Sight, calling and Pledge moves. // TODO: change if any other move effect overrides are introduced - applyMoveAttrs( - "OverrideMoveEffectAttr", - user, - this.getFirstTarget() ?? null, - move, - overridden, - isVirtual(this.useMode), - ); + applyMoveAttrs("OverrideMoveEffectAttr", user, this.getFirstTarget() ?? null, move, overridden, this.useMode); // If other effects were overriden, stop this phase before they can be applied if (overridden.value) { diff --git a/test/moves/delayed_attack.test.ts b/test/moves/delayed_attack.test.ts index 6e2c70a725f..3f21f3e6189 100644 --- a/test/moves/delayed_attack.test.ts +++ b/test/moves/delayed_attack.test.ts @@ -1,4 +1,3 @@ -import { DelayedAttackTag } from "#app/data/positional-tags/positional-tag"; import { getPokemonNameWithAffix } from "#app/messages"; import { AttackTypeBoosterModifier } from "#app/modifier/modifier"; import { allMoves } from "#data/data-lists"; @@ -8,6 +7,7 @@ import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { PokemonType } from "#enums/pokemon-type"; +import { PositionalTagType } from "#enums/positional-tag-type"; import { SpeciesId } from "#enums/species-id"; import { GameManager } from "#test/testUtils/gameManager"; import i18next from "i18next"; @@ -68,7 +68,9 @@ describe("Moves - Delayed Attacks", () => { * @param numAttacks - The number of delayed attacks that should be queued; default `1` */ function expectFutureSightActive(numAttacks = 1) { - const delayedAttacks = game.scene.arena.positionalTagManager["tags"].filter(t => t instanceof DelayedAttackTag)!; + const delayedAttacks = game.scene.arena.positionalTagManager["tags"].filter( + t => t.tagType === PositionalTagType.DELAYED_ATTACK, + ); expect(delayedAttacks).toHaveLength(numAttacks); } @@ -171,8 +173,8 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(4); - game.move.use(MoveId.TAILWIND); - game.move.use(MoveId.COTTON_SPORE); + game.move.use(MoveId.TAILWIND, BattlerIndex.PLAYER); + game.move.use(MoveId.COTTON_SPORE, BattlerIndex.PLAYER_2); await passTurns(1, false); expect(game.field.getSpeedOrder()).not.toEqual(usageOrder); @@ -251,12 +253,10 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(1); - await passTurns(1); - - game.move.use(MoveId.SPLASH); await game.killPokemon(enemy2); - await game.toNextTurn(); + await passTurns(2); + expectFutureSightActive(0); expect(enemy1.hp).toBe(enemy1.getMaxHp()); expect(game.textInterceptor.logs).not.toContain( i18next.t("moveTriggers:tookMoveAttack", { @@ -279,7 +279,8 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(1); - await passTurns(1); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); await game.move.forceEnemyMove(MoveId.ELECTRIFY, BattlerIndex.PLAYER); diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts new file mode 100644 index 00000000000..34d2353ee02 --- /dev/null +++ b/test/moves/wish.test.ts @@ -0,0 +1,129 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { GameManager } from "#test/testUtils/gameManager"; +import { toDmgValue } from "#utils/common"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Move - Wish", () => { + 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 + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + /** + * Expect that wish is active with the specified number of attacks. + * @param numAttacks - The number of wish instances that should be queued; default `1` + */ + function expectWishActive(numAttacks = 1) { + const wishes = game.scene.arena.positionalTagManager["tags"].filter(t => t.tagType === PositionalTagType.WISH); + expect(wishes).toHaveLength(numAttacks); + } + + it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => { + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey] = game.scene.getPlayerParty(); + alomomola.hp = 1; + blissey.hp = 1; + + game.move.use(MoveId.WISH); + await game.toNextTurn(); + + expectWishActive(); + + game.doSwitchPokemon(1); + await game.toEndOfTurn(); + + expectWishActive(0); + expect(game.textInterceptor.logs).toContain( + i18next.t("arenaTag:wishTagOnAdd", { + pokemonName: getPokemonNameWithAffix(blissey), + }), + ); + expect(alomomola.hp).toBe(1); + expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); + }); + + it("should function independently of Future Sight", async () => { + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey] = game.scene.getPlayerParty(); + alomomola.hp = 1; + blissey.hp = 1; + + game.move.use(MoveId.WISH); + await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expectWishActive(1); + }); + + it("should work in double battles and triggerin order of creation", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey] = game.scene.getPlayerParty(); + alomomola.hp = 1; + blissey.hp = 1; + + const oldOrder = game.field.getSpeedOrder(); + + game.move.use(MoveId.WISH, BattlerIndex.PLAYER); + game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expectWishActive(2); + + // Lower speed to change turn order + alomomola.setStatStage(Stat.SPD, 6); + blissey.setStatStage(Stat.SPD, -6); + + const newOrder = game.field.getSpeedOrder(); + expect(newOrder).not.toEqual(oldOrder); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("PositionalTagPhase"); + + // Both wishes have activated and added healing phases + expectWishActive(0); + + const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); + expect(healPhases).toHaveLength(4); + expect(healPhases.map(php => php["battlerIndex"])).toEqual(oldOrder); + + await game.toEndOfTurn(); + + expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); + expect(blissey.hp).toBe(toDmgValue(blissey.getMaxHp() / 2) + 1); + }); +});