[Bug] Activate PostSummon Abilities in Speed and Priority Order

https://github.com/pagefaultgames/pokerogue/pull/5513

* Add prependToPhaseWithCondition and use it in SummonPhase to determine speed order

* Move logic to PostSummonPhase

* Add test base

* Pivot to using sort strategy instead

* Add and update tests

* Support priority ability activations

* Ensure priority abilities are still activated on switch in

* Add test for priority

* Update to use priority numbers instead of a boolean

* Add ability priorities to constructors

* Move sorting to BattleScene

* Rename phase file

* Update import

* Move application to applyPostSummonAbAttrs and stop assuming no other phases in queue

* Ensure all PostSummonPhases from encounters are added at the same time

* Switch to priority queue approach

* Ensure that zero/negative priority activations happen after postsummonphase

* Revert 07646fe (not needed due to stable sort)

* Always create separate ability phases for passive and use boolean instead of priority number when applying

* Add test for dynamic updates

* Add BattlerIndex import

* Clear queues for testing

* Benjie suggestion

* Split files

* Update import in battlescene

* Remove extra spaces added by VSCode

* Fix other conflicts

* Update PhaseManager

* Update to use PhaseManager

* Immediately start postsummons

* Fix test

* Fix BattlerIndex import

* Remove unused imports

* Fix postsummon application

* Make priority readonly
This commit is contained in:
Dean 2025-06-11 22:28:27 -07:00 committed by GitHub
parent 1029afcdbf
commit ff9aefb0e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 382 additions and 30 deletions

View File

