diff --git a/src/@types/phase-condition.ts b/src/@types/phase-condition.ts new file mode 100644 index 00000000000..e2e58ad1293 --- /dev/null +++ b/src/@types/phase-condition.ts @@ -0,0 +1,3 @@ +import type { Phase } from "#app/phase"; + +export type PhaseConditionFunc = (phase: Phase) => boolean; diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8d817fffff3..630388b805b 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -527,17 +527,7 @@ export class ShellTrapTag extends BattlerTag { // Trap should only be triggered by opponent's Physical moves if (phaseData?.move.category === MoveCategory.PHYSICAL && pokemon.isOpponent(phaseData.attacker)) { - const shellTrapPhaseIndex = globalScene.phaseManager.phaseQueue.findIndex( - phase => phase.is("MovePhase") && phase.pokemon === pokemon, - ); - const firstMovePhaseIndex = globalScene.phaseManager.phaseQueue.findIndex(phase => phase.is("MovePhase")); - - // Only shift MovePhase timing if it's not already next up - if (shellTrapPhaseIndex !== -1 && shellTrapPhaseIndex !== firstMovePhaseIndex) { - const shellTrapMovePhase = globalScene.phaseManager.phaseQueue.splice(shellTrapPhaseIndex, 1)[0]; - globalScene.phaseManager.prependToPhase(shellTrapMovePhase, "MovePhase"); - } - + globalScene.phaseManager.forceMoveNext((phase: MovePhase) => phase.pokemon === pokemon); this.activated = true; } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 7c3bb26161b..d526e7c4f8b 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -3184,11 +3184,7 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { })); // Move the ally's MovePhase (if needed) so that the ally moves next - const allyMovePhaseIndex = globalScene.phaseManager.phaseQueue.indexOf(allyMovePhase); - const firstMovePhaseIndex = globalScene.phaseManager.phaseQueue.findIndex((phase) => phase.is("MovePhase")); - if (allyMovePhaseIndex !== firstMovePhaseIndex) { - globalScene.phaseManager.prependToPhase(globalScene.phaseManager.phaseQueue.splice(allyMovePhaseIndex, 1)[0], "MovePhase"); - } + globalScene.phaseManager.forceMoveNext((phase: MovePhase) => phase.pokemon === user.getAlly()); overridden.value = true; return true; @@ -4546,12 +4542,7 @@ export class CueNextRoundAttr extends MoveEffectAttr { return false; } - // Update the phase queue so that the next Pokemon using Round moves next - const nextRoundIndex = globalScene.phaseManager.phaseQueue.indexOf(nextRoundPhase); - const nextMoveIndex = globalScene.phaseManager.phaseQueue.findIndex(phase => phase.is("MovePhase")); - if (nextRoundIndex !== nextMoveIndex) { - globalScene.phaseManager.prependToPhase(globalScene.phaseManager.phaseQueue.splice(nextRoundIndex, 1)[0], "MovePhase"); - } + globalScene.phaseManager.forceMoveLast((phase: MovePhase) => phase.is("MovePhase") && phase.move.moveId === MoveId.ROUND); // Mark the corresponding Pokemon as having "joined the Round" (for doubling power later) nextRoundPhase.pokemon.turnData.joinedRound = true; @@ -7923,38 +7914,11 @@ export class ForceLastAttr extends MoveEffectAttr { override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) })); - const targetMovePhase = globalScene.phaseManager.findPhase((phase) => phase.pokemon === target); - if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { - // Finding the phase to insert the move in front of - - // Either the end of the turn or in front of another, slower move which has also been forced last - const prependPhase = globalScene.phaseManager.findPhase((phase) => - [ MovePhase, MoveEndPhase ].every(cls => !(phase instanceof cls)) - || (phase.is("MovePhase")) && phaseForcedSlower(phase, target, !!globalScene.arena.getTag(ArenaTagType.TRICK_ROOM)) - ); - if (prependPhase) { - globalScene.phaseManager.phaseQueue.splice( - globalScene.phaseManager.phaseQueue.indexOf(prependPhase), - 0, - globalScene.phaseManager.create("MovePhase", target, [ ...targetMovePhase.targets ], targetMovePhase.move, false, false, false, true) - ); - } - } + globalScene.phaseManager.forceMoveLast((phase: MovePhase) => phase.pokemon === target); return true; } } -/** Returns whether a {@linkcode MovePhase} has been forced last and the corresponding pokemon is slower than {@linkcode target} */ -const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean): boolean => { - let slower: boolean; - // quashed pokemon still have speed ties - if (phase.pokemon.getEffectiveStat(Stat.SPD) === target.getEffectiveStat(Stat.SPD)) { - slower = !!target.randBattleSeedInt(2); - } else { - slower = !trickRoom ? phase.pokemon.getEffectiveStat(Stat.SPD) < target.getEffectiveStat(Stat.SPD) : phase.pokemon.getEffectiveStat(Stat.SPD) > target.getEffectiveStat(Stat.SPD); - } - return phase.isForcedLast() && slower; -}; - const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY); const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune(); @@ -7975,7 +7939,7 @@ const userSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE); -const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined; +const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.hasPhaseOfType("MovePhase"); const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => { const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 5e5a5491db6..73526cc2448 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -98,6 +98,8 @@ import { UnlockPhase } from "#app/phases/unlock-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; import { WeatherEffectPhase } from "#app/phases/weather-effect-phase"; import { DynamicQueueManager } from "#app/queues/dynamic-queue-manager"; +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; /** * Manager for phases used by battle scene. @@ -217,8 +219,8 @@ export type PhaseConstructorMap = typeof PHASES; */ export class PhaseManager { /** PhaseQueue: dequeue/remove the first element to get the next phase */ - public phaseQueue: Phase[] = []; - public conditionalQueue: Array<[() => boolean, Phase]> = []; + private phaseQueue: Phase[] = []; + private conditionalQueue: Array<[() => boolean, Phase]> = []; /** PhaseQueuePrepend: is a temp storage of what will be added to PhaseQueue */ private phaseQueuePrepend: Phase[] = []; @@ -381,6 +383,15 @@ export class PhaseManager { return true; } + public hasPhaseOfType(type: PhaseString, condition?: PhaseConditionFunc): boolean { + if (this.dynamicQueueManager.isDynamicPhase(type)) { + return this.dynamicQueueManager.exists(type, condition); + } + return [this.phaseQueue, this.phaseQueuePrepend].some((queue: Phase[]) => + queue.find(phase => phase.is(type) && (!condition || condition(phase))), + ); + } + /** * Find a specific {@linkcode Phase} in the phase queue. * @@ -422,6 +433,11 @@ export class PhaseManager { return false; } + public removeAllPhasesOfType(type: PhaseString): void { + this.phaseQueue = this.phaseQueue.filter(phase => !phase.is(type)); + this.phaseQueuePrepend = this.phaseQueuePrepend.filter(phase => !phase.is(type)); + } + /** * Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase() * @param phase - The phase to be added @@ -631,4 +647,12 @@ export class PhaseManager { ): void { this.startDynamicPhase(this.create(phase, ...args)); } + + public forceMoveNext(phaseCondition: PhaseConditionFunc) { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.FIRST); + } + + public forceMoveLast(phaseCondition: PhaseConditionFunc) { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.LAST); + } } diff --git a/src/phases/battle-end-phase.ts b/src/phases/battle-end-phase.ts index e1bf4c2296c..a7c52bad11f 100644 --- a/src/phases/battle-end-phase.ts +++ b/src/phases/battle-end-phase.ts @@ -18,23 +18,11 @@ export class BattleEndPhase extends BattlePhase { super.start(); // cull any extra `BattleEnd` phases from the queue. - globalScene.phaseManager.phaseQueue = globalScene.phaseManager.phaseQueue.filter(phase => { - if (phase.is("BattleEndPhase")) { - this.isVictory ||= phase.isVictory; - return false; - } - return true; - }); - // `phaseQueuePrepend` is private, so we have to use this inefficient loop. - while ( - globalScene.phaseManager.tryRemoveUnshiftedPhase(phase => { - if (phase.is("BattleEndPhase")) { - this.isVictory ||= phase.isVictory; - return true; - } - return false; - }) - ) {} + this.isVictory ||= globalScene.phaseManager.hasPhaseOfType( + "BattleEndPhase", + (phase: BattleEndPhase) => phase.isVictory, + ); + globalScene.phaseManager.removeAllPhasesOfType("BattleEndPhase"); globalScene.gameData.gameStats.battles++; if ( diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 2c36b922a7b..32889bae07a 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -28,6 +28,7 @@ import { StatusEffect } from "#enums/status-effect"; import i18next from "i18next"; import { frenzyMissFunc } from "#app/data/moves/move-utils"; import { PokemonPhase } from "#app/phases/pokemon-phase"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; export class MovePhase extends PokemonPhase { public readonly phaseName = "MovePhase"; @@ -36,7 +37,7 @@ export class MovePhase extends PokemonPhase { protected _targets: BattlerIndex[]; protected followUp: boolean; protected ignorePp: boolean; - protected forcedLast: boolean; + protected _timingModifier: MovePhaseTimingModifier; protected failed = false; protected cancelled = false; protected reflected = false; @@ -65,6 +66,14 @@ export class MovePhase extends PokemonPhase { this._targets = targets; } + public get timingModifier(): MovePhaseTimingModifier { + return this._timingModifier; + } + + public set timingModifier(modifier: MovePhaseTimingModifier) { + this._timingModifier = modifier; + } + /** * @param followUp Indicates that the move being used is a "follow-up" - for example, a move being used by Metronome or Dancer. * Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc. @@ -79,7 +88,6 @@ export class MovePhase extends PokemonPhase { followUp = false, ignorePp = false, reflected = false, - forcedLast = false, ) { super(pokemon.getBattlerIndex()); @@ -89,7 +97,7 @@ export class MovePhase extends PokemonPhase { this.followUp = followUp; this.ignorePp = ignorePp; this.reflected = reflected; - this.forcedLast = forcedLast; + this.timingModifier = MovePhaseTimingModifier.NORMAL; } /** @@ -115,14 +123,6 @@ export class MovePhase extends PokemonPhase { this.cancelled = true; } - /** - * Shows whether the current move has been forced to the end of the turn - * Needed for speed order, see {@linkcode MoveId.QUASH} - * */ - public isForcedLast(): boolean { - return this.forcedLast; - } - public start(): void { super.start(); diff --git a/src/phases/new-battle-phase.ts b/src/phases/new-battle-phase.ts index 65ecc81df2d..4d4d1311992 100644 --- a/src/phases/new-battle-phase.ts +++ b/src/phases/new-battle-phase.ts @@ -6,12 +6,7 @@ export class NewBattlePhase extends BattlePhase { start() { super.start(); - // cull any extra `NewBattle` phases from the queue. - globalScene.phaseManager.phaseQueue = globalScene.phaseManager.phaseQueue.filter( - phase => !phase.is("NewBattlePhase"), - ); - // `phaseQueuePrepend` is private, so we have to use this inefficient loop. - while (globalScene.phaseManager.tryRemoveUnshiftedPhase(phase => phase.is("NewBattlePhase"))) {} + globalScene.phaseManager.removeAllPhasesOfType("NewBattlePhase"); globalScene.newBattle(); diff --git a/src/queues/dynamic-queue-manager.ts b/src/queues/dynamic-queue-manager.ts index 3eebf107ed3..3123c0b712b 100644 --- a/src/queues/dynamic-queue-manager.ts +++ b/src/queues/dynamic-queue-manager.ts @@ -1,3 +1,4 @@ +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; import type { PhaseString } from "#app/@types/phase-types"; import type { Phase } from "#app/phase"; import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; @@ -5,6 +6,7 @@ import { MovePhasePriorityQueue } from "#app/queues/move-phase-priority-queue"; import type { PhasePriorityQueue } from "#app/queues/phase-priority-queue"; import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; import { PostSummonPhasePriorityQueue } from "#app/queues/post-summon-phase-priority-queue"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; export class DynamicQueueManager { private dynamicPhaseMap: Map>; @@ -32,4 +34,13 @@ export class DynamicQueueManager { public isDynamicPhase(type: PhaseString): boolean { return this.dynamicPhaseMap.has(type); } + + public exists(type: PhaseString, condition?: PhaseConditionFunc): boolean { + return !!this.dynamicPhaseMap.get(type)?.hasPhaseWithCondition(condition); + } + + public setMoveTimingModifier(condition: PhaseConditionFunc, modifier: MovePhaseTimingModifier) { + const movePhaseQueue: MovePhasePriorityQueue = this.dynamicPhaseMap.get("MovePhase") as MovePhasePriorityQueue; + movePhaseQueue.setTimingModifier(condition, modifier); + } } diff --git a/src/queues/phase-priority-queue.ts b/src/queues/phase-priority-queue.ts index 8ace2e6af59..00816318fcd 100644 --- a/src/queues/phase-priority-queue.ts +++ b/src/queues/phase-priority-queue.ts @@ -1,3 +1,4 @@ +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; import type { Phase } from "#app/phase"; /** @@ -40,4 +41,8 @@ export abstract class PhasePriorityQueue { public isEmpty(): boolean { return !this.queue.length; } + + public hasPhaseWithCondition(condition?: PhaseConditionFunc): boolean { + return this.queue.find(phase => !condition || condition(phase)) !== undefined; + } } diff --git a/test/moves/focus_punch.test.ts b/test/moves/focus_punch.test.ts index 38b57b201c0..a94ae6ccdd0 100644 --- a/test/moves/focus_punch.test.ts +++ b/test/moves/focus_punch.test.ts @@ -1,6 +1,5 @@ import { BerryPhase } from "#app/phases/berry-phase"; import { MessagePhase } from "#app/phases/message-phase"; -import { MoveHeaderPhase } from "#app/phases/move-header-phase"; import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase"; import { AbilityId } from "#enums/ability-id"; @@ -116,7 +115,7 @@ describe("Moves - Focus Punch", () => { await game.phaseInterceptor.to(TurnStartPhase); expect(game.scene.phaseManager.getCurrentPhase() instanceof SwitchSummonPhase).toBeTruthy(); - expect(game.scene.phaseManager.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined(); + expect(game.scene.phaseManager.hasPhaseOfType("MoveHeaderPhase")).toBeTruthy(); }); it("should replace the 'but it failed' text when the user gets hit", async () => { game.override.enemyMoveset([MoveId.TACKLE]);