Merge remote-tracking branch 'upstream/beta' into todo-test-enable

This commit is contained in:
Bertie690 2025-09-21 01:15:26 -04:00
commit b6c4e4ed80
71 changed files with 1329 additions and 1143 deletions

View File

@ -1,3 +1,5 @@
import type { Pokemon } from "#app/field/pokemon";
import type { Phase } from "#app/phase";
import type { PhaseConstructorMap } from "#app/phase-manager";
import type { ObjectValues } from "#types/type-helpers";
@ -24,3 +26,14 @@ export type PhaseClass = ObjectValues<PhaseConstructorMap>;
* Union type of all phase names as strings.
*/
export type PhaseString = keyof PhaseMap;
/** Type for predicate functions operating on a specific type of {@linkcode Phase}. */
export type PhaseConditionFunc<T extends PhaseString> = (phase: PhaseMap[T]) => boolean;
/**
* Interface type representing the assumption that all phases with pokemon associated are dynamic
*/
export interface DynamicPhase extends Phase {
getPokemon(): Pokemon;
}

View File

@ -104,7 +104,6 @@ import {
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterSaveData } from "#mystery-encounters/mystery-encounter-save-data";
import { allMysteryEncounters, mysteryEncountersByBiome } from "#mystery-encounters/mystery-encounters";
import type { MovePhase } from "#phases/move-phase";
import { expSpriteKeys } from "#sprites/sprite-keys";
import { hasExpSprite } from "#sprites/sprite-utils";
import type { Variant } from "#sprites/variant";
@ -787,12 +786,14 @@ export class BattleScene extends SceneBase {
/**
* Returns an array of EnemyPokemon of length 1 or 2 depending on if in a double battle or not.
* Does not actually check if the pokemon are on the field or not.
* @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon
* @returns array of {@linkcode EnemyPokemon}
*/
public getEnemyField(): EnemyPokemon[] {
public getEnemyField(active = false): EnemyPokemon[] {
const party = this.getEnemyParty();
return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
return party
.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1))
.filter(p => !active || p.isActive());
}
/**
@ -817,25 +818,7 @@ export class BattleScene extends SceneBase {
* @param allyPokemon - The {@linkcode Pokemon} allied with the removed Pokemon; will have moves redirected to it
*/
redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
// failsafe: if not a double battle just return
if (this.currentBattle.double === false) {
return;
}
if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase;
do {
targetingMovePhase = this.phaseManager.findPhase(
mp =>
mp.is("MovePhase")
&& mp.targets.length === 1
&& mp.targets[0] === removedPokemon.getBattlerIndex()
&& mp.pokemon.isPlayer() !== allyPokemon.isPlayer(),
) as MovePhase;
if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) {
targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex();
}
} while (targetingMovePhase);
}
this.phaseManager.redirectMoves(removedPokemon, allyPokemon);
}
/**
@ -1433,7 +1416,7 @@ export class BattleScene extends SceneBase {
}
if (lastBattle?.double && !newDouble) {
this.phaseManager.tryRemovePhase((p: Phase) => p.is("SwitchPhase"));
this.phaseManager.tryRemovePhase("SwitchPhase");
for (const p of this.getPlayerField()) {
p.lapseTag(BattlerTagType.COMMANDED);
}

View File

@ -35,6 +35,7 @@ import { CommonAnim } from "#enums/move-anims-common";
import { MoveCategory } from "#enums/move-category";
import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
import { MoveResult } from "#enums/move-result";
import { MoveTarget } from "#enums/move-target";
import { MoveUseMode } from "#enums/move-use-mode";
@ -2558,7 +2559,7 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr {
override apply({ pokemon, simulated, cancelled }: AbAttrParamsWithCancel): void {
if (!simulated) {
globalScene.phaseManager.pushNew(
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),
false,
@ -3243,6 +3244,7 @@ export class CommanderAbAttr extends AbAttr {
return (
globalScene.currentBattle?.double
&& ally != null
&& ally.isActive(true)
&& ally.species.speciesId === SpeciesId.DONDOZO
&& !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED))
);
@ -3257,7 +3259,7 @@ export class CommanderAbAttr extends AbAttr {
// Apply boosts from this effect to the ally Dondozo
pokemon.getAlly()?.addTag(BattlerTagType.COMMANDED, 0, MoveId.NONE, pokemon.id);
// Cancel the source Pokemon's next move (if a move is queued)
globalScene.phaseManager.tryRemovePhase(phase => phase.is("MovePhase") && phase.pokemon === pokemon);
globalScene.phaseManager.tryRemovePhase("MovePhase", phase => phase.pokemon === pokemon);
}
}
}
@ -5007,7 +5009,14 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr {
// If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance
if (move.getMove().is("AttackMove") || move.getMove().is("StatusMove")) {
const target = this.getTarget(pokemon, source, targets);
globalScene.phaseManager.unshiftNew("MovePhase", pokemon, target, move, MoveUseMode.INDIRECT);
globalScene.phaseManager.unshiftNew(
"MovePhase",
pokemon,
target,
move,
MoveUseMode.INDIRECT,
MovePhaseTimingModifier.FIRST,
);
} else if (move.getMove().is("SelfStatusMove")) {
// If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself
globalScene.phaseManager.unshiftNew(
@ -5016,6 +5025,7 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr {
[pokemon.getBattlerIndex()],
move,
MoveUseMode.INDIRECT,
MovePhaseTimingModifier.FIRST,
);
}
}
@ -6040,11 +6050,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
@ -6060,26 +6065,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) });
}
}
@ -6087,8 +6094,6 @@ export class BypassSpeedChanceAbAttr extends AbAttr {
export interface PreventBypassSpeedChanceAbAttrParams extends AbAttrBaseParams {
/** Holds whether the speed check is bypassed after ability application */
bypass: BooleanHolder;
/** Holds whether the Pokemon can check held items for Quick Claw's effects */
canCheckHeldItems: BooleanHolder;
}
/**
@ -6115,9 +6120,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;
}
}
@ -6215,8 +6219,7 @@ class ForceSwitchOutHelper {
if (switchOutTarget.hp > 0) {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
@ -6238,8 +6241,7 @@ class ForceSwitchOutHelper {
const summonIndex = globalScene.currentBattle.trainer
? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot)
: 0;
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
@ -7173,7 +7175,7 @@ export function initAbilities() {
new Ability(AbilityId.ANALYTIC, 5)
.attr(MovePowerBoostAbAttr, (user) =>
// Boost power if all other Pokemon have already moved (no other moves are slated to execute)
!globalScene.phaseManager.findPhase((phase) => phase.is("MovePhase") && phase.pokemon.id !== user?.id),
!globalScene.phaseManager.hasPhaseOfType("MovePhase", phase => phase.pokemon.id !== user?.id),
1.3),
new Ability(AbilityId.ILLUSION, 5)
// The Pokemon generate an illusion if it's available

View File

@ -74,7 +74,6 @@ function applyAbAttrsInternal<T extends CallableAbAttrString>(
for (const passive of [false, true]) {
params.passive = passive;
applySingleAbAttrs(attrType, params, gainedMidTurn, messages);
globalScene.phaseManager.clearPhaseQueueSplice();
}
// We need to restore passive to its original state in the case that it was undefined on entry
// this is necessary in case this method is called with an object that is reused.

View File

@ -609,17 +609,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;
}
@ -1295,8 +1285,8 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
// If the target has not moved yet,
// replace their upcoming move with the encored move against randomized targets
const movePhase = globalScene.phaseManager.findPhase(
(m): m is MovePhase => m.is("MovePhase") && m.pokemon === pokemon,
const movePhase = globalScene.phaseManager.getMovePhase(
m => m.pokemon === pokemon,
);
if (!movePhase) {
return;
@ -1314,7 +1304,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
? moveTargets.targets
: [moveTargets.targets[pokemon.randBattleSeedInt(moveTargets.targets.length)]];
globalScene.phaseManager.tryReplacePhase(
globalScene.phaseManager.changePhaseMove(
m => m.is("MovePhase") && m.pokemon === pokemon,
globalScene.phaseManager.create(
"MovePhase",
@ -3609,6 +3599,25 @@ export class GrudgeTag extends SerializableBattlerTag {
}
}
/**
* Tag to allow the affected Pokemon's move to go first in its priority bracket.
* Used for {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Draw_(Ability) | Quick Draw}
* and {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Claw | Quick Claw}.
*/
export class BypassSpeedTag extends BattlerTag {
public override readonly tagType = BattlerTagType.BYPASS_SPEED;
constructor() {
super(BattlerTagType.BYPASS_SPEED, BattlerTagLapseType.TURN_END, 1);
}
override canAdd(pokemon: Pokemon): boolean {
const bypass = new BooleanHolder(true);
applyAbAttrs("PreventBypassSpeedChanceAbAttr", { pokemon, bypass });
return bypass.value;
}
}
/**
* Tag used to heal the user of Psycho Shift of its status effect if Psycho Shift succeeds in transferring its status effect to the target Pokemon
*/
@ -3911,6 +3920,8 @@ export function getBattlerTag(
return new MagicCoatTag();
case BattlerTagType.SUPREME_OVERLORD:
return new SupremeOverlordTag();
case BattlerTagType.BYPASS_SPEED:
return new BypassSpeedTag();
}
}
@ -4046,4 +4057,5 @@ export type BattlerTagTypeMap = {
[BattlerTagType.PSYCHO_SHIFT]: PsychoShiftTag;
[BattlerTagType.MAGIC_COAT]: MagicCoatTag;
[BattlerTagType.SUPREME_OVERLORD]: SupremeOverlordTag;
[BattlerTagType.BYPASS_SPEED]: BypassSpeedTag;
};

View File

@ -343,5 +343,5 @@ export const invalidInstructMoves: ReadonlySet<MoveId> = new Set([
MoveId.TRANSFORM,
MoveId.MIMIC,
MoveId.STRUGGLE,
// TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented
// NB: Add Max/G-Max/Z-Move blockage if or when they are implemented
]);

View File