@ -86,6 +86,7 @@ export class Ability implements Localizable {
public name: string; public name: string;
public description: string; public description: string;
public generation: number; public generation: number;
public readonly postSummonPriority: number;
public isBypassFaint: boolean; public isBypassFaint: boolean;
public isIgnorable: boolean; public isIgnorable: boolean;
public isSuppressable = true; public isSuppressable = true;
@ -94,11 +95,12 @@ export class Ability implements Localizable {
public attrs: AbAttr[]; public attrs: AbAttr[];
public conditions: AbAttrCondition[]; public conditions: AbAttrCondition[];
constructor(id: AbilityId, generation: number) { constructor(id: AbilityId, generation: number, postSummonPriority = 0) {
this.id = id; this.id = id;
this.nameAppend = ""; this.nameAppend = "";
this.generation = generation; this.generation = generation;
this.postSummonPriority = postSummonPriority;
this.attrs = []; this.attrs = [];
this.conditions = []; this.conditions = [];
@ -8104,7 +8106,7 @@ export function initAbilities() {
.conditionalAttr(p => globalScene.currentBattle.double && [ AbilityId.PLUS, AbilityId.MINUS ].some(a => (p.getAlly()?.hasAbility(a) ?? false)), StatMultiplierAbAttr, Stat.SPATK, 1.5), .conditionalAttr(p => globalScene.currentBattle.double && [ AbilityId.PLUS, AbilityId.MINUS ].some(a => (p.getAlly()?.hasAbility(a) ?? false)), StatMultiplierAbAttr, Stat.SPATK, 1.5),
new Ability(AbilityId.MINUS, 3) new Ability(AbilityId.MINUS, 3)
.conditionalAttr(p => globalScene.currentBattle.double && [ AbilityId.PLUS, AbilityId.MINUS ].some(a => (p.getAlly()?.hasAbility(a) ?? false)), StatMultiplierAbAttr, Stat.SPATK, 1.5), .conditionalAttr(p => globalScene.currentBattle.double && [ AbilityId.PLUS, AbilityId.MINUS ].some(a => (p.getAlly()?.hasAbility(a) ?? false)), StatMultiplierAbAttr, Stat.SPATK, 1.5),
new Ability(AbilityId.FORECAST, 3) new Ability(AbilityId.FORECAST, 3, -2)
.uncopiable() .uncopiable()
.unreplaceable() .unreplaceable()
.attr(NoFusionAbilityAbAttr) .attr(NoFusionAbilityAbAttr)
@ -8238,7 +8240,7 @@ export function initAbilities() {
.attr(StatusEffectImmunityAbAttr) .attr(StatusEffectImmunityAbAttr)
.condition(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN)) .condition(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN))
.ignorable(), .ignorable(),
new Ability(AbilityId.KLUTZ, 4) new Ability(AbilityId.KLUTZ, 4, 1)
.unimplemented(), .unimplemented(),
new Ability(AbilityId.MOLD_BREAKER, 4) new Ability(AbilityId.MOLD_BREAKER, 4)
.attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonMoldBreaker", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonMoldBreaker", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }))
@ -8290,7 +8292,7 @@ export function initAbilities() {
.uncopiable() .uncopiable()
.unsuppressable() .unsuppressable()
.unreplaceable(), .unreplaceable(),
new Ability(AbilityId.FLOWER_GIFT, 4) new Ability(AbilityId.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.ATK, 1.5)
.conditionalAttr(getWeatherCondition(WeatherType.SUNNY || WeatherType.HARSH_SUN), StatMultiplierAbAttr, Stat.SPDEF, 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) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY || WeatherType.HARSH_SUN), AllyStatMultiplierAbAttr, Stat.ATK, 1.5)
@ -8312,7 +8314,7 @@ export function initAbilities() {
new Ability(AbilityId.CONTRARY, 5) new Ability(AbilityId.CONTRARY, 5)
.attr(StatStageChangeMultiplierAbAttr, -1) .attr(StatStageChangeMultiplierAbAttr, -1)
.ignorable(), .ignorable(),
new Ability(AbilityId.UNNERVE, 5) new Ability(AbilityId.UNNERVE, 5, 1)
.attr(PreventBerryUseAbAttr), .attr(PreventBerryUseAbAttr),
new Ability(AbilityId.DEFIANT, 5) new Ability(AbilityId.DEFIANT, 5)
.attr(PostStatStageChangeStatStageChangeAbAttr, (_target, _statsChanged, stages) => stages < 0, [ Stat.ATK ], 2), .attr(PostStatStageChangeStatStageChangeAbAttr, (_target, _statsChanged, stages) => stages < 0, [ Stat.ATK ], 2),
@ -8554,7 +8556,7 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(AbilityId.MERCILESS, 7) new Ability(AbilityId.MERCILESS, 7)
.attr(ConditionalCritAbAttr, (_user, target, _move) => target?.status?.effect === StatusEffect.TOXIC || target?.status?.effect === StatusEffect.POISON), .attr(ConditionalCritAbAttr, (_user, target, _move) => target?.status?.effect === StatusEffect.TOXIC || target?.status?.effect === StatusEffect.POISON),
new Ability(AbilityId.SHIELDS_DOWN, 7) new Ability(AbilityId.SHIELDS_DOWN, 7, -1)
.attr(PostBattleInitFormChangeAbAttr, () => 0) .attr(PostBattleInitFormChangeAbAttr, () => 0)
.attr(PostSummonFormChangeAbAttr, p => p.formIndex % 7 + (p.getHpRatio() <= 0.5 ? 7 : 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)) .attr(PostTurnFormChangeAbAttr, p => p.formIndex % 7 + (p.getHpRatio() <= 0.5 ? 7 : 0))
@ -8592,7 +8594,7 @@ export function initAbilities() {
.attr(MoveTypeChangeAbAttr, PokemonType.ELECTRIC, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL), .attr(MoveTypeChangeAbAttr, PokemonType.ELECTRIC, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL),
new Ability(AbilityId.SURGE_SURFER, 7) new Ability(AbilityId.SURGE_SURFER, 7)
.conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), StatMultiplierAbAttr, Stat.SPD, 2), .conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), StatMultiplierAbAttr, Stat.SPD, 2),
new Ability(AbilityId.SCHOOLING, 7) new Ability(AbilityId.SCHOOLING, 7, -1)
.attr(PostBattleInitFormChangeAbAttr, () => 0) .attr(PostBattleInitFormChangeAbAttr, () => 0)
.attr(PostSummonFormChangeAbAttr, p => p.level < 20 || p.getHpRatio() <= 0.25 ? 0 : 1) .attr(PostSummonFormChangeAbAttr, p => p.level < 20 || p.getHpRatio() <= 0.25 ? 0 : 1)
.attr(PostTurnFormChangeAbAttr, p => p.level < 20 || p.getHpRatio() <= 0.25 ? 0 : 1) .attr(PostTurnFormChangeAbAttr, p => p.level < 20 || p.getHpRatio() <= 0.25 ? 0 : 1)
@ -8761,7 +8763,7 @@ export function initAbilities() {
.ignorable(), .ignorable(),
new Ability(AbilityId.RIPEN, 8) new Ability(AbilityId.RIPEN, 8)
.attr(DoubleBerryEffectAbAttr), .attr(DoubleBerryEffectAbAttr),
new Ability(AbilityId.ICE_FACE, 8) new Ability(AbilityId.ICE_FACE, 8, -2)
.attr(NoTransformAbilityAbAttr) .attr(NoTransformAbilityAbAttr)
.attr(NoFusionAbilityAbAttr) .attr(NoFusionAbilityAbAttr)
// Add BattlerTagType.ICE_FACE if the pokemon is in ice face form // Add BattlerTagType.ICE_FACE if the pokemon is in ice face form
@ -8781,7 +8783,7 @@ export function initAbilities() {
.ignorable(), .ignorable(),
new Ability(AbilityId.POWER_SPOT, 8) new Ability(AbilityId.POWER_SPOT, 8)
.attr(AllyMoveCategoryPowerBoostAbAttr, [ MoveCategory.SPECIAL, MoveCategory.PHYSICAL ], 1.3), .attr(AllyMoveCategoryPowerBoostAbAttr, [ MoveCategory.SPECIAL, MoveCategory.PHYSICAL ], 1.3),
new Ability(AbilityId.MIMICRY, 8) new Ability(AbilityId.MIMICRY, 8, -1)
.attr(TerrainEventTypeChangeAbAttr), .attr(TerrainEventTypeChangeAbAttr),
new Ability(AbilityId.SCREEN_CLEANER, 8) new Ability(AbilityId.SCREEN_CLEANER, 8)
.attr(PostSummonRemoveArenaTagAbAttr, [ ArenaTagType.AURORA_VEIL, ArenaTagType.LIGHT_SCREEN, ArenaTagType.REFLECT ]), .attr(PostSummonRemoveArenaTagAbAttr, [ ArenaTagType.AURORA_VEIL, ArenaTagType.LIGHT_SCREEN, ArenaTagType.REFLECT ]),
@ -8796,7 +8798,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 .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(AbilityId.GORILLA_TACTICS, 8) new Ability(AbilityId.GORILLA_TACTICS, 8)
.attr(GorillaTacticsAbAttr), .attr(GorillaTacticsAbAttr),
new Ability(AbilityId.NEUTRALIZING_GAS, 8) new Ability(AbilityId.NEUTRALIZING_GAS, 8, 2)
.attr(PostSummonAddArenaTagAbAttr, true, ArenaTagType.NEUTRALIZING_GAS, 0) .attr(PostSummonAddArenaTagAbAttr, true, ArenaTagType.NEUTRALIZING_GAS, 0)
.attr(PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr) .attr(PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr)
.uncopiable() .uncopiable()
@ -8828,14 +8830,14 @@ export function initAbilities() {
.attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1), .attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1),
new Ability(AbilityId.GRIM_NEIGH, 8) new Ability(AbilityId.GRIM_NEIGH, 8)
.attr(PostVictoryStatStageChangeAbAttr, Stat.SPATK, 1), .attr(PostVictoryStatStageChangeAbAttr, Stat.SPATK, 1),
new Ability(AbilityId.AS_ONE_GLASTRIER, 8) new Ability(AbilityId.AS_ONE_GLASTRIER, 8, 1)
.attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAsOneGlastrier", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAsOneGlastrier", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }))
.attr(PreventBerryUseAbAttr) .attr(PreventBerryUseAbAttr)
.attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1) .attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1)
.uncopiable() .uncopiable()
.unreplaceable() .unreplaceable()
.unsuppressable(), .unsuppressable(),
new Ability(AbilityId.AS_ONE_SPECTRIER, 8) new Ability(AbilityId.AS_ONE_SPECTRIER, 8, 1)
.attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAsOneSpectrier", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAsOneSpectrier", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }))
.attr(PreventBerryUseAbAttr) .attr(PreventBerryUseAbAttr)
.attr(PostVictoryStatStageChangeAbAttr, Stat.SPATK, 1) .attr(PostVictoryStatStageChangeAbAttr, Stat.SPATK, 1)
@ -8893,12 +8895,12 @@ export function initAbilities() {
.edgeCase(), // Encore, Frenzy, and other non-`TURN_END` tags don't lapse correctly on the commanding Pokemon. .edgeCase(), // Encore, Frenzy, and other non-`TURN_END` tags don't lapse correctly on the commanding Pokemon.
new Ability(AbilityId.ELECTROMORPHOSIS, 9) new Ability(AbilityId.ELECTROMORPHOSIS, 9)
.attr(PostDefendApplyBattlerTagAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, BattlerTagType.CHARGED), .attr(PostDefendApplyBattlerTagAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, BattlerTagType.CHARGED),
new Ability(AbilityId.PROTOSYNTHESIS, 9) new Ability(AbilityId.PROTOSYNTHESIS, 9, -2)
.conditionalAttr(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN), PostSummonAddBattlerTagAbAttr, BattlerTagType.PROTOSYNTHESIS, 0, true) .conditionalAttr(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN), PostSummonAddBattlerTagAbAttr, BattlerTagType.PROTOSYNTHESIS, 0, true)
.attr(PostWeatherChangeAddBattlerTagAttr, BattlerTagType.PROTOSYNTHESIS, 0, WeatherType.SUNNY, WeatherType.HARSH_SUN) .attr(PostWeatherChangeAddBattlerTagAttr, BattlerTagType.PROTOSYNTHESIS, 0, WeatherType.SUNNY, WeatherType.HARSH_SUN)
.uncopiable() .uncopiable()
.attr(NoTransformAbilityAbAttr), .attr(NoTransformAbilityAbAttr),
new Ability(AbilityId.QUARK_DRIVE, 9) new Ability(AbilityId.QUARK_DRIVE, 9, -2)
.conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), PostSummonAddBattlerTagAbAttr, BattlerTagType.QUARK_DRIVE, 0, true) .conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), PostSummonAddBattlerTagAbAttr, BattlerTagType.QUARK_DRIVE, 0, true)
.attr(PostTerrainChangeAddBattlerTagAttr, BattlerTagType.QUARK_DRIVE, 0, TerrainType.ELECTRIC) .attr(PostTerrainChangeAddBattlerTagAttr, BattlerTagType.QUARK_DRIVE, 0, TerrainType.ELECTRIC)
.uncopiable() .uncopiable()
@ -8942,7 +8944,7 @@ export function initAbilities() {
new Ability(AbilityId.SUPREME_OVERLORD, 9) new Ability(AbilityId.SUPREME_OVERLORD, 9)
.attr(VariableMovePowerBoostAbAttr, (user, _target, _move) => 1 + 0.1 * Math.min(user.isPlayer() ? globalScene.arena.playerFaints : globalScene.currentBattle.enemyFaints, 5)) .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 .partial(), // Should only boost once, on summon
new Ability(AbilityId.COSTAR, 9) new Ability(AbilityId.COSTAR, 9, -2)
.attr(PostSummonCopyAllyStatsAbAttr), .attr(PostSummonCopyAllyStatsAbAttr),
new Ability(AbilityId.TOXIC_DEBRIS, 9) new Ability(AbilityId.TOXIC_DEBRIS, 9)
.attr(PostDefendApplyArenaTrapTagAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, ArenaTagType.TOXIC_SPIKES) .attr(PostDefendApplyArenaTrapTagAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, ArenaTagType.TOXIC_SPIKES)
@ -8964,7 +8966,7 @@ export function initAbilities() {
.ignorable(), .ignorable(),
new Ability(AbilityId.SUPERSWEET_SYRUP, 9) new Ability(AbilityId.SUPERSWEET_SYRUP, 9)
.attr(PostSummonStatStageChangeAbAttr, [ Stat.EVA ], -1), .attr(PostSummonStatStageChangeAbAttr, [ Stat.EVA ], -1),
new Ability(AbilityId.HOSPITALITY, 9) new Ability(AbilityId.HOSPITALITY, 9, -2)
.attr(PostSummonAllyHealAbAttr, 4, true), .attr(PostSummonAllyHealAbAttr, 4, true),
new Ability(AbilityId.TOXIC_CHAIN, 9) new Ability(AbilityId.TOXIC_CHAIN, 9)
.attr(PostAttackApplyStatusEffectAbAttr, false, 30, StatusEffect.TOXIC), .attr(PostAttackApplyStatusEffectAbAttr, false, 30, StatusEffect.TOXIC),
@ -8988,7 +8990,7 @@ export function initAbilities() {
.uncopiable() .uncopiable()
.unreplaceable() .unreplaceable()
.attr(NoTransformAbilityAbAttr), .attr(NoTransformAbilityAbAttr),
new Ability(AbilityId.TERA_SHIFT, 9) new Ability(AbilityId.TERA_SHIFT, 9, 2)
.attr(PostSummonFormChangeAbAttr, p => p.getFormKey() ? 0 : 1) .attr(PostSummonFormChangeAbAttr, p => p.getFormKey() ? 0 : 1)
.uncopiable() .uncopiable()
.unreplaceable() .unreplaceable()

View File

@ -470,15 +470,18 @@ export function applyPostVictoryAbAttrs<K extends AbAttrString>(
export function applyPostSummonAbAttrs<K extends AbAttrString>( export function applyPostSummonAbAttrs<K extends AbAttrString>(
attrType: AbAttrMap[K] extends PostSummonAbAttr ? K : never, attrType: AbAttrMap[K] extends PostSummonAbAttr ? K : never,
pokemon: Pokemon, pokemon: Pokemon,
passive = false,
simulated = false, simulated = false,
...args: any[] ...args: any[]
): void { ): void {
applyAbAttrsInternal( applySingleAbAttrs(
attrType,
pokemon, pokemon,
passive,
attrType,
(attr, passive) => (attr as PostSummonAbAttr).applyPostSummon(pokemon, passive, simulated, args), (attr, passive) => (attr as PostSummonAbAttr).applyPostSummon(pokemon, passive, simulated, args),
(attr, passive) => (attr as PostSummonAbAttr).canApplyPostSummon(pokemon, passive, simulated, args), (attr, passive) => (attr as PostSummonAbAttr).canApplyPostSummon(pokemon, passive, simulated, args),
args, args,
false,
simulated, simulated,
); );
} }

View File

@ -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/common";
import { TrickRoomTag } from "#app/data/arena-tag";
import { DynamicPhaseType } from "#enums/dynamic-phase-type";
/**
* Stores a list of {@linkcode Phase}s
*
* Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder}
*/
export abstract class PhasePriorityQueue {
protected abstract queue: Phase[];
/**
* Sorts the elements in the queue
*/
public abstract reorder(): void;
/**
* Calls {@linkcode reorder} and shifts the queue
* @returns The front element of the queue after sorting
*/
public pop(): Phase | undefined {
this.reorder();
return this.queue.shift();
}
/**
* Adds a phase to the queue
* @param phase The phase to add
*/
public push(phase: Phase): void {
this.queue.push(phase);
}
/**
* Removes all phases from the queue
*/
public clear(): void {
this.queue.splice(0, this.queue.length);
}
}
/**
* Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase}
*
* Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed
*/
export class PostSummonPhasePriorityQueue extends PhasePriorityQueue {
protected override queue: PostSummonPhase[] = [];
public override reorder(): void {
this.queue.sort((phaseA: PostSummonPhase, phaseB: PostSummonPhase) => {
if (phaseA.getPriority() === phaseB.getPriority()) {
return (
(phaseB.getPokemon().getEffectiveStat(Stat.SPD) - phaseA.getPokemon().getEffectiveStat(Stat.SPD)) *
(isTrickRoom() ? -1 : 1)
);
}
return phaseB.getPriority() - phaseA.getPriority();
});
}
public override push(phase: PostSummonPhase): void {
super.push(phase);
this.queueAbilityPhase(phase);
}
/**
* Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase}
* @param phase The {@linkcode PostSummonPhase} that was pushed onto the queue
*/
private queueAbilityPhase(phase: PostSummonPhase): void {
const phasePokemon = phase.getPokemon();
phasePokemon.getAbilityPriorities().forEach((priority, idx) => {
this.queue.push(new PostSummonActivateAbilityPhase(phasePokemon.getBattlerIndex(), priority, !!idx));
globalScene.phaseManager.appendToPhase(
new ActivatePriorityQueuePhase(DynamicPhaseType.POST_SUMMON),
"ActivatePriorityQueuePhase",
(p: ActivatePriorityQueuePhase) => p.getType() === DynamicPhaseType.POST_SUMMON,
);
});
}
}
function isTrickRoom(): boolean {
const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(TrickRoomTag, false, speedReversed);
return speedReversed.value;
}

View File

@ -0,0 +1,6 @@
/**
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}
*/
export enum DynamicPhaseType {
POST_SUMMON
}

View File

@ -2181,6 +2181,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return this.hasPassive() && (!canApply || this.canApplyAbility(true)) && this.getPassiveAbility().hasAttr(attrType); return this.hasPassive() && (!canApply || this.canApplyAbility(true)) && this.getPassiveAbility().hasAttr(attrType);
} }
public getAbilityPriorities(): [number, number] {
return [this.getAbility().postSummonPriority, this.getPassiveAbility().postSummonPriority];
}
/** /**
* Gets the weight of the Pokemon with subtractive modifiers (Autotomize) happening first * Gets the weight of the Pokemon with subtractive modifiers (Autotomize) happening first
* and then multiplicative modifiers happening after (Heavy Metal and Light Metal) * and then multiplicative modifiers happening after (Heavy Metal and Light Metal)

View File

@ -2,6 +2,7 @@ import type { Phase } from "#app/phase";
import type { default as Pokemon } from "#app/field/pokemon"; import type { default as Pokemon } from "#app/field/pokemon";
import type { PhaseMap, PhaseString } from "./@types/phase-types"; import type { PhaseMap, PhaseString } from "./@types/phase-types";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { ActivatePriorityQueuePhase } from "#app/phases/activate-priority-queue-phase";
import { AddEnemyBuffModifierPhase } from "#app/phases/add-enemy-buff-modifier-phase"; import { AddEnemyBuffModifierPhase } from "#app/phases/add-enemy-buff-modifier-phase";
import { AttemptCapturePhase } from "#app/phases/attempt-capture-phase"; import { AttemptCapturePhase } from "#app/phases/attempt-capture-phase";
import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; import { AttemptRunPhase } from "#app/phases/attempt-run-phase";
@ -11,7 +12,9 @@ import { CheckStatusEffectPhase } from "#app/phases/check-status-effect-phase";
import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; import { CheckSwitchPhase } from "#app/phases/check-switch-phase";
import { CommandPhase } from "#app/phases/command-phase"; import { CommandPhase } from "#app/phases/command-phase";
import { CommonAnimPhase } from "#app/phases/common-anim-phase"; import { CommonAnimPhase } from "#app/phases/common-anim-phase";
import type { Constructor } from "#app/utils/common";
import { DamageAnimPhase } from "#app/phases/damage-anim-phase"; import { DamageAnimPhase } from "#app/phases/damage-anim-phase";
import type { DynamicPhaseType } from "#enums/dynamic-phase-type";
import { EggHatchPhase } from "#app/phases/egg-hatch-phase"; import { EggHatchPhase } from "#app/phases/egg-hatch-phase";
import { EggLapsePhase } from "#app/phases/egg-lapse-phase"; import { EggLapsePhase } from "#app/phases/egg-lapse-phase";
import { EggSummaryPhase } from "#app/phases/egg-summary-phase"; import { EggSummaryPhase } from "#app/phases/egg-summary-phase";
@ -55,6 +58,7 @@ import { NextEncounterPhase } from "#app/phases/next-encounter-phase";
import { ObtainStatusEffectPhase } from "#app/phases/obtain-status-effect-phase"; import { ObtainStatusEffectPhase } from "#app/phases/obtain-status-effect-phase";
import { PartyExpPhase } from "#app/phases/party-exp-phase"; import { PartyExpPhase } from "#app/phases/party-exp-phase";
import { PartyHealPhase } from "#app/phases/party-heal-phase"; import { PartyHealPhase } from "#app/phases/party-heal-phase";
import { type PhasePriorityQueue, PostSummonPhasePriorityQueue } from "#app/data/phase-priority-queue";
import { PokemonAnimPhase } from "#app/phases/pokemon-anim-phase"; import { PokemonAnimPhase } from "#app/phases/pokemon-anim-phase";
import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#app/phases/pokemon-transform-phase"; import { PokemonTransformPhase } from "#app/phases/pokemon-transform-phase";
@ -111,6 +115,7 @@ import { WeatherEffectPhase } from "#app/phases/weather-effect-phase";
* This allows for easy creation of new phases without needing to import each phase individually. * This allows for easy creation of new phases without needing to import each phase individually.
*/ */
const PHASES = Object.freeze({ const PHASES = Object.freeze({
ActivatePriorityQueuePhase,
AddEnemyBuffModifierPhase, AddEnemyBuffModifierPhase,
AttemptCapturePhase, AttemptCapturePhase,
AttemptRunPhase, AttemptRunPhase,
@ -222,9 +227,19 @@ export class PhaseManager {
private phaseQueuePrependSpliceIndex = -1; private phaseQueuePrependSpliceIndex = -1;
private nextCommandPhaseQueue: Phase[] = []; 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<Phase>[];
private currentPhase: Phase | null = null; private currentPhase: Phase | null = null;
private standbyPhase: Phase | null = null; private standbyPhase: Phase | null = null;
constructor() {
this.dynamicPhaseQueues = [new PostSummonPhasePriorityQueue()];
this.dynamicPhaseTypes = [PostSummonPhase];
}
/* Phase Functions */ /* Phase Functions */
getCurrentPhase(): Phase | null { getCurrentPhase(): Phase | null {
return this.currentPhase; return this.currentPhase;
@ -254,8 +269,12 @@ export class PhaseManager {
* @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue * @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue
*/ */
pushPhase(phase: Phase, defer = false): void { pushPhase(phase: Phase, defer = false): void {
if (this.getDynamicPhaseType(phase) !== undefined) {
this.pushDynamicPhase(phase);
} else {
(!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase);
} }
}
/** /**
* Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex * Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
@ -283,6 +302,7 @@ export class PhaseManager {
for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue, this.nextCommandPhaseQueue]) { for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue, this.nextCommandPhaseQueue]) {
queue.splice(0, queue.length); queue.splice(0, queue.length);
} }
this.dynamicPhaseQueues.forEach(queue => queue.clear());
this.currentPhase = null; this.currentPhase = null;
this.standbyPhase = null; this.standbyPhase = null;
this.clearPhaseQueueSplice(); this.clearPhaseQueueSplice();
@ -333,8 +353,9 @@ export class PhaseManager {
this.currentPhase = this.phaseQueue.shift() ?? null; this.currentPhase = this.phaseQueue.shift() ?? null;
const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
// Check if there are any conditional phases queued // Check if there are any conditional phases queued
if (this.conditionalQueue?.length) { while (this.conditionalQueue?.length) {
// Retrieve the first conditional phase from the queue // Retrieve the first conditional phase from the queue
const conditionalPhase = this.conditionalQueue.shift(); const conditionalPhase = this.conditionalQueue.shift();
// Evaluate the condition associated with the phase // Evaluate the condition associated with the phase
@ -343,11 +364,12 @@ export class PhaseManager {
this.pushPhase(conditionalPhase[1]); this.pushPhase(conditionalPhase[1]);
} else if (conditionalPhase) { } else if (conditionalPhase) {
// If the condition is not met, re-add the phase back to the front of the conditional queue // 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 { } else {
console.warn("condition phase is undefined/null!", conditionalPhase); console.warn("condition phase is undefined/null!", conditionalPhase);
} }
} }
this.conditionalQueue.push(...unactivatedConditionalPhases);
if (this.currentPhase) { if (this.currentPhase) {
console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;");
@ -431,17 +453,18 @@ export class PhaseManager {
} }
/** /**
* Attempt to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()} * Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()}
* @param phase - The phase(s) to be added * @param phase {@linkcode Phase} the phase(s) to be added
* @param targetPhase - The phase to search for in phaseQueue * @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 * @returns `true` if a `targetPhase` was found to append to
*/ */
appendToPhase(phase: Phase | Phase[], targetPhase: PhaseString): boolean { appendToPhase(phase: Phase | Phase[], targetPhase: PhaseString, condition?: (p: Phase) => boolean): boolean {
if (!Array.isArray(phase)) { if (!Array.isArray(phase)) {
phase = [phase]; phase = [phase];
} }
const target = PHASES[targetPhase]; const target = PHASES[targetPhase];
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target); const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target && (!condition || condition(ph)));
if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) { if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) {
this.phaseQueue.splice(targetIndex + 1, 0, ...phase); this.phaseQueue.splice(targetIndex + 1, 0, ...phase);
@ -451,6 +474,68 @@ export class PhaseManager {
return false; 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 * Adds a MessagePhase, either to PhaseQueuePrepend or nextCommandPhaseQueue
* @param message - string for MessagePhase * @param message - string for MessagePhase
@ -578,4 +663,11 @@ export class PhaseManager {
): boolean { ): boolean {
return this.appendToPhase(this.create(phase, ...args), targetPhase); return this.appendToPhase(this.create(phase, ...args), targetPhase);
} }
public startNewDynamicPhase<T extends PhaseString>(
phase: T,
...args: ConstructorParameters<PhaseConstructorMap[T]>
): void {
this.startDynamicPhase(this.create(phase, ...args));
}
} }

View File

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

View File

@ -0,0 +1,27 @@
import { applyPostSummonAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import { PostSummonPhase } from "#app/phases/post-summon-phase";
import type { BattlerIndex } from "#enums/battler-index";
/**
* 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;
}
}

View File

@ -1,10 +1,10 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { applyAbAttrs, applyPostSummonAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import { ArenaTrapTag } from "#app/data/arena-tag"; import { ArenaTrapTag } from "#app/data/arena-tag";
import { StatusEffect } from "#app/enums/status-effect"; import { StatusEffect } from "#app/enums/status-effect";
import { PokemonPhase } from "./pokemon-phase"; import { PokemonPhase } from "./pokemon-phase";
import { MysteryEncounterPostSummonTag } from "#app/data/battler-tags"; import { MysteryEncounterPostSummonTag } from "#app/data/battler-tags";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs";
export class PostSummonPhase extends PokemonPhase { export class PostSummonPhase extends PokemonPhase {
public readonly phaseName = "PostSummonPhase"; public readonly phaseName = "PostSummonPhase";
@ -26,7 +26,6 @@ export class PostSummonPhase extends PokemonPhase {
pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON); pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON);
} }
applyPostSummonAbAttrs("PostSummonAbAttr", pokemon);
const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField();
for (const p of field) { for (const p of field) {
applyAbAttrs("CommanderAbAttr", p, null, false); applyAbAttrs("CommanderAbAttr", p, null, false);
@ -34,4 +33,8 @@ export class PostSummonPhase extends PokemonPhase {
this.end(); this.end();
} }
public getPriority() {
return 0;
}
} }

View File

@ -245,7 +245,7 @@ export class SwitchSummonPhase extends SummonPhase {
} }
queuePostSummon(): void { queuePostSummon(): void {
globalScene.phaseManager.unshiftNew("PostSummonPhase", this.getPokemon().getBattlerIndex()); globalScene.phaseManager.startNewDynamicPhase("PostSummonPhase", this.getPokemon().getBattlerIndex());
} }
/** /**

View File

@ -0,0 +1,95 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
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 } 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([MoveId.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.disableCrits()
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should activate the ability of the faster Pokemon first", async () => {
game.override.enemyLevel(100).ability(AbilityId.DRIZZLE).enemyAbility(AbilityId.DROUGHT);
await game.classicMode.startBattle([SpeciesId.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(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.DROUGHT)
.ability(AbilityId.DRIZZLE)
.startingHeldItems([{ name: "BASE_STAT_BOOSTER", type: Stat.SPD, count: 100 }]);
await game.classicMode.startBattle([SpeciesId.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(SpeciesId.DITTO)
.enemyAbility(AbilityId.DROUGHT)
.ability(AbilityId.DRIZZLE)
.startingHeldItems([{ name: "SPECIES_STAT_BOOSTER", type: "QUICK_POWDER" }]);
await game.classicMode.startBattle([SpeciesId.DITTO]);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY);
});
it("should activate priority abilities first", async () => {
game.override
.startingLevel(1)
.enemyLevel(100)
.enemySpecies(SpeciesId.ACCELGOR)
.enemyAbility(AbilityId.DROUGHT)
.ability(AbilityId.NEUTRALIZING_GAS);
await game.classicMode.startBattle([SpeciesId.SLOWPOKE]);
expect(game.scene.arena.weather).toBeUndefined();
});
it("should update dynamically based on speed order", async () => {
game.override
.startingLevel(35)
.enemyLevel(50)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.SLOW_START)
.enemyPassiveAbility(AbilityId.DROUGHT)
.ability(AbilityId.DRIZZLE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// Slow start activates and makes enemy slower, so drought activates after drizzle
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY);
});
});