diff --git a/src/@types/phase-condition.ts b/src/@types/phase-condition.ts new file mode 100644 index 00000000000..e2e58ad1293 --- /dev/null +++ b/src/@types/phase-condition.ts @@ -0,0 +1,3 @@ +import type { Phase } from "#app/phase"; + +export type PhaseConditionFunc = (phase: Phase) => boolean; diff --git a/src/@types/phase-types.ts b/src/@types/phase-types.ts index 1d68c7921dd..ca970f00cbf 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"; // Intentionally export the types of everything in phase-manager, as this file is meant to be @@ -23,3 +25,11 @@ export type PhaseClass = PhaseConstructorMap[keyof PhaseConstructorMap]; * Union type of all phase names as strings. */ export type PhaseString = keyof PhaseMap; + +export type DynamicPhaseString = "PostSummonPhase" | "SwitchSummonPhase" | "MovePhase" | "MoveHeaderPhase"; + +export type StaticPhaseString = Exclude; + +export interface DynamicPhase extends Phase { + getPokemon(): Pokemon; +} diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 57ca66e0dc4..f606bef87e8 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -861,8 +861,8 @@ export default class BattleScene extends SceneBase { let targetingMovePhase: MovePhase; do { targetingMovePhase = this.phaseManager.findPhase( + "MovePhase", mp => - mp.is("MovePhase") && mp.targets.length === 1 && mp.targets[0] === removedPokemon.getBattlerIndex() && mp.pokemon.isPlayer() !== allyPokemon.isPlayer(), diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 0aaf5b4b124..d30a2fc18e4 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -82,6 +82,9 @@ import type { Constructor } from "#app/utils/common"; import type { Localizable } from "#app/@types/locales"; import { applyAbAttrs } from "./apply-ab-attrs"; import type { Closed, Exact } from "#app/@types/type-helpers"; +import { MovePriorityModifier } from "#enums/move-priority-modifier"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; +import type { MovePhase } from "#app/phases/move-phase"; // biome-ignore-start lint/correctness/noUnusedImports: Used in TSDoc import type BattleScene from "#app/battle-scene"; @@ -3181,6 +3184,7 @@ export class CommanderAbAttr extends AbAttr { return ( globalScene.currentBattle?.double && !isNullOrUndefined(ally) && + ally.isActive(true) && ally.species.speciesId === SpeciesId.DONDOZO && !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)) ); @@ -4014,6 +4018,25 @@ export class ChangeMovePriorityAbAttr extends AbAttr { } } +export class ChangeMovePriorityModifierAbAttr extends AbAttr { + private newModifier: MovePriorityModifier; + private moveFunc: (pokemon: Pokemon, move: Move) => boolean; + + constructor(moveFunc: (pokemon: Pokemon, move: Move) => boolean, newModifier: MovePriorityModifier) { + super(false); + this.newModifier = newModifier; + this.moveFunc = moveFunc; + } + + override canApply({ pokemon, move }: ChangeMovePriorityAbAttrParams): boolean { + return this.moveFunc(pokemon, move); + } + + override apply({ priority }: ChangeMovePriorityAbAttrParams): void { + priority.value = this.newModifier; + } +} + export class IgnoreContactAbAttr extends AbAttr { private declare readonly _: never; } @@ -4955,15 +4978,23 @@ 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.pushNew( + "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( + globalScene.phaseManager.pushNew( "MovePhase", pokemon, [pokemon.getBattlerIndex()], move, MoveUseMode.INDIRECT, + MovePhaseTimingModifier.FIRST, ); } } @@ -5952,11 +5983,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 @@ -5972,26 +5998,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) }); } } @@ -6027,9 +6055,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; } } @@ -6150,7 +6177,7 @@ class ForceSwitchOutHelper { : 0; globalScene.phaseManager.prependNewToPhase( "MoveEndPhase", - "SwitchSummonPhase", + "StaticSwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), summonIndex, @@ -6518,6 +6545,7 @@ const AbilityAttrs = Object.freeze({ BlockStatusDamageAbAttr, BlockOneHitKOAbAttr, ChangeMovePriorityAbAttr, + ChangeMovePriorityModifierAbAttr, IgnoreContactAbAttr, PreWeatherEffectAbAttr, PreWeatherDamageAbAttr, @@ -6935,7 +6963,7 @@ export function initAbilities() { .attr(AlwaysHitAbAttr) .attr(DoubleBattleChanceAbAttr), new Ability(AbilityId.STALL, 4) - .attr(ChangeMovePriorityAbAttr, (_pokemon, _move: Move) => true, -0.2), + .attr(ChangeMovePriorityModifierAbAttr, (_pokemon, _move: Move) => true, MovePriorityModifier.LAST_IN_BRACKET), new Ability(AbilityId.TECHNICIAN, 4) .attr(MovePowerBoostAbAttr, (user, target, move) => { const power = new NumberHolder(move.power); @@ -7085,7 +7113,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: MovePhase) => phase.pokemon.id !== user?.id), 1.3), new Ability(AbilityId.ILLUSION, 5) // The Pokemon generate an illusion if it's available @@ -7673,7 +7701,7 @@ export function initAbilities() { .attr(TypeImmunityHealAbAttr, PokemonType.GROUND) .ignorable(), new Ability(AbilityId.MYCELIUM_MIGHT, 9) - .attr(ChangeMovePriorityAbAttr, (_pokemon, move) => move.category === MoveCategory.STATUS, -0.2) + .attr(ChangeMovePriorityModifierAbAttr, (_pokemon, move) => move.category === MoveCategory.STATUS, MovePriorityModifier.LAST_IN_BRACKET) .attr(PreventBypassSpeedChanceAbAttr, (_pokemon, move) => move.category === MoveCategory.STATUS) .attr(MoveAbilityBypassAbAttr, (_pokemon, move: Move) => move.category === MoveCategory.STATUS), new Ability(AbilityId.MINDS_EYE, 9) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 0c6d1df7ad8..60601db15e2 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -507,17 +507,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; } @@ -1165,22 +1155,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); } } @@ -3481,6 +3458,23 @@ export class GrudgeTag extends BattlerTag { } } +/** + * 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 { + constructor() { + super(BattlerTagType.BYPASS_SPEED, BattlerTagLapseType.TURN_END, 1); + } + + override canAdd(pokemon: Pokemon): boolean { + const cancelled = new BooleanHolder(false); + applyAbAttrs("PreventBypassSpeedChanceAbAttr", pokemon, null, false, cancelled); + return !cancelled.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 */ @@ -3728,6 +3722,8 @@ export function getBattlerTag( return new PsychoShiftTag(); case BattlerTagType.MAGIC_COAT: return new MagicCoatTag(); + case BattlerTagType.BYPASS_SPEED: + return new BypassSpeedTag(); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0878ece2f01..071ff6e6337 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -90,6 +90,8 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveKindString, MoveCla import { applyMoveAttrs } from "./apply-attrs"; import { frenzyMissFunc, getMoveTargets } from "./move-utils"; import { AbAttrBaseParams, AbAttrParamsWithCancel, PreAttackModifyPowerAbAttrParams } from "../abilities/ability"; +import { MovePriorityModifier } from "#enums/move-priority-modifier"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; /** * A function used to conditionally determine execution of a given {@linkcode MoveAttr}. @@ -870,6 +872,13 @@ export default abstract class Move implements Localizable { return priority.value; } + getPriorityModifier(user: Pokemon, simulated = true): MovePriorityModifier { + const modifierHolder = new NumberHolder(MovePriorityModifier.NORMAL); + applyAbAttrs("ChangeMovePriorityModifierAbAttr", {pokemon: user, simulated: simulated, move: this, priority: modifierHolder}); + modifierHolder.value = user.getTag(BattlerTagType.BYPASS_SPEED) ? MovePriorityModifier.FIRST_IN_BRACKET : modifierHolder.value; + return modifierHolder.value; + } + /** * Calculate the [Expected Power](https://en.wikipedia.org/wiki/Expected_value) per turn * of this move, taking into account multi hit moves, accuracy, and the number of turns it @@ -3182,7 +3191,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.findPhase("MovePhase", (phase) => phase.pokemon.isPlayer() === user.isPlayer()); if (allyMovePhase) { const allyMove = allyMovePhase.move.getMove(); if (allyMove !== move && allyMove.hasAttr("AwaitCombinedPledgeAttr")) { @@ -3195,11 +3204,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; @@ -4455,28 +4460,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)) { @@ -4561,20 +4545,14 @@ 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.findPhase("MovePhase", 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: MovePhase) => phase.is("MovePhase") && phase.move.moveId === MoveId.ROUND); // Mark the corresponding Pokemon as having "joined the Round" (for doubling power later) nextRoundPhase.pokemon.turnData.joinedRound = true; @@ -6253,15 +6231,15 @@ 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((phase: SwitchSummonPhase) => phase.is("StaticSwitchSummonPhase") && phase.getPokemon() === pokemon); // 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.findPhase("MovePhase", (phase: MovePhase) => phase.pokemon === pokemon)?.cancel(); if (user.fieldPosition === FieldPosition.CENTER) { user.setFieldPosition(FieldPosition.LEFT); } - globalScene.phaseManager.unshiftNew("SwitchSummonPhase", SwitchType.SWITCH, allyPokemon.getFieldIndex(), slotIndex, false, false); + globalScene.phaseManager.unshiftNew("StaticSwitchSummonPhase", SwitchType.SWITCH, allyPokemon.getFieldIndex(), slotIndex, false, false); } } return true; @@ -6341,7 +6319,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)]; globalScene.phaseManager.prependNewToPhase( "MoveEndPhase", - "SwitchSummonPhase", + "StaticSwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), slotIndex, @@ -6380,7 +6358,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { switchOutTarget.leaveField(true); const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)]; globalScene.phaseManager.prependNewToPhase("MoveEndPhase", - "SwitchSummonPhase", + "StaticSwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), slotIndex, @@ -6390,7 +6368,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } else { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); globalScene.phaseManager.prependNewToPhase("MoveEndPhase", - "SwitchSummonPhase", + "StaticSwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), (globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), @@ -6817,7 +6795,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.pushNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST); return true; } } @@ -7047,7 +7025,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.pushNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST); return true; } } @@ -7132,7 +7110,7 @@ export class RepeatMoveAttr extends MoveEffectAttr { targetPokemonName: getPokemonNameWithAffix(target) })); target.turnData.extraTurns++; - globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL); + globalScene.phaseManager.pushNew("MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL, MovePhaseTimingModifier.FIRST); return true; } @@ -7920,12 +7898,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; } @@ -7949,45 +7922,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(); @@ -8008,7 +7947,7 @@ const userSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE); -const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined; +const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.hasPhaseOfType("MovePhase"); const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => { const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts deleted file mode 100644 index b815a6ac34f..00000000000 --- a/src/data/phase-priority-queue.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { globalScene } from "#app/global-scene"; -import type { Phase } from "#app/phase"; -import { ActivatePriorityQueuePhase } from "#app/phases/activate-priority-queue-phase"; -import type { PostSummonPhase } from "#app/phases/post-summon-phase"; -import { PostSummonActivateAbilityPhase } from "#app/phases/post-summon-activate-ability-phase"; -import { Stat } from "#enums/stat"; -import { BooleanHolder } from "#app/utils/common"; -import { TrickRoomTag } from "#app/data/arena-tag"; -import { DynamicPhaseType } from "#enums/dynamic-phase-type"; - -/** - * 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); - } -} - -/** - * 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/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 719b08c5b81..b7482bc9fb5 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -95,4 +95,5 @@ export enum BattlerTagType { ENDURE_TOKEN = "ENDURE_TOKEN", POWDER = "POWDER", MAGIC_COAT = "MAGIC_COAT", + 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 a34ac371668..00000000000 --- a/src/enums/dynamic-phase-type.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue} - */ -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..219def57466 --- /dev/null +++ b/src/enums/move-phase-timing-modifier.ts @@ -0,0 +1,5 @@ +export enum MovePhaseTimingModifier { + LAST = 0, + NORMAL, + FIRST +} \ No newline at end of file diff --git a/src/enums/move-priority-modifier.ts b/src/enums/move-priority-modifier.ts new file mode 100644 index 00000000000..60d00ab2862 --- /dev/null +++ b/src/enums/move-priority-modifier.ts @@ -0,0 +1,5 @@ +export enum MovePriorityModifier { + LAST_IN_BRACKET = 0, + NORMAL, + FIRST_IN_BRACKET, +} \ No newline at end of file diff --git a/src/field/arena.ts b/src/field/arena.ts index 4479748667c..7a14e0db083 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -374,9 +374,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 0a8e8469115..682e80fb440 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -182,6 +182,7 @@ import type { IllusionData } from "#app/@types/illusion-data"; import type { TurnMove } from "#app/@types/turn-move"; import type { DamageCalculationResult, DamageResult } from "#app/@types/damage-result"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#app/@types/ability-types"; +import type { TurnCommand } from "#app/battle"; import { getTerrainBlockMessage } from "#app/data/terrain"; import { LearnMoveSituation } from "#enums/learn-move-situation"; @@ -5731,7 +5732,7 @@ export class PlayerPokemon extends Pokemon { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { globalScene.phaseManager.prependNewToPhase( "MoveEndPhase", - "SwitchSummonPhase", + "StaticSwitchSummonPhase", switchType, this.getFieldIndex(), slotIndex, diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 247b64ca2c0..e335927b164 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -12,7 +12,6 @@ import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; import { LearnMoveType } from "#enums/learn-move-type"; import type { VoucherType } from "#app/system/voucher"; -import { Command } from "#enums/command"; import { addTextObject, TextStyle } from "#app/ui/text"; import { BooleanHolder, hslToHex, isNullOrUndefined, NumberHolder, randSeedFloat, toDmgValue } from "#app/utils/common"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -1574,30 +1573,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 a4256f110ef..9aa8633d7aa 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -1,8 +1,7 @@ import type { Phase } from "#app/phase"; import type { default as Pokemon } from "#app/field/pokemon"; -import type { PhaseMap, PhaseString } from "./@types/phase-types"; +import type { PhaseMap, PhaseString, DynamicPhase, StaticPhaseString } from "./@types/phase-types"; import { globalScene } from "#app/global-scene"; -import { ActivatePriorityQueuePhase } from "#app/phases/activate-priority-queue-phase"; import { AddEnemyBuffModifierPhase } from "#app/phases/add-enemy-buff-modifier-phase"; import { AttemptCapturePhase } from "#app/phases/attempt-capture-phase"; import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; @@ -12,9 +11,7 @@ import { CheckStatusEffectPhase } from "#app/phases/check-status-effect-phase"; import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; import { CommandPhase } from "#app/phases/command-phase"; import { CommonAnimPhase } from "#app/phases/common-anim-phase"; -import { coerceArray, type Constructor } from "#app/utils/common"; import { DamageAnimPhase } from "#app/phases/damage-anim-phase"; -import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; import { EggHatchPhase } from "#app/phases/egg-hatch-phase"; import { EggLapsePhase } from "#app/phases/egg-lapse-phase"; import { EggSummaryPhase } from "#app/phases/egg-summary-phase"; @@ -58,7 +55,6 @@ import { NextEncounterPhase } from "#app/phases/next-encounter-phase"; import { ObtainStatusEffectPhase } from "#app/phases/obtain-status-effect-phase"; import { PartyExpPhase } from "#app/phases/party-exp-phase"; import { PartyHealPhase } from "#app/phases/party-heal-phase"; -import { type PhasePriorityQueue, PostSummonPhasePriorityQueue } from "#app/data/phase-priority-queue"; import { PokemonAnimPhase } from "#app/phases/pokemon-anim-phase"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; import { PokemonTransformPhase } from "#app/phases/pokemon-transform-phase"; @@ -99,6 +95,11 @@ import { UnavailablePhase } from "#app/phases/unavailable-phase"; import { UnlockPhase } from "#app/phases/unlock-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; import { WeatherEffectPhase } from "#app/phases/weather-effect-phase"; +import { DynamicQueueManager } from "#app/queues/dynamic-queue-manager"; +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; +import type { PokemonMove } from "#app/data/moves/pokemon-move"; +import { StaticSwitchSummonPhase } from "#app/phases/static-switch-summon-phase"; /* * Manager for phases used by battle scene. @@ -115,7 +116,6 @@ import { WeatherEffectPhase } from "#app/phases/weather-effect-phase"; * This allows for easy creation of new phases without needing to import each phase individually. */ const PHASES = Object.freeze({ - ActivatePriorityQueuePhase, AddEnemyBuffModifierPhase, AttemptCapturePhase, AttemptRunPhase, @@ -190,6 +190,7 @@ const PHASES = Object.freeze({ ShowAbilityPhase, ShowPartyExpBarPhase, ShowTrainerPhase, + StaticSwitchSummonPhase, StatStageChangePhase, SummonMissingPhase, SummonPhase, @@ -213,13 +214,18 @@ const PHASES = Object.freeze({ /** Maps Phase strings to their constructors */ export type PhaseConstructorMap = typeof PHASES; +const turnEndPhases: PhaseString[] = ["WeatherEffectPhase", "BerryPhase", "CheckStatusEffectPhase", "TurnEndPhase"]; + +const ignorablePhases: PhaseString[] = ["ShowAbilityPhase", "HideAbilityPhase"]; +// TODO might be easier to define which phases should be dynamic instead +const nonDynamicPokemonPhases: PhaseString[] = ["SummonPhase", "CommandPhase"]; + /** * 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]> = []; + private phaseQueue: Phase[] = []; /** PhaseQueuePrepend: is a temp storage of what will be added to PhaseQueue */ private phaseQueuePrepend: Phase[] = []; @@ -227,18 +233,12 @@ export class PhaseManager { private phaseQueuePrependSpliceIndex = -1; private nextCommandPhaseQueue: Phase[] = []; - /** 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[]; + public dynamicQueueManager = new DynamicQueueManager(); private currentPhase: Phase | null = null; private standbyPhase: Phase | null = null; - constructor() { - this.dynamicPhaseQueues = [new PostSummonPhasePriorityQueue()]; - this.dynamicPhaseTypes = [PostSummonPhase]; - } + public turnEnded = false; /* Phase Functions */ getCurrentPhase(): Phase | null { @@ -249,31 +249,17 @@ export class PhaseManager { return this.standbyPhase; } - /** - * 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. - * - */ - pushConditionalPhase(phase: Phase, condition: () => boolean): void { - this.conditionalQueue.push([condition, phase]); - } - /** * Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false * @param phase {@linkcode Phase} the phase to add * @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue */ pushPhase(phase: Phase, defer = false): void { - if (this.getDynamicPhaseType(phase) !== undefined) { - this.pushDynamicPhase(phase); - } else { - (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); + if (this.isDynamicPhase(phase) && this.dynamicQueueManager.activeQueueExists(phase.phaseName)) { + this.dynamicQueueManager.queueDynamicPhase(phase); + return; } + (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); } /** @@ -281,6 +267,10 @@ export class PhaseManager { * @param phases {@linkcode Phase} the phase(s) to add */ unshiftPhase(...phases: Phase[]): void { + if (this.isDynamicPhase(phases[0]) && this.dynamicQueueManager.activeQueueExists(phases[0].phaseName)) { + phases.forEach((p: DynamicPhase) => this.dynamicQueueManager.queueDynamicPhase(p)); + return; + } if (this.phaseQueuePrependSpliceIndex === -1) { this.phaseQueuePrepend.push(...phases); } else { @@ -299,12 +289,13 @@ export class PhaseManager { * 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, this.nextCommandPhaseQueue]) { + for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.nextCommandPhaseQueue]) { queue.splice(0, queue.length); } - this.dynamicPhaseQueues.forEach(queue => queue.clear()); + this.dynamicQueueManager.clearQueues(); this.currentPhase = null; this.standbyPhase = null; + this.turnEnded = false; this.clearPhaseQueueSplice(); } @@ -345,32 +336,21 @@ export class PhaseManager { } } } - if (!this.phaseQueue.length) { - this.populatePhaseQueue(); - // Clear the conditionalQueue if there are no phases left in the phaseQueue - this.conditionalQueue = []; + + this.queueDynamicPhasesAtFront(); + + if (this.phaseQueue.every(p => ignorablePhases.includes(p.phaseName))) { + this.startNextDynamicPhase(); + } + + if (!this.turnEnded && (!this.phaseQueue.length || this.phaseQueue[0].is("BattleEndPhase"))) { + if (!this.startNextDynamicPhase()) { + this.turnEndSequence(); + } } this.currentPhase = this.phaseQueue.shift() ?? null; - const unactivatedConditionalPhases: [() => boolean, Phase][] = []; - // Check if there are any conditional phases queued - while (this.conditionalQueue?.length) { - // Retrieve the first conditional phase from the queue - const conditionalPhase = this.conditionalQueue.shift(); - // Evaluate the condition associated with the phase - if (conditionalPhase?.[0]()) { - // If the condition is met, add the phase to the phase queue - this.pushPhase(conditionalPhase[1]); - } else if (conditionalPhase) { - // If the condition is not met, re-add the phase back to the front of the conditional queue - unactivatedConditionalPhases.push(conditionalPhase); - } else { - console.warn("condition phase is undefined/null!", conditionalPhase); - } - } - this.conditionalQueue.push(...unactivatedConditionalPhases); - if (this.currentPhase) { console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); this.currentPhase.start(); @@ -390,17 +370,32 @@ export class PhaseManager { return true; } + public hasPhaseOfType(type: PhaseString, condition?: PhaseConditionFunc): boolean { + if (this.dynamicQueueManager.exists(type, condition)) { + return true; + } + return [this.phaseQueue, this.phaseQueuePrepend].some((queue: Phase[]) => + queue.find(phase => phase.is(type) && (!condition || condition(phase))), + ); + } + /** * Find a specific {@linkcode Phase} in the phase queue. - * + * @param phaseType - A {@linkcode PhaseString} representing which type to search for * @param phaseFilter filter function to use to find the wanted phase * @returns the found phase or undefined if none found */ - findPhase