@ -82,10 +82,8 @@ import { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidAssistMoves, invalidCopycatMoves, invalidInstructMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves";
import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import { MoveEndPhase } from "#phases/move-end-phase";
import { MovePhase } from "#phases/move-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { SwitchSummonPhase } from "#phases/switch-summon-phase";
import type { AttackMoveResult } from "#types/attack-move-result";
import type { Localizable } from "#types/locales";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types";
@ -95,6 +93,7 @@ import { getEnumValues } from "#utils/enums";
import { toCamelCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
import { applyChallenges } from "#utils/challenge-utils";
import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
import type { AbstractConstructor } from "#types/type-helpers";
/**
@ -881,6 +880,10 @@ export abstract class Move implements Localizable {
applyMoveAttrs("IncrementMovePriorityAttr", user, null, this, priority);
applyAbAttrs("ChangeMovePriorityAbAttr", {pokemon: user, simulated, move: this, priority});
if (user.getTag(BattlerTagType.BYPASS_SPEED)) {
priority.value += 0.2;
}
return priority.value;
}
@ -3288,7 +3291,7 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr {
const overridden = args[0] as BooleanHolder;
const allyMovePhase = globalScene.phaseManager.findPhase<MovePhase>((phase) => phase.is("MovePhase") && phase.pokemon.isPlayer() === user.isPlayer());
const allyMovePhase = globalScene.phaseManager.getMovePhase((phase) => phase.pokemon.isPlayer() === user.isPlayer());
if (allyMovePhase) {
const allyMove = allyMovePhase.move.getMove();
if (allyMove !== move && allyMove.hasAttr("AwaitCombinedPledgeAttr")) {
@ -3301,11 +3304,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;
@ -4540,28 +4539,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)) {
@ -4643,20 +4621,13 @@ export class CueNextRoundAttr extends MoveEffectAttr {
}
override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean {
const nextRoundPhase = globalScene.phaseManager.findPhase<MovePhase>(phase =>
phase.is("MovePhase") && phase.move.moveId === MoveId.ROUND
);
const nextRoundPhase = globalScene.phaseManager.getMovePhase(phase => phase.move.moveId === MoveId.ROUND);
if (!nextRoundPhase) {
return false;
}
// Update the phase queue so that the next Pokemon using Round moves next
const nextRoundIndex = globalScene.phaseManager.phaseQueue.indexOf(nextRoundPhase);
const nextMoveIndex = globalScene.phaseManager.phaseQueue.findIndex(phase => phase.is("MovePhase"));
if (nextRoundIndex !== nextMoveIndex) {
globalScene.phaseManager.prependToPhase(globalScene.phaseManager.phaseQueue.splice(nextRoundIndex, 1)[0], "MovePhase");
}
globalScene.phaseManager.forceMoveNext(phase => phase.move.moveId === MoveId.ROUND);
// Mark the corresponding Pokemon as having "joined the Round" (for doubling power later)
nextRoundPhase.pokemon.turnData.joinedRound = true;
@ -6309,11 +6280,11 @@ export class RevivalBlessingAttr extends MoveEffectAttr {
// Handle cases where revived pokemon needs to get switched in on same turn
if (allyPokemon.isFainted() || allyPokemon === pokemon) {
// Enemy switch phase should be removed and replaced with the revived pkmn switching in
globalScene.phaseManager.tryRemovePhase((phase: SwitchSummonPhase) => phase.is("SwitchSummonPhase") && phase.getPokemon() === pokemon);
globalScene.phaseManager.tryRemovePhase("SwitchSummonPhase", phase => phase.getFieldIndex() === slotIndex);
// If the pokemon being revived was alive earlier in the turn, cancel its move
// (revived pokemon can't move in the turn they're brought back)
// TODO: might make sense to move this to `FaintPhase` after checking for Rev Seed (rather than handling it in the move)
globalScene.phaseManager.findPhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel();
globalScene.phaseManager.getMovePhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel();
if (user.fieldPosition === FieldPosition.CENTER) {
user.setFieldPosition(FieldPosition.LEFT);
}
@ -6394,8 +6365,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
if (this.switchType === SwitchType.FORCE_SWITCH) {
switchOutTarget.leaveField(true);
const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)];
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
@ -6405,7 +6375,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
);
} else {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
@ -6434,7 +6404,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
if (this.switchType === SwitchType.FORCE_SWITCH) {
switchOutTarget.leaveField(true);
const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)];
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
@ -6444,7 +6414,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
);
} else {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
@ -6875,7 +6845,7 @@ class CallMoveAttr extends OverrideMoveEffectAttr {
: moveTargets.targets[user.randBattleSeedInt(moveTargets.targets.length)]];
globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", move.id);
globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP);
globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST);
return true;
}
}
@ -7107,7 +7077,7 @@ export class NaturePowerAttr extends OverrideMoveEffectAttr {
// Load the move's animation if we didn't already and unshift a new usage phase
globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", moveId);
globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP);
globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST);
return true;
}
}
@ -7196,7 +7166,7 @@ export class RepeatMoveAttr extends MoveEffectAttr {
targetPokemonName: getPokemonNameWithAffix(target)
}));
target.turnData.extraTurns++;
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, this.movesetMove, MoveUseMode.NORMAL);
globalScene.phaseManager.unshiftNew("MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL, MovePhaseTimingModifier.FIRST);
return true;
}
@ -7907,12 +7877,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 is MovePhase => phase.is("MovePhase") && 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;
}
@ -7935,45 +7900,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 is MovePhase => phase.is("MovePhase") && 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();
@ -7997,7 +7928,7 @@ const userSleptOrComatoseCondition: MoveConditionFunc = (user) => user.status?.e
const targetSleptOrComatoseCondition: MoveConditionFunc = (_user: Pokemon, target: Pokemon, _move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE);
const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.findPhase(phase => phase.is("MovePhase")) !== undefined;
const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.hasPhaseOfType("MovePhase");
const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();

View File

@ -414,7 +414,7 @@ function summonPlayerPokemonAnimation(pokemon: PlayerPokemon): Promise<void> {
pokemon.resetTurnData();
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
globalScene.phaseManager.pushNew("PostSummonPhase", pokemon.getBattlerIndex());
globalScene.phaseManager.unshiftNew("PostSummonPhase", pokemon.getBattlerIndex());
resolve();
});
},

View File

@ -669,7 +669,6 @@ function onGameOver() {
// Clear any leftover battle phases
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.clearPhaseQueueSplice();
// Return enemy Pokemon
const pokemon = globalScene.getEnemyPokemon();

View File

@ -738,7 +738,7 @@ export function setEncounterRewards(
if (customShopRewards) {
globalScene.phaseManager.unshiftNew("SelectModifierPhase", 0, undefined, customShopRewards);
} else {
globalScene.phaseManager.tryRemovePhase(p => p.is("MysteryEncounterRewardsPhase"));
globalScene.phaseManager.removeAllPhasesOfType("MysteryEncounterRewardsPhase");
}
if (eggRewards) {
@ -812,8 +812,7 @@ export function leaveEncounterWithoutBattle(
encounterMode: MysteryEncounterMode = MysteryEncounterMode.NO_BATTLE,
) {
globalScene.currentBattle.mysteryEncounter!.encounterMode = encounterMode;
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.clearPhaseQueueSplice();
globalScene.phaseManager.clearPhaseQueue(true);
handleMysteryEncounterVictory(addHealPhase);
}
@ -826,7 +825,7 @@ export function handleMysteryEncounterVictory(addHealPhase = false, doNotContinu
const allowedPkm = globalScene.getPlayerParty().filter(pkm => pkm.isAllowedInBattle());
if (allowedPkm.length === 0) {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.clearPhaseQueue(true);
globalScene.phaseManager.unshiftNew("GameOverPhase");
return;
}
@ -869,7 +868,7 @@ export function handleMysteryEncounterBattleFailed(addHealPhase = false, doNotCo
const allowedPkm = globalScene.getPlayerParty().filter(pkm => pkm.isAllowedInBattle());
if (allowedPkm.length === 0) {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.clearPhaseQueue(true);
globalScene.phaseManager.unshiftNew("GameOverPhase");
return;
}

View File

@ -1,125 +0,0 @@
import { globalScene } from "#app/global-scene";
import type { Phase } from "#app/phase";
import { TrickRoomTag } from "#data/arena-tag";
import { DynamicPhaseType } from "#enums/dynamic-phase-type";
import { Stat } from "#enums/stat";
import { ActivatePriorityQueuePhase } from "#phases/activate-priority-queue-phase";
import { PostSummonActivateAbilityPhase } from "#phases/post-summon-activate-ability-phase";
import type { PostSummonPhase } from "#phases/post-summon-phase";
import { BooleanHolder } from "#utils/common";
/**
* Stores a list of {@linkcode Phase}s
*
* Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder}
*/
export abstract class PhasePriorityQueue {
protected abstract queue: Phase[];
/**
* Sorts the elements in the queue
*/
public abstract reorder(): void;
/**
* Calls {@linkcode reorder} and shifts the queue
* @returns The front element of the queue after sorting
*/
public pop(): Phase | undefined {
this.reorder();
return this.queue.shift();
}
/**
* Adds a phase to the queue
* @param phase The phase to add
*/
public push(phase: Phase): void {
this.queue.push(phase);
}
/**
* Removes all phases from the queue
*/
public clear(): void {
this.queue.splice(0, this.queue.length);
}
/**
* Attempt to remove one or more Phases from the current queue.
* @param phaseFilter - The function to select phases for removal
* @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases;
* default `1`
* @returns The number of successfully removed phases
* @todo Remove this eventually once the patchwork bug this is used for is fixed
*/
public tryRemovePhase(phaseFilter: (phase: Phase) => boolean, removeCount: number | "all" = 1): number {
if (removeCount === "all") {
removeCount = this.queue.length;
} else if (removeCount < 1) {
return 0;
}
let numRemoved = 0;
do {
const phaseIndex = this.queue.findIndex(phaseFilter);
if (phaseIndex === -1) {
break;
}
this.queue.splice(phaseIndex, 1);
numRemoved++;
} while (numRemoved < removeCount && this.queue.length > 0);
return numRemoved;
}
}
/**
* Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase}
*
* Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed
*/
export class PostSummonPhasePriorityQueue extends PhasePriorityQueue {
protected override queue: PostSummonPhase[] = [];
public override reorder(): void {
this.queue.sort((phaseA: PostSummonPhase, phaseB: PostSummonPhase) => {
if (phaseA.getPriority() === phaseB.getPriority()) {
return (
(phaseB.getPokemon().getEffectiveStat(Stat.SPD) - phaseA.getPokemon().getEffectiveStat(Stat.SPD))
* (isTrickRoom() ? -1 : 1)
);
}
return phaseB.getPriority() - phaseA.getPriority();
});
}
public override push(phase: PostSummonPhase): void {
super.push(phase);
this.queueAbilityPhase(phase);
}
/**
* Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase}
* @param phase The {@linkcode PostSummonPhase} that was pushed onto the queue
*/
private queueAbilityPhase(phase: PostSummonPhase): void {
const phasePokemon = phase.getPokemon();
phasePokemon.getAbilityPriorities().forEach((priority, idx) => {
this.queue.push(new PostSummonActivateAbilityPhase(phasePokemon.getBattlerIndex(), priority, !!idx));
globalScene.phaseManager.appendToPhase(
new ActivatePriorityQueuePhase(DynamicPhaseType.POST_SUMMON),
"ActivatePriorityQueuePhase",
(p: ActivatePriorityQueuePhase) => p.getType() === DynamicPhaseType.POST_SUMMON,
);
});
}
}
function isTrickRoom(): boolean {
const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(TrickRoomTag, false, speedReversed);
return speedReversed.value;
}

View File

@ -0,0 +1,182 @@
import type { DynamicPhase, PhaseConditionFunc, PhaseString } from "#app/@types/phase-types";
import type { PokemonMove } from "#app/data/moves/pokemon-move";
import type { Pokemon } from "#app/field/pokemon";
import type { Phase } from "#app/phase";
import type { MovePhase } from "#app/phases/move-phase";
import { MovePhasePriorityQueue } from "#app/queues/move-phase-priority-queue";
import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue";
import { PostSummonPhasePriorityQueue } from "#app/queues/post-summon-phase-priority-queue";
import type { PriorityQueue } from "#app/queues/priority-queue";
import type { BattlerIndex } from "#enums/battler-index";
import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
// TODO: might be easier to define which phases should be dynamic instead
/** All phases which have defined a `getPokemon` method but should not be sorted dynamically */
const nonDynamicPokemonPhases: readonly PhaseString[] = [
"SummonPhase",
"CommandPhase",
"LearnMovePhase",
"MoveEffectPhase",
"MoveEndPhase",
"FaintPhase",
"DamageAnimPhase",
"VictoryPhase",
"PokemonHealPhase",
"WeatherEffectPhase",
] as const;
/**
* The dynamic queue manager holds priority queues for phases which are queued as dynamic.
*
* Dynamic phases are generally those which hold a pokemon and are unshifted, not pushed. \
* Queues work by sorting their entries in speed order (and possibly with more complex ordering) before each time a phase is popped.
*
* As the holder, this structure is also used to access and modify queued phases.
* This is mostly used in redirection, cancellation, etc. of {@linkcode MovePhase}s.
*/
export class DynamicQueueManager {
/** Maps phase types to their corresponding queues */
private readonly dynamicPhaseMap: Map<PhaseString, PriorityQueue<Phase>>;
constructor() {
this.dynamicPhaseMap = new Map();
// PostSummon and Move phases have specialized queues
this.dynamicPhaseMap.set("PostSummonPhase", new PostSummonPhasePriorityQueue());
this.dynamicPhaseMap.set("MovePhase", new MovePhasePriorityQueue());
}
/** Removes all phases from the manager */
public clearQueues(): void {
for (const queue of this.dynamicPhaseMap.values()) {
queue.clear();
}
}
/**
* Adds a new phase to the manager and creates the priority queue for it if one does not exist.
* @param phase - The {@linkcode Phase} to add
* @returns `true` if the phase was added, or `false` if it is not dynamic
*/
public queueDynamicPhase<T extends Phase>(phase: T): boolean {
if (!this.isDynamicPhase(phase)) {
return false;
}
if (!this.dynamicPhaseMap.has(phase.phaseName)) {
// TS can't figure out that T is dynamic at this point, but it does know that `typeof phase` is
this.dynamicPhaseMap.set(phase.phaseName, new PokemonPhasePriorityQueue<typeof phase>());
}
this.dynamicPhaseMap.get(phase.phaseName)?.push(phase);
return true;
}
/**
* Returns the highest-priority (generally by speed) {@linkcode Phase} of the specified type
* @param type - The {@linkcode PhaseString | type} to pop
* @returns The popped {@linkcode Phase}, or `undefined` if none of the specified type exist
*/
public popNextPhase(type: PhaseString): Phase | undefined {
return this.dynamicPhaseMap.get(type)?.pop();
}
/**
* Determines if there is a queued dynamic {@linkcode Phase} meeting the conditions
* @param type - The {@linkcode PhaseString | type} of phase to search for
* @param condition - An optional {@linkcode PhaseConditionFunc} to add conditions to the search
* @returns Whether a matching phase exists
*/
public exists<T extends PhaseString>(type: T, condition?: PhaseConditionFunc<T>): boolean {
return !!this.dynamicPhaseMap.get(type)?.has(condition);
}
/**
* Finds and removes a single queued {@linkcode Phase}
* @param type - The {@linkcode PhaseString | type} of phase to search for
* @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase
* @returns Whether a removal occurred
*/
public removePhase<T extends PhaseString>(type: T, condition?: PhaseConditionFunc<T>): boolean {
return !!this.dynamicPhaseMap.get(type)?.remove(condition);
}
/**
* Sets the timing modifier of a move (i.e. to force it first or last)
* @param condition - A {@linkcode PhaseConditionFunc} to specify conditions for the move
* @param modifier - The {@linkcode MovePhaseTimingModifier} to switch the move to
*/
public setMoveTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void {
this.getMovePhaseQueue().setTimingModifier(condition, modifier);
}
/**
* Finds the {@linkcode MovePhase} meeting the condition and changes its move
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
* @param move - The {@linkcode PokemonMove | move} to use in replacement
*/
public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove): void {
this.getMovePhaseQueue().setMoveForPhase(condition, move);
}
/**
* Redirects moves which were targeted at a {@linkcode Pokemon} that has been removed
* @param removedPokemon - The removed {@linkcode Pokemon}
* @param allyPokemon - The ally of the removed pokemon
*/
public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
this.getMovePhaseQueue().redirectMoves(removedPokemon, allyPokemon);
}
/**
* Finds a {@linkcode MovePhase} meeting the condition
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
* @returns The MovePhase, or `undefined` if it does not exist
*/
public getMovePhase(condition: PhaseConditionFunc<"MovePhase">): MovePhase | undefined {
return this.getMovePhaseQueue().find(condition);
}
/**
* Finds and cancels a {@linkcode MovePhase} meeting the condition
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
*/
public cancelMovePhase(condition: PhaseConditionFunc<"MovePhase">): void {
this.getMovePhaseQueue().cancelMove(condition);
}
/**
* Sets the move order to a static array rather than a dynamic queue
* @param order - The order of {@linkcode BattlerIndex}s
*/
public setMoveOrder(order: BattlerIndex[]): void {
this.getMovePhaseQueue().setMoveOrder(order);
}
/**
* @returns An in-order array of {@linkcode Pokemon}, representing the turn order as played out in the most recent turn
*/
public getLastTurnOrder(): Pokemon[] {
return this.getMovePhaseQueue().getTurnOrder();
}
/** Clears the stored `Move` turn order */
public clearLastTurnOrder(): void {
this.getMovePhaseQueue().clearTurnOrder();
}
/** Internal helper to get the {@linkcode MovePhasePriorityQueue} */
private getMovePhaseQueue(): MovePhasePriorityQueue {
return this.dynamicPhaseMap.get("MovePhase") as MovePhasePriorityQueue;
}
/**
* Internal helper to determine if a phase is dynamic.
* @param phase - The {@linkcode Phase} to check
* @returns Whether `phase` is dynamic
* @privateRemarks
* Currently, this checks that `phase` has a `getPokemon` method
* and is not blacklisted in `nonDynamicPokemonPhases`.
*/
private isDynamicPhase(phase: Phase): phase is DynamicPhase {
return typeof (phase as any).getPokemon === "function" && !nonDynamicPokemonPhases.includes(phase.phaseName);
}
}

View File

