From 8b65f9afab8bc502dfec8ce4f1ff2ca6cc97cbd9 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 4 Aug 2025 16:27:47 -0400 Subject: [PATCH 1/5] Added TODO test case + documentation for failing intim test --- test/abilities/intimidate.test.ts | 16 ++++++++++ .../test-utils/helpers/classic-mode-helper.ts | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/test/abilities/intimidate.test.ts b/test/abilities/intimidate.test.ts index 3c283e0392b..23c276f9a5d 100644 --- a/test/abilities/intimidate.test.ts +++ b/test/abilities/intimidate.test.ts @@ -44,6 +44,22 @@ describe("Abilities - Intimidate", () => { expect(enemy.getStatStage(Stat.ATK)).toBe(-2); }); + // TODO: This fails due to a limitation in our switching logic - the animations and field entry occur concurrently + // inside `SummonPhase`, unshifting 2 `PostSummmonPhase`s and proccing intimidate twice + it.todo("should lower all opponents' ATK by 1 stage on initial switch prompt", async () => { + await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + await game.classicMode.startBattleWithSwitch(1); + + const [poochyena, mightyena] = game.scene.getPlayerField(); + expect(poochyena.species.speciesId).toBe(SpeciesId.POOCHYENA); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toHaveStatStage(Stat.ATK, -1); + + expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); + expect(mightyena).not.toHaveAbilityApplied(AbilityId.INTIMIDATE); + }); + it("should lower ATK of all opponents in a double battle", async () => { game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index 5d73dc07615..77da57b3c64 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.queueStartOfBattleSwitch(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)]=================="); + } } From 2d6267b668846d67ee012e104dd2c9a8e0099798 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 4 Aug 2025 17:08:28 -0400 Subject: [PATCH 2/5] Fixed comment --- test/test-utils/helpers/classic-mode-helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index 77da57b3c64..18c10ef6d1e 100644 --- a/test/test-utils/helpers/classic-mode-helper.ts +++ b/test/test-utils/helpers/classic-mode-helper.ts @@ -111,7 +111,7 @@ export class ClassicModeHelper extends GameManagerHelper { * @example * ```ts * await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]) - * await game.queueStartOfBattleSwitch(1); + * await game.startBattleWithSwitch(1); * ``` */ public async startBattleWithSwitch(pokemonIndex: number): Promise { From 018a0091f382e0baac7aef43b6501f4357075fa8 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 10 Aug 2025 23:09:11 -0400 Subject: [PATCH 3/5] Fixed intimidate bugs fr fr --- src/data/phase-priority-queue.ts | 25 +++++++++++++ src/phase-manager.ts | 58 ++++++++++++++++++------------- src/phases/check-switch-phase.ts | 12 +++++++ test/abilities/intimidate.test.ts | 22 +++++++++--- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts index 88361b0f4fa..3dfc6827190 100644 --- a/src/data/phase-priority-queue.ts +++ b/src/data/phase-priority-queue.ts @@ -44,6 +44,30 @@ 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 (typeof removeCount === "string") { + removeCount = Number.MAX_SAFE_INTEGER; // For the lulz + } + let numRemoved = 0; + let phaseIndex = this.queue.findIndex(phaseFilter); + if (phaseIndex === -1) { + return 0; + } + do { + this.queue.splice(phaseIndex, 1); + numRemoved++; + } while (numRemoved < removeCount || (phaseIndex = this.queue.findIndex(phaseFilter)) !== -1); + return removeCount; + } } /** @@ -79,6 +103,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 23c276f9a5d..8064f1e62aa 100644 --- a/test/abilities/intimidate.test.ts +++ b/test/abilities/intimidate.test.ts @@ -35,31 +35,45 @@ 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); }); - // TODO: This fails due to a limitation in our switching logic - the animations and field entry occur concurrently - // inside `SummonPhase`, unshifting 2 `PostSummmonPhase`s and proccing intimidate twice - it.todo("should lower all opponents' ATK by 1 stage on initial switch prompt", async () => { + 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.getPlayerField(); + 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 () => { game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); From d576da72d27c5c3e008697c54570dc67727222f6 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:41:44 -0400 Subject: [PATCH 4/5] Update src/data/phase-priority-queue.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/phase-priority-queue.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts index 3dfc6827190..d4143305a89 100644 --- a/src/data/phase-priority-queue.ts +++ b/src/data/phase-priority-queue.ts @@ -54,8 +54,10 @@ export abstract class PhasePriorityQueue { * @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 (typeof removeCount === "string") { - removeCount = Number.MAX_SAFE_INTEGER; // For the lulz + if (removeCount === "all") { + removeCount = Number.MAX_SAFE_INTEGER; + } else if (removeCount < 1) { + return 0; } let numRemoved = 0; let phaseIndex = this.queue.findIndex(phaseFilter); From c1c66a473b6be0e99ce899d64ee5bc63f8f22eba Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:41:55 -0400 Subject: [PATCH 5/5] Update src/data/phase-priority-queue.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/phase-priority-queue.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts index d4143305a89..5f292d9c3a4 100644 --- a/src/data/phase-priority-queue.ts +++ b/src/data/phase-priority-queue.ts @@ -64,11 +64,12 @@ export abstract class PhasePriorityQueue { if (phaseIndex === -1) { return 0; } - do { + while (numRemoved < removeCount && phaseIndex !== -1) { this.queue.splice(phaseIndex, 1); numRemoved++; - } while (numRemoved < removeCount || (phaseIndex = this.queue.findIndex(phaseFilter)) !== -1); - return removeCount; + phaseIndex = this.queue.findIndex(phaseFilter); + } + return numRemoved; } }