diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts index 88361b0f4fa..5f292d9c3a4 100644 --- a/src/data/phase-priority-queue.ts +++ b/src/data/phase-priority-queue.ts @@ -44,6 +44,33 @@ export abstract class PhasePriorityQueue { public clear(): void { this.queue.splice(0, this.queue.length); } + + /** + * Attempt to remove one or more Phases from the current queue. + * @param phaseFilter - The function to select phases for removal + * @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases; + * default `1` + * @returns The number of successfully removed phases + * @todo Remove this eventually once the patchwork bug this is used for is fixed + */ + public tryRemovePhase(phaseFilter: (phase: Phase) => boolean, removeCount: number | "all" = 1): number { + if (removeCount === "all") { + removeCount = Number.MAX_SAFE_INTEGER; + } else if (removeCount < 1) { + return 0; + } + let numRemoved = 0; + let phaseIndex = this.queue.findIndex(phaseFilter); + if (phaseIndex === -1) { + return 0; + } + while (numRemoved < removeCount && phaseIndex !== -1) { + this.queue.splice(phaseIndex, 1); + numRemoved++; + phaseIndex = this.queue.findIndex(phaseFilter); + } + return numRemoved; + } } /** @@ -79,6 +106,7 @@ export class PostSummonPhasePriorityQueue extends PhasePriorityQueue { private queueAbilityPhase(phase: PostSummonPhase): void { const phasePokemon = phase.getPokemon(); + console.log(phasePokemon.getNameToRender()); phasePokemon.getAbilityPriorities().forEach((priority, idx) => { this.queue.push(new PostSummonActivateAbilityPhase(phasePokemon.getBattlerIndex(), priority, !!idx)); globalScene.phaseManager.appendToPhase( diff --git a/src/phase-manager.ts b/src/phase-manager.ts index aa01a0ffc10..8a31689f7b2 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -355,14 +355,23 @@ export class PhaseManager { if (this.phaseQueuePrependSpliceIndex > -1) { this.clearPhaseQueueSplice(); } - if (this.phaseQueuePrepend.length) { - while (this.phaseQueuePrepend.length) { - const poppedPhase = this.phaseQueuePrepend.pop(); - if (poppedPhase) { - this.phaseQueue.unshift(poppedPhase); - } + this.phaseQueue.unshift(...this.phaseQueuePrepend); + this.phaseQueuePrepend.splice(0); + + const unactivatedConditionalPhases: [() => boolean, Phase][] = []; + // Check if there are any conditional phases queued + for (const [condition, phase] of this.conditionalQueue) { + // Evaluate the condition associated with the phase + if (condition()) { + // If the condition is met, add the phase to the phase queue + this.pushPhase(phase); + } else { + // If the condition is not met, re-add the phase back to the end of the conditional queue + unactivatedConditionalPhases.push([condition, phase]); } } + this.conditionalQueue = unactivatedConditionalPhases; + if (!this.phaseQueue.length) { this.populatePhaseQueue(); // Clear the conditionalQueue if there are no phases left in the phaseQueue @@ -371,24 +380,6 @@ export class PhaseManager { this.currentPhase = this.phaseQueue.shift() ?? null; - const unactivatedConditionalPhases: [() => boolean, Phase][] = []; - // Check if there are any conditional phases queued - while (this.conditionalQueue?.length) { - // Retrieve the first conditional phase from the queue - const conditionalPhase = this.conditionalQueue.shift(); - // Evaluate the condition associated with the phase - if (conditionalPhase?.[0]()) { - // If the condition is met, add the phase to the phase queue - this.pushPhase(conditionalPhase[1]); - } else if (conditionalPhase) { - // If the condition is not met, re-add the phase back to the front of the conditional queue - unactivatedConditionalPhases.push(conditionalPhase); - } else { - console.warn("condition phase is undefined/null!", conditionalPhase); - } - } - this.conditionalQueue.push(...unactivatedConditionalPhases); - if (this.currentPhase) { console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); this.currentPhase.start(); @@ -520,6 +511,25 @@ export class PhaseManager { this.dynamicPhaseQueues[type].push(phase); } + /** + * Attempt to remove one or more Phases from the given DynamicPhaseQueue, removing the equivalent amount of {@linkcode ActivatePriorityQueuePhase}s from the queue. + * @param type - The {@linkcode DynamicPhaseType} to check + * @param phaseFilter - The function to select phases for removal + * @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases; + * default `1` + * @todo Remove this eventually once the patchwork bug this is used for is fixed + */ + public tryRemoveDynamicPhase( + type: DynamicPhaseType, + phaseFilter: (phase: Phase) => boolean, + removeCount: number | "all" = 1, + ): void { + const numRemoved = this.dynamicPhaseQueues[type].tryRemovePhase(phaseFilter, removeCount); + for (let x = 0; x < numRemoved; x++) { + this.tryRemovePhase(p => p.is("ActivatePriorityQueuePhase")); + } + } + /** * Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue} * @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index f4e8ee56c55..78ec72b0eb3 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { BattleStyle } from "#enums/battle-style"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { DynamicPhaseType } from "#enums/dynamic-phase-type"; import { SwitchType } from "#enums/switch-type"; import { UiMode } from "#enums/ui-mode"; import { BattlePhase } from "#phases/battle-phase"; @@ -66,6 +67,17 @@ export class CheckSwitchPhase extends BattlePhase { UiMode.CONFIRM, () => { globalScene.ui.setMode(UiMode.MESSAGE); + /* + Remove any pending `ActivatePriorityQueuePhase`s for the currently leaving Pokemon produced by the prior `SwitchSummonPhase`. + This is required to avoid triggering on-switch abilities twice on initial entrance. + TODO: Separate the animations from `SwitchSummonPhase` to another phase and call that on initial switch - this is a band-aid fix + TODO: Confirm with @emdeann what the maximum number of these things that gets unshifted can be + */ + globalScene.phaseManager.tryRemoveDynamicPhase( + DynamicPhaseType.POST_SUMMON, + p => p.is("PostSummonPhase") && p.getPokemon() === pokemon, + 4, + ); globalScene.phaseManager.unshiftNew("SwitchPhase", SwitchType.INITIAL_SWITCH, this.fieldIndex, false, true); this.end(); }, diff --git a/test/abilities/intimidate.test.ts b/test/abilities/intimidate.test.ts index 3c283e0392b..8064f1e62aa 100644 --- a/test/abilities/intimidate.test.ts +++ b/test/abilities/intimidate.test.ts @@ -35,13 +35,43 @@ describe("Abilities - Intimidate", () => { it("should lower all opponents' ATK by 1 stage on entry and switch", async () => { await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + const [mightyena, poochyena] = game.scene.getPlayerParty(); + const enemy = game.field.getEnemyPokemon(); expect(enemy.getStatStage(Stat.ATK)).toBe(-1); + expect(mightyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); game.doSwitchPokemon(1); await game.toNextTurn(); + expect(poochyena.isActive()).toBe(true); expect(enemy.getStatStage(Stat.ATK)).toBe(-2); + expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); + }); + + it("should trigger once on initial switch prompt without cancelling opposing abilities", async () => { + await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + await game.classicMode.startBattleWithSwitch(1); + + const [poochyena, mightyena] = game.scene.getPlayerParty(); + expect(poochyena.species.speciesId).toBe(SpeciesId.POOCHYENA); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toHaveStatStage(Stat.ATK, -1); + expect(poochyena).toHaveStatStage(Stat.ATK, -1); + + expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); + expect(mightyena).not.toHaveAbilityApplied(AbilityId.INTIMIDATE); + }); + + it("should activate on reload with single party", async () => { + await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); + + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1); + + await game.reload.reloadSession(); + + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1); }); it("should lower ATK of all opponents in a double battle", async () => { diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index 5d73dc07615..18c10ef6d1e 100644 --- a/test/test-utils/helpers/classic-mode-helper.ts +++ b/test/test-utils/helpers/classic-mode-helper.ts @@ -1,6 +1,7 @@ import { getGameMode } from "#app/game-mode"; import overrides from "#app/overrides"; import { BattleStyle } from "#enums/battle-style"; +import { Button } from "#enums/buttons"; import { GameModes } from "#enums/game-modes"; import { Nature } from "#enums/nature"; import type { SpeciesId } from "#enums/species-id"; @@ -100,4 +101,33 @@ export class ClassicModeHelper extends GameManagerHelper { await this.game.phaseInterceptor.to(CommandPhase); console.log("==================[New Turn]=================="); } + + /** + * Queue inputs to switch at the start of the next battle, and then start it. + * @param pokemonIndex - The 0-indexed position of the party pokemon to switch to. + * Should never be called with 0 as that will select the currently active pokemon and freeze + * @returns A Promise that resolves once the battle has been started and the switch prompt resolved + * @todo Make this work for double battles + * @example + * ```ts + * await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]) + * await game.startBattleWithSwitch(1); + * ``` + */ + public async startBattleWithSwitch(pokemonIndex: number): Promise { + this.game.scene.battleStyle = BattleStyle.SWITCH; + this.game.onNextPrompt( + "CheckSwitchPhase", + UiMode.CONFIRM, + () => { + this.game.scene.ui.getHandler().setCursor(0); + this.game.scene.ui.getHandler().processInput(Button.ACTION); + }, + () => this.game.isCurrentPhase("CommandPhase") || this.game.isCurrentPhase("TurnInitPhase"), + ); + this.game.doSelectPartyPokemon(pokemonIndex); + + await this.game.phaseInterceptor.to("CommandPhase"); + console.log("==================[New Battle (Initial Switch)]=================="); + } }