diff --git a/src/data/pokemon/pokemon-data.ts b/src/data/pokemon/pokemon-data.ts index 0bd6af0bb04..87ffbbab4cd 100644 --- a/src/data/pokemon/pokemon-data.ts +++ b/src/data/pokemon/pokemon-data.ts @@ -11,6 +11,7 @@ import type { MoveId } from "#enums/move-id"; import type { Nature } from "#enums/nature"; import type { PokemonType } from "#enums/pokemon-type"; import type { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; import type { AttackMoveResult } from "#types/attack-move-result"; import type { IllusionData } from "#types/illusion-data"; import type { TurnMove } from "#types/turn-move"; @@ -326,6 +327,14 @@ export class PokemonTurnData { public switchedInThisTurn = false; public failedRunAway = false; public joinedRound = false; + /** Tracker for a pending status effect + * + * @remarks + * Set whenever {@linkcode Pokemon#trySetStatus} succeeds in order to prevent subsequent status effects + * from being applied. Necessary because the status is not actually set until the {@linkcode ObtainStatusEffectPhase} runs, + * which may not happen before another status effect is attempted to be applied. + */ + public pendingStatus: StatusEffect = StatusEffect.NONE; /** * The amount of times this Pokemon has acted again and used a move in the current turn. * Used to make sure multi-hits occur properly when the user is diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index f02c9a1f30c..c6ec5f25622 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4803,7 +4803,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (effect !== StatusEffect.FAINT) { // Status-overriding moves (i.e. Rest) fail if their respective status already exists; // all other moves fail if the target already has _any_ status - if (overrideStatus ? this.status?.effect === effect : this.status) { + if (overrideStatus ? this.status?.effect === effect : this.status || this.turnData.pendingStatus) { this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message return false; } @@ -4955,6 +4955,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (overrideStatus) { this.resetStatus(false); + } else { + this.turnData.pendingStatus = effect; } globalScene.phaseManager.unshiftNew( @@ -4974,6 +4976,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. * @param effect - The {@linkcode StatusEffect} to set * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon.turnData | turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ doSetStatus(effect: Exclude): void; @@ -4982,6 +4986,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param effect - {@linkcode StatusEffect.SLEEP} * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void; @@ -4991,6 +4997,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * and is unused for all non-sleep Statuses * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void; @@ -5000,6 +5008,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * and is unused for all non-sleep Statuses * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. * @todo Make this and all related fields private and change tests to use a field-based helper or similar */ @@ -5007,6 +5017,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { effect: StatusEffect, sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4), ): void { + // Reset any pending status + this.turnData.pendingStatus = StatusEffect.NONE; switch (effect) { case StatusEffect.POISON: case StatusEffect.TOXIC: diff --git a/test/status-effects/general-status-effect.test.ts b/test/status-effects/general-status-effect.test.ts new file mode 100644 index 00000000000..db73265181b --- /dev/null +++ b/test/status-effects/general-status-effect.test.ts @@ -0,0 +1,60 @@ +import { allAbilities } from "#data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; +import { ObtainStatusEffectPhase } from "#phases/obtain-status-effect-phase"; +import { GameManager } from "#test/test-utils/game-manager"; +import type { PostAttackContactApplyStatusEffectAbAttr } from "#types/ability-types"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +describe("Status Effects - General", () => { + 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") + .enemyLevel(5) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .ability(AbilityId.BALL_FETCH); + }); + + test("multiple status effects from the same interaction should not overwrite each other", async () => { + game.override.ability(AbilityId.POISON_TOUCH).moveset([MoveId.NUZZLE]); + await game.classicMode.startBattle([SpeciesId.PIKACHU]); + + // Force poison touch to always apply + vi.spyOn( + allAbilities[AbilityId.POISON_TOUCH].getAttrs( + "PostAttackContactApplyStatusEffectAbAttr", + // expose chance, which is private, for testing purpose, but keep type safety otherwise + )[0] as unknown as Omit & { chance: number }, + "chance", + "get", + ).mockReturnValue(100); + const statusEffectPhaseSpy = vi.spyOn(ObtainStatusEffectPhase.prototype, "start"); + + game.move.select(MoveId.NUZZLE); + await game.toEndOfTurn(); + + expect(statusEffectPhaseSpy).toHaveBeenCalledOnce(); + const enemy = game.field.getEnemyPokemon(); + // This test does not care which status effect is applied, as long as one is. + expect(enemy.status?.effect).toBeOneOf([StatusEffect.POISON, StatusEffect.PARALYSIS]); + }); +});