@ -1,3 +1,4 @@
// TODO: rename to something else (this isn't used only for arena tags)
export enum ArenaTagSide {
BOTH,
PLAYER,

View File

@ -95,4 +95,5 @@ export enum BattlerTagType {
POWDER = "POWDER",
MAGIC_COAT = "MAGIC_COAT",
SUPREME_OVERLORD = "SUPREME_OVERLORD",
BYPASS_SPEED = "BYPASS_SPEED",
}

View File

@ -1,7 +0,0 @@
/**
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}.
*/
// TODO: We currently assume these are in order
export enum DynamicPhaseType {
POST_SUMMON,
}

View File

@ -0,0 +1,16 @@
import type { ObjectValues } from "#types/type-helpers";
/**
* Enum representing modifiers for the timing of MovePhases.
*
* @remarks
* This system is entirely independent of and takes precedence over move priority
*/
export const MovePhaseTimingModifier = Object.freeze({
/** Used when moves go last regardless of speed and priority (i.e. Quash) */
LAST: 0,
NORMAL: 1,
/** Used to trigger moves immediately (i.e. ones that were called through Instruct). */
FIRST: 2,
});
export type MovePhaseTimingModifier = ObjectValues<typeof MovePhaseTimingModifier>;

View File

@ -371,9 +371,15 @@ export class Arena {
/**
* Function to trigger all weather based form changes
* @param source - The Pokemon causing the changes by removing itself from the field
*/
triggerWeatherBasedFormChanges(): void {
triggerWeatherBasedFormChanges(source?: Pokemon): void {
globalScene.getField(true).forEach(p => {
// TODO - This is a bandaid. Abilities leaving the field needs a better approach than
// calling this method for every switch out that happens
if (p === source) {
return;
}
const isCastformWithForecast = p.hasAbility(AbilityId.FORECAST) && p.species.speciesId === SpeciesId.CASTFORM;
const isCherrimWithFlowerGift = p.hasAbility(AbilityId.FLOWER_GIFT) && p.species.speciesId === SpeciesId.CHERRIM;

View File

@ -3891,15 +3891,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
damage = Math.min(damage, this.hp);
this.hp = this.hp - damage;
if (this.isFainted() && !ignoreFaintPhase) {
/**
* When adding the FaintPhase, want to toggle future unshiftPhase() and queueMessage() calls
* to appear before the FaintPhase (as FaintPhase will potentially end the encounter and add Phases such as
* GameOverPhase, VictoryPhase, etc.. that will interfere with anything else that happens during this MoveEffectPhase)
*
* Once the MoveEffectPhase is over (and calls it's .end() function, shiftPhase() will reset the PhaseQueueSplice via clearPhaseQueueSplice() )
*/
globalScene.phaseManager.setPhaseQueueSplice();
globalScene.phaseManager.unshiftNew("FaintPhase", this.getBattlerIndex(), preventEndure);
globalScene.phaseManager.queueFaintPhase(this.getBattlerIndex(), preventEndure);
this.destroySubstitute();
this.lapseTag(BattlerTagType.COMMANDED);
}
@ -3951,11 +3943,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
damage = 0;
}
damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase);
// Ensure the battle-info bar's HP is updated, though only if the battle info is visible
// TODO: When battle-info UI is refactored, make this only update the HP bar
if (this.battleInfo.visible) {
this.updateInfo();
}
// Damage amount may have changed, but needed to be queued before calling damage function
damagePhase.updateAmount(damage);
/**
@ -5844,8 +5831,7 @@ export class PlayerPokemon extends Pokemon {
this.getFieldIndex(),
(slotIndex: number, _option: PartyOption) => {
if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) {
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
globalScene.phaseManager.queueDeferred(
"SwitchSummonPhase",
switchType,
this.getFieldIndex(),

View File

@ -13,7 +13,6 @@ import { getStatusEffectHealText } from "#data/status-effect";
import { BattlerTagType } from "#enums/battler-tag-type";
import { BerryType } from "#enums/berry-type";
import { Color, ShadowColor } from "#enums/color";
import { Command } from "#enums/command";
import type { FormChangeItem } from "#enums/form-change-item";
import { LearnMoveType } from "#enums/learn-move-type";
import type { MoveId } from "#enums/move-id";
@ -1542,30 +1541,16 @@ export class BypassSpeedChanceModifier extends PokemonHeldItemModifier {
return new BypassSpeedChanceModifier(this.type, this.pokemonId, this.stackCount);
}
/**
* Checks if {@linkcode BypassSpeedChanceModifier} should be applied
* @param pokemon the {@linkcode Pokemon} that holds the item
* @param doBypassSpeed {@linkcode BooleanHolder} that is `true` if speed should be bypassed
* @returns `true` if {@linkcode BypassSpeedChanceModifier} should be applied
*/
override shouldApply(pokemon?: Pokemon, doBypassSpeed?: BooleanHolder): boolean {
return super.shouldApply(pokemon, doBypassSpeed) && !!doBypassSpeed;
}
/**
* Applies {@linkcode BypassSpeedChanceModifier}
* @param pokemon the {@linkcode Pokemon} that holds the item
* @param doBypassSpeed {@linkcode BooleanHolder} that is `true` if speed should be bypassed
* @returns `true` if {@linkcode BypassSpeedChanceModifier} has been applied
*/
override apply(pokemon: Pokemon, doBypassSpeed: BooleanHolder): boolean {
if (!doBypassSpeed.value && pokemon.randBattleSeedInt(10) < this.getStackCount()) {
doBypassSpeed.value = true;
const isCommandFight =
globalScene.currentBattle.turnCommands[pokemon.getBattlerIndex()]?.command === Command.FIGHT;
override apply(pokemon: Pokemon): boolean {
if (pokemon.randBattleSeedInt(10) < this.getStackCount() && pokemon.addTag(BattlerTagType.BYPASS_SPEED)) {
const hasQuickClaw = this.type.is("PokemonHeldItemModifierType") && this.type.id === "QUICK_CLAW";
if (isCommandFight && hasQuickClaw) {
if (hasQuickClaw) {
globalScene.phaseManager.queueMessage(
i18next.t("modifier:bypassSpeedChanceApply", {
pokemonName: getPokemonNameWithAffix(pokemon),

View File

@ -8,12 +8,14 @@
*/
import { PHASE_START_COLOR } from "#app/constants/colors";
import { DynamicQueueManager } from "#app/dynamic-queue-manager";
import { globalScene } from "#app/global-scene";
import type { Phase } from "#app/phase";
import { type PhasePriorityQueue, PostSummonPhasePriorityQueue } from "#data/phase-priority-queue";
import type { DynamicPhaseType } from "#enums/dynamic-phase-type";
import { PhaseTree } from "#app/phase-tree";
import { BattleType } from "#enums/battle-type";
import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
import type { Pokemon } from "#field/pokemon";
import { ActivatePriorityQueuePhase } from "#phases/activate-priority-queue-phase";
import type { PokemonMove } from "#moves/pokemon-move";
import { AddEnemyBuffModifierPhase } from "#phases/add-enemy-buff-modifier-phase";
import { AttemptCapturePhase } from "#phases/attempt-capture-phase";
import { AttemptRunPhase } from "#phases/attempt-run-phase";
@ -25,6 +27,7 @@ import { CheckSwitchPhase } from "#phases/check-switch-phase";
import { CommandPhase } from "#phases/command-phase";
import { CommonAnimPhase } from "#phases/common-anim-phase";
import { DamageAnimPhase } from "#phases/damage-anim-phase";
import { DynamicPhaseMarker } from "#phases/dynamic-phase-marker";
import { EggHatchPhase } from "#phases/egg-hatch-phase";
import { EggLapsePhase } from "#phases/egg-lapse-phase";
import { EggSummaryPhase } from "#phases/egg-summary-phase";
@ -110,8 +113,7 @@ import { UnavailablePhase } from "#phases/unavailable-phase";
import { UnlockPhase } from "#phases/unlock-phase";
import { VictoryPhase } from "#phases/victory-phase";
import { WeatherEffectPhase } from "#phases/weather-effect-phase";
import type { PhaseMap, PhaseString } from "#types/phase-types";
import { type Constructor, coerceArray } from "#utils/common";
import type { PhaseConditionFunc, PhaseMap, PhaseString } from "#types/phase-types";
/**
* Object that holds all of the phase constructors.
@ -122,7 +124,6 @@ import { type Constructor, coerceArray } from "#utils/common";
* This allows for easy creation of new phases without needing to import each phase individually.
*/
const PHASES = Object.freeze({
ActivatePriorityQueuePhase,
AddEnemyBuffModifierPhase,
AttemptCapturePhase,
AttemptRunPhase,
@ -134,6 +135,7 @@ const PHASES = Object.freeze({
CommandPhase,
CommonAnimPhase,
DamageAnimPhase,
DynamicPhaseMarker,
EggHatchPhase,
EggLapsePhase,
EggSummaryPhase,
@ -223,32 +225,30 @@ const PHASES = Object.freeze({
/** Maps Phase strings to their constructors */
export type PhaseConstructorMap = typeof PHASES;
/** Phases pushed at the end of each {@linkcode TurnStartPhase} */
const turnEndPhases: readonly PhaseString[] = [
"WeatherEffectPhase",
"PositionalTagPhase",
"BerryPhase",
"CheckStatusEffectPhase",
"TurnEndPhase",
] as const;
/**
* PhaseManager is responsible for managing the phases in the battle scene
*/
export class PhaseManager {
/** PhaseQueue: dequeue/remove the first element to get the next phase */
public phaseQueue: Phase[] = [];
public conditionalQueue: Array<[() => boolean, Phase]> = [];
/** PhaseQueuePrepend: is a temp storage of what will be added to PhaseQueue */
private phaseQueuePrepend: Phase[] = [];
private readonly phaseQueue: PhaseTree = new PhaseTree();
/** overrides default of inserting phases to end of phaseQueuePrepend array. Useful for inserting Phases "out of order" */
private phaseQueuePrependSpliceIndex = -1;
/** Storage for {@linkcode PhasePriorityQueue}s which hold phases whose order dynamically changes */
private dynamicPhaseQueues: PhasePriorityQueue[];
/** Parallel array to {@linkcode dynamicPhaseQueues} - matches phase types to their queues */
private dynamicPhaseTypes: Constructor<Phase>[];
/** Holds priority queues for dynamically ordered phases */
public dynamicQueueManager = new DynamicQueueManager();
/** The currently-running phase */
private currentPhase: Phase;
/** The phase put on standby if {@linkcode overridePhase} is called */
private standbyPhase: Phase | null = null;
constructor() {
this.dynamicPhaseQueues = [new PostSummonPhasePriorityQueue()];
this.dynamicPhaseTypes = [PostSummonPhase];
}
/**
* Clear all previously set phases, then add a new {@linkcode TitlePhase} to transition to the title screen.
* @param addLogin - Whether to add a new {@linkcode LoginPhase} before the {@linkcode TitlePhase}
@ -276,122 +276,76 @@ export class PhaseManager {
}
/**
* Adds a phase to the conditional queue and ensures it is executed only when the specified condition is met.
*
* This method allows deferring the execution of a phase until certain conditions are met, which is useful for handling
* situations like abilities and entry hazards that depend on specific game states.
*
* @param phase - The phase to be added to the conditional queue.
* @param condition - A function that returns a boolean indicating whether the phase should be executed.
*
* Adds a phase to the end of the queue
* @param phase - The {@linkcode Phase} to add
*/
pushConditionalPhase(phase: Phase, condition: () => boolean): void {
this.conditionalQueue.push([condition, phase]);
public pushPhase(phase: Phase): void {
this.phaseQueue.pushPhase(this.checkDynamic(phase));
}
/**
* Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false
* @param phase {@linkcode Phase} the phase to add
* Queue a phase to be run immediately after the current phase finishes. \
* Unshifted phases are run in FIFO order if multiple are queued during a single phase's execution.
* @param phase - The {@linkcode Phase} to add
*/
pushPhase(phase: Phase): void {
if (this.getDynamicPhaseType(phase) !== undefined) {
this.pushDynamicPhase(phase);
} else {
this.phaseQueue.push(phase);
}
public unshiftPhase(phase: Phase): void {
const toAdd = this.checkDynamic(phase);
phase.is("MovePhase") ? this.phaseQueue.addAfter(toAdd, "MoveEndPhase") : this.phaseQueue.addPhase(toAdd);
}
/**
* Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
* @param phases {@linkcode Phase} the phase(s) to add
* Helper method to queue a phase as dynamic if necessary
* @param phase - The phase to check
* @returns The {@linkcode Phase} or a {@linkcode DynamicPhaseMarker} to be used in its place
*/
unshiftPhase(...phases: Phase[]): void {
if (this.phaseQueuePrependSpliceIndex === -1) {
this.phaseQueuePrepend.push(...phases);
} else {
this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, ...phases);
private checkDynamic(phase: Phase): Phase {
if (this.dynamicQueueManager.queueDynamicPhase(phase)) {
return new DynamicPhaseMarker(phase.phaseName);
}
return phase;
}
/**
* Clears the phaseQueue
* @param leaveUnshifted - If `true`, leaves the top level of the tree intact; default `false`
*/
clearPhaseQueue(): void {
this.phaseQueue.splice(0, this.phaseQueue.length);
public clearPhaseQueue(leaveUnshifted = false): void {
this.phaseQueue.clear(leaveUnshifted);
}
/**
* Clears all phase-related stuff, including all phase queues, the current and standby phases, and a splice index
*/
clearAllPhases(): void {
for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue]) {
queue.splice(0, queue.length);
}
this.dynamicPhaseQueues.forEach(queue => queue.clear());
/** Clears all phase queues and the standby phase */
public clearAllPhases(): void {
this.clearPhaseQueue();
this.dynamicQueueManager.clearQueues();
this.standbyPhase = null;
this.clearPhaseQueueSplice();
}
/**
* Used by function unshiftPhase(), sets index to start inserting at current length instead of the end of the array, useful if phaseQueuePrepend gets longer with Phases
* Determines the next phase to run and starts it.
* @privateRemarks
* This is called by {@linkcode Phase.end} by default, and should not be called by other methods.
*/
setPhaseQueueSplice(): void {
this.phaseQueuePrependSpliceIndex = this.phaseQueuePrepend.length;
}
/**
* Resets phaseQueuePrependSpliceIndex to -1, implies that calls to unshiftPhase will insert at end of phaseQueuePrepend
*/
clearPhaseQueueSplice(): void {
this.phaseQueuePrependSpliceIndex = -1;
}
/**
* Is called by each Phase implementations "end()" by default
* We dump everything from phaseQueuePrepend to the start of of phaseQueue
* then removes first Phase and starts it
*/
shiftPhase(): void {
public shiftPhase(): void {
if (this.standbyPhase) {
this.currentPhase = this.standbyPhase;
this.standbyPhase = null;
return;
}
if (this.phaseQueuePrependSpliceIndex > -1) {
this.clearPhaseQueueSplice();
}
this.phaseQueue.unshift(...this.phaseQueuePrepend);
this.phaseQueuePrepend.splice(0);
let nextPhase = this.phaseQueue.getNextPhase();
const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
// Check if there are any conditional phases queued
for (const [condition, phase] of this.conditionalQueue) {
// Evaluate the condition associated with the phase
if (condition()) {
// If the condition is met, add the phase to the phase queue
this.pushPhase(phase);
} else {
// If the condition is not met, re-add the phase back to the end of the conditional queue
unactivatedConditionalPhases.push([condition, phase]);
}
if (nextPhase?.is("DynamicPhaseMarker")) {
nextPhase = this.dynamicQueueManager.popNextPhase(nextPhase.phaseType);
}
this.conditionalQueue = unactivatedConditionalPhases;
// If no phases are left, unshift phases to start a new turn.
if (this.phaseQueue.length === 0) {
this.populatePhaseQueue();
// Clear the conditionalQueue if there are no phases left in the phaseQueue
this.conditionalQueue = [];
if (nextPhase == null) {
this.turnStart();
} else {
this.currentPhase = nextPhase;
}
// Bang is justified as `populatePhaseQueue` ensures we always have _something_ in the queue at all times
this.currentPhase = this.phaseQueue.shift()!;
this.startCurrentPhase();
}
/**
* Helper method to start and log the current phase.
*/
@ -400,7 +354,14 @@ export class PhaseManager {
this.currentPhase.start();
}
overridePhase(phase: Phase): boolean {
/**
* Overrides the currently running phase with another
* @param phase - The {@linkcode Phase} to override the current one with
* @returns If the override succeeded
*
* @todo This is antithetical to the phase structure and used a single time. Remove it.
*/
public overridePhase(phase: Phase): boolean {
if (this.standbyPhase) {
return false;
}
@ -413,175 +374,47 @@ export class PhaseManager {
}
/**
* Find a specific {@linkcode Phase} in the phase queue.
* Determine if there is a queued {@linkcode Phase} meeting the specified conditions.
* @param type - The {@linkcode PhaseString | type} of phase to search for
* @param condition - An optional {@linkcode PhaseConditionFunc} to add conditions to the search
* @returns Whether a matching phase exists
*/
public hasPhaseOfType<T extends PhaseString>(type: T, condition?: PhaseConditionFunc<T>): boolean {
return this.dynamicQueueManager.exists(type, condition) || this.phaseQueue.exists(type, condition);
}
/**
* Attempt to find and remove the first queued {@linkcode Phase} matching the given conditions.
* @param type - The {@linkcode PhaseString | type} of phase to search for
* @param phaseFilter - An optional {@linkcode PhaseConditionFunc} to add conditions to the search
* @returns Whether a phase was successfully removed
*/
public tryRemovePhase<T extends PhaseString>(type: T, phaseFilter?: PhaseConditionFunc<T>): boolean {
if (this.dynamicQueueManager.removePhase(type, phaseFilter)) {
return true;
}
return this.phaseQueue.remove(type, phaseFilter);
}
/**
* Removes all {@linkcode Phase}s of the given type from the queue
* @param phaseType - The {@linkcode PhaseString | type} of phase to search for
*
* @param phaseFilter filter function to use to find the wanted phase
* @returns the found phase or undefined if none found
* @remarks
* This is not intended to be used with dynamically ordered phases, and does not operate on the dynamic queue. \
* However, it does remove {@linkcode DynamicPhaseMarker}s and so would prevent such phases from activating.
*/
findPhase<P extends Phase = Phase>(phaseFilter: (phase: Phase) => phase is P): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
return this.phaseQueue.find(phaseFilter) as P | undefined;
}
tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean {
const phaseIndex = this.phaseQueue.findIndex(phaseFilter);
if (phaseIndex > -1) {
this.phaseQueue[phaseIndex] = phase;
return true;
}
return false;
}
tryRemovePhase(phaseFilter: (phase: Phase) => boolean): boolean {
const phaseIndex = this.phaseQueue.findIndex(phaseFilter);
if (phaseIndex > -1) {
this.phaseQueue.splice(phaseIndex, 1);
return true;
}
return false;
public removeAllPhasesOfType(type: PhaseString): void {
this.phaseQueue.removeAll(type);
}
/**
* Will search for a specific phase in {@linkcode phaseQueuePrepend} via filter, and remove the first result if a match is found.
* @param phaseFilter filter function
*/
tryRemoveUnshiftedPhase(phaseFilter: (phase: Phase) => boolean): boolean {
const phaseIndex = this.phaseQueuePrepend.findIndex(phaseFilter);
if (phaseIndex > -1) {
this.phaseQueuePrepend.splice(phaseIndex, 1);
return true;
}
return false;
}
/**
* Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase()
* @param phase - The phase to be added
* @param targetPhase - The phase to search for in phaseQueue
* @returns boolean if a targetPhase was found and added
*/
prependToPhase(phase: Phase | Phase[], targetPhase: PhaseString): boolean {
phase = coerceArray(phase);
const target = PHASES[targetPhase];
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target);
if (targetIndex !== -1) {
this.phaseQueue.splice(targetIndex, 0, ...phase);
return true;
}
this.unshiftPhase(...phase);
return false;
}
/**
* Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()}
* @param phase {@linkcode Phase} the phase(s) to be added
* @param targetPhase {@linkcode Phase} the type of phase to search for in {@linkcode phaseQueue}
* @param condition Condition the target phase must meet to be appended to
* @returns `true` if a `targetPhase` was found to append to
*/
appendToPhase(phase: Phase | Phase[], targetPhase: PhaseString, condition?: (p: Phase) => boolean): boolean {
phase = coerceArray(phase);
const target = PHASES[targetPhase];
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target && (!condition || condition(ph)));
if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) {
this.phaseQueue.splice(targetIndex + 1, 0, ...phase);
return true;
}
this.unshiftPhase(...phase);
return false;
}
/**
* Checks a phase and returns the matching {@linkcode DynamicPhaseType}, or undefined if it does not match one
* @param phase The phase to check
* @returns The corresponding {@linkcode DynamicPhaseType} or `undefined`
*/
public getDynamicPhaseType(phase: Phase | null): DynamicPhaseType | undefined {
let phaseType: DynamicPhaseType | undefined;
this.dynamicPhaseTypes.forEach((cls, index) => {
if (phase instanceof cls) {
phaseType = index;
}
});
return phaseType;
}
/**
* Pushes a phase onto its corresponding dynamic queue and marks the activation point in {@linkcode phaseQueue}
*
* The {@linkcode ActivatePriorityQueuePhase} will run the top phase in the dynamic queue (not necessarily {@linkcode phase})
* @param phase The phase to push
*/
public pushDynamicPhase(phase: Phase): void {
const type = this.getDynamicPhaseType(phase);
if (type === undefined) {
return;
}
this.pushPhase(new ActivatePriorityQueuePhase(type));
this.dynamicPhaseQueues[type].push(phase);
}
/**
* Attempt to remove one or more Phases from the given DynamicPhaseQueue, removing the equivalent amount of {@linkcode ActivatePriorityQueuePhase}s from the queue.
* @param type - The {@linkcode DynamicPhaseType} to check
* @param phaseFilter - The function to select phases for removal
* @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases;
* default `1`
* @todo Remove this eventually once the patchwork bug this is used for is fixed
*/
public tryRemoveDynamicPhase(
type: DynamicPhaseType,
phaseFilter: (phase: Phase) => boolean,
removeCount: number | "all" = 1,
): void {
const numRemoved = this.dynamicPhaseQueues[type].tryRemovePhase(phaseFilter, removeCount);
for (let x = 0; x < numRemoved; x++) {
this.tryRemovePhase(p => p.is("ActivatePriorityQueuePhase"));
}
}
/**
* Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue}
* @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start
*/
public startDynamicPhaseType(type: DynamicPhaseType): void {
const phase = this.dynamicPhaseQueues[type].pop();
if (phase) {
this.unshiftPhase(phase);
}
}
/**
* Unshifts an {@linkcode ActivatePriorityQueuePhase} for {@linkcode phase}, then pushes {@linkcode phase} to its dynamic queue
*
* This is the same as {@linkcode pushDynamicPhase}, except the activation phase is unshifted
*
* {@linkcode phase} is not guaranteed to be the next phase from the queue to run (if the queue is not empty)
* @param phase The phase to add
* @returns
*/
public startDynamicPhase(phase: Phase): void {
const type = this.getDynamicPhaseType(phase);
if (type === undefined) {
return;
}
this.unshiftPhase(new ActivatePriorityQueuePhase(type));
this.dynamicPhaseQueues[type].push(phase);
}
/**
* Adds a MessagePhase, either to PhaseQueuePrepend or nextCommandPhaseQueue
* Adds a `MessagePhase` to the queue
* @param message - string for MessagePhase
* @param callbackDelay - optional param for MessagePhase constructor
* @param prompt - optional param for MessagePhase constructor
* @param promptDelay - optional param for MessagePhase constructor
* @param defer - Whether to allow the phase to be deferred
* @param defer - If `true`, push the phase instead of unshifting; default `false`
*
* @see {@linkcode MessagePhase} for more details on the parameters
*/
@ -593,20 +426,18 @@ export class PhaseManager {
defer?: boolean | null,
) {
const phase = new MessagePhase(message, callbackDelay, prompt, promptDelay);
if (!defer) {
// adds to the end of PhaseQueuePrepend
this.unshiftPhase(phase);
} else {
//remember that pushPhase adds it to nextCommandPhaseQueue
if (defer) {
this.pushPhase(phase);
} else {
this.unshiftPhase(phase);
}
}
/**
* Queue a phase to show or hide the ability flyout bar.
* Queues an ability bar flyout phase via {@linkcode unshiftPhase}
* @param pokemon - The {@linkcode Pokemon} whose ability is being activated
* @param passive - Whether the ability is a passive
* @param show - Whether to show or hide the bar
* @param show - If `true`, show the bar. Otherwise, hide it
*/
public queueAbilityDisplay(pokemon: Pokemon, passive: boolean, show: boolean): void {
this.unshiftPhase(show ? new ShowAbilityPhase(pokemon.getBattlerIndex(), passive) : new HideAbilityPhase());
@ -622,10 +453,12 @@ export class PhaseManager {
}
/**
* Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order)
* Clear all dynamic queues and begin a new {@linkcode TurnInitPhase} for the new turn.
* Called whenever the current phase queue is empty.
*/
private populatePhaseQueue(): void {
this.phaseQueue.push(new TurnInitPhase());
private turnStart(): void {
this.dynamicQueueManager.clearQueues();
this.currentPhase = new TurnInitPhase();
}
/**
@ -667,50 +500,119 @@ export class PhaseManager {
}
/**
* Create a new phase and immediately prepend it to an existing phase in the phase queue.
* Equivalent to calling {@linkcode create} followed by {@linkcode prependToPhase}.
* @param targetPhase - The phase to search for in phaseQueue
* @param phase - The name of the phase to create
* Add a {@linkcode FaintPhase} to the queue
* @param args - The arguments to pass to the phase constructor
* @returns `true` if a `targetPhase` was found to prepend to
*
* @remarks
*
* Faint phases are ordered in a special way to allow battle effects to settle before the pokemon faints.
* @see {@linkcode PhaseTree.addPhase}
*/
public prependNewToPhase<T extends PhaseString>(
targetPhase: PhaseString,
phase: T,
...args: ConstructorParameters<PhaseConstructorMap[T]>
): boolean {
return this.prependToPhase(this.create(phase, ...args), targetPhase);
public queueFaintPhase(...args: ConstructorParameters<PhaseConstructorMap["FaintPhase"]>): void {
this.phaseQueue.addPhase(this.create("FaintPhase", ...args), true);
}
/**
* Create a new phase and immediately append it to an existing phase the phase queue.
* Equivalent to calling {@linkcode create} followed by {@linkcode appendToPhase}.
* @param targetPhase - The phase to search for in phaseQueue
* @param phase - The name of the phase to create
* @param args - The arguments to pass to the phase constructor
* @returns `true` if a `targetPhase` was found to append to
* Attempts to add {@linkcode PostSummonPhase}s for the enemy pokemon
*
* This is used to ensure that wild pokemon (which have no {@linkcode SummonPhase}) do not queue a {@linkcode PostSummonPhase}
* until all pokemon are on the field.
*/
public appendNewToPhase<T extends PhaseString>(
targetPhase: PhaseString,
phase: T,
...args: ConstructorParameters<PhaseConstructorMap[T]>
): boolean {
return this.appendToPhase(this.create(phase, ...args), targetPhase);
public tryAddEnemyPostSummonPhases(): void {
if (
![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)
&& !this.phaseQueue.exists("SummonPhase")
) {
globalScene.getEnemyField().forEach(p => {
this.pushPhase(new PostSummonPhase(p.getBattlerIndex(), "SummonPhase"));
});
}
}
public startNewDynamicPhase<T extends PhaseString>(
/**
* Create a new phase and queue it to run after all others queued by the currently running phase.
* @param phase - The name of the phase to create
* @param args - The arguments to pass to the phase constructor
*
* @deprecated Only used for switches and should be phased out eventually.
*/
public queueDeferred<const T extends "SwitchPhase" | "SwitchSummonPhase">(
phase: T,
...args: ConstructorParameters<PhaseConstructorMap[T]>
): void {
this.startDynamicPhase(this.create(phase, ...args));
this.phaseQueue.unshiftToCurrent(this.create(phase, ...args));
}
/**
* Finds the first {@linkcode MovePhase} meeting the condition
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
* @returns The MovePhase, or `undefined` if it does not exist
*/
public getMovePhase(phaseCondition: PhaseConditionFunc<"MovePhase">): MovePhase | undefined {
return this.dynamicQueueManager.getMovePhase(phaseCondition);
}
/**
* Finds and cancels the first {@linkcode MovePhase} meeting the condition
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
*/
public cancelMove(phaseCondition: PhaseConditionFunc<"MovePhase">): void {
this.dynamicQueueManager.cancelMovePhase(phaseCondition);
}
/**
* Finds the first {@linkcode MovePhase} meeting the condition and forces it next
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
*/
public forceMoveNext(phaseCondition: PhaseConditionFunc<"MovePhase">): void {
this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.FIRST);
}
/**
* Finds the first {@linkcode MovePhase} meeting the condition and forces it last
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
*/
public forceMoveLast(phaseCondition: PhaseConditionFunc<"MovePhase">): void {
this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.LAST);
}
/**
* Finds the first {@linkcode MovePhase} meeting the condition and changes its move
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
* @param move - The {@linkcode PokemonMove | move} to use in replacement
*/
public changePhaseMove(phaseCondition: PhaseConditionFunc<"MovePhase">, move: PokemonMove): void {
this.dynamicQueueManager.setMoveForPhase(phaseCondition, move);
}
/**
* Redirects moves which were targeted at a {@linkcode Pokemon} that has been removed
* @param removedPokemon - The removed {@linkcode Pokemon}
* @param allyPokemon - The ally of the removed pokemon
*/
public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
this.dynamicQueueManager.redirectMoves(removedPokemon, allyPokemon);
}
/** Queues phases which run at the end of each turn */
public queueTurnEndPhases(): void {
turnEndPhases.forEach(p => {
this.pushNew(p);
});
}
/** Prevents end of turn effects from triggering when transitioning to a new biome on a X0 wave */
public onInterlude(): void {
const phasesToRemove = ["WeatherEffectPhase", "BerryPhase", "CheckStatusEffectPhase"];
this.phaseQueue = this.phaseQueue.filter(p => !phasesToRemove.includes(p.phaseName));
const phasesToRemove: readonly PhaseString[] = [
"WeatherEffectPhase",
"BerryPhase",
"CheckStatusEffectPhase",
] as const;
for (const phaseType of phasesToRemove) {
this.phaseQueue.removeAll(phaseType);
}
const turnEndPhase = this.findPhase<TurnEndPhase>(p => p.phaseName === "TurnEndPhase");
const turnEndPhase = this.phaseQueue.find("TurnEndPhase");
if (turnEndPhase) {
turnEndPhase.upcomingInterlude = true;
}

206
src/phase-tree.ts Normal file
View File

@ -0,0 +1,206 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
import type { PhaseManager } from "#app/@types/phase-types";
import type { DynamicPhaseMarker } from "#phases/dynamic-phase-marker";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
import type { PhaseMap, PhaseString } from "#app/@types/phase-types";
import type { Phase } from "#app/phase";
import type { PhaseConditionFunc } from "#types/phase-types";
/**
* The PhaseTree is the central storage location for {@linkcode Phase}s by the {@linkcode PhaseManager}.
*
* It has a tiered structure, where unshifted phases are added one level above the currently running Phase. Phases are generally popped from the Tree in FIFO order.
*
* Dynamically ordered phases are queued into the Tree only as {@linkcode DynamicPhaseMarker | Marker}s and as such are not guaranteed to run FIFO (otherwise, they would not be dynamic)
*/
export class PhaseTree {
/** Storage for all levels in the tree. This is a simple array because only one Phase may have "children" at a time. */
private levels: Phase[][] = [[]];
/** The level of the currently running {@linkcode Phase} in the Tree (note that such phase is not actually in the Tree while it is running) */
private currentLevel = 0;
/**
* True if a "deferred" level exists
* @see {@linkcode addPhase}
*/
private deferredActive = false;
/**
* Adds a {@linkcode Phase} to the specified level
* @param phase - The phase to add
* @param level - The numeric level to add the phase
* @throws Error if `level` is out of legal bounds
*/
private add(phase: Phase, level: number): void {
const addLevel = this.levels[level];
if (addLevel == null) {
throw new Error("Attempted to add a phase to a nonexistent level of the PhaseTree!\nLevel: " + level.toString());
}
this.levels[level].push(phase);
}
/**
* Used by the {@linkcode PhaseManager} to add phases to the Tree
* @param phase - The {@linkcode Phase} to be added
* @param defer - Whether to defer the execution of this phase by allowing subsequently-added phases to run before it
*
* @privateRemarks
* Deferral is implemented by moving the queue at {@linkcode currentLevel} up one level and inserting the new phase below it.
* {@linkcode deferredActive} is set until the moved queue (and anything added to it) is exhausted.
*
* If {@linkcode deferredActive} is `true` when a deferred phase is added, the phase will be pushed to the second-highest level queue.
* That is, it will execute after the originally deferred phase, but there is no possibility for nesting with deferral.
*
* @todo `setPhaseQueueSplice` had strange behavior. This is simpler, but there are probably some remnant edge cases with the current implementation
*/
public addPhase(phase: Phase, defer = false): void {
if (defer && !this.deferredActive) {
this.deferredActive = true;
this.levels.splice(-1, 0, []);
}
this.add(phase, this.levels.length - 1 - +defer);
}
/**
* Adds a {@linkcode Phase} after the first occurence of the given type, or to the top of the Tree if no such phase exists
* @param phase - The {@linkcode Phase} to be added
* @param type - A {@linkcode PhaseString} representing the type to search for
*/
public addAfter(phase: Phase, type: PhaseString): void {
for (let i = this.levels.length - 1; i >= 0; i--) {
const insertIdx = this.levels[i].findIndex(p => p.is(type)) + 1;
if (insertIdx !== 0) {
this.levels[i].splice(insertIdx, 0, phase);
return;
}
}
this.addPhase(phase);
}
/**
* Unshifts a {@linkcode Phase} to the current level.
* This is effectively the same as if the phase were added immediately after the currently-running phase, before it started.
* @param phase - The {@linkcode Phase} to be added
*/
public unshiftToCurrent(phase: Phase): void {
this.levels[this.currentLevel].unshift(phase);
}
/**
* Pushes a {@linkcode Phase} to the last level of the queue. It will run only after all previously queued phases have been executed.
* @param phase - The {@linkcode Phase} to be added
*/
public pushPhase(phase: Phase): void {
this.add(phase, 0);
}
/**
* Removes and returns the first {@linkcode Phase} from the topmost level of the tree
* @returns - The next {@linkcode Phase}, or `undefined` if the Tree is empty
*/
public getNextPhase(): Phase | undefined {
this.currentLevel = this.levels.length - 1;
while (this.currentLevel > 0 && this.levels[this.currentLevel].length === 0) {
this.deferredActive = false;
this.levels.pop();
this.currentLevel--;
}
// TODO: right now, this is preventing properly marking when one set of unshifted phases ends
this.levels.push([]);
return this.levels[this.currentLevel].shift();
}
/**
* Finds a particular {@linkcode Phase} in the Tree by searching in pop order
* @param phaseType - The {@linkcode PhaseString | type} of phase to search for
* @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase
* @returns The matching {@linkcode Phase}, or `undefined` if none exists
*/
public find<P extends PhaseString>(phaseType: P, phaseFilter?: PhaseConditionFunc<P>): PhaseMap[P] | undefined {
for (let i = this.levels.length - 1; i >= 0; i--) {
const level = this.levels[i];
const phase = level.find((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p)));
if (phase) {
return phase;
}
}
}
/**
* Finds a particular {@linkcode Phase} in the Tree by searching in pop order
* @param phaseType - The {@linkcode PhaseString | type} of phase to search for
* @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase
* @returns The matching {@linkcode Phase}, or `undefined` if none exists
*/
public findAll<P extends PhaseString>(phaseType: P, phaseFilter?: PhaseConditionFunc<P>): PhaseMap[P][] {
const phases: PhaseMap[P][] = [];
for (let i = this.levels.length - 1; i >= 0; i--) {
const level = this.levels[i];
const levelPhases = level.filter((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p)));
phases.push(...levelPhases);
}
return phases;
}
/**
* Clears the Tree
* @param leaveFirstLevel - If `true`, leaves the top level of the tree intact
*
* @privateremarks
* The parameter on this method exists because {@linkcode PhaseManager.clearPhaseQueue} previously (probably by mistake) ignored `phaseQueuePrepend`.
*
* This is (probably by mistake) relied upon by certain ME functions.
*/
public clear(leaveFirstLevel = false) {
this.levels = [leaveFirstLevel ? (this.levels.at(-1) ?? []) : []];
}
/**
* Finds and removes a single {@linkcode Phase} from the Tree
* @param phaseType - The {@linkcode PhaseString | type} of phase to search for
* @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase
* @returns Whether a removal occurred
*/
public remove<P extends PhaseString>(phaseType: P, phaseFilter?: PhaseConditionFunc<P>): boolean {
for (let i = this.levels.length - 1; i >= 0; i--) {
const level = this.levels[i];
const phaseIndex = level.findIndex(p => p.is(phaseType) && (!phaseFilter || phaseFilter(p)));
if (phaseIndex !== -1) {
level.splice(phaseIndex, 1);
return true;
}
}
return false;
}
/**
* Removes all occurrences of {@linkcode Phase}s of the given type
* @param phaseType - The {@linkcode PhaseString | type} of phase to search for
*/
public removeAll(phaseType: PhaseString): void {
for (let i = 0; i < this.levels.length; i++) {
const level = this.levels[i].filter(phase => !phase.is(phaseType));
this.levels[i] = level;
}
}
/**
* Determines if a particular phase exists in the Tree
* @param phaseType - The {@linkcode PhaseString | type} of phase to search for
* @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase
* @returns Whether a matching phase exists
*/
public exists<P extends PhaseString>(phaseType: P, phaseFilter?: PhaseConditionFunc<P>): boolean {
for (const level of this.levels) {
for (const phase of level) {
if (phase.is(phaseType) && (!phaseFilter || phaseFilter(phase))) {
return true;
}
}
}
return false;
}
}

View File

@ -1,23 +0,0 @@
import { globalScene } from "#app/global-scene";
import { Phase } from "#app/phase";
import type { DynamicPhaseType } from "#enums/dynamic-phase-type";
export class ActivatePriorityQueuePhase extends Phase {
public readonly phaseName = "ActivatePriorityQueuePhase";
private type: DynamicPhaseType;
constructor(type: DynamicPhaseType) {
super();
this.type = type;
}
override start() {
super.start();
globalScene.phaseManager.startDynamicPhaseType(this.type);
this.end();
}
public getType(): DynamicPhaseType {
return this.type;
}
}

View File

@ -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 (

View File

@ -1,20 +1,14 @@
import { globalScene } from "#app/global-scene";
import { Phase } from "#app/phase";
import type { BattlerIndex } from "#enums/battler-index";
export class CheckStatusEffectPhase extends Phase {
public readonly phaseName = "CheckStatusEffectPhase";
private order: BattlerIndex[];
constructor(order: BattlerIndex[]) {
super();
this.order = order;
}
start() {
const field = globalScene.getField();
for (const o of this.order) {
if (field[o].status?.isPostTurn()) {
globalScene.phaseManager.unshiftNew("PostTurnStatusEffectPhase", o);
for (const p of field) {
if (p?.status?.isPostTurn()) {
globalScene.phaseManager.unshiftNew("PostTurnStatusEffectPhase", p.getBattlerIndex());
}
}
this.end();

View File

@ -28,7 +28,8 @@ export class CheckSwitchPhase extends BattlePhase {
// ...if the user is playing in Set Mode
if (globalScene.battleStyle === BattleStyle.SET) {
return super.end();
this.end(true);
return;
}
// ...if the checked Pokemon is somehow not on the field
@ -44,7 +45,8 @@ export class CheckSwitchPhase extends BattlePhase {
.slice(1)
.filter(p => p.isActive()).length === 0
) {
return super.end();
this.end(true);
return;
}
// ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching
@ -53,7 +55,8 @@ export class CheckSwitchPhase extends BattlePhase {
|| pokemon.isTrapped()
|| globalScene.getPlayerField().some(p => p.getTag(BattlerTagType.COMMANDED))
) {
return super.end();
this.end(true);
return;
}
globalScene.ui.showText(
@ -71,10 +74,17 @@ export class CheckSwitchPhase extends BattlePhase {
},
() => {
globalScene.ui.setMode(UiMode.MESSAGE);
this.end();
this.end(true);
},
);
},
);
}
public override end(queuePostSummon = false): void {
if (queuePostSummon) {
globalScene.phaseManager.unshiftNew("PostSummonPhase", this.fieldIndex);
}
super.end();
}
}

View File

@ -0,0 +1,17 @@
import type { PhaseString } from "#app/@types/phase-types";
import { Phase } from "#app/phase";
/**
* This phase exists for the sole purpose of marking the location and type of a dynamic phase for the phase manager
*/
export class DynamicPhaseMarker extends Phase {
public override readonly phaseName = "DynamicPhaseMarker";
/** The type of phase which this phase is a marker for */
public phaseType: PhaseString;
constructor(type: PhaseString) {
super();
this.phaseType = type;
}
}

View File

@ -225,7 +225,7 @@ export class EggHatchPhase extends Phase {
}
end() {
if (globalScene.phaseManager.findPhase(p => p.is("EggHatchPhase"))) {
if (globalScene.phaseManager.hasPhaseOfType("EggHatchPhase")) {
this.eggHatchHandler.clear();
} else {
globalScene.time.delayedCall(250, () => globalScene.setModifiersVisible(true));

View File

@ -565,29 +565,6 @@ export class EncounterPhase extends BattlePhase {
});
if (![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) {
enemyField.map(p =>
globalScene.phaseManager.pushConditionalPhase(
globalScene.phaseManager.create("PostSummonPhase", p.getBattlerIndex()),
() => {
// if there is not a player party, we can't continue
if (globalScene.getPlayerParty().length === 0) {
return false;
}
// how many player pokemon are on the field ?
const pokemonsOnFieldCount = globalScene.getPlayerParty().filter(p => p.isOnField()).length;
// if it's a 2vs1, there will never be a 2nd pokemon on our field even
const requiredPokemonsOnField = Math.min(
globalScene.getPlayerParty().filter(p => !p.isFainted()).length,
2,
);
// if it's a double, there should be 2, otherwise 1
if (globalScene.currentBattle.double) {
return pokemonsOnFieldCount === requiredPokemonsOnField;
}
return pokemonsOnFieldCount === 1;
},
),
);
const ivScannerModifier = globalScene.findModifier(m => m instanceof IvScannerModifier);
if (ivScannerModifier) {
enemyField.map(p => globalScene.phaseManager.pushNew("ScanIvsPhase", p.getBattlerIndex()));
@ -596,36 +573,30 @@ export class EncounterPhase extends BattlePhase {
if (!this.loaded) {
const availablePartyMembers = globalScene.getPokemonAllowedInBattle();
const minPartySize = globalScene.currentBattle.double ? 2 : 1;
const currentBattle = globalScene.currentBattle;
const checkSwitch =
currentBattle.battleType !== BattleType.TRAINER
&& (currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily)
&& availablePartyMembers.length > minPartySize;
const phaseManager = globalScene.phaseManager;
if (!availablePartyMembers[0].isOnField()) {
globalScene.phaseManager.pushNew("SummonPhase", 0);
phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch);
}
if (globalScene.currentBattle.double) {
if (currentBattle.double) {
if (availablePartyMembers.length > 1) {
globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", true);
phaseManager.pushNew("ToggleDoublePositionPhase", true);
if (!availablePartyMembers[1].isOnField()) {
globalScene.phaseManager.pushNew("SummonPhase", 1);
phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch);
}
}
} else {
if (availablePartyMembers.length > 1 && availablePartyMembers[1].isOnField()) {
globalScene.phaseManager.pushNew("ReturnPhase", 1);
}
globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", false);
}
if (
globalScene.currentBattle.battleType !== BattleType.TRAINER
&& (globalScene.currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily)
) {
const minPartySize = globalScene.currentBattle.double ? 2 : 1;
if (availablePartyMembers.length > minPartySize) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double);
if (globalScene.currentBattle.double) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double);
}
}
phaseManager.pushNew("ToggleDoublePositionPhase", false);
}
}
handleTutorial(Tutorial.Access_Menu).then(() => super.end());

View File

@ -84,19 +84,12 @@ export class GameOverPhase extends BattlePhase {
globalScene.phaseManager.pushNew("EncounterPhase", true);
const availablePartyMembers = globalScene.getPokemonAllowedInBattle().length;
globalScene.phaseManager.pushNew("SummonPhase", 0);
if (globalScene.currentBattle.double && availablePartyMembers > 1) {
globalScene.phaseManager.pushNew("SummonPhase", 1);
}
if (
const checkSwitch =
globalScene.currentBattle.waveIndex > 1
&& globalScene.currentBattle.battleType !== BattleType.TRAINER
) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double);
if (globalScene.currentBattle.double && availablePartyMembers > 1) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double);
}
&& globalScene.currentBattle.battleType !== BattleType.TRAINER;
globalScene.phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch);
if (globalScene.currentBattle.double && availablePartyMembers > 1) {
globalScene.phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch);
}
globalScene.ui.fadeIn(1250);
@ -267,7 +260,6 @@ export class GameOverPhase extends BattlePhase {
.then(success => doGameOver(!globalScene.gameMode.isDaily || !!success))
.catch(_err => {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.clearPhaseQueueSplice();
globalScene.phaseManager.unshiftNew("MessagePhase", i18next.t("menu:serverCommunicationFailed"), 2500);
// force the game to reload after 2 seconds.
setTimeout(() => {

View File

@ -187,7 +187,7 @@ export class LearnMovePhase extends PlayerPartyMemberPokemonPhase {
pokemon.usedTMs = [];
}
pokemon.usedTMs.push(this.moveId);
globalScene.phaseManager.tryRemovePhase(phase => phase.is("SelectModifierPhase"));
globalScene.phaseManager.tryRemovePhase("SelectModifierPhase");
} else if (this.learnMoveType === LearnMoveType.MEMORY) {
if (this.cost !== -1) {
if (!Overrides.WAIVE_ROLL_FEE_OVERRIDE) {
@ -197,7 +197,7 @@ export class LearnMovePhase extends PlayerPartyMemberPokemonPhase {
}
globalScene.playSound("se/buy");
} else {
globalScene.phaseManager.tryRemovePhase(phase => phase.is("SelectModifierPhase"));
globalScene.phaseManager.tryRemovePhase("SelectModifierPhase");
}
}
pokemon.setMove(index, this.moveId);

View File

@ -75,7 +75,7 @@ export class MoveChargePhase extends PokemonPhase {
// Otherwise, add the attack portion to the user's move queue to execute next turn.
// TODO: This checks status twice for a single-turn usage...
if (instantCharge.value) {
globalScene.phaseManager.tryRemovePhase(phase => phase.is("MoveEndPhase") && phase.getPokemon() === user);
globalScene.phaseManager.tryRemovePhase("MoveEndPhase", phase => phase.getPokemon() === user);
globalScene.phaseManager.unshiftNew("MovePhase", user, [this.targetIndex], this.move, this.useMode);
} else {
user.pushMoveQueue({ move: move.id, targets: [this.targetIndex], useMode: this.useMode });

View File

@ -16,6 +16,7 @@ import { MoveCategory } from "#enums/move-category";
import { MoveEffectTrigger } from "#enums/move-effect-trigger";
import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
import { MoveResult } from "#enums/move-result";
import { MoveTarget } from "#enums/move-target";
import { isReflected, MoveUseMode } from "#enums/move-use-mode";
@ -318,7 +319,7 @@ export class MoveEffectPhase extends PokemonPhase {
applyMoveAttrs("MissEffectAttr", user, target, this.move);
break;
case HitCheckResult.REFLECTED:
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MoveReflectPhase", target, user, this.move);
globalScene.phaseManager.unshiftNew("MoveReflectPhase", target, user, this.move);
break;
case HitCheckResult.PENDING:
case HitCheckResult.ERROR:
@ -747,10 +748,7 @@ export class MoveEffectPhase extends PokemonPhase {
* @param target - The {@linkcode Pokemon} that fainted
*/
protected onFaintTarget(user: Pokemon, target: Pokemon): void {
// set splice index here, so future scene queues happen before FaintedPhase
globalScene.phaseManager.setPhaseQueueSplice();
globalScene.phaseManager.unshiftNew("FaintPhase", target.getBattlerIndex(), false, user);
globalScene.phaseManager.queueFaintPhase(target.getBattlerIndex(), false, user);
target.destroySubstitute();
target.lapseTag(BattlerTagType.COMMANDED);

View File

@ -5,8 +5,8 @@ import { BattlePhase } from "#phases/battle-phase";
export class MoveHeaderPhase extends BattlePhase {
public readonly phaseName = "MoveHeaderPhase";
public pokemon: Pokemon;
public move: PokemonMove;
public pokemon: Pokemon;
constructor(pokemon: Pokemon, move: PokemonMove) {
super();
@ -15,6 +15,10 @@ export class MoveHeaderPhase extends BattlePhase {
this.move = move;
}
public getPokemon(): Pokemon {
return this.pokemon;
}
canMove(): boolean {
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon);
}

View File

@ -3,6 +3,7 @@ import { MOVE_COLOR } from "#app/constants/colors";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
import { PokemonPhase } from "#app/phases/pokemon-phase";
import { CenterOfAttentionTag } from "#data/battler-tags";
import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers";
import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect";
@ -15,6 +16,7 @@ import { BattlerTagType } from "#enums/battler-tag-type";
import { CommonAnim } from "#enums/move-anims-common";
import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
import { MoveResult } from "#enums/move-result";
import { isIgnorePP, isIgnoreStatus, isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode";
import { PokemonType } from "#enums/pokemon-type";
@ -24,20 +26,19 @@ import type { Pokemon } from "#field/pokemon";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { frenzyMissFunc } from "#moves/move-utils";
import type { PokemonMove } from "#moves/pokemon-move";
import { BattlePhase } from "#phases/battle-phase";
import type { TurnMove } from "#types/turn-move";
import { NumberHolder } from "#utils/common";
import { enumValueToKey } from "#utils/enums";
import i18next from "i18next";
export class MovePhase extends BattlePhase {
export class MovePhase extends PokemonPhase {
public readonly phaseName = "MovePhase";
protected _pokemon: Pokemon;
protected _move: PokemonMove;
public move: PokemonMove;
protected _targets: BattlerIndex[];
public readonly useMode: MoveUseMode; // Made public for quash
/** Whether the current move is forced last (used for Quash). */
protected forcedLast: boolean;
/** The timing modifier of the move (used by Quash and to force called moves to the front of their queue) */
public timingModifier: MovePhaseTimingModifier;
/** Whether the current move should fail but still use PP. */
protected failed = false;
/** Whether the current move should fail and retain PP. */
@ -59,14 +60,6 @@ export class MovePhase extends BattlePhase {
this._pokemon = pokemon;
}
public get move(): PokemonMove {
return this._move;
}
protected set move(move: PokemonMove) {
this._move = move;
}
public get targets(): BattlerIndex[] {
return this._targets;
}
@ -81,16 +74,22 @@ export class MovePhase extends BattlePhase {
* @param move - The {@linkcode PokemonMove} to use
* @param useMode - The {@linkcode MoveUseMode} corresponding to this move's means of execution (usually `MoveUseMode.NORMAL`).
* Not marked optional to ensure callers correctly pass on `useModes`.
* @param forcedLast - Whether to force this phase to occur last in order (for {@linkcode MoveId.QUASH}); default `false`
* @param timingModifier - The {@linkcode MovePhaseTimingModifier} for the move; Default {@linkcode MovePhaseTimingModifier.NORMAL}
*/
constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, useMode: MoveUseMode, forcedLast = false) {
super();
constructor(
pokemon: Pokemon,
targets: BattlerIndex[],
move: PokemonMove,
useMode: MoveUseMode,
timingModifier: MovePhaseTimingModifier = MovePhaseTimingModifier.NORMAL,
) {
super(pokemon.getBattlerIndex());
this.pokemon = pokemon;
this.targets = targets;
this.move = move;
this.useMode = useMode;
this.forcedLast = forcedLast;
this.timingModifier = timingModifier;
this.moveHistoryEntry = {
move: MoveId.NONE,
targets,
@ -121,14 +120,6 @@ export class MovePhase extends BattlePhase {
this.cancelled = true;
}
/**
* Shows whether the current move has been forced to the end of the turn
* Needed for speed order, see {@linkcode MoveId.QUASH}
*/
public isForcedLast(): boolean {
return this.forcedLast;
}
public start(): void {
super.start();

View File

@ -48,7 +48,6 @@ export class MysteryEncounterPhase extends Phase {
// Clears out queued phases that are part of standard battle
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.clearPhaseQueueSplice();
const encounter = globalScene.currentBattle.mysteryEncounter!;
encounter.updateSeedOffset();
@ -233,9 +232,7 @@ export class MysteryEncounterBattleStartCleanupPhase extends Phase {
});
// Remove any status tick phases
while (globalScene.phaseManager.findPhase(p => p.is("PostTurnStatusEffectPhase"))) {
globalScene.phaseManager.tryRemovePhase(p => p.is("PostTurnStatusEffectPhase"));
}
globalScene.phaseManager.removeAllPhasesOfType("PostTurnStatusEffectPhase");
// The total number of Pokemon in the player's party that can legally fight
const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle();
@ -412,16 +409,21 @@ export class MysteryEncounterBattlePhase extends Phase {
}
const availablePartyMembers = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle());
const minPartySize = globalScene.currentBattle.double ? 2 : 1;
const checkSwitch =
encounterMode !== MysteryEncounterMode.TRAINER_BATTLE
&& !this.disableSwitch
&& availablePartyMembers.length > minPartySize;
if (!availablePartyMembers[0].isOnField()) {
globalScene.phaseManager.pushNew("SummonPhase", 0);
globalScene.phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch);
}
if (globalScene.currentBattle.double) {
if (availablePartyMembers.length > 1) {
globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", true);
if (!availablePartyMembers[1].isOnField()) {
globalScene.phaseManager.pushNew("SummonPhase", 1);
globalScene.phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch);
}
}
} else {
@ -432,16 +434,6 @@ export class MysteryEncounterBattlePhase extends Phase {
globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", false);
}
if (encounterMode !== MysteryEncounterMode.TRAINER_BATTLE && !this.disableSwitch) {
const minPartySize = globalScene.currentBattle.double ? 2 : 1;
if (availablePartyMembers.length > minPartySize) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double);
if (globalScene.currentBattle.double) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double);
}
}
}
this.end();
}
@ -540,7 +532,7 @@ export class MysteryEncounterRewardsPhase extends Phase {
if (encounter.doEncounterRewards) {
encounter.doEncounterRewards();
} else if (this.addHealPhase) {
globalScene.phaseManager.tryRemovePhase(p => p.is("SelectModifierPhase"));
globalScene.phaseManager.removeAllPhasesOfType("SelectModifierPhase");
globalScene.phaseManager.unshiftNew("SelectModifierPhase", 0, undefined, {
fillRemaining: false,
rerollMultiplier: -1,

View File

@ -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();

View File

@ -22,4 +22,8 @@ export abstract class PartyMemberPokemonPhase extends FieldPhase {
getPokemon(): Pokemon {
return this.getParty()[this.partyMemberIndex];
}
isPlayer(): boolean {
return this.player;
}
}

View File

@ -6,8 +6,8 @@ import { PostSummonPhase } from "#phases/post-summon-phase";
* Helper to {@linkcode PostSummonPhase} which applies abilities
*/
export class PostSummonActivateAbilityPhase extends PostSummonPhase {
private priority: number;
private passive: boolean;
private readonly priority: number;
private readonly passive: boolean;
constructor(battlerIndex: BattlerIndex, priority: number, passive: boolean) {
super(battlerIndex);

View File

@ -1,19 +1,29 @@
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import type { PhaseString } from "#app/@types/phase-types";
import { globalScene } from "#app/global-scene";
import { EntryHazardTag } from "#data/arena-tag";
import { MysteryEncounterPostSummonTag } from "#data/battler-tags";
import { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { StatusEffect } from "#enums/status-effect";
import { PokemonPhase } from "#phases/pokemon-phase";
export class PostSummonPhase extends PokemonPhase {
public readonly phaseName = "PostSummonPhase";
/** Used to determine whether to push or unshift {@linkcode PostSummonActivateAbilityPhase}s */
public readonly source: PhaseString;
constructor(battlerIndex?: BattlerIndex | number, source: PhaseString = "SwitchSummonPhase") {
super(battlerIndex);
this.source = source;
}
start() {
super.start();
const pokemon = this.getPokemon();
console.log("Ran PSP for:", pokemon.name);
if (pokemon.status?.effect === StatusEffect.TOXIC) {
pokemon.status.toxicTurnCount = 0;
}
@ -29,8 +39,7 @@ export class PostSummonPhase extends PokemonPhase {
) {
pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON);
}
const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField();
const field = pokemon.isPlayer() ? globalScene.getPlayerField(true) : globalScene.getEnemyField(true);
for (const p of field) {
applyAbAttrs("CommanderAbAttr", { pokemon: p });
}

View File

@ -9,7 +9,6 @@ import { BattleSpec } from "#enums/battle-spec";
import { BattlerTagType } from "#enums/battler-tag-type";
import type { Pokemon } from "#field/pokemon";
import { BattlePhase } from "#phases/battle-phase";
import type { MovePhase } from "#phases/move-phase";
export class QuietFormChangePhase extends BattlePhase {
public readonly phaseName = "QuietFormChangePhase";
@ -170,12 +169,7 @@ export class QuietFormChangePhase extends BattlePhase {
this.pokemon.initBattleInfo();
this.pokemon.cry();
const movePhase = globalScene.phaseManager.findPhase(
p => p.is("MovePhase") && p.pokemon === this.pokemon,
) as MovePhase;
if (movePhase) {
movePhase.cancel();
}
globalScene.phaseManager.cancelMove(p => p.pokemon === this.pokemon);
}
if (this.formChange.trigger instanceof SpeciesFormChangeTeraTrigger) {
const params = { pokemon: this.pokemon };

View File

@ -223,10 +223,7 @@ export class StatStageChangePhase extends PokemonPhase {
});
// Look for any other stat change phases; if this is the last one, do White Herb check
const existingPhase = globalScene.phaseManager.findPhase(
p => p.is("StatStageChangePhase") && p.battlerIndex === this.battlerIndex,
);
if (!existingPhase?.is("StatStageChangePhase")) {
if (!globalScene.phaseManager.hasPhaseOfType("StatStageChangePhase", p => p.battlerIndex === this.battlerIndex)) {
// Apply White Herb if needed
const whiteHerb = globalScene.applyModifier(
ResetNegativeStatStageModifier,
@ -297,49 +294,6 @@ export class StatStageChangePhase extends PokemonPhase {
}
}
aggregateStatStageChanges(): void {
const accEva: BattleStat[] = [Stat.ACC, Stat.EVA];
const isAccEva = accEva.some(s => this.stats.includes(s));
let existingPhase: StatStageChangePhase;
if (this.stats.length === 1) {
while (
(existingPhase = globalScene.phaseManager.findPhase(
p =>
p.is("StatStageChangePhase")
&& p.battlerIndex === this.battlerIndex
&& p.stats.length === 1
&& p.stats[0] === this.stats[0]
&& p.selfTarget === this.selfTarget
&& p.showMessage === this.showMessage
&& p.ignoreAbilities === this.ignoreAbilities,
) as StatStageChangePhase)
) {
this.stages += existingPhase.stages;
if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) {
break;
}
}
}
while (
(existingPhase = globalScene.phaseManager.findPhase(
p =>
p.is("StatStageChangePhase")
&& p.battlerIndex === this.battlerIndex
&& p.selfTarget === this.selfTarget
&& accEva.some(s => p.stats.includes(s)) === isAccEva
&& p.stages === this.stages
&& p.showMessage === this.showMessage
&& p.ignoreAbilities === this.ignoreAbilities,
) as StatStageChangePhase)
) {
this.stats.push(...existingPhase.stats);
if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) {
break;
}
}
}
getStatStageChangeMessages(stats: BattleStat[], stages: number, relStages: number[]): string[] {
const messages: string[] = [];

View File

@ -16,12 +16,14 @@ import i18next from "i18next";
export class SummonPhase extends PartyMemberPokemonPhase {
// The union type is needed to keep typescript happy as these phases extend from SummonPhase
public readonly phaseName: "SummonPhase" | "SummonMissingPhase" | "SwitchSummonPhase" | "ReturnPhase" = "SummonPhase";
private loaded: boolean;
private readonly loaded: boolean;
private readonly checkSwitch: boolean;
constructor(fieldIndex: number, player = true, loaded = false) {
constructor(fieldIndex: number, player = true, loaded = false, checkSwitch = false) {
super(fieldIndex, player);
this.loaded = loaded;
this.checkSwitch = checkSwitch;
}
start() {
@ -288,7 +290,17 @@ export class SummonPhase extends PartyMemberPokemonPhase {
}
queuePostSummon(): void {
globalScene.phaseManager.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex());
if (this.checkSwitch) {
globalScene.phaseManager.pushNew(
"CheckSwitchPhase",
this.getPokemon().getFieldIndex(),
globalScene.currentBattle.double,
);
} else {
globalScene.phaseManager.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex(), this.phaseName);
}
globalScene.phaseManager.tryAddEnemyPostSummonPhases();
}
end() {
@ -296,4 +308,8 @@ export class SummonPhase extends PartyMemberPokemonPhase {
super.end();
}
public getFieldIndex(): number {
return this.fieldIndex;
}
}

View File

@ -1,5 +1,4 @@
import { globalScene } from "#app/global-scene";
import { DynamicPhaseType } from "#enums/dynamic-phase-type";
import { SwitchType } from "#enums/switch-type";
import { UiMode } from "#enums/ui-mode";
import { BattlePhase } from "#phases/battle-phase";
@ -77,14 +76,6 @@ export class SwitchPhase extends BattlePhase {
fieldIndex,
(slotIndex: number, option: PartyOption) => {
if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) {
// Remove any pre-existing PostSummonPhase under the same field index.
// Pre-existing PostSummonPhases may occur when this phase is invoked during a prompt to switch at the start of a wave.
// TODO: Separate the animations from `SwitchSummonPhase` and co. into another phase and use that on initial switch - this is a band-aid fix
globalScene.phaseManager.tryRemoveDynamicPhase(
DynamicPhaseType.POST_SUMMON,
p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex,
"all",
);
const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType;
globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn);
}

View File

@ -241,11 +241,11 @@ export class SwitchSummonPhase extends SummonPhase {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
// Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out
globalScene.arena.triggerWeatherBasedFormChanges();
globalScene.arena.triggerWeatherBasedFormChanges(pokemon);
}
queuePostSummon(): void {
globalScene.phaseManager.startNewDynamicPhase("PostSummonPhase", this.getPokemon().getBattlerIndex());
globalScene.phaseManager.unshiftNew("PostSummonPhase", this.getPokemon().getBattlerIndex());
}
/**

View File

@ -315,23 +315,15 @@ export class TitlePhase extends Phase {
if (this.loaded) {
const availablePartyMembers = globalScene.getPokemonAllowedInBattle().length;
globalScene.phaseManager.pushNew("SummonPhase", 0, true, true);
if (globalScene.currentBattle.double && availablePartyMembers > 1) {
globalScene.phaseManager.pushNew("SummonPhase", 1, true, true);
}
if (
const minPartySize = globalScene.currentBattle.double ? 2 : 1;
const checkSwitch =
globalScene.currentBattle.battleType !== BattleType.TRAINER
&& (globalScene.currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily)
) {
const minPartySize = globalScene.currentBattle.double ? 2 : 1;
if (availablePartyMembers > minPartySize) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double);
if (globalScene.currentBattle.double) {
globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double);
}
}
&& availablePartyMembers > minPartySize;
globalScene.phaseManager.pushNew("SummonPhase", 0, true, true, checkSwitch);
if (globalScene.currentBattle.double && availablePartyMembers > 1) {
globalScene.phaseManager.pushNew("SummonPhase", 1, true, true, checkSwitch);
}
}

View File

@ -25,6 +25,7 @@ export class TurnEndPhase extends FieldPhase {
globalScene.currentBattle.incrementTurn();
globalScene.eventTarget.dispatchEvent(new TurnEndEvent(globalScene.currentBattle.turn));
globalScene.phaseManager.dynamicQueueManager.clearLastTurnOrder();
globalScene.phaseManager.hideAbilityBar();

View File

@ -1,89 +1,31 @@
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import type { TurnCommand } from "#app/battle";
import { globalScene } from "#app/global-scene";
import { TrickRoomTag } from "#data/arena-tag";
import { allMoves } from "#data/data-lists";
import { BattlerIndex } from "#enums/battler-index";
import { ArenaTagSide } from "#enums/arena-tag-side";
import type { BattlerIndex } from "#enums/battler-index";
import { Command } from "#enums/command";
import { Stat } from "#enums/stat";
import { SwitchType } from "#enums/switch-type";
import type { Pokemon } from "#field/pokemon";
import { BypassSpeedChanceModifier } from "#modifiers/modifier";
import { PokemonMove } from "#moves/pokemon-move";
import { FieldPhase } from "#phases/field-phase";
import { BooleanHolder, randSeedShuffle } from "#utils/common";
import { inSpeedOrder } from "#utils/speed-order-generator";
export class TurnStartPhase extends FieldPhase {
public readonly phaseName = "TurnStartPhase";
/**
* Helper method to retrieve the current speed order of the combattants.
* It also checks for Trick Room and reverses the array if it is present.
* @returns The {@linkcode BattlerIndex}es of all on-field Pokemon, sorted in speed order.
* @todo Make this private
*/
getSpeedOrder(): BattlerIndex[] {
const playerField = globalScene.getPlayerField().filter(p => p.isActive());
const enemyField = globalScene.getEnemyField().filter(p => p.isActive());
// Shuffle the list before sorting so speed ties produce random results
// This is seeded with the current turn to prevent turn order varying
// based on how long since you last reloaded.
let orderedTargets = (playerField as Pokemon[]).concat(enemyField);
globalScene.executeWithSeedOffset(
() => {
orderedTargets = randSeedShuffle(orderedTargets);
},
globalScene.currentBattle.turn,
globalScene.waveSeed,
);
// Check for Trick Room and reverse sort order if active.
// Notably, Pokerogue does NOT have the "outspeed trick room" glitch at >1809 spd.
const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(TrickRoomTag, false, speedReversed);
orderedTargets.sort((a: Pokemon, b: Pokemon) => {
const aSpeed = a.getEffectiveStat(Stat.SPD);
const bSpeed = b.getEffectiveStat(Stat.SPD);
return speedReversed.value ? aSpeed - bSpeed : bSpeed - aSpeed;
});
return orderedTargets.map(t => t.getFieldIndex() + (t.isEnemy() ? BattlerIndex.ENEMY : BattlerIndex.PLAYER));
}
/**
* This takes the result of {@linkcode getSpeedOrder} and applies priority / bypass speed attributes to it.
* This also considers the priority levels of various commands and changes the result of `getSpeedOrder` based on such.
* @returns The `BattlerIndex`es of all on-field Pokemon sorted in action order.
* Returns an ordering of the current field based on command priority
* @returns The sequence of commands for this turn
*/
getCommandOrder(): BattlerIndex[] {
let moveOrder = this.getSpeedOrder();
// The creation of the battlerBypassSpeed object contains checks for the ability Quick Draw and the held item Quick Claw
// The ability Mycelium Might disables Quick Claw's activation when using a status move
// This occurs before the main loop because of battles with more than two Pokemon
const battlerBypassSpeed = {};
globalScene.getField(true).forEach(p => {
const bypassSpeed = new BooleanHolder(false);
const canCheckHeldItems = new BooleanHolder(true);
applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon: p, bypass: bypassSpeed });
applyAbAttrs("PreventBypassSpeedChanceAbAttr", {
pokemon: p,
bypass: bypassSpeed,
canCheckHeldItems,
});
if (canCheckHeldItems.value) {
globalScene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p, bypassSpeed);
}
battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed;
});
const playerField = globalScene.getPlayerField(true).map(p => p.getBattlerIndex());
const enemyField = globalScene.getEnemyField(true).map(p => p.getBattlerIndex());
const orderedTargets: BattlerIndex[] = playerField.concat(enemyField);
// The function begins sorting orderedTargets based on command priority, move priority, and possible speed bypasses.
// Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands.
moveOrder = moveOrder.slice(0);
moveOrder.sort((a, b) => {
orderedTargets.sort((a, b) => {
const aCommand = globalScene.currentBattle.turnCommands[a];
const bCommand = globalScene.currentBattle.turnCommands[b];
@ -94,41 +36,14 @@ export class TurnStartPhase extends FieldPhase {
if (bCommand?.command === Command.FIGHT) {
return -1;
}
} else if (aCommand?.command === Command.FIGHT) {
const aMove = allMoves[aCommand.move!.move];
const bMove = allMoves[bCommand!.move!.move];
const aUser = globalScene.getField(true).find(p => p.getBattlerIndex() === a)!;
const bUser = globalScene.getField(true).find(p => p.getBattlerIndex() === b)!;
const aPriority = aMove.getPriority(aUser, false);
const bPriority = bMove.getPriority(bUser, false);
// The game now checks for differences in priority levels.
// If the moves share the same original priority bracket, it can check for differences in battlerBypassSpeed and return the result.
// This conditional is used to ensure that Quick Claw can still activate with abilities like Stall and Mycelium Might (attack moves only)
// Otherwise, the game returns the user of the move with the highest priority.
const isSameBracket = Math.ceil(aPriority) - Math.ceil(bPriority) === 0;
if (aPriority !== bPriority) {
if (isSameBracket && battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) {
return battlerBypassSpeed[a].value ? -1 : 1;
}
return aPriority < bPriority ? 1 : -1;
}
}
// If there is no difference between the move's calculated priorities,
// check for differences in battlerBypassSpeed and returns the result.
if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) {
return battlerBypassSpeed[a].value ? -1 : 1;
}
const aIndex = moveOrder.indexOf(a);
const bIndex = moveOrder.indexOf(b);
const aIndex = orderedTargets.indexOf(a);
const bIndex = orderedTargets.indexOf(b);
return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0;
});
return moveOrder;
return orderedTargets;
}
// TODO: Refactor this alongside `CommandPhase.handleCommand` to use SEPARATE METHODS
@ -139,9 +54,8 @@ export class TurnStartPhase extends FieldPhase {
const field = globalScene.getField();
const moveOrder = this.getCommandOrder();
for (const o of this.getSpeedOrder()) {
const pokemon = field[o];
const preTurnCommand = globalScene.currentBattle.preTurnCommands[o];
for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) {
const preTurnCommand = globalScene.currentBattle.preTurnCommands[pokemon.getBattlerIndex()];
if (preTurnCommand?.skip) {
continue;
@ -154,6 +68,10 @@ export class TurnStartPhase extends FieldPhase {
}
const phaseManager = globalScene.phaseManager;
for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) {
applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon });
globalScene.applyModifiers(BypassSpeedChanceModifier, pokemon.isPlayer(), pokemon);
}
moveOrder.forEach((o, index) => {
const pokemon = field[o];
@ -178,13 +96,8 @@ export class TurnStartPhase extends FieldPhase {
// TODO: Re-order these phases to be consistent with mainline turn order:
// https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179
phaseManager.pushNew("WeatherEffectPhase");
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("BerryPhase");
phaseManager.pushNew("CheckStatusEffectPhase", moveOrder);
phaseManager.pushNew("TurnEndPhase");
// TODO: In an ideal world, this is handled by the phase manager. The change is nontrivial due to the ordering of post-turn phases like those queued by VictoryPhase
globalScene.phaseManager.queueTurnEndPhases();
/*
* `this.end()` will call `PhaseManager#shiftPhase()`, which dumps everything from `phaseQueuePrepend`

View File

@ -0,0 +1,103 @@
import type { PokemonMove } from "#app/data/moves/pokemon-move";
import type { Pokemon } from "#app/field/pokemon";
import { globalScene } from "#app/global-scene";
import type { MovePhase } from "#app/phases/move-phase";
import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue";
import type { BattlerIndex } from "#enums/battler-index";
import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier";
import type { PhaseConditionFunc } from "#types/phase-types";
/** A priority queue responsible for the ordering of {@linkcode MovePhase}s */
export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue<MovePhase> {
private lastTurnOrder: Pokemon[] = [];
protected override reorder(): void {
super.reorder();
this.sortPostSpeed();
}
public cancelMove(condition: PhaseConditionFunc<"MovePhase">): void {
this.queue.find(p => condition(p))?.cancel();
}
public setTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void {
const phase = this.queue.find(p => condition(p));
if (phase != null) {
phase.timingModifier = modifier;
}
}
public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove) {
const phase = this.queue.find(p => condition(p));
if (phase != null) {
phase.move = move;
}
}
public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
// failsafe: if not a double battle just return
if (!globalScene.currentBattle.double) {
return;
}
// TODO: simplify later
if (allyPokemon?.isActive(true)) {
this.queue
.filter(
mp =>
mp.targets.length === 1
&& mp.targets[0] === removedPokemon.getBattlerIndex()
&& mp.pokemon.isPlayer() !== allyPokemon.isPlayer(),
)
.forEach(targetingMovePhase => {
if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) {
targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex();
}
});
}
}
public setMoveOrder(order: BattlerIndex[]) {
this.setOrder = order;
}
public override pop(): MovePhase | undefined {
this.reorder();
const phase = this.queue.shift();
if (phase) {
this.lastTurnOrder.push(phase.pokemon);
}
return phase;
}
public getTurnOrder(): Pokemon[] {
return this.lastTurnOrder;
}
public clearTurnOrder(): void {
this.lastTurnOrder = [];
}
public override clear(): void {
this.setOrder = undefined;
this.lastTurnOrder = [];
super.clear();
}
private sortPostSpeed(): void {
this.queue.sort((a: MovePhase, b: MovePhase) => {
const priority = [a, b].map(movePhase => {
const move = movePhase.move.getMove();
return move.getPriority(movePhase.pokemon, true);
});
const timingModifiers = [a, b].map(movePhase => movePhase.timingModifier);
if (timingModifiers[0] !== timingModifiers[1]) {
return timingModifiers[1] - timingModifiers[0];
}
return priority[1] - priority[0];
});
}
}

View File

@ -0,0 +1,20 @@
import type { DynamicPhase } from "#app/@types/phase-types";
import { PriorityQueue } from "#app/queues/priority-queue";
import { sortInSpeedOrder } from "#app/utils/speed-order";
import type { BattlerIndex } from "#enums/battler-index";
/** A generic speed-based priority queue of {@linkcode DynamicPhase}s */
export class PokemonPhasePriorityQueue<T extends DynamicPhase> extends PriorityQueue<T> {
protected setOrder: BattlerIndex[] | undefined;
protected override reorder(): void {
const setOrder = this.setOrder;
if (setOrder) {
this.queue.sort(
(a, b) =>
setOrder.indexOf(a.getPokemon().getBattlerIndex()) - setOrder.indexOf(b.getPokemon().getBattlerIndex()),
);
} else {
this.queue = sortInSpeedOrder(this.queue);
}
}
}

View File

@ -0,0 +1,10 @@
import type { Pokemon } from "#app/field/pokemon";
import { PriorityQueue } from "#app/queues/priority-queue";
import { sortInSpeedOrder } from "#app/utils/speed-order";
/** A priority queue of {@linkcode Pokemon}s */
export class PokemonPriorityQueue extends PriorityQueue<Pokemon> {
protected override reorder(): void {
this.queue = sortInSpeedOrder(this.queue);
}
}

View File

@ -0,0 +1,45 @@
import { globalScene } from "#app/global-scene";
import { PostSummonActivateAbilityPhase } from "#app/phases/post-summon-activate-ability-phase";
import type { PostSummonPhase } from "#app/phases/post-summon-phase";
import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue";
import { sortInSpeedOrder } from "#app/utils/speed-order";
/**
* Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase}
*
* Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed
*/
export class PostSummonPhasePriorityQueue extends PokemonPhasePriorityQueue<PostSummonPhase> {
protected override reorder(): void {
this.queue = sortInSpeedOrder(this.queue, false);
this.queue.sort((phaseA, phaseB) => phaseB.getPriority() - phaseA.getPriority());
}
public override push(phase: PostSummonPhase): void {
super.push(phase);
this.queueAbilityPhase(phase);
}
/**
* Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase}
* @param phase - The {@linkcode PostSummonPhase} that was pushed onto the queue
*/
private queueAbilityPhase(phase: PostSummonPhase): void {
if (phase instanceof PostSummonActivateAbilityPhase) {
return;
}
const phasePokemon = phase.getPokemon();
phasePokemon.getAbilityPriorities().forEach((priority, idx) => {
const activateAbilityPhase = new PostSummonActivateAbilityPhase(
phasePokemon.getBattlerIndex(),
priority,
idx !== 0,
);
phase.source === "SummonPhase"
? globalScene.phaseManager.pushPhase(activateAbilityPhase)
: globalScene.phaseManager.unshiftPhase(activateAbilityPhase);
});
}
}

View File

@ -0,0 +1,78 @@
/**
* Stores a list of elements.
*
* Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder}.
*/
export abstract class PriorityQueue<T> {
protected queue: T[] = [];
/**
* Sorts the elements in the queue
*/
protected abstract reorder(): void;
/**
* Calls {@linkcode reorder} and shifts the queue
* @returns The front element of the queue after sorting, or `undefined` if the queue is empty
* @sealed
*/
public pop(): T | undefined {
if (this.isEmpty()) {
return;
}
this.reorder();
return this.queue.shift();
}
/**
* Adds an element to the queue
* @param element The element to add
*/
public push(element: T): void {
this.queue.push(element);
}
/**
* Removes all elements from the queue
* @sealed
*/
public clear(): void {
this.queue.splice(0, this.queue.length);
}
/**
* @returns Whether the queue is empty
* @sealed
*/
public isEmpty(): boolean {
return this.queue.length === 0;
}
/**
* Removes the first element matching the condition
* @param condition - An optional condition function (defaults to a function that always returns `true`)
* @returns Whether a removal occurred
*/
public remove(condition: (t: T) => boolean = () => true): boolean {
// Reorder to remove the first element
this.reorder();
const index = this.queue.findIndex(condition);
if (index === -1) {
return false;
}
this.queue.splice(index, 1);
return true;
}
/** @returns An element matching the condition function */
public find(condition?: (t: T) => boolean): T | undefined {
return this.queue.find(e => !condition || condition(e));
}
/** @returns Whether an element matching the condition function exists */
public has(condition?: (t: T) => boolean): boolean {
return this.queue.some(e => !condition || condition(e));
}
}

View File

@ -0,0 +1,39 @@
import { globalScene } from "#app/global-scene";
import { PokemonPriorityQueue } from "#app/queues/pokemon-priority-queue";
import { ArenaTagSide } from "#enums/arena-tag-side";
import type { Pokemon } from "#field/pokemon";
/**
* A generator function which uses a priority queue to yield each pokemon from a given side of the field in speed order.
* @param side - The {@linkcode ArenaTagSide | side} of the field to use
* @returns A {@linkcode Generator} of {@linkcode Pokemon}
*
* @remarks
* This should almost always be used by iteration in a `for...of` loop
*/
export function* inSpeedOrder(side: ArenaTagSide = ArenaTagSide.BOTH): Generator<Pokemon, number> {
let pokemonList: Pokemon[];
switch (side) {
case ArenaTagSide.PLAYER:
pokemonList = globalScene.getPlayerField(true);
break;
case ArenaTagSide.ENEMY:
pokemonList = globalScene.getEnemyField(true);
break;
default:
pokemonList = globalScene.getField(true);
}
const queue = new PokemonPriorityQueue();
let i = 0;
pokemonList.forEach(p => {
queue.push(p);
});
while (!queue.isEmpty()) {
// If the queue is not empty, this can never be undefined
i++;
yield queue.pop()!;
}
return i;
}

57
src/utils/speed-order.ts Normal file
View File

@ -0,0 +1,57 @@
import { Pokemon } from "#app/field/pokemon";
import { globalScene } from "#app/global-scene";
import { BooleanHolder, randSeedShuffle } from "#app/utils/common";
import { ArenaTagType } from "#enums/arena-tag-type";
import { Stat } from "#enums/stat";
/** Interface representing an object associated with a specific Pokemon */
interface hasPokemon {
getPokemon(): Pokemon;
}
/**
* Sorts an array of {@linkcode Pokemon} by speed, taking Trick Room into account.
* @param pokemonList - The list of Pokemon or objects containing Pokemon
* @param shuffleFirst - Whether to shuffle the list before sorting (to handle speed ties). Default `true`.
* @returns The sorted array of {@linkcode Pokemon}
*/
export function sortInSpeedOrder<T extends Pokemon | hasPokemon>(pokemonList: T[], shuffleFirst = true): T[] {
pokemonList = shuffleFirst ? shufflePokemonList(pokemonList) : pokemonList;
sortBySpeed(pokemonList);
return pokemonList;
}
/**
* @param pokemonList - The array of Pokemon or objects containing Pokemon
* @returns The shuffled array
*/
function shufflePokemonList<T extends Pokemon | hasPokemon>(pokemonList: T[]): T[] {
// This is seeded with the current turn to prevent an inconsistency where it
// was varying based on how long since you last reloaded
globalScene.executeWithSeedOffset(
() => {
pokemonList = randSeedShuffle(pokemonList);
},
globalScene.currentBattle.turn * 1000 + pokemonList.length,
globalScene.waveSeed,
);
return pokemonList;
}
/** Sorts an array of {@linkcode Pokemon} by speed (without shuffling) */
function sortBySpeed<T extends Pokemon | hasPokemon>(pokemonList: T[]): void {
pokemonList.sort((a, b) => {
const aSpeed = (a instanceof Pokemon ? a : a.getPokemon()).getEffectiveStat(Stat.SPD);
const bSpeed = (b instanceof Pokemon ? b : b.getPokemon()).getEffectiveStat(Stat.SPD);
return bSpeed - aSpeed;
});
/** 'true' if Trick Room is on the field. */
const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, false, speedReversed);
if (speedReversed.value) {
pokemonList.reverse();
}
}

View File

@ -34,7 +34,7 @@ describe("Abilities - Dancer", () => {
game.override.enemyAbility(AbilityId.DANCER).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset(MoveId.VICTORY_DANCE);
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
const [oricorio, feebas] = game.scene.getPlayerField();
const [oricorio, feebas, magikarp1] = game.scene.getField();
game.move.changeMoveset(oricorio, [MoveId.SWORDS_DANCE, MoveId.VICTORY_DANCE, MoveId.SPLASH]);
game.move.changeMoveset(feebas, [MoveId.SWORDS_DANCE, MoveId.SPLASH]);
@ -44,8 +44,9 @@ describe("Abilities - Dancer", () => {
await game.phaseInterceptor.to("MovePhase"); // feebas uses swords dance
await game.phaseInterceptor.to("MovePhase", false); // oricorio copies swords dance
// Dancer order will be Magikarp, Oricorio, Magikarp based on set turn order
let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(currentPhase.pokemon).toBe(oricorio);
expect(currentPhase.pokemon).toBe(magikarp1);
expect(currentPhase.move.moveId).toBe(MoveId.SWORDS_DANCE);
await game.phaseInterceptor.to("MoveEndPhase"); // end oricorio's move

View File

@ -2,8 +2,6 @@ import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { TurnEndPhase } from "#phases/turn-end-phase";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -45,65 +43,50 @@ describe("Abilities - Mycelium Might", () => {
it("should move last in its priority bracket and ignore protective abilities", async () => {
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
const enemyPokemon = game.field.getEnemyPokemon();
const playerIndex = game.field.getPlayerPokemon().getBattlerIndex();
const enemyIndex = enemyPokemon.getBattlerIndex();
const enemy = game.field.getEnemyPokemon();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.BABY_DOLL_EYES);
await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.to("MoveEndPhase", false);
// The opponent Pokemon (without Mycelium Might) goes first despite having lower speed than the player Pokemon.
// The player Pokemon (with Mycelium Might) goes last despite having higher speed than the opponent.
expect(speedOrder).toEqual([playerIndex, enemyIndex]);
expect(commandOrder).toEqual([enemyIndex, playerIndex]);
await game.phaseInterceptor.to(TurnEndPhase);
expect(player.hp).not.toEqual(player.getMaxHp());
await game.phaseInterceptor.to("TurnEndPhase");
// Despite the opponent's ability (Clear Body), its ATK stat stage is still reduced.
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1);
expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
});
it("should still go first if a status move that is in a higher priority bracket than the opponent's move is used", async () => {
game.override.enemyMoveset(MoveId.TACKLE);
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
const enemyPokemon = game.field.getEnemyPokemon();
const playerIndex = game.field.getPlayerPokemon().getBattlerIndex();
const enemyIndex = enemyPokemon.getBattlerIndex();
const enemy = game.field.getEnemyPokemon();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.BABY_DOLL_EYES);
await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.to("MoveEndPhase", false);
// The player Pokemon (with M.M.) goes first because its move is still within a higher priority bracket than its opponent.
// The enemy Pokemon goes second because its move is in a lower priority bracket.
expect(speedOrder).toEqual([playerIndex, enemyIndex]);
expect(commandOrder).toEqual([playerIndex, enemyIndex]);
await game.phaseInterceptor.to(TurnEndPhase);
expect(player.hp).toEqual(player.getMaxHp());
await game.phaseInterceptor.to("TurnEndPhase");
// Despite the opponent's ability (Clear Body), its ATK stat stage is still reduced.
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1);
expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
});
it("should not affect non-status moves", async () => {
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
const playerIndex = game.field.getPlayerPokemon().getBattlerIndex();
const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.QUICK_ATTACK);
await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.to("MoveEndPhase", false);
// The player Pokemon (with M.M.) goes first because it has a higher speed and did not use a status move.
// The enemy Pokemon (without M.M.) goes second because its speed is lower.
// This means that the commandOrder should be identical to the speedOrder
expect(speedOrder).toEqual([playerIndex, enemyIndex]);
expect(commandOrder).toEqual([playerIndex, enemyIndex]);
expect(player.hp).toEqual(player.getMaxHp());
});
});

View File

@ -59,7 +59,7 @@ describe("Abilities - Neutralizing Gas", () => {
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(1);
});
it.todo("should activate before other abilities", async () => {
it("should activate before other abilities", async () => {
game.override.enemySpecies(SpeciesId.ACCELGOR).enemyLevel(100).enemyAbility(AbilityId.INTIMIDATE);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);

View File

@ -5,7 +5,7 @@ import { SpeciesId } from "#enums/species-id";
import { FaintPhase } from "#phases/faint-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Quick Draw", () => {
let phaserGame: Phaser.Game;
@ -25,7 +25,6 @@ describe("Abilities - Quick Draw", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.starterSpecies(SpeciesId.MAGIKARP)
.ability(AbilityId.QUICK_DRAW)
.moveset([MoveId.TACKLE, MoveId.TAIL_WHIP])
.enemyLevel(100)
@ -40,8 +39,8 @@ describe("Abilities - Quick Draw", () => {
).mockReturnValue(100);
});
test("makes pokemon going first in its priority bracket", async () => {
await game.classicMode.startBattle();
it("makes pokemon go first in its priority bracket", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const pokemon = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
@ -57,33 +56,27 @@ describe("Abilities - Quick Draw", () => {
expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.QUICK_DRAW);
});
test(
"does not triggered by non damage moves",
{
retry: 5,
},
async () => {
await game.classicMode.startBattle();
it("is not triggered by non damaging moves", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const pokemon = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
const pokemon = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
pokemon.hp = 1;
enemy.hp = 1;
pokemon.hp = 1;
enemy.hp = 1;
game.move.select(MoveId.TAIL_WHIP);
await game.phaseInterceptor.to(FaintPhase, false);
game.move.select(MoveId.TAIL_WHIP);
await game.phaseInterceptor.to(FaintPhase, false);
expect(pokemon.isFainted()).toBe(true);
expect(enemy.isFainted()).toBe(false);
expect(pokemon.waveData.abilitiesApplied).not.contain(AbilityId.QUICK_DRAW);
},
);
expect(pokemon.isFainted()).toBe(true);
expect(enemy.isFainted()).toBe(false);
expect(pokemon.waveData.abilitiesApplied).not.contain(AbilityId.QUICK_DRAW);
});
test("does not increase priority", async () => {
it("does not increase priority", async () => {
game.override.enemyMoveset([MoveId.EXTREME_SPEED]);
await game.classicMode.startBattle();
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const pokemon = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();

View File

@ -1,7 +1,6 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -40,56 +39,41 @@ describe("Abilities - Stall", () => {
it("Pokemon with Stall should move last in its priority bracket regardless of speed", async () => {
await game.classicMode.startBattle([SpeciesId.SHUCKLE]);
const playerIndex = game.field.getPlayerPokemon().getBattlerIndex();
const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.QUICK_ATTACK);
await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.to("MoveEndPhase", false);
// The player Pokemon (without Stall) goes first despite having lower speed than the opponent.
// The opponent Pokemon (with Stall) goes last despite having higher speed than the player Pokemon.
expect(speedOrder).toEqual([enemyIndex, playerIndex]);
expect(commandOrder).toEqual([playerIndex, enemyIndex]);
expect(player).toHaveFullHp();
});
it("Pokemon with Stall will go first if a move that is in a higher priority bracket than the opponent's move is used", async () => {
await game.classicMode.startBattle([SpeciesId.SHUCKLE]);
const playerIndex = game.field.getPlayerPokemon().getBattlerIndex();
const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.to("MoveEndPhase", false);
// The opponent Pokemon (with Stall) goes first because its move is still within a higher priority bracket than its opponent.
// The player Pokemon goes second because its move is in a lower priority bracket.
expect(speedOrder).toEqual([enemyIndex, playerIndex]);
expect(commandOrder).toEqual([enemyIndex, playerIndex]);
expect(player).not.toHaveFullHp();
});
it("If both Pokemon have stall and use the same move, speed is used to determine who goes first.", async () => {
game.override.ability(AbilityId.STALL);
await game.classicMode.startBattle([SpeciesId.SHUCKLE]);
const playerIndex = game.field.getPlayerPokemon().getBattlerIndex();
const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.to("MoveEndPhase", false);
// The opponent Pokemon (with Stall) goes first because it has a higher speed.
// The player Pokemon (with Stall) goes second because its speed is lower.
expect(speedOrder).toEqual([enemyIndex, playerIndex]);
expect(commandOrder).toEqual([enemyIndex, playerIndex]);
expect(player).not.toHaveFullHp();
});
});

View File

@ -1,7 +1,8 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import type { TurnStartPhase } from "#phases/turn-start-phase";
import type { MovePhase } from "#phases/move-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -34,38 +35,34 @@ describe("Battle order", () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const playerPokemon = game.field.getPlayerPokemon();
const playerStartHp = playerPokemon.hp;
const enemyPokemon = game.field.getEnemyPokemon();
const enemyStartHp = enemyPokemon.hp;
vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set playerPokemon's speed to 50
vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("TurnStartPhase", false);
const playerPokemonIndex = playerPokemon.getBattlerIndex();
const enemyPokemonIndex = enemyPokemon.getBattlerIndex();
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const order = phase.getCommandOrder();
expect(order[0]).toBe(enemyPokemonIndex);
expect(order[1]).toBe(playerPokemonIndex);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(playerPokemon.hp).not.toEqual(playerStartHp);
expect(enemyPokemon.hp).toEqual(enemyStartHp);
});
it("Player faster than opponent 150 vs 50", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const playerPokemon = game.field.getPlayerPokemon();
const playerStartHp = playerPokemon.hp;
const enemyPokemon = game.field.getEnemyPokemon();
const enemyStartHp = enemyPokemon.hp;
vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set playerPokemon's speed to 150
vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set enemyPokemon's speed to 50
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("TurnStartPhase", false);
const playerPokemonIndex = playerPokemon.getBattlerIndex();
const enemyPokemonIndex = enemyPokemon.getBattlerIndex();
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const order = phase.getCommandOrder();
expect(order[0]).toBe(playerPokemonIndex);
expect(order[1]).toBe(enemyPokemonIndex);
await game.phaseInterceptor.to("MoveEndPhase", false);
expect(playerPokemon.hp).toEqual(playerStartHp);
expect(enemyPokemon.hp).not.toEqual(enemyStartHp);
});
it("double - both opponents faster than player 50/50 vs 150/150", async () => {
@ -73,23 +70,24 @@ describe("Battle order", () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.BLASTOISE]);
const playerPokemon = game.scene.getPlayerField();
const playerHps = playerPokemon.map(p => p.hp);
const enemyPokemon = game.scene.getEnemyField();
const enemyHps = enemyPokemon.map(p => p.hp);
playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50])); // set both playerPokemons' speed to 50
enemyPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150])); // set both enemyPokemons' speed to 150
const playerIndices = playerPokemon.map(p => p?.getBattlerIndex());
const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex());
game.move.select(MoveId.TACKLE);
game.move.select(MoveId.TACKLE, 1);
await game.phaseInterceptor.to("TurnStartPhase", false);
await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER);
await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const order = phase.getCommandOrder();
expect(order.slice(0, 2).includes(enemyIndices[0])).toBe(true);
expect(order.slice(0, 2).includes(enemyIndices[1])).toBe(true);
expect(order.slice(2, 4).includes(playerIndices[0])).toBe(true);
expect(order.slice(2, 4).includes(playerIndices[1])).toBe(true);
await game.phaseInterceptor.to("MoveEndPhase", true);
await game.phaseInterceptor.to("MoveEndPhase", false);
for (let i = 0; i < 2; i++) {
expect(playerPokemon[i].hp).not.toEqual(playerHps[i]);
expect(enemyPokemon[i].hp).toEqual(enemyHps[i]);
}
});
it("double - speed tie except 1 - 100/100 vs 100/150", async () => {
@ -101,18 +99,13 @@ describe("Battle order", () => {
playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100])); //set both playerPokemons' speed to 100
vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set enemyPokemon's speed to 100
vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150
const playerIndices = playerPokemon.map(p => p?.getBattlerIndex());
const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex());
game.move.select(MoveId.TACKLE);
game.move.select(MoveId.TACKLE, 1);
await game.phaseInterceptor.to("TurnStartPhase", false);
await game.phaseInterceptor.to("MovePhase", false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const order = phase.getCommandOrder();
// enemy 2 should be first, followed by some other assortment of the other 3 pokemon
expect(order[0]).toBe(enemyIndices[1]);
expect(order.slice(1, 4)).toEqual(expect.arrayContaining([enemyIndices[0], ...playerIndices]));
const phase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(phase.pokemon).toEqual(enemyPokemon[1]);
});
it("double - speed tie 100/150 vs 100/150", async () => {
@ -125,17 +118,13 @@ describe("Battle order", () => {
vi.spyOn(playerPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other playerPokemon's speed to 150
vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set one enemyPokemon's speed to 100
vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other enemyPokemon's speed to 150
const playerIndices = playerPokemon.map(p => p?.getBattlerIndex());
const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex());
game.move.select(MoveId.TACKLE);
game.move.select(MoveId.TACKLE, 1);
await game.phaseInterceptor.to("TurnStartPhase", false);
const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase;
const order = phase.getCommandOrder();
// P2/E2 should be randomly first/second, then P1/E1 randomly 3rd/4th
expect(order.slice(0, 2)).toStrictEqual(expect.arrayContaining([playerIndices[1], enemyIndices[1]]));
expect(order.slice(2, 4)).toStrictEqual(expect.arrayContaining([playerIndices[0], enemyIndices[0]]));
await game.phaseInterceptor.to("MovePhase", false);
const phase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(enemyPokemon[1] === phase.pokemon || playerPokemon[1] === phase.pokemon);
});
});

View File

@ -76,12 +76,7 @@ describe("Moves - Baton Pass", () => {
expect(game.field.getEnemyPokemon().getStatStage(Stat.SPATK)).toEqual(2);
// confirm that a switch actually happened. can't use species because I
// can't find a way to override trainer parties with more than 1 pokemon species
expect(game.phaseInterceptor.log.slice(-4)).toEqual([
"MoveEffectPhase",
"SwitchSummonPhase",
"SummonPhase",
"PostSummonPhase",
]);
expect(game.field.getEnemyPokemon().summonData.moveHistory).toHaveLength(0);
});
it("doesn't transfer effects that aren't transferrable", async () => {

View File

@ -193,7 +193,7 @@ describe("Moves - Delayed Attacks", () => {
// All attacks have concluded at this point, unshifting new `MoveEffectPhase`s to the queue.
expectFutureSightActive(0);
const MEPs = game.scene.phaseManager.phaseQueue.filter(p => p.is("MoveEffectPhase"));
const MEPs = game.scene.phaseManager["phaseQueue"].findAll("MoveEffectPhase");
expect(MEPs).toHaveLength(4);
expect(MEPs.map(mep => mep.getPokemon())).toEqual(oldOrder);
});

View File

@ -3,7 +3,6 @@ import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { BerryPhase } from "#phases/berry-phase";
import { MessagePhase } from "#phases/message-phase";
import { MoveHeaderPhase } from "#phases/move-header-phase";
import { SwitchSummonPhase } from "#phases/switch-summon-phase";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { GameManager } from "#test/test-utils/game-manager";
@ -116,7 +115,7 @@ describe("Moves - Focus Punch", () => {
await game.phaseInterceptor.to(TurnStartPhase);
expect(game.scene.phaseManager.getCurrentPhase() instanceof SwitchSummonPhase).toBeTruthy();
expect(game.scene.phaseManager.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined();
expect(game.scene.phaseManager.hasPhaseOfType("MoveHeaderPhase")).toBe(true);
});
it("should replace the 'but it failed' text when the user gets hit", async () => {
game.override.enemyMoveset([MoveId.TACKLE]);

View File

@ -166,7 +166,6 @@ describe("Moves - Rage Fist", () => {
// Charizard hit
game.move.select(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(getPartyHitCount()).toEqual([1, 0]);

View File

@ -119,17 +119,16 @@ describe("Moves - Revival Blessing", () => {
game.override
.battleStyle("double")
.enemyMoveset([MoveId.REVIVAL_BLESSING])
.moveset([MoveId.SPLASH])
.moveset([MoveId.SPLASH, MoveId.JUDGMENT])
.startingLevel(100)
.startingWave(25); // 2nd rival battle - must have 3+ pokemon
await game.classicMode.startBattle([SpeciesId.ARCEUS, SpeciesId.GIRATINA]);
const enemyFainting = game.scene.getEnemyField()[0];
game.move.select(MoveId.SPLASH, 0);
game.move.use(MoveId.JUDGMENT, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.SPLASH, 1);
await game.killPokemon(enemyFainting);
await game.phaseInterceptor.to("BerryPhase");
await game.toNextTurn();
// If there are incorrectly two switch phases into this slot, the fainted pokemon will end up in slot 3
// Make sure it's still in slot 1

View File

@ -48,7 +48,7 @@ describe("Moves - Shell Trap", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]);
await game.phaseInterceptor.to(MoveEndPhase);
await game.phaseInterceptor.to("MoveEndPhase");
const movePhase = game.scene.phaseManager.getCurrentPhase();
expect(movePhase instanceof MovePhase).toBeTruthy();

View File

@ -5,10 +5,10 @@ import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Move - Trick Room", () => {
let phaserGame: Phaser.Game;
@ -56,13 +56,11 @@ describe("Move - Trick Room", () => {
turnCount: 4, // The 5 turn limit _includes_ the current turn!
});
// Now, check that speed was indeed reduced
const turnOrderSpy = vi.spyOn(TurnStartPhase.prototype, "getSpeedOrder");
game.move.use(MoveId.SPLASH);
game.move.use(MoveId.SUNNY_DAY);
await game.move.forceEnemyMove(MoveId.RAIN_DANCE);
await game.toEndOfTurn();
expect(turnOrderSpy).toHaveLastReturnedWith([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
expect(game.scene.arena.getWeatherType()).toBe(WeatherType.SUNNY);
});
it("should be removed when overlapped", async () => {

View File

@ -135,7 +135,7 @@ describe("Move - Wish", () => {
// all wishes have activated and added healing phases
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase"));
const healPhases = game.scene.phaseManager["phaseQueue"].findAll("PokemonHealPhase");
expect(healPhases).toHaveLength(4);
expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder);

View File

@ -70,7 +70,6 @@ export async function runMysteryEncounterToEnd(
// If a battle is started, fast forward to end of the battle
game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => {
game.scene.phaseManager.clearPhaseQueue();
game.scene.phaseManager.clearPhaseQueueSplice();
game.scene.phaseManager.unshiftPhase(new VictoryPhase(0));
game.endPhase();
});
@ -196,7 +195,6 @@ async function handleSecondaryOptionSelect(game: GameManager, pokemonNo: number,
*/
export async function skipBattleRunMysteryEncounterRewardsPhase(game: GameManager, runRewardsPhase = true) {
game.scene.phaseManager.clearPhaseQueue();
game.scene.phaseManager.clearPhaseQueueSplice();
game.scene.getEnemyParty().forEach(p => {
p.hp = 0;
p.status = new Status(StatusEffect.FAINT);

View File

@ -355,7 +355,6 @@ describe("The Winstrate Challenge - Mystery Encounter", () => {
*/
async function skipBattleToNextBattle(game: GameManager, isFinalBattle = false) {
game.scene.phaseManager.clearPhaseQueue();
game.scene.phaseManager.clearPhaseQueueSplice();
const commandUiHandler = game.scene.ui.handlers[UiMode.COMMAND];
commandUiHandler.clear();
game.scene.getEnemyParty().forEach(p => {

View File

@ -468,6 +468,9 @@ export class GameManager {
* Faint a player or enemy pokemon instantly by setting their HP to 0.
* @param pokemon - The player/enemy pokemon being fainted
* @returns A Promise that resolves once the fainted pokemon's FaintPhase finishes running.
* @remarks
* This method *pushes* a FaintPhase and runs until it's finished. This may cause a turn to play out unexpectedly
* @todo Consider whether running the faint phase immediately can be done
*/
async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) {
pokemon.hp = 0;
@ -537,7 +540,7 @@ export class GameManager {
}
/**
* Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. \
* Modifies the queue manager to return move phases in a particular order.
* Used to manually modify Pokemon turn order.
*
* @param order - The turn order to set, as an array of {@linkcode BattlerIndex}es
@ -551,7 +554,7 @@ export class GameManager {
async setTurnOrder(order: BattlerIndex[]): Promise<void> {
await this.phaseInterceptor.to("TurnStartPhase", false);
vi.spyOn(this.scene.phaseManager.getCurrentPhase() as TurnStartPhase, "getSpeedOrder").mockReturnValue(order);
this.scene.phaseManager.dynamicQueueManager.setMoveOrder(order);
}
/**