From 87e6095a001f24e81118e65957f760e7d247834d Mon Sep 17 00:00:00 2001 From: Dean <69436131+emdeann@users.noreply.github.com> Date: Sat, 20 Sep 2025 15:49:40 -0700 Subject: [PATCH 1/2] [Misc/Feature] Add dynamic turn order (#6036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new priority queues * Add dynamic queue manager * Add timing modifier and fix post speed ordering * Make `phaseQueue` private * Fix `gameManager.setTurnOrder` * Update `findPhase` to also check dynamic queues * Modify existing phase manager methods to check dynamic queues * Fix move order persisting through tests * Fix magic coat/bounce * Use append for magic coat/bounce * Remove `getSpeedOrder` from `TurnStartPhase`, fix references to `getCommandOrder` in tests * Fix round queuing last instead of next * Add quick draw application * Add quick claw activation * Fix turn order tracking * Add move header queue to fix ordering * Fix abilities activating immediately on summon * Fix `postsummonphases` being shuffled (need to handle speed ties differently here) * Update speed order function * Add `StaticSwitchSummonPhase` * Fix magic coat/bounce error from conflict resolution * Remove conditional queue * Fix dancer and baton pass tests * Automatically queue consecutive Pokémon phases as dynamic * Move turn end phases queuing back to `TurnStartPhase` * Fix `LearnMovePhase` * Remove `PrependSplice` * Move DQM to phase manager * Fix various phases being pushed instead of unshifted * Remove `StaticSwitchSummonPhase` * Ensure the top queue is always at length - 1 * Fix encounter `PostSummonPhase`s and Revival Blessing * Fix move headers * Remove implicit ordering from DQM * Fix `PostSummonPhase`s in encounters running too early * Fix `tryRemovePhase` usages * Add `MovePhase` after `MoveEndPhase` automatically * Implement an `inSpeedOrder` function * Merge fixes * Fix encounter rewards * Defer `FaintPhase`s where splice was used previously * Separate speed order utils to avoid circular imports * Temporarily disable lunar dance test * Simplify deferral * Remove move priority modifier * Fix TS errors in code files * Fix ts errors in tests * Fix more test files * Fix postsummon + checkswitch ability activations * Fix `removeAll` * Reposition `positionalTagPhase` * Re-add `startCurrentPhase` * Avoid overwriting `currentPhase` after `turnStart` * Delete `switchSummonPhasePriorityQueue` * Update `phase-manager.ts` * Remove uses of `isNullOrUndefined` * Rename deferral methods * Update docs and use `getPlayerField(true)` in turn start phase * Use `.getEnemyField(true)` * Update docs for post summon phase priority queue (psppq) * Update speed order utils * Remove null from `nextPhase` * Update move phase timing modifier docs * Remove mention of phases from base priority queue class * Remove and replace `applyInSpeedOrder` * Don't sort weather effect phases * Order priority queues before removing - Add some `readonly` and `public` modifiers - Remove unused `queuedPhases` field from `MoveEffectPhase` * Fix linting in `phase-manager.ts` * Remove unnecessary turn order modification in Rage Fist test --------- Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/@types/phase-types.ts | 13 + src/battle-scene.ts | 31 +- src/data/abilities/ability.ts | 48 +- src/data/abilities/apply-ab-attrs.ts | 1 - src/data/battler-tags.ts | 53 +- src/data/moves/move.ts | 113 +--- .../encounters/fun-and-games-encounter.ts | 2 +- .../the-expert-pokemon-breeder-encounter.ts | 1 - .../utils/encounter-phase-utils.ts | 9 +- src/data/phase-priority-queue.ts | 125 ----- src/dynamic-queue-manager.ts | 182 +++++++ src/enums/arena-tag-side.ts | 1 + src/enums/battler-tag-type.ts | 1 + src/enums/dynamic-phase-type.ts | 7 - src/enums/move-phase-timing-modifier.ts | 16 + src/field/arena.ts | 8 +- src/field/pokemon.ts | 13 +- src/modifier/modifier.ts | 21 +- src/phase-manager.ts | 504 +++++++----------- src/phase-tree.ts | 206 +++++++ src/phases/activate-priority-queue-phase.ts | 23 - src/phases/battle-end-phase.ts | 22 +- src/phases/check-status-effect-phase.ts | 12 +- src/phases/check-switch-phase.ts | 18 +- src/phases/dynamic-phase-marker.ts | 17 + src/phases/egg-hatch-phase.ts | 2 +- src/phases/encounter-phase.ts | 53 +- src/phases/game-over-phase.ts | 18 +- src/phases/learn-move-phase.ts | 4 +- src/phases/move-charge-phase.ts | 2 +- src/phases/move-effect-phase.ts | 45 +- src/phases/move-header-phase.ts | 6 +- src/phases/move-phase.ts | 41 +- src/phases/mystery-encounter-phases.ts | 26 +- src/phases/new-battle-phase.ts | 7 +- src/phases/party-member-pokemon-phase.ts | 4 + .../post-summon-activate-ability-phase.ts | 4 +- src/phases/post-summon-phase.ts | 15 +- src/phases/quiet-form-change-phase.ts | 8 +- src/phases/stat-stage-change-phase.ts | 48 +- src/phases/summon-phase.ts | 22 +- src/phases/switch-phase.ts | 9 - src/phases/switch-summon-phase.ts | 4 +- src/phases/title-phase.ts | 22 +- src/phases/turn-end-phase.ts | 1 + src/phases/turn-start-phase.ts | 127 +---- src/queues/move-phase-priority-queue.ts | 103 ++++ src/queues/pokemon-phase-priority-queue.ts | 20 + src/queues/pokemon-priority-queue.ts | 10 + .../post-summon-phase-priority-queue.ts | 45 ++ src/queues/priority-queue.ts | 78 +++ src/utils/speed-order-generator.ts | 39 ++ src/utils/speed-order.ts | 57 ++ test/abilities/dancer.test.ts | 5 +- test/abilities/mycelium-might.test.ts | 47 +- test/abilities/neutralizing-gas.test.ts | 2 +- test/abilities/quick-draw.test.ts | 41 +- test/abilities/stall.test.ts | 34 +- test/battle/battle-order.test.ts | 71 ++- test/moves/baton-pass.test.ts | 7 +- test/moves/delayed-attack.test.ts | 2 +- test/moves/focus-punch.test.ts | 3 +- test/moves/rage-fist.test.ts | 1 - test/moves/revival-blessing.test.ts | 7 +- test/moves/shell-trap.test.ts | 2 +- test/moves/trick-room.test.ts | 12 +- test/moves/wish.test.ts | 2 +- .../mystery-encounter/encounter-test-utils.ts | 2 - .../the-winstrate-challenge-encounter.test.ts | 1 - test/test-utils/game-manager.ts | 7 +- 70 files changed, 1340 insertions(+), 1173 deletions(-) delete mode 100644 src/data/phase-priority-queue.ts create mode 100644 src/dynamic-queue-manager.ts delete mode 100644 src/enums/dynamic-phase-type.ts create mode 100644 src/enums/move-phase-timing-modifier.ts create mode 100644 src/phase-tree.ts delete mode 100644 src/phases/activate-priority-queue-phase.ts create mode 100644 src/phases/dynamic-phase-marker.ts create mode 100644 src/queues/move-phase-priority-queue.ts create mode 100644 src/queues/pokemon-phase-priority-queue.ts create mode 100644 src/queues/pokemon-priority-queue.ts create mode 100644 src/queues/post-summon-phase-priority-queue.ts create mode 100644 src/queues/priority-queue.ts create mode 100644 src/utils/speed-order-generator.ts create mode 100644 src/utils/speed-order.ts diff --git a/src/@types/phase-types.ts b/src/@types/phase-types.ts index 91673053747..d396375c5fa 100644 --- a/src/@types/phase-types.ts +++ b/src/@types/phase-types.ts @@ -1,3 +1,5 @@ +import type { Pokemon } from "#app/field/pokemon"; +import type { Phase } from "#app/phase"; import type { PhaseConstructorMap } from "#app/phase-manager"; import type { ObjectValues } from "#types/type-helpers"; @@ -24,3 +26,14 @@ export type PhaseClass = ObjectValues; * Union type of all phase names as strings. */ export type PhaseString = keyof PhaseMap; + +/** Type for predicate functions operating on a specific type of {@linkcode Phase}. */ + +export type PhaseConditionFunc = (phase: PhaseMap[T]) => boolean; + +/** + * Interface type representing the assumption that all phases with pokemon associated are dynamic + */ +export interface DynamicPhase extends Phase { + getPokemon(): Pokemon; +} diff --git a/src/battle-scene.ts b/src/battle-scene.ts index cbda368782e..289c9a8f051 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -104,7 +104,6 @@ import { import { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounterSaveData } from "#mystery-encounters/mystery-encounter-save-data"; import { allMysteryEncounters, mysteryEncountersByBiome } from "#mystery-encounters/mystery-encounters"; -import type { MovePhase } from "#phases/move-phase"; import { expSpriteKeys } from "#sprites/sprite-keys"; import { hasExpSprite } from "#sprites/sprite-utils"; import type { Variant } from "#sprites/variant"; @@ -787,12 +786,14 @@ export class BattleScene extends SceneBase { /** * Returns an array of EnemyPokemon of length 1 or 2 depending on if in a double battle or not. - * Does not actually check if the pokemon are on the field or not. + * @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon * @returns array of {@linkcode EnemyPokemon} */ - public getEnemyField(): EnemyPokemon[] { + public getEnemyField(active = false): EnemyPokemon[] { const party = this.getEnemyParty(); - return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)); + return party + .slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)) + .filter(p => !active || p.isActive()); } /** @@ -817,25 +818,7 @@ export class BattleScene extends SceneBase { * @param allyPokemon - The {@linkcode Pokemon} allied with the removed Pokemon; will have moves redirected to it */ redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { - // failsafe: if not a double battle just return - if (this.currentBattle.double === false) { - return; - } - if (allyPokemon?.isActive(true)) { - let targetingMovePhase: MovePhase; - do { - targetingMovePhase = this.phaseManager.findPhase( - mp => - mp.is("MovePhase") - && mp.targets.length === 1 - && mp.targets[0] === removedPokemon.getBattlerIndex() - && mp.pokemon.isPlayer() !== allyPokemon.isPlayer(), - ) as MovePhase; - if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) { - targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex(); - } - } while (targetingMovePhase); - } + this.phaseManager.redirectMoves(removedPokemon, allyPokemon); } /** @@ -1433,7 +1416,7 @@ export class BattleScene extends SceneBase { } if (lastBattle?.double && !newDouble) { - this.phaseManager.tryRemovePhase((p: Phase) => p.is("SwitchPhase")); + this.phaseManager.tryRemovePhase("SwitchPhase"); for (const p of this.getPlayerField()) { p.lapseTag(BattlerTagType.COMMANDED); } diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index ebe8b816e5e..f6494548b99 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -33,6 +33,7 @@ import { CommonAnim } from "#enums/move-anims-common"; import { MoveCategory } from "#enums/move-category"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; import { MoveUseMode } from "#enums/move-use-mode"; @@ -2555,7 +2556,7 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr { override apply({ pokemon, simulated, cancelled }: AbAttrParamsWithCancel): void { if (!simulated) { - globalScene.phaseManager.pushNew( + globalScene.phaseManager.unshiftNew( "StatStageChangePhase", pokemon.getBattlerIndex(), false, @@ -3240,6 +3241,7 @@ export class CommanderAbAttr extends AbAttr { return ( globalScene.currentBattle?.double && ally != null + && ally.isActive(true) && ally.species.speciesId === SpeciesId.DONDOZO && !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)) ); @@ -3254,7 +3256,7 @@ export class CommanderAbAttr extends AbAttr { // Apply boosts from this effect to the ally Dondozo pokemon.getAlly()?.addTag(BattlerTagType.COMMANDED, 0, MoveId.NONE, pokemon.id); // Cancel the source Pokemon's next move (if a move is queued) - globalScene.phaseManager.tryRemovePhase(phase => phase.is("MovePhase") && phase.pokemon === pokemon); + globalScene.phaseManager.tryRemovePhase("MovePhase", phase => phase.pokemon === pokemon); } } } @@ -5004,7 +5006,14 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { // If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance if (move.getMove().is("AttackMove") || move.getMove().is("StatusMove")) { const target = this.getTarget(pokemon, source, targets); - globalScene.phaseManager.unshiftNew("MovePhase", pokemon, target, move, MoveUseMode.INDIRECT); + globalScene.phaseManager.unshiftNew( + "MovePhase", + pokemon, + target, + move, + MoveUseMode.INDIRECT, + MovePhaseTimingModifier.FIRST, + ); } else if (move.getMove().is("SelfStatusMove")) { // If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself globalScene.phaseManager.unshiftNew( @@ -5013,6 +5022,7 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { [pokemon.getBattlerIndex()], move, MoveUseMode.INDIRECT, + MovePhaseTimingModifier.FIRST, ); } } @@ -6028,11 +6038,6 @@ export class IllusionPostBattleAbAttr extends PostBattleAbAttr { } } -export interface BypassSpeedChanceAbAttrParams extends AbAttrBaseParams { - /** Holds whether the speed check is bypassed after ability application */ - bypass: BooleanHolder; -} - /** * If a Pokémon with this Ability selects a damaging move, it has a 30% chance of going first in its priority bracket. If the Ability activates, this is announced at the start of the turn (after move selection). * @sealed @@ -6048,26 +6053,28 @@ export class BypassSpeedChanceAbAttr extends AbAttr { this.chance = chance; } - override canApply({ bypass, simulated, pokemon }: BypassSpeedChanceAbAttrParams): boolean { + override canApply({ simulated, pokemon }: AbAttrBaseParams): boolean { // TODO: Consider whether we can move the simulated check to the `apply` method // May be difficult as we likely do not want to modify the randBattleSeed const turnCommand = globalScene.currentBattle.turnCommands[pokemon.getBattlerIndex()]; - const isCommandFight = turnCommand?.command === Command.FIGHT; const move = turnCommand?.move?.move ? allMoves[turnCommand.move.move] : null; const isDamageMove = move?.category === MoveCategory.PHYSICAL || move?.category === MoveCategory.SPECIAL; return ( - !simulated && !bypass.value && pokemon.randBattleSeedInt(100) < this.chance && isCommandFight && isDamageMove + !simulated + && pokemon.randBattleSeedInt(100) < this.chance + && isDamageMove + && pokemon.canAddTag(BattlerTagType.BYPASS_SPEED) ); } /** * bypass move order in their priority bracket when pokemon choose damaging move */ - override apply({ bypass }: BypassSpeedChanceAbAttrParams): void { - bypass.value = true; + override apply({ pokemon }: AbAttrBaseParams): void { + pokemon.addTag(BattlerTagType.BYPASS_SPEED); } - override getTriggerMessage({ pokemon }: BypassSpeedChanceAbAttrParams, _abilityName: string): string { + override getTriggerMessage({ pokemon }: AbAttrBaseParams, _abilityName: string): string { return i18next.t("abilityTriggers:quickDraw", { pokemonName: getPokemonNameWithAffix(pokemon) }); } } @@ -6075,8 +6082,6 @@ export class BypassSpeedChanceAbAttr extends AbAttr { export interface PreventBypassSpeedChanceAbAttrParams extends AbAttrBaseParams { /** Holds whether the speed check is bypassed after ability application */ bypass: BooleanHolder; - /** Holds whether the Pokemon can check held items for Quick Claw's effects */ - canCheckHeldItems: BooleanHolder; } /** @@ -6103,9 +6108,8 @@ export class PreventBypassSpeedChanceAbAttr extends AbAttr { return isCommandFight && this.condition(pokemon, move!); } - override apply({ bypass, canCheckHeldItems }: PreventBypassSpeedChanceAbAttrParams): void { + override apply({ bypass }: PreventBypassSpeedChanceAbAttrParams): void { bypass.value = false; - canCheckHeldItems.value = false; } } @@ -6203,8 +6207,7 @@ class ForceSwitchOutHelper { if (switchOutTarget.hp > 0) { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6226,8 +6229,7 @@ class ForceSwitchOutHelper { const summonIndex = globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0; - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -7161,7 +7163,7 @@ export function initAbilities() { new Ability(AbilityId.ANALYTIC, 5) .attr(MovePowerBoostAbAttr, (user) => // Boost power if all other Pokemon have already moved (no other moves are slated to execute) - !globalScene.phaseManager.findPhase((phase) => phase.is("MovePhase") && phase.pokemon.id !== user?.id), + !globalScene.phaseManager.hasPhaseOfType("MovePhase", phase => phase.pokemon.id !== user?.id), 1.3), new Ability(AbilityId.ILLUSION, 5) // The Pokemon generate an illusion if it's available diff --git a/src/data/abilities/apply-ab-attrs.ts b/src/data/abilities/apply-ab-attrs.ts index 58f63c5924a..23b16a4cac7 100644 --- a/src/data/abilities/apply-ab-attrs.ts +++ b/src/data/abilities/apply-ab-attrs.ts @@ -74,7 +74,6 @@ function applyAbAttrsInternal( for (const passive of [false, true]) { params.passive = passive; applySingleAbAttrs(attrType, params, gainedMidTurn, messages); - globalScene.phaseManager.clearPhaseQueueSplice(); } // We need to restore passive to its original state in the case that it was undefined on entry // this is necessary in case this method is called with an object that is reused. diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index b6c3cf2b5a6..8abd98f4683 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -606,17 +606,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; } @@ -1279,22 +1269,9 @@ export class EncoreTag extends MoveRestrictionBattlerTag { }), ); - const movePhase = globalScene.phaseManager.findPhase(m => m.is("MovePhase") && m.pokemon === pokemon); - if (movePhase) { - const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - if (movesetMove) { - const lastMove = pokemon.getLastXMoves(1)[0]; - globalScene.phaseManager.tryReplacePhase( - m => m.is("MovePhase") && m.pokemon === pokemon, - globalScene.phaseManager.create( - "MovePhase", - pokemon, - lastMove.targets ?? [], - movesetMove, - MoveUseMode.NORMAL, - ), - ); - } + const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); + if (movesetMove) { + globalScene.phaseManager.changePhaseMove((phase: MovePhase) => phase.pokemon === pokemon, movesetMove); } } @@ -3578,6 +3555,25 @@ export class GrudgeTag extends SerializableBattlerTag { } } +/** + * Tag to allow the affected Pokemon's move to go first in its priority bracket. + * Used for {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Draw_(Ability) | Quick Draw} + * and {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Claw | Quick Claw}. + */ +export class BypassSpeedTag extends BattlerTag { + public override readonly tagType = BattlerTagType.BYPASS_SPEED; + + constructor() { + super(BattlerTagType.BYPASS_SPEED, BattlerTagLapseType.TURN_END, 1); + } + + override canAdd(pokemon: Pokemon): boolean { + const bypass = new BooleanHolder(true); + applyAbAttrs("PreventBypassSpeedChanceAbAttr", { pokemon, bypass }); + return bypass.value; + } +} + /** * Tag used to heal the user of Psycho Shift of its status effect if Psycho Shift succeeds in transferring its status effect to the target Pokemon */ @@ -3863,6 +3859,8 @@ export function getBattlerTag( return new MagicCoatTag(); case BattlerTagType.SUPREME_OVERLORD: return new SupremeOverlordTag(); + case BattlerTagType.BYPASS_SPEED: + return new BypassSpeedTag(); } } @@ -3998,4 +3996,5 @@ export type BattlerTagTypeMap = { [BattlerTagType.PSYCHO_SHIFT]: PsychoShiftTag; [BattlerTagType.MAGIC_COAT]: MagicCoatTag; [BattlerTagType.SUPREME_OVERLORD]: SupremeOverlordTag; + [BattlerTagType.BYPASS_SPEED]: BypassSpeedTag; }; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 72376b7934f..075876d8ddd 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -81,10 +81,8 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; -import { MoveEndPhase } from "#phases/move-end-phase"; import { MovePhase } from "#phases/move-phase"; import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; -import { SwitchSummonPhase } from "#phases/switch-summon-phase"; import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types"; @@ -94,6 +92,7 @@ import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { applyChallenges } from "#utils/challenge-utils"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import type { AbstractConstructor } from "#types/type-helpers"; /** @@ -891,6 +890,10 @@ export abstract class Move implements Localizable { applyMoveAttrs("IncrementMovePriorityAttr", user, null, this, priority); applyAbAttrs("ChangeMovePriorityAbAttr", {pokemon: user, simulated, move: this, priority}); + if (user.getTag(BattlerTagType.BYPASS_SPEED)) { + priority.value += 0.2; + } + return priority.value; } @@ -3298,7 +3301,7 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { const overridden = args[0] as BooleanHolder; - const allyMovePhase = globalScene.phaseManager.findPhase((phase) => phase.is("MovePhase") && phase.pokemon.isPlayer() === user.isPlayer()); + const allyMovePhase = globalScene.phaseManager.getMovePhase((phase) => phase.pokemon.isPlayer() === user.isPlayer()); if (allyMovePhase) { const allyMove = allyMovePhase.move.getMove(); if (allyMove !== move && allyMove.hasAttr("AwaitCombinedPledgeAttr")) { @@ -3311,11 +3314,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; @@ -4550,28 +4549,7 @@ export class LastMoveDoublePowerAttr extends VariablePowerAttr { */ apply(user: Pokemon, _target: Pokemon, _move: Move, args: any[]): boolean { const power = args[0] as NumberHolder; - const enemy = user.getOpponent(0); - const pokemonActed: Pokemon[] = []; - - if (enemy?.turnData.acted) { - pokemonActed.push(enemy); - } - - if (globalScene.currentBattle.double) { - const userAlly = user.getAlly(); - const enemyAlly = enemy?.getAlly(); - - if (userAlly?.turnData.acted) { - pokemonActed.push(userAlly); - } - if (enemyAlly?.turnData.acted) { - pokemonActed.push(enemyAlly); - } - } - - pokemonActed.sort((a, b) => b.turnData.order - a.turnData.order); - - for (const p of pokemonActed) { + for (const p of globalScene.phaseManager.dynamicQueueManager.getLastTurnOrder().slice(0, -1).reverse()) { const [ lastMove ] = p.getLastXMoves(1); if (lastMove.result !== MoveResult.FAIL) { if ((lastMove.result === MoveResult.SUCCESS) && (lastMove.move === this.move)) { @@ -4653,20 +4631,13 @@ export class CueNextRoundAttr extends MoveEffectAttr { } override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean { - const nextRoundPhase = globalScene.phaseManager.findPhase(phase => - phase.is("MovePhase") && phase.move.moveId === MoveId.ROUND - ); + const nextRoundPhase = globalScene.phaseManager.getMovePhase(phase => phase.move.moveId === MoveId.ROUND); if (!nextRoundPhase) { 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.forceMoveNext(phase => phase.move.moveId === MoveId.ROUND); // Mark the corresponding Pokemon as having "joined the Round" (for doubling power later) nextRoundPhase.pokemon.turnData.joinedRound = true; @@ -6291,11 +6262,11 @@ export class RevivalBlessingAttr extends MoveEffectAttr { // Handle cases where revived pokemon needs to get switched in on same turn if (allyPokemon.isFainted() || allyPokemon === pokemon) { // Enemy switch phase should be removed and replaced with the revived pkmn switching in - globalScene.phaseManager.tryRemovePhase((phase: SwitchSummonPhase) => phase.is("SwitchSummonPhase") && phase.getPokemon() === pokemon); + globalScene.phaseManager.tryRemovePhase("SwitchSummonPhase", phase => phase.getFieldIndex() === slotIndex); // If the pokemon being revived was alive earlier in the turn, cancel its move // (revived pokemon can't move in the turn they're brought back) // TODO: might make sense to move this to `FaintPhase` after checking for Rev Seed (rather than handling it in the move) - globalScene.phaseManager.findPhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel(); + globalScene.phaseManager.getMovePhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel(); if (user.fieldPosition === FieldPosition.CENTER) { user.setFieldPosition(FieldPosition.LEFT); } @@ -6376,8 +6347,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { if (this.switchType === SwitchType.FORCE_SWITCH) { switchOutTarget.leaveField(true); const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)]; - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6387,7 +6357,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { ); } else { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6416,7 +6386,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { if (this.switchType === SwitchType.FORCE_SWITCH) { switchOutTarget.leaveField(true); const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)]; - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6426,7 +6396,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { ); } else { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6857,7 +6827,7 @@ class CallMoveAttr extends OverrideMoveEffectAttr { : moveTargets.targets[user.randBattleSeedInt(moveTargets.targets.length)]]; globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", move.id); - globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP); + globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST); return true; } } @@ -7089,7 +7059,7 @@ export class NaturePowerAttr extends OverrideMoveEffectAttr { // Load the move's animation if we didn't already and unshift a new usage phase globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", moveId); - globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP); + globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST); return true; } } @@ -7173,7 +7143,7 @@ export class RepeatMoveAttr extends MoveEffectAttr { targetPokemonName: getPokemonNameWithAffix(target) })); target.turnData.extraTurns++; - globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL); + globalScene.phaseManager.unshiftNew("MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL, MovePhaseTimingModifier.FIRST); return true; } @@ -7946,12 +7916,7 @@ export class AfterYouAttr extends MoveEffectAttr { */ override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:afterYou", { targetName: getPokemonNameWithAffix(target) })); - - // Will find next acting phase of the targeted pokémon, delete it and queue it right after us. - const targetNextPhase = globalScene.phaseManager.findPhase(phase => phase.pokemon === target); - if (targetNextPhase && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { - globalScene.phaseManager.prependToPhase(targetNextPhase, "MovePhase"); - } + globalScene.phaseManager.forceMoveNext((phase: MovePhase) => phase.pokemon === target); return true; } @@ -7974,45 +7939,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) })); - // TODO: Refactor this to be more readable and less janky - 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, targetMovePhase.useMode, 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}. - - * TODO: - - Make this a class method - - Make this look at speed order from TurnStartPhase -*/ -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(); @@ -8036,7 +7967,7 @@ const userSleptOrComatoseCondition: MoveConditionFunc = (user) => user.status?.e const targetSleptOrComatoseCondition: MoveConditionFunc = (_user: Pokemon, target: Pokemon, _move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE); -const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.findPhase(phase => phase.is("MovePhase")) !== undefined; +const failIfLastCondition: MoveConditionFunc = () => 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/data/mystery-encounters/encounters/fun-and-games-encounter.ts b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts index d883fdbb567..f2363ade500 100644 --- a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts +++ b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts @@ -414,7 +414,7 @@ function summonPlayerPokemonAnimation(pokemon: PlayerPokemon): Promise { pokemon.resetTurnData(); globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); - globalScene.phaseManager.pushNew("PostSummonPhase", pokemon.getBattlerIndex()); + globalScene.phaseManager.unshiftNew("PostSummonPhase", pokemon.getBattlerIndex()); resolve(); }); }, diff --git a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts index b5084743613..67e778d8c4b 100644 --- a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts @@ -669,7 +669,6 @@ function onGameOver() { // Clear any leftover battle phases globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); // Return enemy Pokemon const pokemon = globalScene.getEnemyPokemon(); diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index 86cd3fa3a32..0ba0dec896a 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -738,7 +738,7 @@ export function setEncounterRewards( if (customShopRewards) { globalScene.phaseManager.unshiftNew("SelectModifierPhase", 0, undefined, customShopRewards); } else { - globalScene.phaseManager.tryRemovePhase(p => p.is("MysteryEncounterRewardsPhase")); + globalScene.phaseManager.removeAllPhasesOfType("MysteryEncounterRewardsPhase"); } if (eggRewards) { @@ -812,8 +812,7 @@ export function leaveEncounterWithoutBattle( encounterMode: MysteryEncounterMode = MysteryEncounterMode.NO_BATTLE, ) { globalScene.currentBattle.mysteryEncounter!.encounterMode = encounterMode; - globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); + globalScene.phaseManager.clearPhaseQueue(true); handleMysteryEncounterVictory(addHealPhase); } @@ -826,7 +825,7 @@ export function handleMysteryEncounterVictory(addHealPhase = false, doNotContinu const allowedPkm = globalScene.getPlayerParty().filter(pkm => pkm.isAllowedInBattle()); if (allowedPkm.length === 0) { - globalScene.phaseManager.clearPhaseQueue(); + globalScene.phaseManager.clearPhaseQueue(true); globalScene.phaseManager.unshiftNew("GameOverPhase"); return; } @@ -869,7 +868,7 @@ export function handleMysteryEncounterBattleFailed(addHealPhase = false, doNotCo const allowedPkm = globalScene.getPlayerParty().filter(pkm => pkm.isAllowedInBattle()); if (allowedPkm.length === 0) { - globalScene.phaseManager.clearPhaseQueue(); + globalScene.phaseManager.clearPhaseQueue(true); globalScene.phaseManager.unshiftNew("GameOverPhase"); return; } diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts deleted file mode 100644 index 2c83348cc7b..00000000000 --- a/src/data/phase-priority-queue.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { globalScene } from "#app/global-scene"; -import type { Phase } from "#app/phase"; -import { TrickRoomTag } from "#data/arena-tag"; -import { DynamicPhaseType } from "#enums/dynamic-phase-type"; -import { Stat } from "#enums/stat"; -import { ActivatePriorityQueuePhase } from "#phases/activate-priority-queue-phase"; -import { PostSummonActivateAbilityPhase } from "#phases/post-summon-activate-ability-phase"; -import type { PostSummonPhase } from "#phases/post-summon-phase"; -import { BooleanHolder } from "#utils/common"; - -/** - * Stores a list of {@linkcode Phase}s - * - * Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder} - */ -export abstract class PhasePriorityQueue { - protected abstract queue: Phase[]; - - /** - * Sorts the elements in the queue - */ - public abstract reorder(): void; - - /** - * Calls {@linkcode reorder} and shifts the queue - * @returns The front element of the queue after sorting - */ - public pop(): Phase | undefined { - this.reorder(); - return this.queue.shift(); - } - - /** - * Adds a phase to the queue - * @param phase The phase to add - */ - public push(phase: Phase): void { - this.queue.push(phase); - } - - /** - * Removes all phases from the queue - */ - 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 = this.queue.length; - } else if (removeCount < 1) { - return 0; - } - let numRemoved = 0; - - do { - const phaseIndex = this.queue.findIndex(phaseFilter); - if (phaseIndex === -1) { - break; - } - this.queue.splice(phaseIndex, 1); - numRemoved++; - } while (numRemoved < removeCount && this.queue.length > 0); - - return numRemoved; - } -} - -/** - * Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase} - * - * Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed - */ -export class PostSummonPhasePriorityQueue extends PhasePriorityQueue { - protected override queue: PostSummonPhase[] = []; - - public override reorder(): void { - this.queue.sort((phaseA: PostSummonPhase, phaseB: PostSummonPhase) => { - if (phaseA.getPriority() === phaseB.getPriority()) { - return ( - (phaseB.getPokemon().getEffectiveStat(Stat.SPD) - phaseA.getPokemon().getEffectiveStat(Stat.SPD)) - * (isTrickRoom() ? -1 : 1) - ); - } - - return phaseB.getPriority() - phaseA.getPriority(); - }); - } - - public override push(phase: PostSummonPhase): void { - super.push(phase); - this.queueAbilityPhase(phase); - } - - /** - * Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase} - * @param phase The {@linkcode PostSummonPhase} that was pushed onto the queue - */ - private queueAbilityPhase(phase: PostSummonPhase): void { - const phasePokemon = phase.getPokemon(); - - phasePokemon.getAbilityPriorities().forEach((priority, idx) => { - this.queue.push(new PostSummonActivateAbilityPhase(phasePokemon.getBattlerIndex(), priority, !!idx)); - globalScene.phaseManager.appendToPhase( - new ActivatePriorityQueuePhase(DynamicPhaseType.POST_SUMMON), - "ActivatePriorityQueuePhase", - (p: ActivatePriorityQueuePhase) => p.getType() === DynamicPhaseType.POST_SUMMON, - ); - }); - } -} - -function isTrickRoom(): boolean { - const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); - return speedReversed.value; -} diff --git a/src/dynamic-queue-manager.ts b/src/dynamic-queue-manager.ts new file mode 100644 index 00000000000..7356f67bc1d --- /dev/null +++ b/src/dynamic-queue-manager.ts @@ -0,0 +1,182 @@ +import type { DynamicPhase, PhaseConditionFunc, PhaseString } from "#app/@types/phase-types"; +import type { PokemonMove } from "#app/data/moves/pokemon-move"; +import type { Pokemon } from "#app/field/pokemon"; +import type { Phase } from "#app/phase"; +import type { MovePhase } from "#app/phases/move-phase"; +import { MovePhasePriorityQueue } from "#app/queues/move-phase-priority-queue"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import { PostSummonPhasePriorityQueue } from "#app/queues/post-summon-phase-priority-queue"; +import type { PriorityQueue } from "#app/queues/priority-queue"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; + +// TODO: might be easier to define which phases should be dynamic instead +/** All phases which have defined a `getPokemon` method but should not be sorted dynamically */ +const nonDynamicPokemonPhases: readonly PhaseString[] = [ + "SummonPhase", + "CommandPhase", + "LearnMovePhase", + "MoveEffectPhase", + "MoveEndPhase", + "FaintPhase", + "DamageAnimPhase", + "VictoryPhase", + "PokemonHealPhase", + "WeatherEffectPhase", +] as const; + +/** + * The dynamic queue manager holds priority queues for phases which are queued as dynamic. + * + * Dynamic phases are generally those which hold a pokemon and are unshifted, not pushed. \ + * Queues work by sorting their entries in speed order (and possibly with more complex ordering) before each time a phase is popped. + * + * As the holder, this structure is also used to access and modify queued phases. + * This is mostly used in redirection, cancellation, etc. of {@linkcode MovePhase}s. + */ +export class DynamicQueueManager { + /** Maps phase types to their corresponding queues */ + private readonly dynamicPhaseMap: Map>; + + constructor() { + this.dynamicPhaseMap = new Map(); + // PostSummon and Move phases have specialized queues + this.dynamicPhaseMap.set("PostSummonPhase", new PostSummonPhasePriorityQueue()); + this.dynamicPhaseMap.set("MovePhase", new MovePhasePriorityQueue()); + } + + /** Removes all phases from the manager */ + public clearQueues(): void { + for (const queue of this.dynamicPhaseMap.values()) { + queue.clear(); + } + } + + /** + * Adds a new phase to the manager and creates the priority queue for it if one does not exist. + * @param phase - The {@linkcode Phase} to add + * @returns `true` if the phase was added, or `false` if it is not dynamic + */ + public queueDynamicPhase(phase: T): boolean { + if (!this.isDynamicPhase(phase)) { + return false; + } + + if (!this.dynamicPhaseMap.has(phase.phaseName)) { + // TS can't figure out that T is dynamic at this point, but it does know that `typeof phase` is + this.dynamicPhaseMap.set(phase.phaseName, new PokemonPhasePriorityQueue()); + } + this.dynamicPhaseMap.get(phase.phaseName)?.push(phase); + return true; + } + + /** + * Returns the highest-priority (generally by speed) {@linkcode Phase} of the specified type + * @param type - The {@linkcode PhaseString | type} to pop + * @returns The popped {@linkcode Phase}, or `undefined` if none of the specified type exist + */ + public popNextPhase(type: PhaseString): Phase | undefined { + return this.dynamicPhaseMap.get(type)?.pop(); + } + + /** + * Determines if there is a queued dynamic {@linkcode Phase} meeting the conditions + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param condition - An optional {@linkcode PhaseConditionFunc} to add conditions to the search + * @returns Whether a matching phase exists + */ + public exists(type: T, condition?: PhaseConditionFunc): boolean { + return !!this.dynamicPhaseMap.get(type)?.has(condition); + } + + /** + * Finds and removes a single queued {@linkcode Phase} + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns Whether a removal occurred + */ + public removePhase(type: T, condition?: PhaseConditionFunc): boolean { + return !!this.dynamicPhaseMap.get(type)?.remove(condition); + } + + /** + * Sets the timing modifier of a move (i.e. to force it first or last) + * @param condition - A {@linkcode PhaseConditionFunc} to specify conditions for the move + * @param modifier - The {@linkcode MovePhaseTimingModifier} to switch the move to + */ + public setMoveTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void { + this.getMovePhaseQueue().setTimingModifier(condition, modifier); + } + + /** + * Finds the {@linkcode MovePhase} meeting the condition and changes its move + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @param move - The {@linkcode PokemonMove | move} to use in replacement + */ + public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove): void { + this.getMovePhaseQueue().setMoveForPhase(condition, move); + } + + /** + * Redirects moves which were targeted at a {@linkcode Pokemon} that has been removed + * @param removedPokemon - The removed {@linkcode Pokemon} + * @param allyPokemon - The ally of the removed pokemon + */ + public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + this.getMovePhaseQueue().redirectMoves(removedPokemon, allyPokemon); + } + + /** + * Finds a {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @returns The MovePhase, or `undefined` if it does not exist + */ + public getMovePhase(condition: PhaseConditionFunc<"MovePhase">): MovePhase | undefined { + return this.getMovePhaseQueue().find(condition); + } + + /** + * Finds and cancels a {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public cancelMovePhase(condition: PhaseConditionFunc<"MovePhase">): void { + this.getMovePhaseQueue().cancelMove(condition); + } + + /** + * Sets the move order to a static array rather than a dynamic queue + * @param order - The order of {@linkcode BattlerIndex}s + */ + public setMoveOrder(order: BattlerIndex[]): void { + this.getMovePhaseQueue().setMoveOrder(order); + } + + /** + * @returns An in-order array of {@linkcode Pokemon}, representing the turn order as played out in the most recent turn + */ + public getLastTurnOrder(): Pokemon[] { + return this.getMovePhaseQueue().getTurnOrder(); + } + + /** Clears the stored `Move` turn order */ + public clearLastTurnOrder(): void { + this.getMovePhaseQueue().clearTurnOrder(); + } + + /** Internal helper to get the {@linkcode MovePhasePriorityQueue} */ + private getMovePhaseQueue(): MovePhasePriorityQueue { + return this.dynamicPhaseMap.get("MovePhase") as MovePhasePriorityQueue; + } + + /** + * Internal helper to determine if a phase is dynamic. + * @param phase - The {@linkcode Phase} to check + * @returns Whether `phase` is dynamic + * @privateRemarks + * Currently, this checks that `phase` has a `getPokemon` method + * and is not blacklisted in `nonDynamicPokemonPhases`. + */ + private isDynamicPhase(phase: Phase): phase is DynamicPhase { + return typeof (phase as any).getPokemon === "function" && !nonDynamicPokemonPhases.includes(phase.phaseName); + } +} diff --git a/src/enums/arena-tag-side.ts b/src/enums/arena-tag-side.ts index 5f25a74ab36..50741751fbb 100644 --- a/src/enums/arena-tag-side.ts +++ b/src/enums/arena-tag-side.ts @@ -1,3 +1,4 @@ +// TODO: rename to something else (this isn't used only for arena tags) export enum ArenaTagSide { BOTH, PLAYER, diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 7956e506886..4f0ac491e8b 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -95,4 +95,5 @@ export enum BattlerTagType { POWDER = "POWDER", MAGIC_COAT = "MAGIC_COAT", SUPREME_OVERLORD = "SUPREME_OVERLORD", + BYPASS_SPEED = "BYPASS_SPEED", } diff --git a/src/enums/dynamic-phase-type.ts b/src/enums/dynamic-phase-type.ts deleted file mode 100644 index 3146b136dac..00000000000 --- a/src/enums/dynamic-phase-type.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}. - */ -// TODO: We currently assume these are in order -export enum DynamicPhaseType { - POST_SUMMON, -} diff --git a/src/enums/move-phase-timing-modifier.ts b/src/enums/move-phase-timing-modifier.ts new file mode 100644 index 00000000000..a452d37e7ff --- /dev/null +++ b/src/enums/move-phase-timing-modifier.ts @@ -0,0 +1,16 @@ +import type { ObjectValues } from "#types/type-helpers"; + +/** + * Enum representing modifiers for the timing of MovePhases. + * + * @remarks + * This system is entirely independent of and takes precedence over move priority + */ +export const MovePhaseTimingModifier = Object.freeze({ + /** Used when moves go last regardless of speed and priority (i.e. Quash) */ + LAST: 0, + NORMAL: 1, + /** Used to trigger moves immediately (i.e. ones that were called through Instruct). */ + FIRST: 2, +}); +export type MovePhaseTimingModifier = ObjectValues; diff --git a/src/field/arena.ts b/src/field/arena.ts index 5ab50e540ee..3e214ff1ea7 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -371,9 +371,15 @@ export class Arena { /** * Function to trigger all weather based form changes + * @param source - The Pokemon causing the changes by removing itself from the field */ - triggerWeatherBasedFormChanges(): void { + triggerWeatherBasedFormChanges(source?: Pokemon): void { globalScene.getField(true).forEach(p => { + // TODO - This is a bandaid. Abilities leaving the field needs a better approach than + // calling this method for every switch out that happens + if (p === source) { + return; + } const isCastformWithForecast = p.hasAbility(AbilityId.FORECAST) && p.species.speciesId === SpeciesId.CASTFORM; const isCherrimWithFlowerGift = p.hasAbility(AbilityId.FLOWER_GIFT) && p.species.speciesId === SpeciesId.CHERRIM; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 3154f273cf5..ec813e52e56 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3890,15 +3890,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { damage = Math.min(damage, this.hp); this.hp = this.hp - damage; if (this.isFainted() && !ignoreFaintPhase) { - /** - * When adding the FaintPhase, want to toggle future unshiftPhase() and queueMessage() calls - * to appear before the FaintPhase (as FaintPhase will potentially end the encounter and add Phases such as - * GameOverPhase, VictoryPhase, etc.. that will interfere with anything else that happens during this MoveEffectPhase) - * - * Once the MoveEffectPhase is over (and calls it's .end() function, shiftPhase() will reset the PhaseQueueSplice via clearPhaseQueueSplice() ) - */ - globalScene.phaseManager.setPhaseQueueSplice(); - globalScene.phaseManager.unshiftNew("FaintPhase", this.getBattlerIndex(), preventEndure); + globalScene.phaseManager.queueFaintPhase(this.getBattlerIndex(), preventEndure); this.destroySubstitute(); this.lapseTag(BattlerTagType.COMMANDED); } @@ -5842,8 +5834,7 @@ export class PlayerPokemon extends Pokemon { this.getFieldIndex(), (slotIndex: number, _option: PartyOption) => { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", switchType, this.getFieldIndex(), diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index b94c479e96e..19ddc77d436 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -13,7 +13,6 @@ import { getStatusEffectHealText } from "#data/status-effect"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; import { Color, ShadowColor } from "#enums/color"; -import { Command } from "#enums/command"; import type { FormChangeItem } from "#enums/form-change-item"; import { LearnMoveType } from "#enums/learn-move-type"; import type { MoveId } from "#enums/move-id"; @@ -1542,30 +1541,16 @@ export class BypassSpeedChanceModifier extends PokemonHeldItemModifier { return new BypassSpeedChanceModifier(this.type, this.pokemonId, this.stackCount); } - /** - * Checks if {@linkcode BypassSpeedChanceModifier} should be applied - * @param pokemon the {@linkcode Pokemon} that holds the item - * @param doBypassSpeed {@linkcode BooleanHolder} that is `true` if speed should be bypassed - * @returns `true` if {@linkcode BypassSpeedChanceModifier} should be applied - */ - override shouldApply(pokemon?: Pokemon, doBypassSpeed?: BooleanHolder): boolean { - return super.shouldApply(pokemon, doBypassSpeed) && !!doBypassSpeed; - } - /** * Applies {@linkcode BypassSpeedChanceModifier} * @param pokemon the {@linkcode Pokemon} that holds the item - * @param doBypassSpeed {@linkcode BooleanHolder} that is `true` if speed should be bypassed * @returns `true` if {@linkcode BypassSpeedChanceModifier} has been applied */ - override apply(pokemon: Pokemon, doBypassSpeed: BooleanHolder): boolean { - if (!doBypassSpeed.value && pokemon.randBattleSeedInt(10) < this.getStackCount()) { - doBypassSpeed.value = true; - const isCommandFight = - globalScene.currentBattle.turnCommands[pokemon.getBattlerIndex()]?.command === Command.FIGHT; + override apply(pokemon: Pokemon): boolean { + if (pokemon.randBattleSeedInt(10) < this.getStackCount() && pokemon.addTag(BattlerTagType.BYPASS_SPEED)) { const hasQuickClaw = this.type.is("PokemonHeldItemModifierType") && this.type.id === "QUICK_CLAW"; - if (isCommandFight && hasQuickClaw) { + if (hasQuickClaw) { globalScene.phaseManager.queueMessage( i18next.t("modifier:bypassSpeedChanceApply", { pokemonName: getPokemonNameWithAffix(pokemon), diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 125ca00786b..350e77e52eb 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -8,12 +8,14 @@ */ import { PHASE_START_COLOR } from "#app/constants/colors"; +import { DynamicQueueManager } from "#app/dynamic-queue-manager"; import { globalScene } from "#app/global-scene"; import type { Phase } from "#app/phase"; -import { type PhasePriorityQueue, PostSummonPhasePriorityQueue } from "#data/phase-priority-queue"; -import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; +import { PhaseTree } from "#app/phase-tree"; +import { BattleType } from "#enums/battle-type"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import type { Pokemon } from "#field/pokemon"; -import { ActivatePriorityQueuePhase } from "#phases/activate-priority-queue-phase"; +import type { PokemonMove } from "#moves/pokemon-move"; import { AddEnemyBuffModifierPhase } from "#phases/add-enemy-buff-modifier-phase"; import { AttemptCapturePhase } from "#phases/attempt-capture-phase"; import { AttemptRunPhase } from "#phases/attempt-run-phase"; @@ -25,6 +27,7 @@ import { CheckSwitchPhase } from "#phases/check-switch-phase"; import { CommandPhase } from "#phases/command-phase"; import { CommonAnimPhase } from "#phases/common-anim-phase"; import { DamageAnimPhase } from "#phases/damage-anim-phase"; +import { DynamicPhaseMarker } from "#phases/dynamic-phase-marker"; import { EggHatchPhase } from "#phases/egg-hatch-phase"; import { EggLapsePhase } from "#phases/egg-lapse-phase"; import { EggSummaryPhase } from "#phases/egg-summary-phase"; @@ -109,8 +112,7 @@ import { UnavailablePhase } from "#phases/unavailable-phase"; import { UnlockPhase } from "#phases/unlock-phase"; import { VictoryPhase } from "#phases/victory-phase"; import { WeatherEffectPhase } from "#phases/weather-effect-phase"; -import type { PhaseMap, PhaseString } from "#types/phase-types"; -import { type Constructor, coerceArray } from "#utils/common"; +import type { PhaseConditionFunc, PhaseMap, PhaseString } from "#types/phase-types"; /** * Object that holds all of the phase constructors. @@ -121,7 +123,6 @@ import { type Constructor, coerceArray } from "#utils/common"; * This allows for easy creation of new phases without needing to import each phase individually. */ const PHASES = Object.freeze({ - ActivatePriorityQueuePhase, AddEnemyBuffModifierPhase, AttemptCapturePhase, AttemptRunPhase, @@ -133,6 +134,7 @@ const PHASES = Object.freeze({ CommandPhase, CommonAnimPhase, DamageAnimPhase, + DynamicPhaseMarker, EggHatchPhase, EggLapsePhase, EggSummaryPhase, @@ -221,32 +223,30 @@ const PHASES = Object.freeze({ /** Maps Phase strings to their constructors */ export type PhaseConstructorMap = typeof PHASES; +/** Phases pushed at the end of each {@linkcode TurnStartPhase} */ +const turnEndPhases: readonly PhaseString[] = [ + "WeatherEffectPhase", + "PositionalTagPhase", + "BerryPhase", + "CheckStatusEffectPhase", + "TurnEndPhase", +] as const; + /** * PhaseManager is responsible for managing the phases in the battle scene */ export class PhaseManager { /** PhaseQueue: dequeue/remove the first element to get the next phase */ - public phaseQueue: Phase[] = []; - public conditionalQueue: Array<[() => boolean, Phase]> = []; - /** PhaseQueuePrepend: is a temp storage of what will be added to PhaseQueue */ - private phaseQueuePrepend: Phase[] = []; + private readonly phaseQueue: PhaseTree = new PhaseTree(); - /** overrides default of inserting phases to end of phaseQueuePrepend array. Useful for inserting Phases "out of order" */ - private phaseQueuePrependSpliceIndex = -1; - - /** Storage for {@linkcode PhasePriorityQueue}s which hold phases whose order dynamically changes */ - private dynamicPhaseQueues: PhasePriorityQueue[]; - /** Parallel array to {@linkcode dynamicPhaseQueues} - matches phase types to their queues */ - private dynamicPhaseTypes: Constructor[]; + /** Holds priority queues for dynamically ordered phases */ + public dynamicQueueManager = new DynamicQueueManager(); + /** The currently-running phase */ private currentPhase: Phase; + /** The phase put on standby if {@linkcode overridePhase} is called */ private standbyPhase: Phase | null = null; - constructor() { - this.dynamicPhaseQueues = [new PostSummonPhasePriorityQueue()]; - this.dynamicPhaseTypes = [PostSummonPhase]; - } - /** * Clear all previously set phases, then add a new {@linkcode TitlePhase} to transition to the title screen. * @param addLogin - Whether to add a new {@linkcode LoginPhase} before the {@linkcode TitlePhase} @@ -274,122 +274,76 @@ export class PhaseManager { } /** - * Adds a phase to the conditional queue and ensures it is executed only when the specified condition is met. - * - * This method allows deferring the execution of a phase until certain conditions are met, which is useful for handling - * situations like abilities and entry hazards that depend on specific game states. - * - * @param phase - The phase to be added to the conditional queue. - * @param condition - A function that returns a boolean indicating whether the phase should be executed. - * + * Adds a phase to the end of the queue + * @param phase - The {@linkcode Phase} to add */ - pushConditionalPhase(phase: Phase, condition: () => boolean): void { - this.conditionalQueue.push([condition, phase]); + public pushPhase(phase: Phase): void { + this.phaseQueue.pushPhase(this.checkDynamic(phase)); } /** - * Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false - * @param phase {@linkcode Phase} the phase to add + * Queue a phase to be run immediately after the current phase finishes. \ + * Unshifted phases are run in FIFO order if multiple are queued during a single phase's execution. + * @param phase - The {@linkcode Phase} to add */ - pushPhase(phase: Phase): void { - if (this.getDynamicPhaseType(phase) !== undefined) { - this.pushDynamicPhase(phase); - } else { - this.phaseQueue.push(phase); - } + public unshiftPhase(phase: Phase): void { + const toAdd = this.checkDynamic(phase); + phase.is("MovePhase") ? this.phaseQueue.addAfter(toAdd, "MoveEndPhase") : this.phaseQueue.addPhase(toAdd); } /** - * Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex - * @param phases {@linkcode Phase} the phase(s) to add + * Helper method to queue a phase as dynamic if necessary + * @param phase - The phase to check + * @returns The {@linkcode Phase} or a {@linkcode DynamicPhaseMarker} to be used in its place */ - unshiftPhase(...phases: Phase[]): void { - if (this.phaseQueuePrependSpliceIndex === -1) { - this.phaseQueuePrepend.push(...phases); - } else { - this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, ...phases); + private checkDynamic(phase: Phase): Phase { + if (this.dynamicQueueManager.queueDynamicPhase(phase)) { + return new DynamicPhaseMarker(phase.phaseName); } + return phase; } /** * Clears the phaseQueue + * @param leaveUnshifted - If `true`, leaves the top level of the tree intact; default `false` */ - clearPhaseQueue(): void { - this.phaseQueue.splice(0, this.phaseQueue.length); + public clearPhaseQueue(leaveUnshifted = false): void { + this.phaseQueue.clear(leaveUnshifted); } - /** - * Clears all phase-related stuff, including all phase queues, the current and standby phases, and a splice index - */ - clearAllPhases(): void { - for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue]) { - queue.splice(0, queue.length); - } - this.dynamicPhaseQueues.forEach(queue => queue.clear()); + /** Clears all phase queues and the standby phase */ + public clearAllPhases(): void { + this.clearPhaseQueue(); + this.dynamicQueueManager.clearQueues(); this.standbyPhase = null; - this.clearPhaseQueueSplice(); } /** - * Used by function unshiftPhase(), sets index to start inserting at current length instead of the end of the array, useful if phaseQueuePrepend gets longer with Phases + * Determines the next phase to run and starts it. + * @privateRemarks + * This is called by {@linkcode Phase.end} by default, and should not be called by other methods. */ - setPhaseQueueSplice(): void { - this.phaseQueuePrependSpliceIndex = this.phaseQueuePrepend.length; - } - - /** - * Resets phaseQueuePrependSpliceIndex to -1, implies that calls to unshiftPhase will insert at end of phaseQueuePrepend - */ - clearPhaseQueueSplice(): void { - this.phaseQueuePrependSpliceIndex = -1; - } - - /** - * Is called by each Phase implementations "end()" by default - * We dump everything from phaseQueuePrepend to the start of of phaseQueue - * then removes first Phase and starts it - */ - shiftPhase(): void { + public shiftPhase(): void { if (this.standbyPhase) { this.currentPhase = this.standbyPhase; this.standbyPhase = null; return; } - if (this.phaseQueuePrependSpliceIndex > -1) { - this.clearPhaseQueueSplice(); - } - this.phaseQueue.unshift(...this.phaseQueuePrepend); - this.phaseQueuePrepend.splice(0); + let nextPhase = this.phaseQueue.getNextPhase(); - 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]); - } + if (nextPhase?.is("DynamicPhaseMarker")) { + nextPhase = this.dynamicQueueManager.popNextPhase(nextPhase.phaseType); } - this.conditionalQueue = unactivatedConditionalPhases; - - // If no phases are left, unshift phases to start a new turn. - if (this.phaseQueue.length === 0) { - this.populatePhaseQueue(); - // Clear the conditionalQueue if there are no phases left in the phaseQueue - this.conditionalQueue = []; + if (nextPhase == null) { + this.turnStart(); + } else { + this.currentPhase = nextPhase; } - // Bang is justified as `populatePhaseQueue` ensures we always have _something_ in the queue at all times - this.currentPhase = this.phaseQueue.shift()!; - this.startCurrentPhase(); } - /** * Helper method to start and log the current phase. */ @@ -398,7 +352,14 @@ export class PhaseManager { this.currentPhase.start(); } - overridePhase(phase: Phase): boolean { + /** + * Overrides the currently running phase with another + * @param phase - The {@linkcode Phase} to override the current one with + * @returns If the override succeeded + * + * @todo This is antithetical to the phase structure and used a single time. Remove it. + */ + public overridePhase(phase: Phase): boolean { if (this.standbyPhase) { return false; } @@ -411,173 +372,47 @@ export class PhaseManager { } /** - * Find a specific {@linkcode Phase} in the phase queue. + * Determine if there is a queued {@linkcode Phase} meeting the specified conditions. + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param condition - An optional {@linkcode PhaseConditionFunc} to add conditions to the search + * @returns Whether a matching phase exists + */ + public hasPhaseOfType(type: T, condition?: PhaseConditionFunc): boolean { + return this.dynamicQueueManager.exists(type, condition) || this.phaseQueue.exists(type, condition); + } + + /** + * Attempt to find and remove the first queued {@linkcode Phase} matching the given conditions. + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - An optional {@linkcode PhaseConditionFunc} to add conditions to the search + * @returns Whether a phase was successfully removed + */ + public tryRemovePhase(type: T, phaseFilter?: PhaseConditionFunc): boolean { + if (this.dynamicQueueManager.removePhase(type, phaseFilter)) { + return true; + } + return this.phaseQueue.remove(type, phaseFilter); + } + + /** + * Removes all {@linkcode Phase}s of the given type from the queue + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for * - * @param phaseFilter filter function to use to find the wanted phase - * @returns the found phase or undefined if none found + * @remarks + * This is not intended to be used with dynamically ordered phases, and does not operate on the dynamic queue. \ + * However, it does remove {@linkcode DynamicPhaseMarker}s and so would prevent such phases from activating. */ - findPhase

(phaseFilter: (phase: P) => boolean): P | undefined { - return this.phaseQueue.find(phaseFilter) as P | undefined; - } - - tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean { - const phaseIndex = this.phaseQueue.findIndex(phaseFilter); - if (phaseIndex > -1) { - this.phaseQueue[phaseIndex] = phase; - return true; - } - return false; - } - - tryRemovePhase(phaseFilter: (phase: Phase) => boolean): boolean { - const phaseIndex = this.phaseQueue.findIndex(phaseFilter); - if (phaseIndex > -1) { - this.phaseQueue.splice(phaseIndex, 1); - return true; - } - return false; + public removeAllPhasesOfType(type: PhaseString): void { + this.phaseQueue.removeAll(type); } /** - * Will search for a specific phase in {@linkcode phaseQueuePrepend} via filter, and remove the first result if a match is found. - * @param phaseFilter filter function - */ - tryRemoveUnshiftedPhase(phaseFilter: (phase: Phase) => boolean): boolean { - const phaseIndex = this.phaseQueuePrepend.findIndex(phaseFilter); - if (phaseIndex > -1) { - this.phaseQueuePrepend.splice(phaseIndex, 1); - return true; - } - return false; - } - - /** - * 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 - * @param targetPhase - The phase to search for in phaseQueue - * @returns boolean if a targetPhase was found and added - */ - prependToPhase(phase: Phase | Phase[], targetPhase: PhaseString): boolean { - phase = coerceArray(phase); - const target = PHASES[targetPhase]; - const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target); - - if (targetIndex !== -1) { - this.phaseQueue.splice(targetIndex, 0, ...phase); - return true; - } - this.unshiftPhase(...phase); - return false; - } - - /** - * Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()} - * @param phase {@linkcode Phase} the phase(s) to be added - * @param targetPhase {@linkcode Phase} the type of phase to search for in {@linkcode phaseQueue} - * @param condition Condition the target phase must meet to be appended to - * @returns `true` if a `targetPhase` was found to append to - */ - appendToPhase(phase: Phase | Phase[], targetPhase: PhaseString, condition?: (p: Phase) => boolean): boolean { - phase = coerceArray(phase); - const target = PHASES[targetPhase]; - const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target && (!condition || condition(ph))); - - if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) { - this.phaseQueue.splice(targetIndex + 1, 0, ...phase); - return true; - } - this.unshiftPhase(...phase); - return false; - } - - /** - * Checks a phase and returns the matching {@linkcode DynamicPhaseType}, or undefined if it does not match one - * @param phase The phase to check - * @returns The corresponding {@linkcode DynamicPhaseType} or `undefined` - */ - public getDynamicPhaseType(phase: Phase | null): DynamicPhaseType | undefined { - let phaseType: DynamicPhaseType | undefined; - this.dynamicPhaseTypes.forEach((cls, index) => { - if (phase instanceof cls) { - phaseType = index; - } - }); - - return phaseType; - } - - /** - * Pushes a phase onto its corresponding dynamic queue and marks the activation point in {@linkcode phaseQueue} - * - * The {@linkcode ActivatePriorityQueuePhase} will run the top phase in the dynamic queue (not necessarily {@linkcode phase}) - * @param phase The phase to push - */ - public pushDynamicPhase(phase: Phase): void { - const type = this.getDynamicPhaseType(phase); - if (type === undefined) { - return; - } - - this.pushPhase(new ActivatePriorityQueuePhase(type)); - 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 - */ - public startDynamicPhaseType(type: DynamicPhaseType): void { - const phase = this.dynamicPhaseQueues[type].pop(); - if (phase) { - this.unshiftPhase(phase); - } - } - - /** - * Unshifts an {@linkcode ActivatePriorityQueuePhase} for {@linkcode phase}, then pushes {@linkcode phase} to its dynamic queue - * - * This is the same as {@linkcode pushDynamicPhase}, except the activation phase is unshifted - * - * {@linkcode phase} is not guaranteed to be the next phase from the queue to run (if the queue is not empty) - * @param phase The phase to add - * @returns - */ - public startDynamicPhase(phase: Phase): void { - const type = this.getDynamicPhaseType(phase); - if (type === undefined) { - return; - } - - this.unshiftPhase(new ActivatePriorityQueuePhase(type)); - this.dynamicPhaseQueues[type].push(phase); - } - - /** - * Adds a MessagePhase, either to PhaseQueuePrepend or nextCommandPhaseQueue + * Adds a `MessagePhase` to the queue * @param message - string for MessagePhase * @param callbackDelay - optional param for MessagePhase constructor * @param prompt - optional param for MessagePhase constructor * @param promptDelay - optional param for MessagePhase constructor - * @param defer - Whether to allow the phase to be deferred + * @param defer - If `true`, push the phase instead of unshifting; default `false` * * @see {@linkcode MessagePhase} for more details on the parameters */ @@ -589,20 +424,18 @@ export class PhaseManager { defer?: boolean | null, ) { const phase = new MessagePhase(message, callbackDelay, prompt, promptDelay); - if (!defer) { - // adds to the end of PhaseQueuePrepend - this.unshiftPhase(phase); - } else { - //remember that pushPhase adds it to nextCommandPhaseQueue + if (defer) { this.pushPhase(phase); + } else { + this.unshiftPhase(phase); } } /** - * Queue a phase to show or hide the ability flyout bar. + * Queues an ability bar flyout phase via {@linkcode unshiftPhase} * @param pokemon - The {@linkcode Pokemon} whose ability is being activated * @param passive - Whether the ability is a passive - * @param show - Whether to show or hide the bar + * @param show - If `true`, show the bar. Otherwise, hide it */ public queueAbilityDisplay(pokemon: Pokemon, passive: boolean, show: boolean): void { this.unshiftPhase(show ? new ShowAbilityPhase(pokemon.getBattlerIndex(), passive) : new HideAbilityPhase()); @@ -618,10 +451,12 @@ export class PhaseManager { } /** - * Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order) + * Clear all dynamic queues and begin a new {@linkcode TurnInitPhase} for the new turn. + * Called whenever the current phase queue is empty. */ - private populatePhaseQueue(): void { - this.phaseQueue.push(new TurnInitPhase()); + private turnStart(): void { + this.dynamicQueueManager.clearQueues(); + this.currentPhase = new TurnInitPhase(); } /** @@ -663,50 +498,119 @@ export class PhaseManager { } /** - * Create a new phase and immediately prepend it to an existing phase in the phase queue. - * Equivalent to calling {@linkcode create} followed by {@linkcode prependToPhase}. - * @param targetPhase - The phase to search for in phaseQueue - * @param phase - The name of the phase to create + * Add a {@linkcode FaintPhase} to the queue * @param args - The arguments to pass to the phase constructor - * @returns `true` if a `targetPhase` was found to prepend to + * + * @remarks + * + * Faint phases are ordered in a special way to allow battle effects to settle before the pokemon faints. + * @see {@linkcode PhaseTree.addPhase} */ - public prependNewToPhase( - targetPhase: PhaseString, - phase: T, - ...args: ConstructorParameters - ): boolean { - return this.prependToPhase(this.create(phase, ...args), targetPhase); + public queueFaintPhase(...args: ConstructorParameters): void { + this.phaseQueue.addPhase(this.create("FaintPhase", ...args), true); } /** - * Create a new phase and immediately append it to an existing phase the phase queue. - * Equivalent to calling {@linkcode create} followed by {@linkcode appendToPhase}. - * @param targetPhase - The phase to search for in phaseQueue - * @param phase - The name of the phase to create - * @param args - The arguments to pass to the phase constructor - * @returns `true` if a `targetPhase` was found to append to + * Attempts to add {@linkcode PostSummonPhase}s for the enemy pokemon + * + * This is used to ensure that wild pokemon (which have no {@linkcode SummonPhase}) do not queue a {@linkcode PostSummonPhase} + * until all pokemon are on the field. */ - public appendNewToPhase( - targetPhase: PhaseString, - phase: T, - ...args: ConstructorParameters - ): boolean { - return this.appendToPhase(this.create(phase, ...args), targetPhase); + public tryAddEnemyPostSummonPhases(): void { + if ( + ![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) + && !this.phaseQueue.exists("SummonPhase") + ) { + globalScene.getEnemyField().forEach(p => { + this.pushPhase(new PostSummonPhase(p.getBattlerIndex(), "SummonPhase")); + }); + } } - public startNewDynamicPhase( + /** + * Create a new phase and queue it to run after all others queued by the currently running phase. + * @param phase - The name of the phase to create + * @param args - The arguments to pass to the phase constructor + * + * @deprecated Only used for switches and should be phased out eventually. + */ + public queueDeferred( phase: T, ...args: ConstructorParameters ): void { - this.startDynamicPhase(this.create(phase, ...args)); + this.phaseQueue.unshiftToCurrent(this.create(phase, ...args)); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @returns The MovePhase, or `undefined` if it does not exist + */ + public getMovePhase(phaseCondition: PhaseConditionFunc<"MovePhase">): MovePhase | undefined { + return this.dynamicQueueManager.getMovePhase(phaseCondition); + } + + /** + * Finds and cancels the first {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public cancelMove(phaseCondition: PhaseConditionFunc<"MovePhase">): void { + this.dynamicQueueManager.cancelMovePhase(phaseCondition); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition and forces it next + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public forceMoveNext(phaseCondition: PhaseConditionFunc<"MovePhase">): void { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.FIRST); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition and forces it last + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public forceMoveLast(phaseCondition: PhaseConditionFunc<"MovePhase">): void { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.LAST); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition and changes its move + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @param move - The {@linkcode PokemonMove | move} to use in replacement + */ + public changePhaseMove(phaseCondition: PhaseConditionFunc<"MovePhase">, move: PokemonMove): void { + this.dynamicQueueManager.setMoveForPhase(phaseCondition, move); + } + + /** + * Redirects moves which were targeted at a {@linkcode Pokemon} that has been removed + * @param removedPokemon - The removed {@linkcode Pokemon} + * @param allyPokemon - The ally of the removed pokemon + */ + public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + this.dynamicQueueManager.redirectMoves(removedPokemon, allyPokemon); + } + + /** Queues phases which run at the end of each turn */ + public queueTurnEndPhases(): void { + turnEndPhases.forEach(p => { + this.pushNew(p); + }); } /** Prevents end of turn effects from triggering when transitioning to a new biome on a X0 wave */ public onInterlude(): void { - const phasesToRemove = ["WeatherEffectPhase", "BerryPhase", "CheckStatusEffectPhase"]; - this.phaseQueue = this.phaseQueue.filter(p => !phasesToRemove.includes(p.phaseName)); + const phasesToRemove: readonly PhaseString[] = [ + "WeatherEffectPhase", + "BerryPhase", + "CheckStatusEffectPhase", + ] as const; + for (const phaseType of phasesToRemove) { + this.phaseQueue.removeAll(phaseType); + } - const turnEndPhase = this.findPhase(p => p.phaseName === "TurnEndPhase"); + const turnEndPhase = this.phaseQueue.find("TurnEndPhase"); if (turnEndPhase) { turnEndPhase.upcomingInterlude = true; } diff --git a/src/phase-tree.ts b/src/phase-tree.ts new file mode 100644 index 00000000000..55476f38d65 --- /dev/null +++ b/src/phase-tree.ts @@ -0,0 +1,206 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +import type { PhaseManager } from "#app/@types/phase-types"; +import type { DynamicPhaseMarker } from "#phases/dynamic-phase-marker"; + +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports + +import type { PhaseMap, PhaseString } from "#app/@types/phase-types"; +import type { Phase } from "#app/phase"; +import type { PhaseConditionFunc } from "#types/phase-types"; + +/** + * The PhaseTree is the central storage location for {@linkcode Phase}s by the {@linkcode PhaseManager}. + * + * It has a tiered structure, where unshifted phases are added one level above the currently running Phase. Phases are generally popped from the Tree in FIFO order. + * + * Dynamically ordered phases are queued into the Tree only as {@linkcode DynamicPhaseMarker | Marker}s and as such are not guaranteed to run FIFO (otherwise, they would not be dynamic) + */ +export class PhaseTree { + /** Storage for all levels in the tree. This is a simple array because only one Phase may have "children" at a time. */ + private levels: Phase[][] = [[]]; + /** The level of the currently running {@linkcode Phase} in the Tree (note that such phase is not actually in the Tree while it is running) */ + private currentLevel = 0; + /** + * True if a "deferred" level exists + * @see {@linkcode addPhase} + */ + private deferredActive = false; + + /** + * Adds a {@linkcode Phase} to the specified level + * @param phase - The phase to add + * @param level - The numeric level to add the phase + * @throws Error if `level` is out of legal bounds + */ + private add(phase: Phase, level: number): void { + const addLevel = this.levels[level]; + if (addLevel == null) { + throw new Error("Attempted to add a phase to a nonexistent level of the PhaseTree!\nLevel: " + level.toString()); + } + this.levels[level].push(phase); + } + + /** + * Used by the {@linkcode PhaseManager} to add phases to the Tree + * @param phase - The {@linkcode Phase} to be added + * @param defer - Whether to defer the execution of this phase by allowing subsequently-added phases to run before it + * + * @privateRemarks + * Deferral is implemented by moving the queue at {@linkcode currentLevel} up one level and inserting the new phase below it. + * {@linkcode deferredActive} is set until the moved queue (and anything added to it) is exhausted. + * + * If {@linkcode deferredActive} is `true` when a deferred phase is added, the phase will be pushed to the second-highest level queue. + * That is, it will execute after the originally deferred phase, but there is no possibility for nesting with deferral. + * + * @todo `setPhaseQueueSplice` had strange behavior. This is simpler, but there are probably some remnant edge cases with the current implementation + */ + public addPhase(phase: Phase, defer = false): void { + if (defer && !this.deferredActive) { + this.deferredActive = true; + this.levels.splice(-1, 0, []); + } + this.add(phase, this.levels.length - 1 - +defer); + } + + /** + * Adds a {@linkcode Phase} after the first occurence of the given type, or to the top of the Tree if no such phase exists + * @param phase - The {@linkcode Phase} to be added + * @param type - A {@linkcode PhaseString} representing the type to search for + */ + public addAfter(phase: Phase, type: PhaseString): void { + for (let i = this.levels.length - 1; i >= 0; i--) { + const insertIdx = this.levels[i].findIndex(p => p.is(type)) + 1; + if (insertIdx !== 0) { + this.levels[i].splice(insertIdx, 0, phase); + return; + } + } + + this.addPhase(phase); + } + + /** + * Unshifts a {@linkcode Phase} to the current level. + * This is effectively the same as if the phase were added immediately after the currently-running phase, before it started. + * @param phase - The {@linkcode Phase} to be added + */ + public unshiftToCurrent(phase: Phase): void { + this.levels[this.currentLevel].unshift(phase); + } + + /** + * Pushes a {@linkcode Phase} to the last level of the queue. It will run only after all previously queued phases have been executed. + * @param phase - The {@linkcode Phase} to be added + */ + public pushPhase(phase: Phase): void { + this.add(phase, 0); + } + + /** + * Removes and returns the first {@linkcode Phase} from the topmost level of the tree + * @returns - The next {@linkcode Phase}, or `undefined` if the Tree is empty + */ + public getNextPhase(): Phase | undefined { + this.currentLevel = this.levels.length - 1; + while (this.currentLevel > 0 && this.levels[this.currentLevel].length === 0) { + this.deferredActive = false; + this.levels.pop(); + this.currentLevel--; + } + + // TODO: right now, this is preventing properly marking when one set of unshifted phases ends + this.levels.push([]); + return this.levels[this.currentLevel].shift(); + } + + /** + * Finds a particular {@linkcode Phase} in the Tree by searching in pop order + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns The matching {@linkcode Phase}, or `undefined` if none exists + */ + public find

(phaseType: P, phaseFilter?: PhaseConditionFunc

): PhaseMap[P] | undefined { + for (let i = this.levels.length - 1; i >= 0; i--) { + const level = this.levels[i]; + const phase = level.find((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p))); + if (phase) { + return phase; + } + } + } + + /** + * Finds a particular {@linkcode Phase} in the Tree by searching in pop order + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns The matching {@linkcode Phase}, or `undefined` if none exists + */ + public findAll

(phaseType: P, phaseFilter?: PhaseConditionFunc

): PhaseMap[P][] { + const phases: PhaseMap[P][] = []; + for (let i = this.levels.length - 1; i >= 0; i--) { + const level = this.levels[i]; + const levelPhases = level.filter((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p))); + phases.push(...levelPhases); + } + return phases; + } + + /** + * Clears the Tree + * @param leaveFirstLevel - If `true`, leaves the top level of the tree intact + * + * @privateremarks + * The parameter on this method exists because {@linkcode PhaseManager.clearPhaseQueue} previously (probably by mistake) ignored `phaseQueuePrepend`. + * + * This is (probably by mistake) relied upon by certain ME functions. + */ + public clear(leaveFirstLevel = false) { + this.levels = [leaveFirstLevel ? (this.levels.at(-1) ?? []) : []]; + } + + /** + * Finds and removes a single {@linkcode Phase} from the Tree + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns Whether a removal occurred + */ + public remove

(phaseType: P, phaseFilter?: PhaseConditionFunc

): boolean { + for (let i = this.levels.length - 1; i >= 0; i--) { + const level = this.levels[i]; + const phaseIndex = level.findIndex(p => p.is(phaseType) && (!phaseFilter || phaseFilter(p))); + if (phaseIndex !== -1) { + level.splice(phaseIndex, 1); + return true; + } + } + return false; + } + + /** + * Removes all occurrences of {@linkcode Phase}s of the given type + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + */ + public removeAll(phaseType: PhaseString): void { + for (let i = 0; i < this.levels.length; i++) { + const level = this.levels[i].filter(phase => !phase.is(phaseType)); + this.levels[i] = level; + } + } + + /** + * Determines if a particular phase exists in the Tree + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns Whether a matching phase exists + */ + public exists

(phaseType: P, phaseFilter?: PhaseConditionFunc

): boolean { + for (const level of this.levels) { + for (const phase of level) { + if (phase.is(phaseType) && (!phaseFilter || phaseFilter(phase))) { + return true; + } + } + } + return false; + } +} diff --git a/src/phases/activate-priority-queue-phase.ts b/src/phases/activate-priority-queue-phase.ts deleted file mode 100644 index a31d3291a60..00000000000 --- a/src/phases/activate-priority-queue-phase.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { globalScene } from "#app/global-scene"; -import { Phase } from "#app/phase"; -import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; - -export class ActivatePriorityQueuePhase extends Phase { - public readonly phaseName = "ActivatePriorityQueuePhase"; - private type: DynamicPhaseType; - - constructor(type: DynamicPhaseType) { - super(); - this.type = type; - } - - override start() { - super.start(); - globalScene.phaseManager.startDynamicPhaseType(this.type); - this.end(); - } - - public getType(): DynamicPhaseType { - return this.type; - } -} diff --git a/src/phases/battle-end-phase.ts b/src/phases/battle-end-phase.ts index 8a798d67554..45b0db76ced 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/check-status-effect-phase.ts b/src/phases/check-status-effect-phase.ts index bdaa536986a..5955cd42c55 100644 --- a/src/phases/check-status-effect-phase.ts +++ b/src/phases/check-status-effect-phase.ts @@ -1,20 +1,14 @@ import { globalScene } from "#app/global-scene"; import { Phase } from "#app/phase"; -import type { BattlerIndex } from "#enums/battler-index"; export class CheckStatusEffectPhase extends Phase { public readonly phaseName = "CheckStatusEffectPhase"; - private order: BattlerIndex[]; - constructor(order: BattlerIndex[]) { - super(); - this.order = order; - } start() { const field = globalScene.getField(); - for (const o of this.order) { - if (field[o].status?.isPostTurn()) { - globalScene.phaseManager.unshiftNew("PostTurnStatusEffectPhase", o); + for (const p of field) { + if (p?.status?.isPostTurn()) { + globalScene.phaseManager.unshiftNew("PostTurnStatusEffectPhase", p.getBattlerIndex()); } } this.end(); diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index 504bb6eb4bd..a55db4203bc 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -28,7 +28,8 @@ export class CheckSwitchPhase extends BattlePhase { // ...if the user is playing in Set Mode if (globalScene.battleStyle === BattleStyle.SET) { - return super.end(); + this.end(true); + return; } // ...if the checked Pokemon is somehow not on the field @@ -44,7 +45,8 @@ export class CheckSwitchPhase extends BattlePhase { .slice(1) .filter(p => p.isActive()).length === 0 ) { - return super.end(); + this.end(true); + return; } // ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching @@ -53,7 +55,8 @@ export class CheckSwitchPhase extends BattlePhase { || pokemon.isTrapped() || globalScene.getPlayerField().some(p => p.getTag(BattlerTagType.COMMANDED)) ) { - return super.end(); + this.end(true); + return; } globalScene.ui.showText( @@ -71,10 +74,17 @@ export class CheckSwitchPhase extends BattlePhase { }, () => { globalScene.ui.setMode(UiMode.MESSAGE); - this.end(); + this.end(true); }, ); }, ); } + + public override end(queuePostSummon = false): void { + if (queuePostSummon) { + globalScene.phaseManager.unshiftNew("PostSummonPhase", this.fieldIndex); + } + super.end(); + } } diff --git a/src/phases/dynamic-phase-marker.ts b/src/phases/dynamic-phase-marker.ts new file mode 100644 index 00000000000..e2b241f29de --- /dev/null +++ b/src/phases/dynamic-phase-marker.ts @@ -0,0 +1,17 @@ +import type { PhaseString } from "#app/@types/phase-types"; +import { Phase } from "#app/phase"; + +/** + * This phase exists for the sole purpose of marking the location and type of a dynamic phase for the phase manager + */ +export class DynamicPhaseMarker extends Phase { + public override readonly phaseName = "DynamicPhaseMarker"; + + /** The type of phase which this phase is a marker for */ + public phaseType: PhaseString; + + constructor(type: PhaseString) { + super(); + this.phaseType = type; + } +} diff --git a/src/phases/egg-hatch-phase.ts b/src/phases/egg-hatch-phase.ts index 946288c4fd8..3f9b999e0c1 100644 --- a/src/phases/egg-hatch-phase.ts +++ b/src/phases/egg-hatch-phase.ts @@ -225,7 +225,7 @@ export class EggHatchPhase extends Phase { } end() { - if (globalScene.phaseManager.findPhase(p => p.is("EggHatchPhase"))) { + if (globalScene.phaseManager.hasPhaseOfType("EggHatchPhase")) { this.eggHatchHandler.clear(); } else { globalScene.time.delayedCall(250, () => globalScene.setModifiersVisible(true)); diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 0918ced65e5..9345170e718 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -565,29 +565,6 @@ export class EncounterPhase extends BattlePhase { }); if (![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { - enemyField.map(p => - globalScene.phaseManager.pushConditionalPhase( - globalScene.phaseManager.create("PostSummonPhase", p.getBattlerIndex()), - () => { - // if there is not a player party, we can't continue - if (globalScene.getPlayerParty().length === 0) { - return false; - } - // how many player pokemon are on the field ? - const pokemonsOnFieldCount = globalScene.getPlayerParty().filter(p => p.isOnField()).length; - // if it's a 2vs1, there will never be a 2nd pokemon on our field even - const requiredPokemonsOnField = Math.min( - globalScene.getPlayerParty().filter(p => !p.isFainted()).length, - 2, - ); - // if it's a double, there should be 2, otherwise 1 - if (globalScene.currentBattle.double) { - return pokemonsOnFieldCount === requiredPokemonsOnField; - } - return pokemonsOnFieldCount === 1; - }, - ), - ); const ivScannerModifier = globalScene.findModifier(m => m instanceof IvScannerModifier); if (ivScannerModifier) { enemyField.map(p => globalScene.phaseManager.pushNew("ScanIvsPhase", p.getBattlerIndex())); @@ -596,36 +573,30 @@ export class EncounterPhase extends BattlePhase { if (!this.loaded) { const availablePartyMembers = globalScene.getPokemonAllowedInBattle(); + const minPartySize = globalScene.currentBattle.double ? 2 : 1; + const currentBattle = globalScene.currentBattle; + const checkSwitch = + currentBattle.battleType !== BattleType.TRAINER + && (currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily) + && availablePartyMembers.length > minPartySize; + const phaseManager = globalScene.phaseManager; if (!availablePartyMembers[0].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 0); + phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch); } - if (globalScene.currentBattle.double) { + if (currentBattle.double) { if (availablePartyMembers.length > 1) { - globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", true); + phaseManager.pushNew("ToggleDoublePositionPhase", true); if (!availablePartyMembers[1].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 1); + phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch); } } } else { if (availablePartyMembers.length > 1 && availablePartyMembers[1].isOnField()) { globalScene.phaseManager.pushNew("ReturnPhase", 1); } - globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", false); - } - - if ( - globalScene.currentBattle.battleType !== BattleType.TRAINER - && (globalScene.currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily) - ) { - const minPartySize = globalScene.currentBattle.double ? 2 : 1; - if (availablePartyMembers.length > minPartySize) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } - } + phaseManager.pushNew("ToggleDoublePositionPhase", false); } } handleTutorial(Tutorial.Access_Menu).then(() => super.end()); diff --git a/src/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index dd29b97d590..f229f872958 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -84,19 +84,12 @@ export class GameOverPhase extends BattlePhase { globalScene.phaseManager.pushNew("EncounterPhase", true); const availablePartyMembers = globalScene.getPokemonAllowedInBattle().length; - - globalScene.phaseManager.pushNew("SummonPhase", 0); - if (globalScene.currentBattle.double && availablePartyMembers > 1) { - globalScene.phaseManager.pushNew("SummonPhase", 1); - } - if ( + const checkSwitch = globalScene.currentBattle.waveIndex > 1 - && globalScene.currentBattle.battleType !== BattleType.TRAINER - ) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double && availablePartyMembers > 1) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } + && globalScene.currentBattle.battleType !== BattleType.TRAINER; + globalScene.phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch); + if (globalScene.currentBattle.double && availablePartyMembers > 1) { + globalScene.phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch); } globalScene.ui.fadeIn(1250); @@ -267,7 +260,6 @@ export class GameOverPhase extends BattlePhase { .then(success => doGameOver(!globalScene.gameMode.isDaily || !!success)) .catch(_err => { globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); globalScene.phaseManager.unshiftNew("MessagePhase", i18next.t("menu:serverCommunicationFailed"), 2500); // force the game to reload after 2 seconds. setTimeout(() => { diff --git a/src/phases/learn-move-phase.ts b/src/phases/learn-move-phase.ts index 4fc38b08d16..bbd1d0f5a2e 100644 --- a/src/phases/learn-move-phase.ts +++ b/src/phases/learn-move-phase.ts @@ -187,7 +187,7 @@ export class LearnMovePhase extends PlayerPartyMemberPokemonPhase { pokemon.usedTMs = []; } pokemon.usedTMs.push(this.moveId); - globalScene.phaseManager.tryRemovePhase(phase => phase.is("SelectModifierPhase")); + globalScene.phaseManager.tryRemovePhase("SelectModifierPhase"); } else if (this.learnMoveType === LearnMoveType.MEMORY) { if (this.cost !== -1) { if (!Overrides.WAIVE_ROLL_FEE_OVERRIDE) { @@ -197,7 +197,7 @@ export class LearnMovePhase extends PlayerPartyMemberPokemonPhase { } globalScene.playSound("se/buy"); } else { - globalScene.phaseManager.tryRemovePhase(phase => phase.is("SelectModifierPhase")); + globalScene.phaseManager.tryRemovePhase("SelectModifierPhase"); } } pokemon.setMove(index, this.moveId); diff --git a/src/phases/move-charge-phase.ts b/src/phases/move-charge-phase.ts index 0c83db10511..5dd75f4bab8 100644 --- a/src/phases/move-charge-phase.ts +++ b/src/phases/move-charge-phase.ts @@ -75,7 +75,7 @@ export class MoveChargePhase extends PokemonPhase { // Otherwise, add the attack portion to the user's move queue to execute next turn. // TODO: This checks status twice for a single-turn usage... if (instantCharge.value) { - globalScene.phaseManager.tryRemovePhase(phase => phase.is("MoveEndPhase") && phase.getPokemon() === user); + globalScene.phaseManager.tryRemovePhase("MoveEndPhase", phase => phase.getPokemon() === user); globalScene.phaseManager.unshiftNew("MovePhase", user, [this.targetIndex], this.move, this.useMode); } else { user.pushMoveQueue({ move: move.id, targets: [this.targetIndex], useMode: this.useMode }); diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 18e25b328f8..be6d0164698 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,7 +1,6 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { Phase } from "#app/phase"; import { ConditionalProtectTag } from "#data/arena-tag"; import { MoveAnim } from "#data/battle-anims"; import { DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TypeBoostTag } from "#data/battler-tags"; @@ -17,6 +16,7 @@ import { MoveCategory } from "#enums/move-category"; import { MoveEffectTrigger } from "#enums/move-effect-trigger"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; import { isReflected, MoveUseMode } from "#enums/move-use-mode"; @@ -67,12 +67,6 @@ export class MoveEffectPhase extends PokemonPhase { /** Is this the last strike of a move? */ private lastHit: boolean; - /** - * Phases queued during moves; used to add a new MovePhase for reflected moves after triggering. - * TODO: Remove this and move the reflection logic to ability-side - */ - private queuedPhases: Phase[] = []; - /** * @param useMode - The {@linkcode MoveUseMode} corresponding to how this move was used. */ @@ -148,7 +142,7 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Queue the phaes that should occur when the target reflects the move back to the user + * Queue the phases that should occur when the target reflects the move back to the user * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} that is reflecting the move * TODO: Rework this to use `onApply` of Magic Coat @@ -159,24 +153,21 @@ export class MoveEffectPhase extends PokemonPhase { : [user.getBattlerIndex()]; // TODO: ability displays should be handled by the ability if (!target.getTag(BattlerTagType.MAGIC_COAT)) { - this.queuedPhases.push( - globalScene.phaseManager.create( - "ShowAbilityPhase", - target.getBattlerIndex(), - target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), - ), + globalScene.phaseManager.unshiftNew( + "ShowAbilityPhase", + target.getBattlerIndex(), + target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), ); - this.queuedPhases.push(globalScene.phaseManager.create("HideAbilityPhase")); + globalScene.phaseManager.unshiftNew("HideAbilityPhase"); } - this.queuedPhases.push( - globalScene.phaseManager.create( - "MovePhase", - target, - newTargets, - new PokemonMove(this.move.id), - MoveUseMode.REFLECTED, - ), + globalScene.phaseManager.unshiftNew( + "MovePhase", + target, + newTargets, + new PokemonMove(this.move.id), + MoveUseMode.REFLECTED, + MovePhaseTimingModifier.FIRST, ); } @@ -344,9 +335,6 @@ export class MoveEffectPhase extends PokemonPhase { return; } - if (this.queuedPhases.length > 0) { - globalScene.phaseManager.appendToPhase(this.queuedPhases, "MoveEndPhase"); - } const moveType = user.getMoveType(this.move, true); if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) { user.stellarTypesBoosted.push(moveType); @@ -905,10 +893,7 @@ export class MoveEffectPhase extends PokemonPhase { * @param target - The {@linkcode Pokemon} that fainted */ protected onFaintTarget(user: Pokemon, target: Pokemon): void { - // set splice index here, so future scene queues happen before FaintedPhase - globalScene.phaseManager.setPhaseQueueSplice(); - - globalScene.phaseManager.unshiftNew("FaintPhase", target.getBattlerIndex(), false, user); + globalScene.phaseManager.queueFaintPhase(target.getBattlerIndex(), false, user); target.destroySubstitute(); target.lapseTag(BattlerTagType.COMMANDED); diff --git a/src/phases/move-header-phase.ts b/src/phases/move-header-phase.ts index 5c69dcd1217..5b8a6f998a1 100644 --- a/src/phases/move-header-phase.ts +++ b/src/phases/move-header-phase.ts @@ -5,8 +5,8 @@ import { BattlePhase } from "#phases/battle-phase"; export class MoveHeaderPhase extends BattlePhase { public readonly phaseName = "MoveHeaderPhase"; - public pokemon: Pokemon; public move: PokemonMove; + public pokemon: Pokemon; constructor(pokemon: Pokemon, move: PokemonMove) { super(); @@ -15,6 +15,10 @@ export class MoveHeaderPhase extends BattlePhase { this.move = move; } + public getPokemon(): Pokemon { + return this.pokemon; + } + canMove(): boolean { return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 96943065ff0..5e85401db77 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -3,6 +3,7 @@ import { MOVE_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; +import { PokemonPhase } from "#app/phases/pokemon-phase"; import { CenterOfAttentionTag } from "#data/battler-tags"; import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect"; @@ -15,6 +16,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { CommonAnim } from "#enums/move-anims-common"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import { MoveResult } from "#enums/move-result"; import { isIgnorePP, isIgnoreStatus, isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; @@ -24,20 +26,19 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; -import { BattlePhase } from "#phases/battle-phase"; import type { TurnMove } from "#types/turn-move"; import { NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; import i18next from "i18next"; -export class MovePhase extends BattlePhase { +export class MovePhase extends PokemonPhase { public readonly phaseName = "MovePhase"; protected _pokemon: Pokemon; - protected _move: PokemonMove; + public move: PokemonMove; protected _targets: BattlerIndex[]; public readonly useMode: MoveUseMode; // Made public for quash - /** Whether the current move is forced last (used for Quash). */ - protected forcedLast: boolean; + /** The timing modifier of the move (used by Quash and to force called moves to the front of their queue) */ + public timingModifier: MovePhaseTimingModifier; /** Whether the current move should fail but still use PP. */ protected failed = false; /** Whether the current move should fail and retain PP. */ @@ -59,14 +60,6 @@ export class MovePhase extends BattlePhase { this._pokemon = pokemon; } - public get move(): PokemonMove { - return this._move; - } - - protected set move(move: PokemonMove) { - this._move = move; - } - public get targets(): BattlerIndex[] { return this._targets; } @@ -81,16 +74,22 @@ export class MovePhase extends BattlePhase { * @param move - The {@linkcode PokemonMove} to use * @param useMode - The {@linkcode MoveUseMode} corresponding to this move's means of execution (usually `MoveUseMode.NORMAL`). * Not marked optional to ensure callers correctly pass on `useModes`. - * @param forcedLast - Whether to force this phase to occur last in order (for {@linkcode MoveId.QUASH}); default `false` + * @param timingModifier - The {@linkcode MovePhaseTimingModifier} for the move; Default {@linkcode MovePhaseTimingModifier.NORMAL} */ - constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, useMode: MoveUseMode, forcedLast = false) { - super(); + constructor( + pokemon: Pokemon, + targets: BattlerIndex[], + move: PokemonMove, + useMode: MoveUseMode, + timingModifier: MovePhaseTimingModifier = MovePhaseTimingModifier.NORMAL, + ) { + super(pokemon.getBattlerIndex()); this.pokemon = pokemon; this.targets = targets; this.move = move; this.useMode = useMode; - this.forcedLast = forcedLast; + this.timingModifier = timingModifier; this.moveHistoryEntry = { move: MoveId.NONE, targets, @@ -121,14 +120,6 @@ export class MovePhase extends BattlePhase { 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/mystery-encounter-phases.ts b/src/phases/mystery-encounter-phases.ts index 4f50b40c965..bb3f4a92033 100644 --- a/src/phases/mystery-encounter-phases.ts +++ b/src/phases/mystery-encounter-phases.ts @@ -48,7 +48,6 @@ export class MysteryEncounterPhase extends Phase { // Clears out queued phases that are part of standard battle globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); const encounter = globalScene.currentBattle.mysteryEncounter!; encounter.updateSeedOffset(); @@ -233,9 +232,7 @@ export class MysteryEncounterBattleStartCleanupPhase extends Phase { }); // Remove any status tick phases - while (globalScene.phaseManager.findPhase(p => p.is("PostTurnStatusEffectPhase"))) { - globalScene.phaseManager.tryRemovePhase(p => p.is("PostTurnStatusEffectPhase")); - } + globalScene.phaseManager.removeAllPhasesOfType("PostTurnStatusEffectPhase"); // The total number of Pokemon in the player's party that can legally fight const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle(); @@ -412,16 +409,21 @@ export class MysteryEncounterBattlePhase extends Phase { } const availablePartyMembers = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle()); + const minPartySize = globalScene.currentBattle.double ? 2 : 1; + const checkSwitch = + encounterMode !== MysteryEncounterMode.TRAINER_BATTLE + && !this.disableSwitch + && availablePartyMembers.length > minPartySize; if (!availablePartyMembers[0].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 0); + globalScene.phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch); } if (globalScene.currentBattle.double) { if (availablePartyMembers.length > 1) { globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", true); if (!availablePartyMembers[1].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 1); + globalScene.phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch); } } } else { @@ -432,16 +434,6 @@ export class MysteryEncounterBattlePhase extends Phase { globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", false); } - if (encounterMode !== MysteryEncounterMode.TRAINER_BATTLE && !this.disableSwitch) { - const minPartySize = globalScene.currentBattle.double ? 2 : 1; - if (availablePartyMembers.length > minPartySize) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } - } - } - this.end(); } @@ -540,7 +532,7 @@ export class MysteryEncounterRewardsPhase extends Phase { if (encounter.doEncounterRewards) { encounter.doEncounterRewards(); } else if (this.addHealPhase) { - globalScene.phaseManager.tryRemovePhase(p => p.is("SelectModifierPhase")); + globalScene.phaseManager.removeAllPhasesOfType("SelectModifierPhase"); globalScene.phaseManager.unshiftNew("SelectModifierPhase", 0, undefined, { fillRemaining: false, rerollMultiplier: -1, diff --git a/src/phases/new-battle-phase.ts b/src/phases/new-battle-phase.ts index b9a57161bd0..7b5d132ccd2 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/phases/party-member-pokemon-phase.ts b/src/phases/party-member-pokemon-phase.ts index 9536dafda60..545799cf36a 100644 --- a/src/phases/party-member-pokemon-phase.ts +++ b/src/phases/party-member-pokemon-phase.ts @@ -22,4 +22,8 @@ export abstract class PartyMemberPokemonPhase extends FieldPhase { getPokemon(): Pokemon { return this.getParty()[this.partyMemberIndex]; } + + isPlayer(): boolean { + return this.player; + } } diff --git a/src/phases/post-summon-activate-ability-phase.ts b/src/phases/post-summon-activate-ability-phase.ts index 5f790c01ad1..a2b6c059bee 100644 --- a/src/phases/post-summon-activate-ability-phase.ts +++ b/src/phases/post-summon-activate-ability-phase.ts @@ -6,8 +6,8 @@ import { PostSummonPhase } from "#phases/post-summon-phase"; * Helper to {@linkcode PostSummonPhase} which applies abilities */ export class PostSummonActivateAbilityPhase extends PostSummonPhase { - private priority: number; - private passive: boolean; + private readonly priority: number; + private readonly passive: boolean; constructor(battlerIndex: BattlerIndex, priority: number, passive: boolean) { super(battlerIndex); diff --git a/src/phases/post-summon-phase.ts b/src/phases/post-summon-phase.ts index 5f66cf91eca..136f2fbd601 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -1,19 +1,29 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; +import type { PhaseString } from "#app/@types/phase-types"; import { globalScene } from "#app/global-scene"; import { EntryHazardTag } from "#data/arena-tag"; import { MysteryEncounterPostSummonTag } from "#data/battler-tags"; import { ArenaTagType } from "#enums/arena-tag-type"; +import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { StatusEffect } from "#enums/status-effect"; import { PokemonPhase } from "#phases/pokemon-phase"; export class PostSummonPhase extends PokemonPhase { public readonly phaseName = "PostSummonPhase"; + /** Used to determine whether to push or unshift {@linkcode PostSummonActivateAbilityPhase}s */ + public readonly source: PhaseString; + + constructor(battlerIndex?: BattlerIndex | number, source: PhaseString = "SwitchSummonPhase") { + super(battlerIndex); + this.source = source; + } + start() { super.start(); const pokemon = this.getPokemon(); - + console.log("Ran PSP for:", pokemon.name); if (pokemon.status?.effect === StatusEffect.TOXIC) { pokemon.status.toxicTurnCount = 0; } @@ -29,8 +39,7 @@ export class PostSummonPhase extends PokemonPhase { ) { pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON); } - - const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); + const field = pokemon.isPlayer() ? globalScene.getPlayerField(true) : globalScene.getEnemyField(true); for (const p of field) { applyAbAttrs("CommanderAbAttr", { pokemon: p }); } diff --git a/src/phases/quiet-form-change-phase.ts b/src/phases/quiet-form-change-phase.ts index ef53b16cc56..920ff2252b8 100644 --- a/src/phases/quiet-form-change-phase.ts +++ b/src/phases/quiet-form-change-phase.ts @@ -9,7 +9,6 @@ import { BattleSpec } from "#enums/battle-spec"; import { BattlerTagType } from "#enums/battler-tag-type"; import type { Pokemon } from "#field/pokemon"; import { BattlePhase } from "#phases/battle-phase"; -import type { MovePhase } from "#phases/move-phase"; export class QuietFormChangePhase extends BattlePhase { public readonly phaseName = "QuietFormChangePhase"; @@ -170,12 +169,7 @@ export class QuietFormChangePhase extends BattlePhase { this.pokemon.initBattleInfo(); this.pokemon.cry(); - const movePhase = globalScene.phaseManager.findPhase( - p => p.is("MovePhase") && p.pokemon === this.pokemon, - ) as MovePhase; - if (movePhase) { - movePhase.cancel(); - } + globalScene.phaseManager.cancelMove(p => p.pokemon === this.pokemon); } if (this.formChange.trigger instanceof SpeciesFormChangeTeraTrigger) { const params = { pokemon: this.pokemon }; diff --git a/src/phases/stat-stage-change-phase.ts b/src/phases/stat-stage-change-phase.ts index 2731c037d5f..3c2d1cb5fad 100644 --- a/src/phases/stat-stage-change-phase.ts +++ b/src/phases/stat-stage-change-phase.ts @@ -223,10 +223,7 @@ export class StatStageChangePhase extends PokemonPhase { }); // Look for any other stat change phases; if this is the last one, do White Herb check - const existingPhase = globalScene.phaseManager.findPhase( - p => p.is("StatStageChangePhase") && p.battlerIndex === this.battlerIndex, - ); - if (!existingPhase?.is("StatStageChangePhase")) { + if (!globalScene.phaseManager.hasPhaseOfType("StatStageChangePhase", p => p.battlerIndex === this.battlerIndex)) { // Apply White Herb if needed const whiteHerb = globalScene.applyModifier( ResetNegativeStatStageModifier, @@ -297,49 +294,6 @@ export class StatStageChangePhase extends PokemonPhase { } } - aggregateStatStageChanges(): void { - const accEva: BattleStat[] = [Stat.ACC, Stat.EVA]; - const isAccEva = accEva.some(s => this.stats.includes(s)); - let existingPhase: StatStageChangePhase; - if (this.stats.length === 1) { - while ( - (existingPhase = globalScene.phaseManager.findPhase( - p => - p.is("StatStageChangePhase") - && p.battlerIndex === this.battlerIndex - && p.stats.length === 1 - && p.stats[0] === this.stats[0] - && p.selfTarget === this.selfTarget - && p.showMessage === this.showMessage - && p.ignoreAbilities === this.ignoreAbilities, - ) as StatStageChangePhase) - ) { - this.stages += existingPhase.stages; - - if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) { - break; - } - } - } - while ( - (existingPhase = globalScene.phaseManager.findPhase( - p => - p.is("StatStageChangePhase") - && p.battlerIndex === this.battlerIndex - && p.selfTarget === this.selfTarget - && accEva.some(s => p.stats.includes(s)) === isAccEva - && p.stages === this.stages - && p.showMessage === this.showMessage - && p.ignoreAbilities === this.ignoreAbilities, - ) as StatStageChangePhase) - ) { - this.stats.push(...existingPhase.stats); - if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) { - break; - } - } - } - getStatStageChangeMessages(stats: BattleStat[], stages: number, relStages: number[]): string[] { const messages: string[] = []; diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index dda70f46ec9..26a8ba40ffc 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -16,12 +16,14 @@ import i18next from "i18next"; export class SummonPhase extends PartyMemberPokemonPhase { // The union type is needed to keep typescript happy as these phases extend from SummonPhase public readonly phaseName: "SummonPhase" | "SummonMissingPhase" | "SwitchSummonPhase" | "ReturnPhase" = "SummonPhase"; - private loaded: boolean; + private readonly loaded: boolean; + private readonly checkSwitch: boolean; - constructor(fieldIndex: number, player = true, loaded = false) { + constructor(fieldIndex: number, player = true, loaded = false, checkSwitch = false) { super(fieldIndex, player); this.loaded = loaded; + this.checkSwitch = checkSwitch; } start() { @@ -288,7 +290,17 @@ export class SummonPhase extends PartyMemberPokemonPhase { } queuePostSummon(): void { - globalScene.phaseManager.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex()); + if (this.checkSwitch) { + globalScene.phaseManager.pushNew( + "CheckSwitchPhase", + this.getPokemon().getFieldIndex(), + globalScene.currentBattle.double, + ); + } else { + globalScene.phaseManager.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex(), this.phaseName); + } + + globalScene.phaseManager.tryAddEnemyPostSummonPhases(); } end() { @@ -296,4 +308,8 @@ export class SummonPhase extends PartyMemberPokemonPhase { super.end(); } + + public getFieldIndex(): number { + return this.fieldIndex; + } } diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index 83a699b6b08..9ab06ec827c 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -1,5 +1,4 @@ import { globalScene } from "#app/global-scene"; -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"; @@ -77,14 +76,6 @@ export class SwitchPhase extends BattlePhase { fieldIndex, (slotIndex: number, option: PartyOption) => { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { - // Remove any pre-existing PostSummonPhase under the same field index. - // Pre-existing PostSummonPhases may occur when this phase is invoked during a prompt to switch at the start of a wave. - // TODO: Separate the animations from `SwitchSummonPhase` and co. into another phase and use that on initial switch - this is a band-aid fix - globalScene.phaseManager.tryRemoveDynamicPhase( - DynamicPhaseType.POST_SUMMON, - p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex, - "all", - ); const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType; globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn); } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index ac47068c619..8cc7843b55f 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -241,11 +241,11 @@ export class SwitchSummonPhase extends SummonPhase { globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out - globalScene.arena.triggerWeatherBasedFormChanges(); + globalScene.arena.triggerWeatherBasedFormChanges(pokemon); } queuePostSummon(): void { - globalScene.phaseManager.startNewDynamicPhase("PostSummonPhase", this.getPokemon().getBattlerIndex()); + globalScene.phaseManager.unshiftNew("PostSummonPhase", this.getPokemon().getBattlerIndex()); } /** diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts index 414be4c820c..1920db8d20e 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -315,23 +315,15 @@ export class TitlePhase extends Phase { if (this.loaded) { const availablePartyMembers = globalScene.getPokemonAllowedInBattle().length; - - globalScene.phaseManager.pushNew("SummonPhase", 0, true, true); - if (globalScene.currentBattle.double && availablePartyMembers > 1) { - globalScene.phaseManager.pushNew("SummonPhase", 1, true, true); - } - - if ( + const minPartySize = globalScene.currentBattle.double ? 2 : 1; + const checkSwitch = globalScene.currentBattle.battleType !== BattleType.TRAINER && (globalScene.currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily) - ) { - const minPartySize = globalScene.currentBattle.double ? 2 : 1; - if (availablePartyMembers > minPartySize) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } - } + && availablePartyMembers > minPartySize; + + globalScene.phaseManager.pushNew("SummonPhase", 0, true, true, checkSwitch); + if (globalScene.currentBattle.double && availablePartyMembers > 1) { + globalScene.phaseManager.pushNew("SummonPhase", 1, true, true, checkSwitch); } } diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index 463f26e73a2..22ebbd2607b 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -25,6 +25,7 @@ export class TurnEndPhase extends FieldPhase { globalScene.currentBattle.incrementTurn(); globalScene.eventTarget.dispatchEvent(new TurnEndEvent(globalScene.currentBattle.turn)); + globalScene.phaseManager.dynamicQueueManager.clearLastTurnOrder(); globalScene.phaseManager.hideAbilityBar(); diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 1733901d527..cd45a73c813 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -1,89 +1,31 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import type { TurnCommand } from "#app/battle"; import { globalScene } from "#app/global-scene"; -import { TrickRoomTag } from "#data/arena-tag"; -import { allMoves } from "#data/data-lists"; -import { BattlerIndex } from "#enums/battler-index"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import type { BattlerIndex } from "#enums/battler-index"; import { Command } from "#enums/command"; -import { Stat } from "#enums/stat"; import { SwitchType } from "#enums/switch-type"; import type { Pokemon } from "#field/pokemon"; import { BypassSpeedChanceModifier } from "#modifiers/modifier"; import { PokemonMove } from "#moves/pokemon-move"; import { FieldPhase } from "#phases/field-phase"; -import { BooleanHolder, randSeedShuffle } from "#utils/common"; +import { inSpeedOrder } from "#utils/speed-order-generator"; export class TurnStartPhase extends FieldPhase { public readonly phaseName = "TurnStartPhase"; /** - * Helper method to retrieve the current speed order of the combattants. - * It also checks for Trick Room and reverses the array if it is present. - * @returns The {@linkcode BattlerIndex}es of all on-field Pokemon, sorted in speed order. - * @todo Make this private - */ - getSpeedOrder(): BattlerIndex[] { - const playerField = globalScene.getPlayerField().filter(p => p.isActive()); - const enemyField = globalScene.getEnemyField().filter(p => p.isActive()); - - // Shuffle the list before sorting so speed ties produce random results - // This is seeded with the current turn to prevent turn order varying - // based on how long since you last reloaded. - let orderedTargets = (playerField as Pokemon[]).concat(enemyField); - globalScene.executeWithSeedOffset( - () => { - orderedTargets = randSeedShuffle(orderedTargets); - }, - globalScene.currentBattle.turn, - globalScene.waveSeed, - ); - - // Check for Trick Room and reverse sort order if active. - // Notably, Pokerogue does NOT have the "outspeed trick room" glitch at >1809 spd. - const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); - - orderedTargets.sort((a: Pokemon, b: Pokemon) => { - const aSpeed = a.getEffectiveStat(Stat.SPD); - const bSpeed = b.getEffectiveStat(Stat.SPD); - - return speedReversed.value ? aSpeed - bSpeed : bSpeed - aSpeed; - }); - - return orderedTargets.map(t => t.getFieldIndex() + (t.isEnemy() ? BattlerIndex.ENEMY : BattlerIndex.PLAYER)); - } - - /** - * This takes the result of {@linkcode getSpeedOrder} and applies priority / bypass speed attributes to it. - * This also considers the priority levels of various commands and changes the result of `getSpeedOrder` based on such. - * @returns The `BattlerIndex`es of all on-field Pokemon sorted in action order. + * Returns an ordering of the current field based on command priority + * @returns The sequence of commands for this turn */ getCommandOrder(): BattlerIndex[] { - let moveOrder = this.getSpeedOrder(); - // The creation of the battlerBypassSpeed object contains checks for the ability Quick Draw and the held item Quick Claw - // The ability Mycelium Might disables Quick Claw's activation when using a status move - // This occurs before the main loop because of battles with more than two Pokemon - const battlerBypassSpeed = {}; - - globalScene.getField(true).forEach(p => { - const bypassSpeed = new BooleanHolder(false); - const canCheckHeldItems = new BooleanHolder(true); - applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon: p, bypass: bypassSpeed }); - applyAbAttrs("PreventBypassSpeedChanceAbAttr", { - pokemon: p, - bypass: bypassSpeed, - canCheckHeldItems, - }); - if (canCheckHeldItems.value) { - globalScene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p, bypassSpeed); - } - battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed; - }); + const playerField = globalScene.getPlayerField(true).map(p => p.getBattlerIndex()); + const enemyField = globalScene.getEnemyField(true).map(p => p.getBattlerIndex()); + const orderedTargets: BattlerIndex[] = playerField.concat(enemyField); // The function begins sorting orderedTargets based on command priority, move priority, and possible speed bypasses. // Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands. - moveOrder = moveOrder.slice(0); - moveOrder.sort((a, b) => { + orderedTargets.sort((a, b) => { const aCommand = globalScene.currentBattle.turnCommands[a]; const bCommand = globalScene.currentBattle.turnCommands[b]; @@ -94,41 +36,14 @@ export class TurnStartPhase extends FieldPhase { if (bCommand?.command === Command.FIGHT) { return -1; } - } else if (aCommand?.command === Command.FIGHT) { - const aMove = allMoves[aCommand.move!.move]; - const bMove = allMoves[bCommand!.move!.move]; - - const aUser = globalScene.getField(true).find(p => p.getBattlerIndex() === a)!; - const bUser = globalScene.getField(true).find(p => p.getBattlerIndex() === b)!; - - const aPriority = aMove.getPriority(aUser, false); - const bPriority = bMove.getPriority(bUser, false); - - // The game now checks for differences in priority levels. - // If the moves share the same original priority bracket, it can check for differences in battlerBypassSpeed and return the result. - // This conditional is used to ensure that Quick Claw can still activate with abilities like Stall and Mycelium Might (attack moves only) - // Otherwise, the game returns the user of the move with the highest priority. - const isSameBracket = Math.ceil(aPriority) - Math.ceil(bPriority) === 0; - if (aPriority !== bPriority) { - if (isSameBracket && battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { - return battlerBypassSpeed[a].value ? -1 : 1; - } - return aPriority < bPriority ? 1 : -1; - } } - // If there is no difference between the move's calculated priorities, - // check for differences in battlerBypassSpeed and returns the result. - if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { - return battlerBypassSpeed[a].value ? -1 : 1; - } - - const aIndex = moveOrder.indexOf(a); - const bIndex = moveOrder.indexOf(b); + const aIndex = orderedTargets.indexOf(a); + const bIndex = orderedTargets.indexOf(b); return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0; }); - return moveOrder; + return orderedTargets; } // TODO: Refactor this alongside `CommandPhase.handleCommand` to use SEPARATE METHODS @@ -139,9 +54,8 @@ export class TurnStartPhase extends FieldPhase { const field = globalScene.getField(); const moveOrder = this.getCommandOrder(); - for (const o of this.getSpeedOrder()) { - const pokemon = field[o]; - const preTurnCommand = globalScene.currentBattle.preTurnCommands[o]; + for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) { + const preTurnCommand = globalScene.currentBattle.preTurnCommands[pokemon.getBattlerIndex()]; if (preTurnCommand?.skip) { continue; @@ -154,6 +68,10 @@ export class TurnStartPhase extends FieldPhase { } const phaseManager = globalScene.phaseManager; + for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) { + applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon }); + globalScene.applyModifiers(BypassSpeedChanceModifier, pokemon.isPlayer(), pokemon); + } moveOrder.forEach((o, index) => { const pokemon = field[o]; @@ -178,13 +96,8 @@ export class TurnStartPhase extends FieldPhase { // TODO: Re-order these phases to be consistent with mainline turn order: // https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179 - phaseManager.pushNew("WeatherEffectPhase"); - phaseManager.pushNew("PositionalTagPhase"); - phaseManager.pushNew("BerryPhase"); - - phaseManager.pushNew("CheckStatusEffectPhase", moveOrder); - - phaseManager.pushNew("TurnEndPhase"); + // TODO: In an ideal world, this is handled by the phase manager. The change is nontrivial due to the ordering of post-turn phases like those queued by VictoryPhase + globalScene.phaseManager.queueTurnEndPhases(); /* * `this.end()` will call `PhaseManager#shiftPhase()`, which dumps everything from `phaseQueuePrepend` diff --git a/src/queues/move-phase-priority-queue.ts b/src/queues/move-phase-priority-queue.ts new file mode 100644 index 00000000000..5f0b20c3c2e --- /dev/null +++ b/src/queues/move-phase-priority-queue.ts @@ -0,0 +1,103 @@ +import type { PokemonMove } from "#app/data/moves/pokemon-move"; +import type { Pokemon } from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; +import type { MovePhase } from "#app/phases/move-phase"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; +import type { PhaseConditionFunc } from "#types/phase-types"; + +/** A priority queue responsible for the ordering of {@linkcode MovePhase}s */ +export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue { + private lastTurnOrder: Pokemon[] = []; + + protected override reorder(): void { + super.reorder(); + this.sortPostSpeed(); + } + + public cancelMove(condition: PhaseConditionFunc<"MovePhase">): void { + this.queue.find(p => condition(p))?.cancel(); + } + + public setTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void { + const phase = this.queue.find(p => condition(p)); + if (phase != null) { + phase.timingModifier = modifier; + } + } + + public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove) { + const phase = this.queue.find(p => condition(p)); + if (phase != null) { + phase.move = move; + } + } + + public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + // failsafe: if not a double battle just return + if (!globalScene.currentBattle.double) { + return; + } + + // TODO: simplify later + if (allyPokemon?.isActive(true)) { + this.queue + .filter( + mp => + mp.targets.length === 1 + && mp.targets[0] === removedPokemon.getBattlerIndex() + && mp.pokemon.isPlayer() !== allyPokemon.isPlayer(), + ) + .forEach(targetingMovePhase => { + if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) { + targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex(); + } + }); + } + } + + public setMoveOrder(order: BattlerIndex[]) { + this.setOrder = order; + } + + public override pop(): MovePhase | undefined { + this.reorder(); + const phase = this.queue.shift(); + if (phase) { + this.lastTurnOrder.push(phase.pokemon); + } + return phase; + } + + public getTurnOrder(): Pokemon[] { + return this.lastTurnOrder; + } + + public clearTurnOrder(): void { + this.lastTurnOrder = []; + } + + public override clear(): void { + this.setOrder = undefined; + this.lastTurnOrder = []; + super.clear(); + } + + private sortPostSpeed(): void { + this.queue.sort((a: MovePhase, b: MovePhase) => { + const priority = [a, b].map(movePhase => { + const move = movePhase.move.getMove(); + return move.getPriority(movePhase.pokemon, true); + }); + + const timingModifiers = [a, b].map(movePhase => movePhase.timingModifier); + + if (timingModifiers[0] !== timingModifiers[1]) { + return timingModifiers[1] - timingModifiers[0]; + } + + return priority[1] - priority[0]; + }); + } +} diff --git a/src/queues/pokemon-phase-priority-queue.ts b/src/queues/pokemon-phase-priority-queue.ts new file mode 100644 index 00000000000..3098c5be435 --- /dev/null +++ b/src/queues/pokemon-phase-priority-queue.ts @@ -0,0 +1,20 @@ +import type { DynamicPhase } from "#app/@types/phase-types"; +import { PriorityQueue } from "#app/queues/priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; +import type { BattlerIndex } from "#enums/battler-index"; + +/** A generic speed-based priority queue of {@linkcode DynamicPhase}s */ +export class PokemonPhasePriorityQueue extends PriorityQueue { + protected setOrder: BattlerIndex[] | undefined; + protected override reorder(): void { + const setOrder = this.setOrder; + if (setOrder) { + this.queue.sort( + (a, b) => + setOrder.indexOf(a.getPokemon().getBattlerIndex()) - setOrder.indexOf(b.getPokemon().getBattlerIndex()), + ); + } else { + this.queue = sortInSpeedOrder(this.queue); + } + } +} diff --git a/src/queues/pokemon-priority-queue.ts b/src/queues/pokemon-priority-queue.ts new file mode 100644 index 00000000000..597bfb32c0d --- /dev/null +++ b/src/queues/pokemon-priority-queue.ts @@ -0,0 +1,10 @@ +import type { Pokemon } from "#app/field/pokemon"; +import { PriorityQueue } from "#app/queues/priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; + +/** A priority queue of {@linkcode Pokemon}s */ +export class PokemonPriorityQueue extends PriorityQueue { + protected override reorder(): void { + this.queue = sortInSpeedOrder(this.queue); + } +} diff --git a/src/queues/post-summon-phase-priority-queue.ts b/src/queues/post-summon-phase-priority-queue.ts new file mode 100644 index 00000000000..37da90a1427 --- /dev/null +++ b/src/queues/post-summon-phase-priority-queue.ts @@ -0,0 +1,45 @@ +import { globalScene } from "#app/global-scene"; +import { PostSummonActivateAbilityPhase } from "#app/phases/post-summon-activate-ability-phase"; +import type { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; + +/** + * Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase} + * + * Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed + */ +export class PostSummonPhasePriorityQueue extends PokemonPhasePriorityQueue { + protected override reorder(): void { + this.queue = sortInSpeedOrder(this.queue, false); + this.queue.sort((phaseA, phaseB) => phaseB.getPriority() - phaseA.getPriority()); + } + + public override push(phase: PostSummonPhase): void { + super.push(phase); + this.queueAbilityPhase(phase); + } + + /** + * Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase} + * @param phase - The {@linkcode PostSummonPhase} that was pushed onto the queue + */ + private queueAbilityPhase(phase: PostSummonPhase): void { + if (phase instanceof PostSummonActivateAbilityPhase) { + return; + } + + const phasePokemon = phase.getPokemon(); + + phasePokemon.getAbilityPriorities().forEach((priority, idx) => { + const activateAbilityPhase = new PostSummonActivateAbilityPhase( + phasePokemon.getBattlerIndex(), + priority, + idx !== 0, + ); + phase.source === "SummonPhase" + ? globalScene.phaseManager.pushPhase(activateAbilityPhase) + : globalScene.phaseManager.unshiftPhase(activateAbilityPhase); + }); + } +} diff --git a/src/queues/priority-queue.ts b/src/queues/priority-queue.ts new file mode 100644 index 00000000000..b53cfec3f4d --- /dev/null +++ b/src/queues/priority-queue.ts @@ -0,0 +1,78 @@ +/** + * Stores a list of elements. + * + * Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder}. + */ +export abstract class PriorityQueue { + protected queue: T[] = []; + + /** + * Sorts the elements in the queue + */ + protected abstract reorder(): void; + + /** + * Calls {@linkcode reorder} and shifts the queue + * @returns The front element of the queue after sorting, or `undefined` if the queue is empty + * @sealed + */ + public pop(): T | undefined { + if (this.isEmpty()) { + return; + } + + this.reorder(); + return this.queue.shift(); + } + + /** + * Adds an element to the queue + * @param element The element to add + */ + public push(element: T): void { + this.queue.push(element); + } + + /** + * Removes all elements from the queue + * @sealed + */ + public clear(): void { + this.queue.splice(0, this.queue.length); + } + + /** + * @returns Whether the queue is empty + * @sealed + */ + public isEmpty(): boolean { + return this.queue.length === 0; + } + + /** + * Removes the first element matching the condition + * @param condition - An optional condition function (defaults to a function that always returns `true`) + * @returns Whether a removal occurred + */ + public remove(condition: (t: T) => boolean = () => true): boolean { + // Reorder to remove the first element + this.reorder(); + const index = this.queue.findIndex(condition); + if (index === -1) { + return false; + } + + this.queue.splice(index, 1); + return true; + } + + /** @returns An element matching the condition function */ + public find(condition?: (t: T) => boolean): T | undefined { + return this.queue.find(e => !condition || condition(e)); + } + + /** @returns Whether an element matching the condition function exists */ + public has(condition?: (t: T) => boolean): boolean { + return this.queue.some(e => !condition || condition(e)); + } +} diff --git a/src/utils/speed-order-generator.ts b/src/utils/speed-order-generator.ts new file mode 100644 index 00000000000..24f95de665f --- /dev/null +++ b/src/utils/speed-order-generator.ts @@ -0,0 +1,39 @@ +import { globalScene } from "#app/global-scene"; +import { PokemonPriorityQueue } from "#app/queues/pokemon-priority-queue"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import type { Pokemon } from "#field/pokemon"; + +/** + * A generator function which uses a priority queue to yield each pokemon from a given side of the field in speed order. + * @param side - The {@linkcode ArenaTagSide | side} of the field to use + * @returns A {@linkcode Generator} of {@linkcode Pokemon} + * + * @remarks + * This should almost always be used by iteration in a `for...of` loop + */ +export function* inSpeedOrder(side: ArenaTagSide = ArenaTagSide.BOTH): Generator { + let pokemonList: Pokemon[]; + switch (side) { + case ArenaTagSide.PLAYER: + pokemonList = globalScene.getPlayerField(true); + break; + case ArenaTagSide.ENEMY: + pokemonList = globalScene.getEnemyField(true); + break; + default: + pokemonList = globalScene.getField(true); + } + + const queue = new PokemonPriorityQueue(); + let i = 0; + pokemonList.forEach(p => { + queue.push(p); + }); + while (!queue.isEmpty()) { + // If the queue is not empty, this can never be undefined + i++; + yield queue.pop()!; + } + + return i; +} diff --git a/src/utils/speed-order.ts b/src/utils/speed-order.ts new file mode 100644 index 00000000000..1d894369bb3 --- /dev/null +++ b/src/utils/speed-order.ts @@ -0,0 +1,57 @@ +import { Pokemon } from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; +import { BooleanHolder, randSeedShuffle } from "#app/utils/common"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { Stat } from "#enums/stat"; + +/** Interface representing an object associated with a specific Pokemon */ +interface hasPokemon { + getPokemon(): Pokemon; +} + +/** + * Sorts an array of {@linkcode Pokemon} by speed, taking Trick Room into account. + * @param pokemonList - The list of Pokemon or objects containing Pokemon + * @param shuffleFirst - Whether to shuffle the list before sorting (to handle speed ties). Default `true`. + * @returns The sorted array of {@linkcode Pokemon} + */ +export function sortInSpeedOrder(pokemonList: T[], shuffleFirst = true): T[] { + pokemonList = shuffleFirst ? shufflePokemonList(pokemonList) : pokemonList; + sortBySpeed(pokemonList); + return pokemonList; +} + +/** + * @param pokemonList - The array of Pokemon or objects containing Pokemon + * @returns The shuffled array + */ +function shufflePokemonList(pokemonList: T[]): T[] { + // This is seeded with the current turn to prevent an inconsistency where it + // was varying based on how long since you last reloaded + globalScene.executeWithSeedOffset( + () => { + pokemonList = randSeedShuffle(pokemonList); + }, + globalScene.currentBattle.turn * 1000 + pokemonList.length, + globalScene.waveSeed, + ); + return pokemonList; +} + +/** Sorts an array of {@linkcode Pokemon} by speed (without shuffling) */ +function sortBySpeed(pokemonList: T[]): void { + pokemonList.sort((a, b) => { + const aSpeed = (a instanceof Pokemon ? a : a.getPokemon()).getEffectiveStat(Stat.SPD); + const bSpeed = (b instanceof Pokemon ? b : b.getPokemon()).getEffectiveStat(Stat.SPD); + + return bSpeed - aSpeed; + }); + + /** 'true' if Trick Room is on the field. */ + const speedReversed = new BooleanHolder(false); + globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, false, speedReversed); + + if (speedReversed.value) { + pokemonList.reverse(); + } +} diff --git a/test/abilities/dancer.test.ts b/test/abilities/dancer.test.ts index e640e326d58..e206152715e 100644 --- a/test/abilities/dancer.test.ts +++ b/test/abilities/dancer.test.ts @@ -34,7 +34,7 @@ describe("Abilities - Dancer", () => { game.override.enemyAbility(AbilityId.DANCER).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset(MoveId.VICTORY_DANCE); await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); - const [oricorio, feebas] = game.scene.getPlayerField(); + const [oricorio, feebas, magikarp1] = game.scene.getField(); game.move.changeMoveset(oricorio, [MoveId.SWORDS_DANCE, MoveId.VICTORY_DANCE, MoveId.SPLASH]); game.move.changeMoveset(feebas, [MoveId.SWORDS_DANCE, MoveId.SPLASH]); @@ -44,8 +44,9 @@ describe("Abilities - Dancer", () => { await game.phaseInterceptor.to("MovePhase"); // feebas uses swords dance await game.phaseInterceptor.to("MovePhase", false); // oricorio copies swords dance + // Dancer order will be Magikarp, Oricorio, Magikarp based on set turn order let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; - expect(currentPhase.pokemon).toBe(oricorio); + expect(currentPhase.pokemon).toBe(magikarp1); expect(currentPhase.move.moveId).toBe(MoveId.SWORDS_DANCE); await game.phaseInterceptor.to("MoveEndPhase"); // end oricorio's move diff --git a/test/abilities/mycelium-might.test.ts b/test/abilities/mycelium-might.test.ts index c3b7b4753b6..21b856d341e 100644 --- a/test/abilities/mycelium-might.test.ts +++ b/test/abilities/mycelium-might.test.ts @@ -2,8 +2,6 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { TurnEndPhase } from "#phases/turn-end-phase"; -import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -45,65 +43,50 @@ describe("Abilities - Mycelium Might", () => { it("should move last in its priority bracket and ignore protective abilities", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const enemyPokemon = game.field.getEnemyPokemon(); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = enemyPokemon.getBattlerIndex(); + const enemy = game.field.getEnemyPokemon(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.BABY_DOLL_EYES); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The opponent Pokemon (without Mycelium Might) goes first despite having lower speed than the player Pokemon. // The player Pokemon (with Mycelium Might) goes last despite having higher speed than the opponent. - expect(speedOrder).toEqual([playerIndex, enemyIndex]); - expect(commandOrder).toEqual([enemyIndex, playerIndex]); - await game.phaseInterceptor.to(TurnEndPhase); + expect(player.hp).not.toEqual(player.getMaxHp()); + await game.phaseInterceptor.to("TurnEndPhase"); // Despite the opponent's ability (Clear Body), its ATK stat stage is still reduced. - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy.getStatStage(Stat.ATK)).toBe(-1); }); it("should still go first if a status move that is in a higher priority bracket than the opponent's move is used", async () => { game.override.enemyMoveset(MoveId.TACKLE); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const enemyPokemon = game.field.getEnemyPokemon(); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = enemyPokemon.getBattlerIndex(); + const enemy = game.field.getEnemyPokemon(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.BABY_DOLL_EYES); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The player Pokemon (with M.M.) goes first because its move is still within a higher priority bracket than its opponent. // The enemy Pokemon goes second because its move is in a lower priority bracket. - expect(speedOrder).toEqual([playerIndex, enemyIndex]); - expect(commandOrder).toEqual([playerIndex, enemyIndex]); - await game.phaseInterceptor.to(TurnEndPhase); + expect(player.hp).toEqual(player.getMaxHp()); + await game.phaseInterceptor.to("TurnEndPhase"); // Despite the opponent's ability (Clear Body), its ATK stat stage is still reduced. - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy.getStatStage(Stat.ATK)).toBe(-1); }); it("should not affect non-status moves", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.QUICK_ATTACK); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The player Pokemon (with M.M.) goes first because it has a higher speed and did not use a status move. // The enemy Pokemon (without M.M.) goes second because its speed is lower. // This means that the commandOrder should be identical to the speedOrder - expect(speedOrder).toEqual([playerIndex, enemyIndex]); - expect(commandOrder).toEqual([playerIndex, enemyIndex]); + expect(player.hp).toEqual(player.getMaxHp()); }); }); diff --git a/test/abilities/neutralizing-gas.test.ts b/test/abilities/neutralizing-gas.test.ts index 555e5f8a19c..fd9138e4174 100644 --- a/test/abilities/neutralizing-gas.test.ts +++ b/test/abilities/neutralizing-gas.test.ts @@ -59,7 +59,7 @@ describe("Abilities - Neutralizing Gas", () => { expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(1); }); - it.todo("should activate before other abilities", async () => { + it("should activate before other abilities", async () => { game.override.enemySpecies(SpeciesId.ACCELGOR).enemyLevel(100).enemyAbility(AbilityId.INTIMIDATE); await game.classicMode.startBattle([SpeciesId.FEEBAS]); diff --git a/test/abilities/quick-draw.test.ts b/test/abilities/quick-draw.test.ts index ce5873af3a8..257892145e5 100644 --- a/test/abilities/quick-draw.test.ts +++ b/test/abilities/quick-draw.test.ts @@ -5,7 +5,7 @@ import { SpeciesId } from "#enums/species-id"; import { FaintPhase } from "#phases/faint-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; describe("Abilities - Quick Draw", () => { let phaserGame: Phaser.Game; @@ -25,7 +25,6 @@ describe("Abilities - Quick Draw", () => { game = new GameManager(phaserGame); game.override .battleStyle("single") - .starterSpecies(SpeciesId.MAGIKARP) .ability(AbilityId.QUICK_DRAW) .moveset([MoveId.TACKLE, MoveId.TAIL_WHIP]) .enemyLevel(100) @@ -40,8 +39,8 @@ describe("Abilities - Quick Draw", () => { ).mockReturnValue(100); }); - test("makes pokemon going first in its priority bracket", async () => { - await game.classicMode.startBattle(); + it("makes pokemon go first in its priority bracket", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const pokemon = game.field.getPlayerPokemon(); const enemy = game.field.getEnemyPokemon(); @@ -57,33 +56,27 @@ describe("Abilities - Quick Draw", () => { expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.QUICK_DRAW); }); - test( - "does not triggered by non damage moves", - { - retry: 5, - }, - async () => { - await game.classicMode.startBattle(); + it("is not triggered by non damaging moves", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const pokemon = game.field.getPlayerPokemon(); - const enemy = game.field.getEnemyPokemon(); + const pokemon = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); - pokemon.hp = 1; - enemy.hp = 1; + pokemon.hp = 1; + enemy.hp = 1; - game.move.select(MoveId.TAIL_WHIP); - await game.phaseInterceptor.to(FaintPhase, false); + game.move.select(MoveId.TAIL_WHIP); + await game.phaseInterceptor.to(FaintPhase, false); - expect(pokemon.isFainted()).toBe(true); - expect(enemy.isFainted()).toBe(false); - expect(pokemon.waveData.abilitiesApplied).not.contain(AbilityId.QUICK_DRAW); - }, - ); + expect(pokemon.isFainted()).toBe(true); + expect(enemy.isFainted()).toBe(false); + expect(pokemon.waveData.abilitiesApplied).not.contain(AbilityId.QUICK_DRAW); + }); - test("does not increase priority", async () => { + it("does not increase priority", async () => { game.override.enemyMoveset([MoveId.EXTREME_SPEED]); - await game.classicMode.startBattle(); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const pokemon = game.field.getPlayerPokemon(); const enemy = game.field.getEnemyPokemon(); diff --git a/test/abilities/stall.test.ts b/test/abilities/stall.test.ts index 5b4e38f7099..b6a88964e09 100644 --- a/test/abilities/stall.test.ts +++ b/test/abilities/stall.test.ts @@ -1,7 +1,6 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -40,56 +39,41 @@ describe("Abilities - Stall", () => { it("Pokemon with Stall should move last in its priority bracket regardless of speed", async () => { await game.classicMode.startBattle([SpeciesId.SHUCKLE]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.QUICK_ATTACK); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The player Pokemon (without Stall) goes first despite having lower speed than the opponent. // The opponent Pokemon (with Stall) goes last despite having higher speed than the player Pokemon. - expect(speedOrder).toEqual([enemyIndex, playerIndex]); - expect(commandOrder).toEqual([playerIndex, enemyIndex]); + expect(player).toHaveFullHp(); }); it("Pokemon with Stall will go first if a move that is in a higher priority bracket than the opponent's move is used", async () => { await game.classicMode.startBattle([SpeciesId.SHUCKLE]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The opponent Pokemon (with Stall) goes first because its move is still within a higher priority bracket than its opponent. // The player Pokemon goes second because its move is in a lower priority bracket. - expect(speedOrder).toEqual([enemyIndex, playerIndex]); - expect(commandOrder).toEqual([enemyIndex, playerIndex]); + expect(player).not.toHaveFullHp(); }); it("If both Pokemon have stall and use the same move, speed is used to determine who goes first.", async () => { game.override.ability(AbilityId.STALL); await game.classicMode.startBattle([SpeciesId.SHUCKLE]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The opponent Pokemon (with Stall) goes first because it has a higher speed. // The player Pokemon (with Stall) goes second because its speed is lower. - expect(speedOrder).toEqual([enemyIndex, playerIndex]); - expect(commandOrder).toEqual([enemyIndex, playerIndex]); + expect(player).not.toHaveFullHp(); }); }); diff --git a/test/battle/battle-order.test.ts b/test/battle/battle-order.test.ts index 0b24fcbfa7d..de13b22df79 100644 --- a/test/battle/battle-order.test.ts +++ b/test/battle/battle-order.test.ts @@ -1,7 +1,8 @@ import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import type { TurnStartPhase } from "#phases/turn-start-phase"; +import type { MovePhase } from "#phases/move-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -34,38 +35,34 @@ describe("Battle order", () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const playerPokemon = game.field.getPlayerPokemon(); + const playerStartHp = playerPokemon.hp; const enemyPokemon = game.field.getEnemyPokemon(); + const enemyStartHp = enemyPokemon.hp; + vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set playerPokemon's speed to 50 vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150 - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("TurnStartPhase", false); - const playerPokemonIndex = playerPokemon.getBattlerIndex(); - const enemyPokemonIndex = enemyPokemon.getBattlerIndex(); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - expect(order[0]).toBe(enemyPokemonIndex); - expect(order[1]).toBe(playerPokemonIndex); + await game.phaseInterceptor.to("MoveEndPhase", false); + expect(playerPokemon.hp).not.toEqual(playerStartHp); + expect(enemyPokemon.hp).toEqual(enemyStartHp); }); it("Player faster than opponent 150 vs 50", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const playerPokemon = game.field.getPlayerPokemon(); + const playerStartHp = playerPokemon.hp; const enemyPokemon = game.field.getEnemyPokemon(); + const enemyStartHp = enemyPokemon.hp; vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set playerPokemon's speed to 150 vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set enemyPokemon's speed to 50 game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("TurnStartPhase", false); - const playerPokemonIndex = playerPokemon.getBattlerIndex(); - const enemyPokemonIndex = enemyPokemon.getBattlerIndex(); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - expect(order[0]).toBe(playerPokemonIndex); - expect(order[1]).toBe(enemyPokemonIndex); + await game.phaseInterceptor.to("MoveEndPhase", false); + expect(playerPokemon.hp).toEqual(playerStartHp); + expect(enemyPokemon.hp).not.toEqual(enemyStartHp); }); it("double - both opponents faster than player 50/50 vs 150/150", async () => { @@ -73,23 +70,24 @@ describe("Battle order", () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.BLASTOISE]); const playerPokemon = game.scene.getPlayerField(); + const playerHps = playerPokemon.map(p => p.hp); const enemyPokemon = game.scene.getEnemyField(); + const enemyHps = enemyPokemon.map(p => p.hp); playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50])); // set both playerPokemons' speed to 50 enemyPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150])); // set both enemyPokemons' speed to 150 - const playerIndices = playerPokemon.map(p => p?.getBattlerIndex()); - const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex()); game.move.select(MoveId.TACKLE); game.move.select(MoveId.TACKLE, 1); - await game.phaseInterceptor.to("TurnStartPhase", false); + await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER); + await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - expect(order.slice(0, 2).includes(enemyIndices[0])).toBe(true); - expect(order.slice(0, 2).includes(enemyIndices[1])).toBe(true); - expect(order.slice(2, 4).includes(playerIndices[0])).toBe(true); - expect(order.slice(2, 4).includes(playerIndices[1])).toBe(true); + await game.phaseInterceptor.to("MoveEndPhase", true); + await game.phaseInterceptor.to("MoveEndPhase", false); + for (let i = 0; i < 2; i++) { + expect(playerPokemon[i].hp).not.toEqual(playerHps[i]); + expect(enemyPokemon[i].hp).toEqual(enemyHps[i]); + } }); it("double - speed tie except 1 - 100/100 vs 100/150", async () => { @@ -101,18 +99,13 @@ describe("Battle order", () => { playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100])); //set both playerPokemons' speed to 100 vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set enemyPokemon's speed to 100 vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150 - const playerIndices = playerPokemon.map(p => p?.getBattlerIndex()); - const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex()); game.move.select(MoveId.TACKLE); game.move.select(MoveId.TACKLE, 1); - await game.phaseInterceptor.to("TurnStartPhase", false); + await game.phaseInterceptor.to("MovePhase", false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - // enemy 2 should be first, followed by some other assortment of the other 3 pokemon - expect(order[0]).toBe(enemyIndices[1]); - expect(order.slice(1, 4)).toEqual(expect.arrayContaining([enemyIndices[0], ...playerIndices])); + const phase = game.scene.phaseManager.getCurrentPhase() as MovePhase; + expect(phase.pokemon).toEqual(enemyPokemon[1]); }); it("double - speed tie 100/150 vs 100/150", async () => { @@ -125,17 +118,13 @@ describe("Battle order", () => { vi.spyOn(playerPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other playerPokemon's speed to 150 vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set one enemyPokemon's speed to 100 vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other enemyPokemon's speed to 150 - const playerIndices = playerPokemon.map(p => p?.getBattlerIndex()); - const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex()); game.move.select(MoveId.TACKLE); game.move.select(MoveId.TACKLE, 1); - await game.phaseInterceptor.to("TurnStartPhase", false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - // P2/E2 should be randomly first/second, then P1/E1 randomly 3rd/4th - expect(order.slice(0, 2)).toStrictEqual(expect.arrayContaining([playerIndices[1], enemyIndices[1]])); - expect(order.slice(2, 4)).toStrictEqual(expect.arrayContaining([playerIndices[0], enemyIndices[0]])); + await game.phaseInterceptor.to("MovePhase", false); + + const phase = game.scene.phaseManager.getCurrentPhase() as MovePhase; + expect(enemyPokemon[1] === phase.pokemon || playerPokemon[1] === phase.pokemon); }); }); diff --git a/test/moves/baton-pass.test.ts b/test/moves/baton-pass.test.ts index f9bd92a63cd..caabcfa7158 100644 --- a/test/moves/baton-pass.test.ts +++ b/test/moves/baton-pass.test.ts @@ -76,12 +76,7 @@ describe("Moves - Baton Pass", () => { expect(game.field.getEnemyPokemon().getStatStage(Stat.SPATK)).toEqual(2); // confirm that a switch actually happened. can't use species because I // can't find a way to override trainer parties with more than 1 pokemon species - expect(game.phaseInterceptor.log.slice(-4)).toEqual([ - "MoveEffectPhase", - "SwitchSummonPhase", - "SummonPhase", - "PostSummonPhase", - ]); + expect(game.field.getEnemyPokemon().summonData.moveHistory).toHaveLength(0); }); it("doesn't transfer effects that aren't transferrable", async () => { diff --git a/test/moves/delayed-attack.test.ts b/test/moves/delayed-attack.test.ts index 6817c7fd17a..e31c7f28e48 100644 --- a/test/moves/delayed-attack.test.ts +++ b/test/moves/delayed-attack.test.ts @@ -193,7 +193,7 @@ describe("Moves - Delayed Attacks", () => { // All attacks have concluded at this point, unshifting new `MoveEffectPhase`s to the queue. expectFutureSightActive(0); - const MEPs = game.scene.phaseManager.phaseQueue.filter(p => p.is("MoveEffectPhase")); + const MEPs = game.scene.phaseManager["phaseQueue"].findAll("MoveEffectPhase"); expect(MEPs).toHaveLength(4); expect(MEPs.map(mep => mep.getPokemon())).toEqual(oldOrder); }); diff --git a/test/moves/focus-punch.test.ts b/test/moves/focus-punch.test.ts index d7b40569aaa..06594e85e27 100644 --- a/test/moves/focus-punch.test.ts +++ b/test/moves/focus-punch.test.ts @@ -3,7 +3,6 @@ import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { BerryPhase } from "#phases/berry-phase"; import { MessagePhase } from "#phases/message-phase"; -import { MoveHeaderPhase } from "#phases/move-header-phase"; import { SwitchSummonPhase } from "#phases/switch-summon-phase"; import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; @@ -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")).toBe(true); }); it("should replace the 'but it failed' text when the user gets hit", async () => { game.override.enemyMoveset([MoveId.TACKLE]); diff --git a/test/moves/rage-fist.test.ts b/test/moves/rage-fist.test.ts index 61164b5710c..c58d1296ac5 100644 --- a/test/moves/rage-fist.test.ts +++ b/test/moves/rage-fist.test.ts @@ -166,7 +166,6 @@ describe("Moves - Rage Fist", () => { // Charizard hit game.move.select(MoveId.SPLASH); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); expect(getPartyHitCount()).toEqual([1, 0]); diff --git a/test/moves/revival-blessing.test.ts b/test/moves/revival-blessing.test.ts index 4dc7cb97f2d..8c751458ff7 100644 --- a/test/moves/revival-blessing.test.ts +++ b/test/moves/revival-blessing.test.ts @@ -119,17 +119,16 @@ describe("Moves - Revival Blessing", () => { game.override .battleStyle("double") .enemyMoveset([MoveId.REVIVAL_BLESSING]) - .moveset([MoveId.SPLASH]) + .moveset([MoveId.SPLASH, MoveId.JUDGMENT]) + .startingLevel(100) .startingWave(25); // 2nd rival battle - must have 3+ pokemon await game.classicMode.startBattle([SpeciesId.ARCEUS, SpeciesId.GIRATINA]); const enemyFainting = game.scene.getEnemyField()[0]; - game.move.select(MoveId.SPLASH, 0); + game.move.use(MoveId.JUDGMENT, 0, BattlerIndex.ENEMY); game.move.select(MoveId.SPLASH, 1); - await game.killPokemon(enemyFainting); - await game.phaseInterceptor.to("BerryPhase"); await game.toNextTurn(); // If there are incorrectly two switch phases into this slot, the fainted pokemon will end up in slot 3 // Make sure it's still in slot 1 diff --git a/test/moves/shell-trap.test.ts b/test/moves/shell-trap.test.ts index 5ecad3116af..2a83f2c3266 100644 --- a/test/moves/shell-trap.test.ts +++ b/test/moves/shell-trap.test.ts @@ -48,7 +48,7 @@ describe("Moves - Shell Trap", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]); - await game.phaseInterceptor.to(MoveEndPhase); + await game.phaseInterceptor.to("MoveEndPhase"); const movePhase = game.scene.phaseManager.getCurrentPhase(); expect(movePhase instanceof MovePhase).toBeTruthy(); diff --git a/test/moves/trick-room.test.ts b/test/moves/trick-room.test.ts index a1d81efb17e..d970dc9762d 100644 --- a/test/moves/trick-room.test.ts +++ b/test/moves/trick-room.test.ts @@ -5,10 +5,10 @@ import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { TurnStartPhase } from "#phases/turn-start-phase"; +import { WeatherType } from "#enums/weather-type"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Move - Trick Room", () => { let phaserGame: Phaser.Game; @@ -56,13 +56,11 @@ describe("Move - Trick Room", () => { turnCount: 4, // The 5 turn limit _includes_ the current turn! }); - // Now, check that speed was indeed reduced - const turnOrderSpy = vi.spyOn(TurnStartPhase.prototype, "getSpeedOrder"); - - game.move.use(MoveId.SPLASH); + game.move.use(MoveId.SUNNY_DAY); + await game.move.forceEnemyMove(MoveId.RAIN_DANCE); await game.toEndOfTurn(); - expect(turnOrderSpy).toHaveLastReturnedWith([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + expect(game.scene.arena.getWeatherType()).toBe(WeatherType.SUNNY); }); it("should be removed when overlapped", async () => { diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts index 1c1f3f3b8ba..b64a15ac654 100644 --- a/test/moves/wish.test.ts +++ b/test/moves/wish.test.ts @@ -135,7 +135,7 @@ describe("Move - Wish", () => { // all wishes have activated and added healing phases expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); - const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); + const healPhases = game.scene.phaseManager["phaseQueue"].findAll("PokemonHealPhase"); expect(healPhases).toHaveLength(4); expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder); diff --git a/test/mystery-encounter/encounter-test-utils.ts b/test/mystery-encounter/encounter-test-utils.ts index 4aad0e000d9..165678a88da 100644 --- a/test/mystery-encounter/encounter-test-utils.ts +++ b/test/mystery-encounter/encounter-test-utils.ts @@ -70,7 +70,6 @@ export async function runMysteryEncounterToEnd( // If a battle is started, fast forward to end of the battle game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { game.scene.phaseManager.clearPhaseQueue(); - game.scene.phaseManager.clearPhaseQueueSplice(); game.scene.phaseManager.unshiftPhase(new VictoryPhase(0)); game.endPhase(); }); @@ -196,7 +195,6 @@ async function handleSecondaryOptionSelect(game: GameManager, pokemonNo: number, */ export async function skipBattleRunMysteryEncounterRewardsPhase(game: GameManager, runRewardsPhase = true) { game.scene.phaseManager.clearPhaseQueue(); - game.scene.phaseManager.clearPhaseQueueSplice(); game.scene.getEnemyParty().forEach(p => { p.hp = 0; p.status = new Status(StatusEffect.FAINT); diff --git a/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts b/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts index 814e2ee07fb..3bbb858a15d 100644 --- a/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts +++ b/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts @@ -355,7 +355,6 @@ describe("The Winstrate Challenge - Mystery Encounter", () => { */ async function skipBattleToNextBattle(game: GameManager, isFinalBattle = false) { game.scene.phaseManager.clearPhaseQueue(); - game.scene.phaseManager.clearPhaseQueueSplice(); const commandUiHandler = game.scene.ui.handlers[UiMode.COMMAND]; commandUiHandler.clear(); game.scene.getEnemyParty().forEach(p => { diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index f9db964ad26..f681846d935 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -464,6 +464,9 @@ export class GameManager { * Faint a player or enemy pokemon instantly by setting their HP to 0. * @param pokemon - The player/enemy pokemon being fainted * @returns A Promise that resolves once the fainted pokemon's FaintPhase finishes running. + * @remarks + * This method *pushes* a FaintPhase and runs until it's finished. This may cause a turn to play out unexpectedly + * @todo Consider whether running the faint phase immediately can be done */ async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) { pokemon.hp = 0; @@ -533,7 +536,7 @@ export class GameManager { } /** - * Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. + * Modifies the queue manager to return move phases in a particular order * Used to manually modify Pokemon turn order. * Note: This *DOES NOT* account for priority. * @param order - The turn order to set as an array of {@linkcode BattlerIndex}es. @@ -545,7 +548,7 @@ export class GameManager { async setTurnOrder(order: BattlerIndex[]): Promise { await this.phaseInterceptor.to("TurnStartPhase", false); - vi.spyOn(this.scene.phaseManager.getCurrentPhase() as TurnStartPhase, "getSpeedOrder").mockReturnValue(order); + this.scene.phaseManager.dynamicQueueManager.setMoveOrder(order); } /** From 25416ebf47bd1e55e5fbdfa35c2f8ee0093e0624 Mon Sep 17 00:00:00 2001 From: Dean <69436131+emdeann@users.noreply.github.com> Date: Sat, 20 Sep 2025 20:24:50 -0700 Subject: [PATCH 2/2] [UI] Avoid prematurely updating HP bar when applying damage (#6582) Avoid prematurely updating HP bar when applying damage --- src/field/pokemon.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index ec813e52e56..ea7c74904d8 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3942,11 +3942,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { damage = 0; } damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase); - // Ensure the battle-info bar's HP is updated, though only if the battle info is visible - // TODO: When battle-info UI is refactored, make this only update the HP bar - if (this.battleInfo.visible) { - this.updateInfo(); - } // Damage amount may have changed, but needed to be queued before calling damage function damagePhase.updateAmount(damage); /**