(phaseFilter: (phase: P) => boolean): P | undefined { - return this.phaseQueue.find(phaseFilter) as P | undefined; + findPhase

( + phaseType: P, + phaseFilter?: (phase: PhaseMap[P]) => boolean, + ): PhaseMap[P] | undefined { + if (this.dynamicQueueManager.exists(phaseType, phaseFilter)) { + return this.dynamicQueueManager.findPhaseOfType(phaseType, phaseFilter) as PhaseMap[P]; + } + return this.phaseQueue.find(phase => phase.is(phaseType) && (!phaseFilter || phaseFilter(phase))) as PhaseMap[P]; } - tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean { + tryReplacePhase(phaseFilter: PhaseConditionFunc, phase: Phase): boolean { const phaseIndex = this.phaseQueue.findIndex(phaseFilter); if (phaseIndex > -1) { this.phaseQueue[phaseIndex] = phase; @@ -409,20 +404,23 @@ export class PhaseManager { return false; } - tryRemovePhase(phaseFilter: (phase: Phase) => boolean): boolean { + tryRemovePhase(phaseFilter: PhaseConditionFunc): boolean { + if (this.dynamicQueueManager.removePhase(phaseFilter)) { + return true; + } const phaseIndex = this.phaseQueue.findIndex(phaseFilter); if (phaseIndex > -1) { this.phaseQueue.splice(phaseIndex, 1); return true; } - return false; + return this.dynamicQueueManager.removePhase(phaseFilter); } /** * 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 { + tryRemoveUnshiftedPhase(phaseFilter: PhaseConditionFunc): boolean { const phaseIndex = this.phaseQueuePrepend.findIndex(phaseFilter); if (phaseIndex > -1) { this.phaseQueuePrepend.splice(phaseIndex, 1); @@ -431,22 +429,27 @@ export class PhaseManager { return false; } + public removeAllPhasesOfType(type: PhaseString): void { + this.phaseQueue = this.phaseQueue.filter(phase => !phase.is(type)); + this.phaseQueuePrepend = this.phaseQueuePrepend.filter(phase => !phase.is(type)); + } + /** * Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase() * @param phase - The phase to be added * @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); + prependToPhase(phase: Phase, targetPhase: StaticPhaseString): boolean { const target = PHASES[targetPhase]; const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target); if (targetIndex !== -1) { - this.phaseQueue.splice(targetIndex, 0, ...phase); + this.phaseQueue.splice(targetIndex, 0, phase); return true; } - this.unshiftPhase(...phase); + this.unshiftPhase(phase); + return false; } @@ -457,81 +460,18 @@ export class PhaseManager { * @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); + appendToPhase(phase: Phase, targetPhase: StaticPhaseString, condition?: PhaseConditionFunc): boolean { 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); + this.phaseQueue.splice(targetIndex + 1, 0, phase); return true; } - this.unshiftPhase(...phase); + 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); - } - - /** - * 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 * @param message - string for MessagePhase @@ -582,7 +522,10 @@ export class PhaseManager { /** * Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order) */ - private populatePhaseQueue(): void { + private turnEndSequence(): void { + this.turnEnded = true; + this.dynamicQueueManager.clearQueues(); + this.queueTurnEndPhases(); if (this.nextCommandPhaseQueue.length) { this.phaseQueue.push(...this.nextCommandPhaseQueue); this.nextCommandPhaseQueue.splice(0, this.nextCommandPhaseQueue.length); @@ -637,7 +580,7 @@ export class PhaseManager { * @returns `true` if a `targetPhase` was found to prepend to */ public prependNewToPhase( - targetPhase: PhaseString, + targetPhase: StaticPhaseString, phase: T, ...args: ConstructorParameters ): boolean { @@ -653,17 +596,62 @@ export class PhaseManager { * @returns `true` if a `targetPhase` was found to append to */ public appendNewToPhase( - targetPhase: PhaseString, + targetPhase: StaticPhaseString, phase: T, ...args: ConstructorParameters ): boolean { return this.appendToPhase(this.create(phase, ...args), targetPhase); } - public startNewDynamicPhase( - phase: T, - ...args: ConstructorParameters - ): void { - this.startDynamicPhase(this.create(phase, ...args)); + public forceMoveNext(phaseCondition: PhaseConditionFunc) { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.FIRST); + } + + public forceMoveLast(phaseCondition: PhaseConditionFunc) { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.LAST); + } + + public changePhaseMove(phaseCondition: PhaseConditionFunc, move: PokemonMove) { + this.dynamicQueueManager.setMoveForPhase(phaseCondition, move); + } + + public queueTurnEndPhases(): void { + turnEndPhases + .slice() + .reverse() + .forEach(p => this.phaseQueue.unshift(this.create(p))); + } + + private consecutivePokemonPhases(): DynamicPhase[] | undefined { + if (this.phaseQueue.length < 1 || !this.isDynamicPhase(this.phaseQueue[0])) { + return; + } + + let spliceLength = this.phaseQueue.findIndex(p => !p.is(this.phaseQueue[0].phaseName)); + spliceLength = spliceLength !== -1 ? spliceLength : this.phaseQueue.length; + if (spliceLength > 1) { + return this.phaseQueue.splice(0, spliceLength) as DynamicPhase[]; + } + } + + private queueDynamicPhasesAtFront(): void { + const dynamicPhases = this.consecutivePokemonPhases(); + if (dynamicPhases) { + dynamicPhases.forEach((p: DynamicPhase) => { + globalScene.phaseManager.dynamicQueueManager.queueDynamicPhase(p); + }); + } + } + + public startNextDynamicPhase(): boolean { + const dynamicPhase = this.dynamicQueueManager.popNextPhase(); + if (dynamicPhase) { + this.phaseQueue.unshift(dynamicPhase); + } + return !!dynamicPhase; + } + + private isDynamicPhase(phase: Phase): phase is DynamicPhase { + return typeof (phase as any).getPokemon === "function" && !nonDynamicPokemonPhases.includes(phase.phaseName); } } diff --git a/src/phases/activate-priority-queue-phase.ts b/src/phases/activate-priority-queue-phase.ts deleted file mode 100644 index df42c491676..00000000000 --- a/src/phases/activate-priority-queue-phase.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; -import { globalScene } from "#app/global-scene"; -import { Phase } from "#app/phase"; - -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 297e20cb445..97fe548b628 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 43495e038e9..c757e13a68c 100644 --- a/src/phases/check-status-effect-phase.ts +++ b/src/phases/check-status-effect-phase.ts @@ -1,20 +1,14 @@ import { Phase } from "#app/phase"; -import type { BattlerIndex } from "#enums/battler-index"; import { globalScene } from "#app/global-scene"; 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/egg-hatch-phase.ts b/src/phases/egg-hatch-phase.ts index d6c40a1510e..10beee1f222 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.findPhase("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 52c2b2e465d..b864bda729c 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -562,29 +562,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) { - 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())); @@ -625,6 +602,9 @@ export class EncounterPhase extends BattlePhase { } } } + if (![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { + enemyField.map(p => globalScene.phaseManager.pushNew("PostSummonPhase", p.getBattlerIndex())); + } handleTutorial(Tutorial.Access_Menu).then(() => super.end()); } diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index c2658b62b23..4bc926c7129 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -181,7 +181,14 @@ export class FaintPhase extends PokemonPhase { .filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot) .length; if (hasReservePartyMember) { - globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false); + globalScene.phaseManager.pushNew( + "StaticSwitchSummonPhase", + SwitchType.SWITCH, + this.fieldIndex, + -1, + false, + false, + ); } } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 0f53561f5f3..664e070fc6e 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -50,6 +50,7 @@ import type Move from "#app/data/moves/move"; import { isFieldTargeted } from "#app/data/moves/move-utils"; import { DamageAchv } from "#app/system/achv"; import { isVirtual, isReflected, MoveUseMode } from "#enums/move-use-mode"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; export type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; @@ -156,7 +157,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 @@ -167,24 +168,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.pushNew( + "MovePhase", + target, + newTargets, + new PokemonMove(this.move.id), + MoveUseMode.REFLECTED, + MovePhaseTimingModifier.FIRST, ); } @@ -376,9 +374,6 @@ export class MoveEffectPhase extends PokemonPhase { return; } - if (this.queuedPhases.length) { - 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); diff --git a/src/phases/move-header-phase.ts b/src/phases/move-header-phase.ts index 8c2d184c3f5..a5bf6083898 100644 --- a/src/phases/move-header-phase.ts +++ b/src/phases/move-header-phase.ts @@ -1,29 +1,27 @@ import { applyMoveAttrs } from "#app/data/moves/apply-attrs"; import type { PokemonMove } from "#app/data/moves/pokemon-move"; -import type Pokemon from "#app/field/pokemon"; -import { BattlePhase } from "./battle-phase"; +import { PokemonPhase } from "#app/phases/pokemon-phase"; +import type { BattlerIndex } from "#enums/battler-index"; -export class MoveHeaderPhase extends BattlePhase { +export class MoveHeaderPhase extends PokemonPhase { public readonly phaseName = "MoveHeaderPhase"; - public pokemon: Pokemon; public move: PokemonMove; - constructor(pokemon: Pokemon, move: PokemonMove) { - super(); + constructor(battlerIndex: BattlerIndex, move: PokemonMove) { + super(battlerIndex); - this.pokemon = pokemon; this.move = move; } canMove(): boolean { - return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon); + return this.getPokemon().isActive(true) && this.move.isUsable(this.getPokemon()); } start() { super.start(); if (this.canMove()) { - applyMoveAttrs("MoveHeaderAttr", this.pokemon, null, this.move.getMove()); + applyMoveAttrs("MoveHeaderAttr", this.getPokemon(), null, this.move.getMove()); } this.end(); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 3bc46d3aea8..6c04c43aa7e 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -18,7 +18,6 @@ import type Pokemon from "#app/field/pokemon"; import { MoveResult } from "#enums/move-result"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; -import { BattlePhase } from "#app/phases/battle-phase"; import { enumValueToKey, NumberHolder } from "#app/utils/common"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagType } from "#enums/arena-tag-type"; @@ -29,14 +28,16 @@ import i18next from "i18next"; import { getTerrainBlockMessage } from "#app/data/terrain"; import { isVirtual, isIgnorePP, isReflected, MoveUseMode, isIgnoreStatus } from "#enums/move-use-mode"; import { frenzyMissFunc } from "#app/data/moves/move-utils"; +import { PokemonPhase } from "#app/phases/pokemon-phase"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; -export class MovePhase extends BattlePhase { +export class MovePhase extends PokemonPhase { public readonly phaseName = "MovePhase"; protected _pokemon: Pokemon; protected _move: PokemonMove; protected _targets: BattlerIndex[]; public readonly useMode: MoveUseMode; // Made public for quash - protected forcedLast: boolean; + protected _timingModifier: MovePhaseTimingModifier; /** Whether the current move should fail but still use PP */ protected failed = false; @@ -56,7 +57,7 @@ export class MovePhase extends BattlePhase { return this._move; } - protected set move(move: PokemonMove) { + public set move(move: PokemonMove) { this._move = move; } @@ -68,6 +69,14 @@ export class MovePhase extends BattlePhase { this._targets = targets; } + public get timingModifier(): MovePhaseTimingModifier { + return this._timingModifier; + } + + public set timingModifier(modifier: MovePhaseTimingModifier) { + this._timingModifier = modifier; + } + /** * Create a new MovePhase for using moves. * @param pokemon - The {@linkcode Pokemon} using the move @@ -76,14 +85,14 @@ export class MovePhase extends BattlePhase { * 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` */ - constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, useMode: MoveUseMode, forcedLast = false) { - super(); + constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, useMode: MoveUseMode, timingModifier = MovePhaseTimingModifier.NORMAL) { + super(pokemon.getBattlerIndex()); this.pokemon = pokemon; this.targets = targets; this.move = move; this.useMode = useMode; - this.forcedLast = forcedLast; + this.timingModifier = timingModifier; } /** @@ -109,14 +118,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 9aae796211f..14b961deb73 100644 --- a/src/phases/mystery-encounter-phases.ts +++ b/src/phases/mystery-encounter-phases.ts @@ -237,7 +237,7 @@ export class MysteryEncounterBattleStartCleanupPhase extends Phase { }); // Remove any status tick phases - while (globalScene.phaseManager.findPhase(p => p.is("PostTurnStatusEffectPhase"))) { + while (globalScene.phaseManager.findPhase("PostTurnStatusEffectPhase")) { globalScene.phaseManager.tryRemovePhase(p => p.is("PostTurnStatusEffectPhase")); } diff --git a/src/phases/new-battle-phase.ts b/src/phases/new-battle-phase.ts index 65ecc81df2d..4d4d1311992 100644 --- a/src/phases/new-battle-phase.ts +++ b/src/phases/new-battle-phase.ts @@ -6,12 +6,7 @@ export class NewBattlePhase extends BattlePhase { start() { super.start(); - // cull any extra `NewBattle` phases from the queue. - globalScene.phaseManager.phaseQueue = globalScene.phaseManager.phaseQueue.filter( - phase => !phase.is("NewBattlePhase"), - ); - // `phaseQueuePrepend` is private, so we have to use this inefficient loop. - while (globalScene.phaseManager.tryRemoveUnshiftedPhase(phase => phase.is("NewBattlePhase"))) {} + globalScene.phaseManager.removeAllPhasesOfType("NewBattlePhase"); globalScene.newBattle(); diff --git a/src/phases/party-member-pokemon-phase.ts b/src/phases/party-member-pokemon-phase.ts index a782eabda38..ac9de5a2b15 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-phase.ts b/src/phases/post-summon-phase.ts index 7f22148fdcf..be4cf165aed 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -28,7 +28,9 @@ export class PostSummonPhase extends PokemonPhase { const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); for (const p of field) { - applyAbAttrs("CommanderAbAttr", { pokemon: p }); + if (p.isActive(true)) { + applyAbAttrs("CommanderAbAttr", { pokemon: p }); + } } this.end(); diff --git a/src/phases/quiet-form-change-phase.ts b/src/phases/quiet-form-change-phase.ts index 9c4a0638b54..c6c0b446fbd 100644 --- a/src/phases/quiet-form-change-phase.ts +++ b/src/phases/quiet-form-change-phase.ts @@ -11,7 +11,6 @@ import { BattlerTagType } from "#app/enums/battler-tag-type"; import type Pokemon from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { BattlePhase } from "./battle-phase"; -import type { MovePhase } from "./move-phase"; import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs"; export class QuietFormChangePhase extends BattlePhase { @@ -173,9 +172,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; + const movePhase = globalScene.phaseManager.findPhase("MovePhase", p => p.pokemon === this.pokemon); if (movePhase) { movePhase.cancel(); } diff --git a/src/phases/revival-blessing-phase.ts b/src/phases/revival-blessing-phase.ts index e3e69f7ef25..fcbc276ea71 100644 --- a/src/phases/revival-blessing-phase.ts +++ b/src/phases/revival-blessing-phase.ts @@ -50,7 +50,7 @@ export class RevivalBlessingPhase extends BattlePhase { if (slotIndex <= 1) { // Revived ally pokemon globalScene.phaseManager.unshiftNew( - "SwitchSummonPhase", + "StaticSwitchSummonPhase", SwitchType.SWITCH, pokemon.getFieldIndex(), slotIndex, @@ -61,7 +61,7 @@ export class RevivalBlessingPhase extends BattlePhase { } else if (allyPokemon.isFainted()) { // Revived party pokemon, and ally pokemon is fainted globalScene.phaseManager.unshiftNew( - "SwitchSummonPhase", + "StaticSwitchSummonPhase", SwitchType.SWITCH, allyPokemon.getFieldIndex(), slotIndex, diff --git a/src/phases/stat-stage-change-phase.ts b/src/phases/stat-stage-change-phase.ts index 77fb7b38600..402895df483 100644 --- a/src/phases/stat-stage-change-phase.ts +++ b/src/phases/stat-stage-change-phase.ts @@ -231,7 +231,8 @@ 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, + "StatStageChangePhase", + p => p.battlerIndex === this.battlerIndex, ); if (!existingPhase?.is("StatStageChangePhase")) { // Apply White Herb if needed @@ -311,8 +312,8 @@ export class StatStageChangePhase extends PokemonPhase { if (this.stats.length === 1) { while ( (existingPhase = globalScene.phaseManager.findPhase( + "StatStageChangePhase", p => - p.is("StatStageChangePhase") && p.battlerIndex === this.battlerIndex && p.stats.length === 1 && p.stats[0] === this.stats[0] && @@ -330,8 +331,8 @@ export class StatStageChangePhase extends PokemonPhase { } while ( (existingPhase = globalScene.phaseManager.findPhase( + "StatStageChangePhase", p => - p.is("StatStageChangePhase") && p.battlerIndex === this.battlerIndex && p.selfTarget === this.selfTarget && accEva.some(s => p.stats.includes(s)) === isAccEva && diff --git a/src/phases/static-switch-summon-phase.ts b/src/phases/static-switch-summon-phase.ts new file mode 100644 index 00000000000..5fe8ef17bd0 --- /dev/null +++ b/src/phases/static-switch-summon-phase.ts @@ -0,0 +1,5 @@ +import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; + +export class StaticSwitchSummonPhase extends SwitchSummonPhase { + public readonly phaseName = "StaticSwitchSummonPhase"; +} diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index 95e4367d8df..627b8b6d59f 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -15,7 +15,12 @@ import { globalScene } from "#app/global-scene"; 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"; + public readonly phaseName: + | "SummonPhase" + | "SummonMissingPhase" + | "SwitchSummonPhase" + | "ReturnPhase" + | "StaticSwitchSummonPhase" = "SummonPhase"; private loaded: boolean; constructor(fieldIndex: number, player = true, loaded = false) { @@ -296,4 +301,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 8d18e29e6a6..49d84909895 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -79,7 +79,13 @@ export class SwitchPhase extends BattlePhase { p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex, ); const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType; - globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn); + globalScene.phaseManager.unshiftNew( + "StaticSwitchSummonPhase", + switchType, + fieldIndex, + slotIndex, + this.doReturn, + ); } globalScene.ui.setMode(UiMode.MESSAGE).then(() => super.end()); }, diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index ccd0681c068..8c7b2fdd504 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -14,7 +14,7 @@ import { SubstituteTag } from "#app/data/battler-tags"; import { SwitchType } from "#enums/switch-type"; export class SwitchSummonPhase extends SummonPhase { - public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" = "SwitchSummonPhase"; + public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" | "StaticSwitchSummonPhase" = "SwitchSummonPhase"; private readonly switchType: SwitchType; private readonly slotIndex: number; private readonly doReturn: boolean; @@ -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.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex()); } /** diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index b5e56f6d63f..5213125c4bd 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -23,6 +23,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-init-phase.ts b/src/phases/turn-init-phase.ts index 8d0508c5ebb..0b1c5a4fc78 100644 --- a/src/phases/turn-init-phase.ts +++ b/src/phases/turn-init-phase.ts @@ -14,6 +14,7 @@ export class TurnInitPhase extends FieldPhase { start() { super.start(); + globalScene.phaseManager.turnEnded = false; globalScene.getPlayerField().forEach(p => { // If this pokemon is in play and evolved into something illegal under the current challenge, force a switch if (p.isOnField() && !p.isAllowedInBattle()) { diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index bfae2f06de4..fde4bc9ae58 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -1,86 +1,34 @@ -import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs"; -import { allMoves } from "#app/data/data-lists"; -import { Stat } from "#app/enums/stat"; -import type Pokemon from "#app/field/pokemon"; import { PokemonMove } from "#app/data/moves/pokemon-move"; -import { BypassSpeedChanceModifier } from "#app/modifier/modifier"; import { Command } from "#enums/command"; -import { randSeedShuffle, BooleanHolder } from "#app/utils/common"; import { FieldPhase } from "./field-phase"; -import { BattlerIndex } from "#enums/battler-index"; -import { TrickRoomTag } from "#app/data/arena-tag"; +import type { BattlerIndex } from "#enums/battler-index"; import { SwitchType } from "#enums/switch-type"; import { globalScene } from "#app/global-scene"; +import { applyInSpeedOrder } from "#app/utils/speed-order"; +import type Pokemon from "#app/field/pokemon"; +import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs"; +import { BypassSpeedChanceModifier } from "#app/modifier/modifier"; export class TurnStartPhase extends FieldPhase { public readonly phaseName = "TurnStartPhase"; /** - * This orders the active Pokemon on the field by speed into an BattlerIndex array and returns that array. - * It also checks for Trick Room and reverses the array if it is present. - * @returns {@linkcode BattlerIndex[]} the battle indices of all pokemon on the field ordered by speed - */ - getSpeedOrder(): BattlerIndex[] { - const playerField = globalScene.getPlayerField().filter(p => p.isActive()) as Pokemon[]; - const enemyField = globalScene.getEnemyField().filter(p => p.isActive()) as Pokemon[]; - - // We shuffle the list before sorting so speed ties produce random results - let orderedTargets: Pokemon[] = playerField.concat(enemyField); - // We seed it with the current turn to prevent an inconsistency where it - // was varying based on how long since you last reloaded - globalScene.executeWithSeedOffset( - () => { - orderedTargets = randSeedShuffle(orderedTargets); - }, - globalScene.currentBattle.turn, - globalScene.waveSeed, - ); - - // Next, a check for Trick Room is applied to determine sort order. - const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); - - // Adjust the sort function based on whether Trick Room is active. - orderedTargets.sort((a: Pokemon, b: Pokemon) => { - const aSpeed = a?.getEffectiveStat(Stat.SPD) ?? 0; - const bSpeed = b?.getEffectiveStat(Stat.SPD) ?? 0; - - return speedReversed.value ? aSpeed - bSpeed : bSpeed - aSpeed; - }); - - return orderedTargets.map(t => t.getFieldIndex() + (!t.isPlayer() ? BattlerIndex.ENEMY : BattlerIndex.PLAYER)); - } - - /** - * This takes the result of 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 {@linkcode BattlerIndex[]} the final sequence of commands for this turn + * Returns an ordering of the current field based on command priority + * @returns {@linkcode BattlerIndex[]} 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: canCheckHeldItems, - }); - if (canCheckHeldItems.value) { - globalScene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p, bypassSpeed); - } - battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed; - }); + const playerField = globalScene + .getPlayerField() + .filter(p => p.isActive()) + .map(p => p.getBattlerIndex()); + const enemyField = globalScene + .getEnemyField() + .filter(p => p.isActive()) + .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]; @@ -91,40 +39,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, the game checks 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 @@ -133,25 +55,27 @@ export class TurnStartPhase extends FieldPhase { super.start(); const field = globalScene.getField(); + const activeField = globalScene.getField(true); const moveOrder = this.getCommandOrder(); - let orderIndex = 0; - - for (const o of this.getSpeedOrder()) { - const pokemon = field[o]; - const preTurnCommand = globalScene.currentBattle.preTurnCommands[o]; + applyInSpeedOrder(activeField, (p: Pokemon) => { + const preTurnCommand = globalScene.currentBattle.preTurnCommands[p.getBattlerIndex()]; if (preTurnCommand?.skip) { - continue; + return; } switch (preTurnCommand?.command) { case Command.TERA: - globalScene.phaseManager.pushNew("TeraPhase", pokemon); + globalScene.phaseManager.pushNew("TeraPhase", p); } - } + }); const phaseManager = globalScene.phaseManager; + applyInSpeedOrder(activeField, (p: Pokemon) => { + applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon: p }); + globalScene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p); + }); for (const o of moveOrder) { const pokemon = field[o]; @@ -164,7 +88,6 @@ export class TurnStartPhase extends FieldPhase { switch (turnCommand?.command) { case Command.FIGHT: { const queuedMove = turnCommand.move; - pokemon.turnData.order = orderIndex++; if (!queuedMove) { continue; } @@ -172,7 +95,7 @@ export class TurnStartPhase extends FieldPhase { pokemon.getMoveset().find(m => m.moveId === queuedMove.move && m.ppUsed < m.getMovePp()) ?? new PokemonMove(queuedMove.move); if (move.getMove().hasAttr("MoveHeaderAttr")) { - phaseManager.unshiftNew("MoveHeaderPhase", pokemon, move); + phaseManager.pushNew("MoveHeaderPhase", pokemon.getBattlerIndex(), move); } if (pokemon.isPlayer() && turnCommand.cursor === -1) { @@ -200,7 +123,7 @@ export class TurnStartPhase extends FieldPhase { case Command.POKEMON: { const switchType = turnCommand.args?.[0] ? SwitchType.BATON_PASS : SwitchType.SWITCH; - phaseManager.unshiftNew( + phaseManager.pushNew( "SwitchSummonPhase", switchType, pokemon.getFieldIndex(), @@ -219,14 +142,6 @@ export class TurnStartPhase extends FieldPhase { } } - phaseManager.pushNew("WeatherEffectPhase"); - phaseManager.pushNew("BerryPhase"); - - /** Add a new phase to check who should be taking status damage */ - phaseManager.pushNew("CheckStatusEffectPhase", moveOrder); - - phaseManager.pushNew("TurnEndPhase"); - /** * this.end() will call shiftPhase(), which dumps everything from PrependQueue (aka everything that is unshifted()) to the front * of the queue and dequeues to start the next phase diff --git a/src/queues/dynamic-queue-manager.ts b/src/queues/dynamic-queue-manager.ts new file mode 100644 index 00000000000..eb6c1c08c5f --- /dev/null +++ b/src/queues/dynamic-queue-manager.ts @@ -0,0 +1,103 @@ +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; +import type { PhaseString, DynamicPhase } 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 { MovePhasePriorityQueue } from "#app/queues/move-phase-priority-queue"; +import type { PhasePriorityQueue } from "#app/queues/phase-priority-queue"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import { PostSummonPhasePriorityQueue } from "#app/queues/post-summon-phase-priority-queue"; +import { SwitchSummonPhasePriorityQueue } from "#app/queues/switch-summon-phase-priority-queue"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; + +export class DynamicQueueManager { + private dynamicPhaseMap: Map>; + private alwaysDynamic: PhaseString[] = ["SwitchSummonPhase", "PostSummonPhase", "MovePhase"]; + private popOrder: PhaseString[] = []; + + constructor() { + this.dynamicPhaseMap = new Map(); + this.dynamicPhaseMap.set("SwitchSummonPhase", new SwitchSummonPhasePriorityQueue()); + this.dynamicPhaseMap.set("PostSummonPhase", new PostSummonPhasePriorityQueue()); + this.dynamicPhaseMap.set("MovePhase", new MovePhasePriorityQueue()); + } + + public clearQueues(): void { + for (const queue of this.dynamicPhaseMap.values()) { + queue.clear(); + } + this.popOrder.splice(0, this.popOrder.length); + } + + public queueDynamicPhase(phase: T): void { + if (!this.dynamicPhaseMap.has(phase.phaseName)) { + this.dynamicPhaseMap.set(phase.phaseName, new PokemonPhasePriorityQueue()); + } + this.dynamicPhaseMap.get(phase.phaseName)?.push(phase); + this.popOrder.push(phase.phaseName); + } + + public popNextPhase(): Phase | undefined { + const type = this.popOrder.pop(); + if (!type) { + return; + } + if (!this.alwaysDynamic.includes(type)) { + return this.dynamicPhaseMap.get(type)?.pop(); + } + return this.alwaysDynamic + .map((p: PhaseString) => this.dynamicPhaseMap.get(p)) + .find(q => q && !q.isEmpty()) + ?.pop(); + } + + public findPhaseOfType(type: PhaseString, condition?: PhaseConditionFunc): Phase | undefined { + return this.dynamicPhaseMap.get(type)?.findPhase(condition); + } + + public activeQueueExists(type: PhaseString) { + return this.alwaysDynamic.includes(type) || this.dynamicPhaseMap.get(type)?.isEmpty() === false; + } + + public exists(type: PhaseString, condition?: PhaseConditionFunc): boolean { + return !!this.dynamicPhaseMap.get(type)?.hasPhaseWithCondition(condition); + } + + public removePhase(condition: PhaseConditionFunc) { + for (const queue of this.dynamicPhaseMap.values()) { + if (queue.remove(condition)) { + return true; + } + } + return false; + } + + public setMoveTimingModifier(condition: PhaseConditionFunc, modifier: MovePhaseTimingModifier) { + this.getMovePhaseQueue().setTimingModifier(condition, modifier); + } + + public setMoveForPhase(condition: PhaseConditionFunc, move: PokemonMove) { + this.getMovePhaseQueue().setMoveForPhase(condition, move); + } + + public setMoveOrder(order: BattlerIndex[]) { + this.getMovePhaseQueue().setMoveOrder(order); + } + + public getLastTurnOrder(): Pokemon[] { + return this.getMovePhaseQueue().getTurnOrder(); + } + + public clearLastTurnOrder(): void { + this.getMovePhaseQueue().clearTurnOrder(); + } + + private getMovePhaseQueue(): MovePhasePriorityQueue { + return this.dynamicPhaseMap.get("MovePhase") as MovePhasePriorityQueue; + } + + public addPopType(type: PhaseString): void { + this.popOrder.push(type); + } +} diff --git a/src/queues/move-phase-priority-queue.ts b/src/queues/move-phase-priority-queue.ts new file mode 100644 index 00000000000..eee61e77c46 --- /dev/null +++ b/src/queues/move-phase-priority-queue.ts @@ -0,0 +1,84 @@ +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; +import type { PokemonMove } from "#app/data/moves/pokemon-move"; +import type Pokemon from "#app/field/pokemon"; +import type { MovePhase } from "#app/phases/move-phase"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import { isNullOrUndefined } from "#app/utils/common"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; + +export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue { + private lastTurnOrder: Pokemon[] = []; + + public override reorder(): void { + super.reorder(); + this.sortPostSpeed(); + console.log(this.queue.map(p => p.getPokemon().name)); + } + + public setTimingModifier(condition: PhaseConditionFunc, modifier: MovePhaseTimingModifier): void { + const phase = this.queue.find(phase => condition(phase)); + if (!isNullOrUndefined(phase)) { + phase.timingModifier = modifier; + } + } + + public setMoveForPhase(condition: PhaseConditionFunc, move: PokemonMove) { + const phase = this.queue.find(phase => condition(phase)); + if (!isNullOrUndefined(phase)) { + phase.move = move; + } + } + + 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 priorityModifiers = [a, b].map(movePhase => + movePhase.move.getMove().getPriorityModifier(movePhase.pokemon), + ); + + const timingModifiers = [a, b].map(movePhase => movePhase.timingModifier); + + if (timingModifiers[0] !== timingModifiers[1]) { + return timingModifiers[1] - timingModifiers[0]; + } + + if (priority[0] === priority[1] && priorityModifiers[0] !== priorityModifiers[1]) { + return priorityModifiers[1] - priorityModifiers[0]; + } + + return priority[1] - priority[0]; + }); + } +} diff --git a/src/queues/phase-priority-queue.ts b/src/queues/phase-priority-queue.ts new file mode 100644 index 00000000000..0b9a10b2e45 --- /dev/null +++ b/src/queues/phase-priority-queue.ts @@ -0,0 +1,61 @@ +import type { PhaseConditionFunc } from "#app/@types/phase-condition"; +import type { Phase } from "#app/phase"; + +/** + * 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 queue: T[] = []; + + /** + * 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(): T | undefined { + this.reorder(); + return this.queue.shift(); + } + + /** + * Adds a phase to the queue + * @param phase The phase to add + */ + public push(phase: T): void { + this.queue.push(phase); + } + + /** + * Removes all phases from the queue + */ + public clear(): void { + this.queue.splice(0, this.queue.length); + } + + public isEmpty(): boolean { + return !this.queue.length; + } + + public remove(condition: PhaseConditionFunc): boolean { + const phaseIndex = this.queue.findIndex(condition); + if (phaseIndex > -1) { + this.queue.splice(phaseIndex, 1); + return true; + } + return false; + } + + public findPhase(condition?: PhaseConditionFunc): Phase | undefined { + return this.queue.find(phase => !condition || condition(phase)); + } + + public hasPhaseWithCondition(condition?: PhaseConditionFunc): boolean { + return this.queue.find(phase => !condition || condition(phase)) !== undefined; + } +} diff --git a/src/queues/pokemon-phase-priority-queue.ts b/src/queues/pokemon-phase-priority-queue.ts new file mode 100644 index 00000000000..21e2cf64f8e --- /dev/null +++ b/src/queues/pokemon-phase-priority-queue.ts @@ -0,0 +1,20 @@ +import type { DynamicPhase } from "#app/@types/phase-types"; +import { PhasePriorityQueue } from "#app/queues/phase-priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; +import type { BattlerIndex } from "#enums/battler-index"; + +export class PokemonPhasePriorityQueue extends PhasePriorityQueue { + protected setOrder: BattlerIndex[] | undefined; + public override reorder(): void { + this.queue = this.queue.filter(phase => phase.getPokemon()?.isActive(true)); + if (this.setOrder) { + this.queue.sort( + (a, b) => + this.setOrder!.indexOf(a.getPokemon().getBattlerIndex()) - + this.setOrder!.indexOf(b.getPokemon().getBattlerIndex()), + ); + } else { + 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..8336ad4cc92 --- /dev/null +++ b/src/queues/post-summon-phase-priority-queue.ts @@ -0,0 +1,38 @@ +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 { + public override reorder(): void { + this.queue = sortInSpeedOrder(this.queue, false); + this.queue.sort((phaseA: PostSummonPhase, phaseB: PostSummonPhase) => { + 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.dynamicQueueManager.addPopType("PostSummonPhase"); + }); + } +} diff --git a/src/queues/switch-summon-phase-priority-queue.ts b/src/queues/switch-summon-phase-priority-queue.ts new file mode 100644 index 00000000000..adc1508c275 --- /dev/null +++ b/src/queues/switch-summon-phase-priority-queue.ts @@ -0,0 +1,14 @@ +import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; + +export class SwitchSummonPhasePriorityQueue extends PokemonPhasePriorityQueue { + public override push(phase: SwitchSummonPhase): void { + // The same pokemon or slot cannot be switched into at the same time + this.queue = this.queue.filter( + old => + old.getPokemon() !== phase.getPokemon() && + !(old.isPlayer() === phase.isPlayer() && old.getFieldIndex() === phase.getFieldIndex()), + ); + super.push(phase); + } +} diff --git a/src/utils/speed-order.ts b/src/utils/speed-order.ts new file mode 100644 index 00000000000..301c19701d1 --- /dev/null +++ b/src/utils/speed-order.ts @@ -0,0 +1,50 @@ +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"; + +export interface hasPokemon { + getPokemon(): Pokemon; +} + +export function applyInSpeedOrder(pokemonList: T[], callback: (pokemon: Pokemon) => void): void { + sortInSpeedOrder(pokemonList).forEach(pokemon => callback(pokemon)); +} + +export function sortInSpeedOrder(pokemonList: T[], shuffleFirst = true): T[] { + pokemonList = shuffleFirst ? shuffle(pokemonList) : pokemonList; + sortBySpeed(pokemonList); + return pokemonList; +} + +/** Randomly shuffles the queue. */ +function shuffle(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; +} + +function sortBySpeed(pokemonList: T[]): void { + pokemonList.sort((a, b) => { + const [aSpeed, bSpeed] = [a, b].map(pkmn => + pkmn instanceof Pokemon ? pkmn.getEffectiveStat(Stat.SPD) : pkmn.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 2a4a3c36bcc..a2a3ca47288 100644 --- a/test/abilities/dancer.test.ts +++ b/test/abilities/dancer.test.ts @@ -35,6 +35,7 @@ describe("Abilities - Dancer", () => { await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); const [oricorio, feebas] = game.scene.getPlayerField(); + const [magikarp1] = game.scene.getEnemyField(); game.move.changeMoveset(oricorio, [MoveId.SWORDS_DANCE, MoveId.VICTORY_DANCE, MoveId.SPLASH]); game.move.changeMoveset(feebas, [MoveId.SWORDS_DANCE, MoveId.SPLASH]); @@ -44,8 +45,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 56af51c00f7..ec9ba64d582 100644 --- a/test/abilities/mycelium_might.test.ts +++ b/test/abilities/mycelium_might.test.ts @@ -1,5 +1,3 @@ -import { TurnEndPhase } from "#app/phases/turn-end-phase"; -import { TurnStartPhase } from "#app/phases/turn-start-phase"; import GameManager from "#test/testUtils/gameManager"; import { AbilityId } from "#enums/ability-id"; import { Stat } from "#enums/stat"; @@ -45,65 +43,50 @@ describe("Abilities - Mycelium Might", () => { it("will move last in its priority bracket and ignore protective abilities", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const enemyPokemon = game.scene.getEnemyPokemon(); - const playerIndex = game.scene.getPlayerPokemon()?.getBattlerIndex(); - const enemyIndex = enemyPokemon?.getBattlerIndex(); + const enemy = game.scene.getEnemyPokemon()!; + const player = game.scene.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("will 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.scene.getEnemyPokemon(); - const playerIndex = game.scene.getPlayerPokemon()?.getBattlerIndex(); - const enemyIndex = enemyPokemon?.getBattlerIndex(); + const enemy = game.scene.getEnemyPokemon()!; + const player = game.scene.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("will not affect non-status moves", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const playerIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); - const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + const player = game.scene.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 ec1197c30ca..c2e92687f06 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.scene.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 11418f31375..4179dffbc1a 100644 --- a/test/abilities/quick_draw.test.ts +++ b/test/abilities/quick_draw.test.ts @@ -5,7 +5,7 @@ import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; 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.scene.getPlayerPokemon()!; const enemy = game.scene.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.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; + const pokemon = game.scene.getPlayerPokemon()!; + const enemy = game.scene.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.scene.getPlayerPokemon()!; const enemy = game.scene.getEnemyPokemon()!; diff --git a/test/abilities/stall.test.ts b/test/abilities/stall.test.ts index 04a29a4f284..e0f4af4ea37 100644 --- a/test/abilities/stall.test.ts +++ b/test/abilities/stall.test.ts @@ -4,7 +4,6 @@ import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { TurnStartPhase } from "#app/phases/turn-start-phase"; describe("Abilities - Stall", () => { let phaserGame: Phaser.Game; @@ -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.scene.getPlayerPokemon()!.getBattlerIndex(); - const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + const player = game.scene.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.hp).toEqual(player.getMaxHp()); }); 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.scene.getPlayerPokemon()!.getBattlerIndex(); - const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + const player = game.scene.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.hp).not.toEqual(player.getMaxHp()); }); 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.scene.getPlayerPokemon()!.getBattlerIndex(); - const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + const player = game.scene.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.hp).not.toEqual(player.getMaxHp()); }); }); diff --git a/test/battle/battle-order.test.ts b/test/battle/battle-order.test.ts index 114592c8c8e..fb79a11f9ce 100644 --- a/test/battle/battle-order.test.ts +++ b/test/battle/battle-order.test.ts @@ -1,7 +1,6 @@ -import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; -import { SelectTargetPhase } from "#app/phases/select-target-phase"; -import { TurnStartPhase } from "#app/phases/turn-start-phase"; +import type { MovePhase } from "#app/phases/move-phase"; 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 GameManager from "#test/testUtils/gameManager"; @@ -36,38 +35,34 @@ describe("Battle order", () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const playerPokemon = game.scene.getPlayerPokemon()!; + const playerStartHp = playerPokemon.hp; const enemyPokemon = game.scene.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.run(EnemyCommandPhase); - 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.scene.getPlayerPokemon()!; + const playerStartHp = playerPokemon.hp; const enemyPokemon = game.scene.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.run(EnemyCommandPhase); - 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 () => { @@ -75,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.runFrom(SelectTargetPhase).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 () => { @@ -103,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.runFrom(SelectTargetPhase).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 () => { @@ -127,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.runFrom(SelectTargetPhase).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 1b1b0620133..486bb6adb90 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.scene.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.scene.getEnemyPokemon()?.summonData.moveHistory.length).toEqual(0); }); it("doesn't transfer effects that aren't transferrable", async () => { diff --git a/test/moves/focus_punch.test.ts b/test/moves/focus_punch.test.ts index 38b57b201c0..a94ae6ccdd0 100644 --- a/test/moves/focus_punch.test.ts +++ b/test/moves/focus_punch.test.ts @@ -1,6 +1,5 @@ import { BerryPhase } from "#app/phases/berry-phase"; import { MessagePhase } from "#app/phases/message-phase"; -import { MoveHeaderPhase } from "#app/phases/move-header-phase"; import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase"; import { AbilityId } from "#enums/ability-id"; @@ -116,7 +115,7 @@ describe("Moves - Focus Punch", () => { await game.phaseInterceptor.to(TurnStartPhase); expect(game.scene.phaseManager.getCurrentPhase() instanceof SwitchSummonPhase).toBeTruthy(); - expect(game.scene.phaseManager.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined(); + expect(game.scene.phaseManager.hasPhaseOfType("MoveHeaderPhase")).toBeTruthy(); }); it("should replace the 'but it failed' text when the user gets hit", async () => { game.override.enemyMoveset([MoveId.TACKLE]); diff --git a/test/moves/rage_fist.test.ts b/test/moves/rage_fist.test.ts index 31dd987cb87..a5994457e3d 100644 --- a/test/moves/rage_fist.test.ts +++ b/test/moves/rage_fist.test.ts @@ -166,7 +166,7 @@ describe("Moves - Rage Fist", () => { // Charizard hit game.move.select(MoveId.SPLASH); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); expect(getPartyHitCount()).toEqual([1, 0]); diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index 91f9096f3d4..8f8725c585c 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -523,7 +523,7 @@ export default 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. @@ -535,7 +535,7 @@ export default 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); } /**