diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 8ae2be5af43..bffbf982569 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -184,6 +184,10 @@ import { HideAbilityPhase } from "#app/phases/hide-ability-phase"; import { expSpriteKeys } from "./sprites/sprite-keys"; import { hasExpSprite } from "./sprites/sprite-utils"; import { timedEventManager } from "./global-event-manager"; +import { type PhasePriorityQueue, PostSummonPhasePriorityQueue } from "#app/data/phase-priority-queue"; +import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; +import { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { ActivatePriorityQueuePhase } from "#app/phases/activate-priority-queue-phase"; export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1"; @@ -314,6 +318,10 @@ export default class BattleScene extends SceneBase { /** overrides default of inserting phases to end of phaseQueuePrepend array, useful or inserting Phases "out of order" */ private phaseQueuePrependSpliceIndex: number; private nextCommandPhaseQueue: Phase[]; + /** Storage for {@linkcode PhasePriorityQueue}s which hold phases whose order dynamically changes */ + private dynamicPhaseQueues: PhasePriorityQueue[]; + /** Parallel array to {@linkcode dynamicPhaseQueues} - matches phase types to their queues */ + private dynamicPhaseTypes: Constructor[]; private currentPhase: Phase | null; private standbyPhase: Phase | null; @@ -409,6 +417,8 @@ export default class BattleScene extends SceneBase { this.conditionalQueue = []; this.phaseQueuePrependSpliceIndex = -1; this.nextCommandPhaseQueue = []; + this.dynamicPhaseQueues = [new PostSummonPhasePriorityQueue()]; + this.dynamicPhaseTypes = [PostSummonPhase]; this.eventManager = new TimedEventManager(); this.updateGameInfo(); initGlobalScene(this); @@ -2635,12 +2645,16 @@ export default class BattleScene extends SceneBase { } /** - * Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false + * Adds a phase to the end of the appropriate queue (dynamic or {@linkcode phaseQueue} / {@linkcode nextCommandPhaseQueue}) * @param phase {@linkcode Phase} the phase to add - * @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue + * @param defer If `true`, add to {@linkcode nextCommandPhaseQueue} instead of {@linkcode phaseQueue} */ pushPhase(phase: Phase, defer = false): void { - (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); + if (this.getDynamicPhaseType(phase) !== undefined) { + this.pushDynamicPhase(phase); + } else { + (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); + } } /** @@ -2669,6 +2683,7 @@ export default class BattleScene extends SceneBase { for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue, this.nextCommandPhaseQueue]) { queue.splice(0, queue.length); } + this.dynamicPhaseQueues.forEach(queue => queue.clear()); this.currentPhase = null; this.standbyPhase = null; this.clearPhaseQueueSplice(); @@ -2719,8 +2734,9 @@ export default class BattleScene extends SceneBase { this.currentPhase = this.phaseQueue.shift() ?? null; + const unactivatedConditionalPhases: [() => boolean, Phase][] = []; // Check if there are any conditional phases queued - if (this.conditionalQueue?.length) { + while (this.conditionalQueue?.length) { // Retrieve the first conditional phase from the queue const conditionalPhase = this.conditionalQueue.shift(); // Evaluate the condition associated with the phase @@ -2729,11 +2745,12 @@ export default class BattleScene extends SceneBase { this.pushPhase(conditionalPhase[1]); } else if (conditionalPhase) { // If the condition is not met, re-add the phase back to the front of the conditional queue - this.conditionalQueue.unshift(conditionalPhase); + unactivatedConditionalPhases.push(conditionalPhase); } else { console.warn("condition phase is undefined/null!", conditionalPhase); } } + this.conditionalQueue.push(...unactivatedConditionalPhases); if (this.currentPhase) { console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); @@ -2819,13 +2836,14 @@ export default class BattleScene extends SceneBase { * 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: Constructor): boolean { + appendToPhase(phase: Phase | Phase[], targetPhase: Constructor, condition?: (p: Phase) => boolean): boolean { if (!Array.isArray(phase)) { phase = [phase]; } - const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase); + const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase && (!condition || condition(ph))); if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) { this.phaseQueue.splice(targetIndex + 1, 0, ...phase); @@ -2835,6 +2853,68 @@ export default class BattleScene extends SceneBase { return false; } + /** + * Checks a phase and returns the matching {@linkcode DynamicPhaseType}, or undefined if it does not match one + * @param phase The phase to check + * @returns The corresponding {@linkcode DynamicPhaseType} or `undefined` + */ + public getDynamicPhaseType(phase: Phase | null): DynamicPhaseType | undefined { + let phaseType: DynamicPhaseType | undefined; + this.dynamicPhaseTypes.forEach((cls, index) => { + if (phase instanceof cls) { + phaseType = index; + } + }); + + return phaseType; + } + + /** + * Pushes a phase onto its corresponding dynamic queue and marks the activation point in {@linkcode phaseQueue} + * + * The {@linkcode ActivatePriorityQueuePhase} will run the top phase in the dynamic queue (not necessarily {@linkcode phase}) + * @param phase The phase to push + */ + public pushDynamicPhase(phase: Phase): void { + const type = this.getDynamicPhaseType(phase); + if (type === undefined) { + return; + } + + this.pushPhase(new ActivatePriorityQueuePhase(type)); + this.dynamicPhaseQueues[type].push(phase); + } + + /** + * Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue} + * @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start + */ + public startDynamicPhaseType(type: DynamicPhaseType): void { + const phase = this.dynamicPhaseQueues[type].pop(); + if (phase) { + this.unshiftPhase(phase); + } + } + + /** + * Unshifts an {@linkcode ActivatePriorityQueuePhase} for {@linkcode phase}, then pushes {@linkcode phase} to its dynamic queue + * + * This is the same as {@linkcode pushDynamicPhase}, except the activation phase is unshifted + * + * {@linkcode phase} is not guaranteed to be the next phase from the queue to run (if the queue is not empty) + * @param phase The phase to add + * @returns + */ + public startDynamicPhase(phase: Phase): void { + const type = this.getDynamicPhaseType(phase); + if (type === undefined) { + return; + } + + this.unshiftPhase(new ActivatePriorityQueuePhase(type)); + this.dynamicPhaseQueues[type].push(phase); + } + /** * Adds a MessagePhase, either to PhaseQueuePrepend or nextCommandPhaseQueue * @param message string for MessagePhase diff --git a/src/data/ability.ts b/src/data/ability.ts index b07f13c18e9..264487e8961 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -54,6 +54,7 @@ export class Ability implements Localizable { public name: string; public description: string; public generation: number; + public postSummonPriority: number; public isBypassFaint: boolean; public isIgnorable: boolean; public isSuppressable = true; @@ -62,11 +63,12 @@ export class Ability implements Localizable { public attrs: AbAttr[]; public conditions: AbAttrCondition[]; - constructor(id: Abilities, generation: number) { + constructor(id: Abilities, generation: number, postSummonPriority: number = 0) { this.id = id; this.nameAppend = ""; this.generation = generation; + this.postSummonPriority = postSummonPriority; this.attrs = []; this.conditions = []; @@ -5421,8 +5423,8 @@ function applySingleAbAttrs( applyFunc: AbAttrApplyFunc, successFunc: AbAttrSuccessFunc, args: any[], - gainedMidTurn: boolean = false, - simulated: boolean = false, + gainedMidTurn = false, + simulated = false, messages: string[] = [] ) { if (!pokemon?.canApplyAbility(passive) || (passive && (pokemon.getPassiveAbility().id === pokemon.getAbility().id))) { @@ -5721,6 +5723,18 @@ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { this.helper.switchOutLogic(pokemon); } } + +/** + * Main Function for handling ability application. Applies both the normal ability and passive of the Pokemon + * @param attrType The type of {@linkcode AbAttr} to apply + * @param pokemon The {@linkcode Pokemon} whose abilities should be applied + * @param applyFunc The {@linkcode AbAttrApplyFunc} corresponding to {@linkcode attrType} + * @param args Extra arguments, handled by individual {@linkcode AbAttr}s + * @param showAbilityInstant If `true`, show the ability bar instantly instead of queuing it + * @param simulated `true` if the call is simulated and the battle state should not be changes + * @param messages Array of messages which will be added to if the ability displays a message + * @param gainedMidTurn `true` if the ability is activating because it was gained during the battle + */ function applyAbAttrsInternal( attrType: Constructor, pokemon: Pokemon | null, @@ -5729,7 +5743,7 @@ function applyAbAttrsInternal( args: any[], simulated: boolean = false, messages: string[] = [], - gainedMidTurn = false + gainedMidTurn = false, ) { for (const passive of [ false, true ]) { if (pokemon) { @@ -6004,16 +6018,19 @@ export function applyPostVictoryAbAttrs( export function applyPostSummonAbAttrs( attrType: Constructor, pokemon: Pokemon, + passive = false, simulated = false, ...args: any[] ): void { - applyAbAttrsInternal( - attrType, + applySingleAbAttrs( pokemon, + passive, + attrType, (attr, passive) => attr.applyPostSummon(pokemon, passive, simulated, args), (attr, passive) => attr.canApplyPostSummon(pokemon, passive, simulated, args), args, - simulated, + false, + simulated ); } @@ -6527,7 +6544,7 @@ export function initAbilities() { .conditionalAttr(p => globalScene.currentBattle.double && [ Abilities.PLUS, Abilities.MINUS ].some(a => (p.getAlly()?.hasAbility(a) ?? false)), StatMultiplierAbAttr, Stat.SPATK, 1.5), new Ability(Abilities.MINUS, 3) .conditionalAttr(p => globalScene.currentBattle.double && [ Abilities.PLUS, Abilities.MINUS ].some(a => (p.getAlly()?.hasAbility(a) ?? false)), StatMultiplierAbAttr, Stat.SPATK, 1.5), - new Ability(Abilities.FORECAST, 3) + new Ability(Abilities.FORECAST, 3, -2) .uncopiable() .unreplaceable() .attr(NoFusionAbilityAbAttr) @@ -6668,7 +6685,7 @@ export function initAbilities() { .attr(StatusEffectImmunityAbAttr) .condition(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN)) .ignorable(), - new Ability(Abilities.KLUTZ, 4) + new Ability(Abilities.KLUTZ, 4, 1) .unimplemented(), new Ability(Abilities.MOLD_BREAKER, 4) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonMoldBreaker", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) @@ -6720,7 +6737,7 @@ export function initAbilities() { .uncopiable() .unsuppressable() .unreplaceable(), - new Ability(Abilities.FLOWER_GIFT, 4) + new Ability(Abilities.FLOWER_GIFT, 4, -2) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY || WeatherType.HARSH_SUN), StatMultiplierAbAttr, Stat.ATK, 1.5) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY || WeatherType.HARSH_SUN), StatMultiplierAbAttr, Stat.SPDEF, 1.5) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY || WeatherType.HARSH_SUN), AllyStatMultiplierAbAttr, Stat.ATK, 1.5) @@ -6742,7 +6759,7 @@ export function initAbilities() { new Ability(Abilities.CONTRARY, 5) .attr(StatStageChangeMultiplierAbAttr, -1) .ignorable(), - new Ability(Abilities.UNNERVE, 5) + new Ability(Abilities.UNNERVE, 5, 1) .attr(PreventBerryUseAbAttr), new Ability(Abilities.DEFIANT, 5) .attr(PostStatStageChangeStatStageChangeAbAttr, (target, statsChanged, stages) => stages < 0, [ Stat.ATK ], 2), @@ -6976,7 +6993,7 @@ export function initAbilities() { .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(Abilities.MERCILESS, 7) .attr(ConditionalCritAbAttr, (user, target, move) => target?.status?.effect === StatusEffect.TOXIC || target?.status?.effect === StatusEffect.POISON), - new Ability(Abilities.SHIELDS_DOWN, 7) + new Ability(Abilities.SHIELDS_DOWN, 7, -1) .attr(PostBattleInitFormChangeAbAttr, () => 0) .attr(PostSummonFormChangeAbAttr, p => p.formIndex % 7 + (p.getHpRatio() <= 0.5 ? 7 : 0)) .attr(PostTurnFormChangeAbAttr, p => p.formIndex % 7 + (p.getHpRatio() <= 0.5 ? 7 : 0)) @@ -7014,7 +7031,7 @@ export function initAbilities() { .attr(MoveTypeChangeAbAttr, PokemonType.ELECTRIC, 1.2, (user, target, move) => move.type === PokemonType.NORMAL && !move.hasAttr(VariableMoveTypeAttr)), new Ability(Abilities.SURGE_SURFER, 7) .conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), StatMultiplierAbAttr, Stat.SPD, 2), - new Ability(Abilities.SCHOOLING, 7) + new Ability(Abilities.SCHOOLING, 7, -1) .attr(PostBattleInitFormChangeAbAttr, () => 0) .attr(PostSummonFormChangeAbAttr, p => p.level < 20 || p.getHpRatio() <= 0.25 ? 0 : 1) .attr(PostTurnFormChangeAbAttr, p => p.level < 20 || p.getHpRatio() <= 0.25 ? 0 : 1) @@ -7183,7 +7200,7 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.RIPEN, 8) .attr(DoubleBerryEffectAbAttr), - new Ability(Abilities.ICE_FACE, 8) + new Ability(Abilities.ICE_FACE, 8, -2) .attr(NoTransformAbilityAbAttr) .attr(NoFusionAbilityAbAttr) // Add BattlerTagType.ICE_FACE if the pokemon is in ice face form @@ -7203,7 +7220,7 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.POWER_SPOT, 8) .attr(AllyMoveCategoryPowerBoostAbAttr, [ MoveCategory.SPECIAL, MoveCategory.PHYSICAL ], 1.3), - new Ability(Abilities.MIMICRY, 8) + new Ability(Abilities.MIMICRY, 8, -1) .attr(TerrainEventTypeChangeAbAttr), new Ability(Abilities.SCREEN_CLEANER, 8) .attr(PostSummonRemoveArenaTagAbAttr, [ ArenaTagType.AURORA_VEIL, ArenaTagType.LIGHT_SCREEN, ArenaTagType.REFLECT ]), @@ -7218,7 +7235,7 @@ export function initAbilities() { .edgeCase(), // interacts incorrectly with rock head. It's meant to switch abilities before recoil would apply so that a pokemon with rock head would lose rock head first and still take the recoil new Ability(Abilities.GORILLA_TACTICS, 8) .attr(GorillaTacticsAbAttr), - new Ability(Abilities.NEUTRALIZING_GAS, 8) + new Ability(Abilities.NEUTRALIZING_GAS, 8, 2) .attr(PostSummonAddArenaTagAbAttr, true, ArenaTagType.NEUTRALIZING_GAS, 0) .attr(PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr) .uncopiable() @@ -7250,14 +7267,14 @@ export function initAbilities() { .attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1), new Ability(Abilities.GRIM_NEIGH, 8) .attr(PostVictoryStatStageChangeAbAttr, Stat.SPATK, 1), - new Ability(Abilities.AS_ONE_GLASTRIER, 8) + new Ability(Abilities.AS_ONE_GLASTRIER, 8, 1) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAsOneGlastrier", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(PreventBerryUseAbAttr) .attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1) .uncopiable() .unreplaceable() .unsuppressable(), - new Ability(Abilities.AS_ONE_SPECTRIER, 8) + new Ability(Abilities.AS_ONE_SPECTRIER, 8, 1) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAsOneSpectrier", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(PreventBerryUseAbAttr) .attr(PostVictoryStatStageChangeAbAttr, Stat.SPATK, 1) @@ -7315,12 +7332,12 @@ export function initAbilities() { .edgeCase(), // Encore, Frenzy, and other non-`TURN_END` tags don't lapse correctly on the commanding Pokemon. new Ability(Abilities.ELECTROMORPHOSIS, 9) .attr(PostDefendApplyBattlerTagAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, BattlerTagType.CHARGED), - new Ability(Abilities.PROTOSYNTHESIS, 9) + new Ability(Abilities.PROTOSYNTHESIS, 9, -2) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN), PostSummonAddBattlerTagAbAttr, BattlerTagType.PROTOSYNTHESIS, 0, true) .attr(PostWeatherChangeAddBattlerTagAttr, BattlerTagType.PROTOSYNTHESIS, 0, WeatherType.SUNNY, WeatherType.HARSH_SUN) .uncopiable() .attr(NoTransformAbilityAbAttr), - new Ability(Abilities.QUARK_DRIVE, 9) + new Ability(Abilities.QUARK_DRIVE, 9, -2) .conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), PostSummonAddBattlerTagAbAttr, BattlerTagType.QUARK_DRIVE, 0, true) .attr(PostTerrainChangeAddBattlerTagAttr, BattlerTagType.QUARK_DRIVE, 0, TerrainType.ELECTRIC) .uncopiable() @@ -7359,7 +7376,7 @@ export function initAbilities() { new Ability(Abilities.SUPREME_OVERLORD, 9) .attr(VariableMovePowerBoostAbAttr, (user, target, move) => 1 + 0.1 * Math.min(user.isPlayer() ? globalScene.arena.playerFaints : globalScene.currentBattle.enemyFaints, 5)) .partial(), // Should only boost once, on summon - new Ability(Abilities.COSTAR, 9) + new Ability(Abilities.COSTAR, 9, -2) .attr(PostSummonCopyAllyStatsAbAttr), new Ability(Abilities.TOXIC_DEBRIS, 9) .attr(PostDefendApplyArenaTrapTagAbAttr, (target, user, move) => move.category === MoveCategory.PHYSICAL, ArenaTagType.TOXIC_SPIKES) @@ -7381,7 +7398,7 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.SUPERSWEET_SYRUP, 9) .attr(PostSummonStatStageChangeAbAttr, [ Stat.EVA ], -1), - new Ability(Abilities.HOSPITALITY, 9) + new Ability(Abilities.HOSPITALITY, 9, -2) .attr(PostSummonAllyHealAbAttr, 4, true), new Ability(Abilities.TOXIC_CHAIN, 9) .attr(PostAttackApplyStatusEffectAbAttr, false, 30, StatusEffect.TOXIC), @@ -7405,7 +7422,7 @@ export function initAbilities() { .uncopiable() .unreplaceable() .attr(NoTransformAbilityAbAttr), - new Ability(Abilities.TERA_SHIFT, 9) + new Ability(Abilities.TERA_SHIFT, 9, 2) .attr(PostSummonFormChangeAbAttr, p => p.getFormKey() ? 0 : 1) .uncopiable() .unreplaceable() diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts new file mode 100644 index 00000000000..dfdd60c75f6 --- /dev/null +++ b/src/data/phase-priority-queue.ts @@ -0,0 +1,97 @@ +import { globalScene } from "#app/global-scene"; +import type { Phase } from "#app/phase"; +import { ActivatePriorityQueuePhase } from "#app/phases/activate-priority-queue-phase"; +import type { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { PostSummonActivateAbilityPhase } from "#app/phases/post-summon-activate-ability-phase"; +import { Stat } from "#enums/stat"; +import { BooleanHolder } from "#app/utils"; +import { TrickRoomTag } from "#app/data/arena-tag"; +import { DynamicPhaseType } from "#enums/dynamic-phase-type"; + +/** + * Stores a list of {@linkcode Phase}s + * + * Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder} + */ +export abstract class PhasePriorityQueue { + protected abstract queue: Phase[]; + + /** + * Sorts the elements in the queue + */ + public abstract reorder(): void; + + /** + * Calls {@linkcode reorder} and shifts the queue + * @returns The front element of the queue after sorting + */ + public pop(): Phase | undefined { + this.reorder(); + return this.queue.shift(); + } + + /** + * Adds a phase to the queue + * @param phase The phase to add + */ + public push(phase: Phase): void { + this.queue.push(phase); + } + + /** + * Removes all phases from the queue + */ + public clear(): void { + this.queue.splice(0, this.queue.length); + } +} + +/** + * Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase} + * + * Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed + */ +export class PostSummonPhasePriorityQueue extends PhasePriorityQueue { + protected override queue: PostSummonPhase[] = []; + + public override reorder(): void { + this.queue.sort((phaseA: PostSummonPhase, phaseB: PostSummonPhase) => { + if (phaseA.getPriority() === phaseB.getPriority()) { + return ( + (phaseB.getPokemon().getEffectiveStat(Stat.SPD) - phaseA.getPokemon().getEffectiveStat(Stat.SPD)) * + (isTrickRoom() ? -1 : 1) + ); + } + + return phaseB.getPriority() - phaseA.getPriority(); + }); + } + + public override push(phase: PostSummonPhase): void { + super.push(phase); + this.queueAbilityPhase(phase); + } + + /** + * Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase} + * @param phase The {@linkcode PostSummonPhase} that was pushed onto the queue + */ + private queueAbilityPhase(phase: PostSummonPhase): void { + const phasePokemon = phase.getPokemon(); + + phasePokemon.getAbilityPriorities().forEach((priority, idx) => { + this.queue.push(new PostSummonActivateAbilityPhase(phasePokemon.getBattlerIndex(), priority, !!idx)); + globalScene.appendToPhase( + new ActivatePriorityQueuePhase(DynamicPhaseType.POST_SUMMON), + ActivatePriorityQueuePhase, + (p: ActivatePriorityQueuePhase) => p.getType() === DynamicPhaseType.POST_SUMMON, + ); + }); + } +} + +function isTrickRoom(): boolean { + const speedReversed = new BooleanHolder(false); + globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); + return speedReversed.value; +} diff --git a/src/enums/dynamic-phase-type.ts b/src/enums/dynamic-phase-type.ts new file mode 100644 index 00000000000..40f3f42393c --- /dev/null +++ b/src/enums/dynamic-phase-type.ts @@ -0,0 +1,7 @@ +/** + * Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue} + */ + +export enum DynamicPhaseType { + POST_SUMMON +} diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 8fc75ca657d..0ae40cd36c8 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2224,6 +2224,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return false; } + public getAbilityPriorities(): [number, number] { + return [this.getAbility().postSummonPriority, this.getPassiveAbility().postSummonPriority]; + } + /** * Gets the weight of the Pokemon with subtractive modifiers (Autotomize) happening first * and then multiplicative modifiers happening after (Heavy Metal and Light Metal) diff --git a/src/phases/activate-priority-queue-phase.ts b/src/phases/activate-priority-queue-phase.ts new file mode 100644 index 00000000000..9d1850a2451 --- /dev/null +++ b/src/phases/activate-priority-queue-phase.ts @@ -0,0 +1,22 @@ +import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; +import { globalScene } from "#app/global-scene"; +import { Phase } from "#app/phase"; + +export class ActivatePriorityQueuePhase extends Phase { + private type: DynamicPhaseType; + + constructor(type: DynamicPhaseType) { + super(); + this.type = type; + } + + override start() { + super.start(); + globalScene.startDynamicPhaseType(this.type); + this.end(); + } + + public getType(): DynamicPhaseType { + return this.type; + } +} diff --git a/src/phases/post-summon-activate-ability-phase.ts b/src/phases/post-summon-activate-ability-phase.ts new file mode 100644 index 00000000000..f8b1b91a009 --- /dev/null +++ b/src/phases/post-summon-activate-ability-phase.ts @@ -0,0 +1,28 @@ +import type { BattlerIndex } from "#app/battle"; +import { applyPostSummonAbAttrs, PostSummonAbAttr } from "#app/data/ability"; +import { PostSummonPhase } from "#app/phases/post-summon-phase"; + +/** + * Helper to {@linkcode PostSummonPhase} which applies abilities + */ + +export class PostSummonActivateAbilityPhase extends PostSummonPhase { + private priority: number; + private passive: boolean; + + constructor(battlerIndex: BattlerIndex, priority: number, passive: boolean) { + super(battlerIndex); + this.priority = priority; + this.passive = passive; + } + + start() { + applyPostSummonAbAttrs(PostSummonAbAttr, this.getPokemon(), this.passive, false); + + this.end(); + } + + public override getPriority() { + return this.priority; + } +} diff --git a/src/phases/post-summon-phase.ts b/src/phases/post-summon-phase.ts index 45b0a0f65ce..32ea8679cbd 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -1,5 +1,5 @@ import { globalScene } from "#app/global-scene"; -import { applyAbAttrs, applyPostSummonAbAttrs, CommanderAbAttr, PostSummonAbAttr } from "#app/data/ability"; +import { applyAbAttrs, CommanderAbAttr } from "#app/data/ability"; import { ArenaTrapTag } from "#app/data/arena-tag"; import { StatusEffect } from "#app/enums/status-effect"; import { PokemonPhase } from "./pokemon-phase"; @@ -25,7 +25,6 @@ export class PostSummonPhase extends PokemonPhase { pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON); } - applyPostSummonAbAttrs(PostSummonAbAttr, pokemon); const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); for (const p of field) { applyAbAttrs(CommanderAbAttr, p, null, false); @@ -33,4 +32,8 @@ export class PostSummonPhase extends PokemonPhase { this.end(); } + + public getPriority() { + return 0; + } } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index e0903ada275..0e61578d1ad 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -246,6 +246,6 @@ export class SwitchSummonPhase extends SummonPhase { } queuePostSummon(): void { - globalScene.unshiftPhase(new PostSummonPhase(this.getPokemon().getBattlerIndex())); + globalScene.startDynamicPhase(new PostSummonPhase(this.getPokemon().getBattlerIndex())); } } diff --git a/test/abilities/ability_activation_order.test.ts b/test/abilities/ability_activation_order.test.ts new file mode 100644 index 00000000000..a6fba65bec3 --- /dev/null +++ b/test/abilities/ability_activation_order.test.ts @@ -0,0 +1,96 @@ +import { PreventBerryUseAbAttr, TerrainEventTypeChangeAbAttr } from "#app/data/ability"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import { WeatherType } from "#enums/weather-type"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Ability Activation Order", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset([Moves.SPLASH]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should activate the ability of the faster Pokemon first", async () => { + game.override.enemyLevel(100).ability(Abilities.DRIZZLE).enemyAbility(Abilities.DROUGHT); + await game.classicMode.startBattle([Species.SLOWPOKE]); + + // Enemy's ability should activate first, so sun ends up replaced with rain + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.RAIN); + }); + + it("should consider base stat boosting items in determining order", async () => { + game.override + .startingLevel(25) + .enemyLevel(50) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.DROUGHT) + .ability(Abilities.DRIZZLE) + .startingHeldItems([{ name: "BASE_STAT_BOOSTER", type: Stat.SPD, count: 100 }]); + + await game.classicMode.startBattle([Species.MAGIKARP]); + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY); + }); + + it("should consider stat boosting items in determining order", async () => { + game.override + .startingLevel(35) + .enemyLevel(50) + .enemySpecies(Species.DITTO) + .enemyAbility(Abilities.DROUGHT) + .ability(Abilities.DRIZZLE) + .startingHeldItems([{ name: "SPECIES_STAT_BOOSTER", type: "QUICK_POWDER" }]); + + await game.classicMode.startBattle([Species.DITTO]); + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY); + }); + + it("should activate priority abilities first", async () => { + game.override + .startingLevel(1) + .enemyLevel(100) + .enemySpecies(Species.ACCELGOR) + .enemyAbility(Abilities.DROUGHT) + .ability(Abilities.NEUTRALIZING_GAS); + + await game.classicMode.startBattle([Species.SLOWPOKE]); + expect(game.scene.arena.weather).toBeUndefined(); + }); + + it("should update dynamically based on speed order", async () => { + game.override + .startingLevel(35) + .enemyLevel(50) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.SLOW_START) + .enemyPassiveAbility(Abilities.DROUGHT) + .ability(Abilities.DRIZZLE); + + await game.classicMode.startBattle([Species.MAGIKARP]); + // Slow start activates and makes enemy slower, so drought activates after drizzle + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY); + }); +});