[Bug] Fix status effects overwriting each other (#6392)

* Ensure status effects from same source interaction cannot override each other

* Update test/status-effects/general-status-effect.test.ts

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
This commit is contained in:
Sirz Benjie 2025-08-24 12:51:16 -05:00 committed by GitHub
parent ae58f9de4f
commit 049932c001
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 82 additions and 1 deletions

View File

@ -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

View File

@ -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<StatusEffect, StatusEffect.SLEEP>): 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:

View File

@ -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<PostAttackContactApplyStatusEffectAbAttr, "chance"> & { 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]);
});
});