This commit is contained in:
Bertie690 2025-09-22 21:10:29 -04:00 committed by GitHub
commit 6b96e9160a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 161 additions and 185 deletions

View File

@ -236,7 +236,7 @@
},
"overrides": [
{
"includes": ["**/test/**/*.test.ts"],
"includes": ["**/test/**/*.ts"],
"linter": {
"rules": {
"performance": {
@ -245,8 +245,16 @@
},
"style": {
"noNonNullAssertion": "off" // tedious in some tests
},
}
}
}
},
{
"includes": ["**/test/**/*.test.ts"],
"linter": {
"rules": {
"nursery": {
// TODO: Enable for normal test folder files as well
"noFloatingPromises": "error"
}
}

View File

@ -7,7 +7,6 @@ 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
@ -76,8 +75,8 @@ export class DynamicQueueManager {
}
/**
* Returns the highest-priority (generally by speed) {@linkcode Phase} of the specified type
* @param type - The {@linkcode PhaseString | type} to pop
* Returns the highest-priority (generally by speed) {@linkcode Phase} of the specified type.
* @param type - The {@linkcode PhaseString | type} of phase to access
* @returns The popped {@linkcode Phase}, or `undefined` if none of the specified type exist
*/
public popNextPhase(type: PhaseString): Phase | undefined {
@ -90,7 +89,7 @@ export class DynamicQueueManager {
* @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 {
public exists<T extends PhaseString>(type: T, condition: PhaseConditionFunc<T> = () => true): boolean {
return !!this.dynamicPhaseMap.get(type)?.has(condition);
}
@ -141,21 +140,13 @@ export class DynamicQueueManager {
}
/**
* Finds and cancels a {@linkcode MovePhase} meeting the condition
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function
* Find and cancel a {@linkcode MovePhase} meeting the condition
* @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function to filter phases by
*/
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
*/
@ -176,7 +167,7 @@ export class DynamicQueueManager {
/**
* Internal helper to determine if a phase is dynamic.
* @param phase - The {@linkcode Phase} to check
* @returns Whether `phase` is dynamic
* @returns Whether `phase` is dynamic.
* @privateRemarks
* Currently, this checks that `phase` has a `getPokemon` method
* and is not blacklisted in `nonDynamicPokemonPhases`.

View File

@ -3218,9 +3218,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Check whether the specified Pokémon is an opponent
* Check whether 2 Pokémon oppose one another during battle.
* @param target - The {@linkcode Pokemon} to compare against
* @returns `true` if the two pokemon are allies, `false` otherwise
* @returns Whether this Pokemon is an opponent of `target` (one is player and the other enemy).
*/
public isOpponent(target: Pokemon): boolean {
return this.isPlayer() !== target.isPlayer();

View File

@ -96,10 +96,11 @@ export class PhaseTree {
}
/**
* Removes and returns the first {@linkcode Phase} from the topmost level of the tree
* Remove and return 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 {
// Clear out all empty levels from the tree
this.currentLevel = this.levels.length - 1;
while (this.currentLevel > 0 && this.levels[this.currentLevel].length === 0) {
this.deferredActive = false;
@ -113,10 +114,10 @@ export class PhaseTree {
}
/**
* Finds a particular {@linkcode Phase} in the Tree by searching in pop order
* Find the first {@linkcode Phase} in the Tree matching the given conditions.
* @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
* @param phaseFilter - An optional {@linkcode PhaseConditionFunc} to add conditions to the search
* @returns The first `Phase` that matches the criteria, 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--) {
@ -129,26 +130,24 @@ export class PhaseTree {
}
/**
* Finds a particular {@linkcode Phase} in the Tree by searching in pop order
* Find all {@linkcode Phase}s in the Tree matching the given conditions.
* @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
* @param phaseFilter - An optional {@linkcode PhaseConditionFunc} to add conditions to the search
* @returns An array containing all `Phase`s matching the criteria.
*/
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);
phases.push(...level.filter((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p))));
}
return phases;
}
/**
* Clears the Tree
* @param leaveFirstLevel - If `true`, leaves the top level of the tree intact
*
* @privateremarks
* Clear all Phases from the Tree.
* @param leaveFirstLevel - Whether to leave the top level of the tree intact; default `false`
* @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.
@ -181,25 +180,17 @@ export class PhaseTree {
*/
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;
this.levels[i] = this.levels[i].filter(phase => !phase.is(phaseType));
}
}
/**
* Determines if a particular phase exists in the Tree
* Check whether 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
* @param phaseFilter - An optional {@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;
return this.levels.some(level => level.some(phase => phase.is(phaseType) && (!phaseFilter || phaseFilter(phase))));
}
}

View File

@ -3,7 +3,6 @@ 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";
@ -17,18 +16,18 @@ export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue<MovePhase>
}
public cancelMove(condition: PhaseConditionFunc<"MovePhase">): void {
this.queue.find(p => condition(p))?.cancel();
this.queue.find(condition)?.cancel();
}
public setTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void {
const phase = this.queue.find(p => condition(p));
const phase = this.queue.find(condition);
if (phase != null) {
phase.timingModifier = modifier;
}
}
public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove) {
const phase = this.queue.find(p => condition(p));
const phase = this.queue.find(condition);
if (phase != null) {
phase.move = move;
}
@ -47,7 +46,7 @@ export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue<MovePhase>
mp =>
mp.targets.length === 1
&& mp.targets[0] === removedPokemon.getBattlerIndex()
&& mp.pokemon.isPlayer() !== allyPokemon.isPlayer(),
&& mp.pokemon.isOpponent(allyPokemon),
)
.forEach(targetingMovePhase => {
if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) {
@ -57,10 +56,6 @@ export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue<MovePhase>
}
}
public setMoveOrder(order: BattlerIndex[]) {
this.setOrder = order;
}
public override pop(): MovePhase | undefined {
this.reorder();
const phase = this.queue.shift();
@ -79,25 +74,20 @@ export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue<MovePhase>
}
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];
});
this.queue.sort(
(a: MovePhase, b: MovePhase) =>
// formatting
b.timingModifier - a.timingModifier || getPriorityForMP(b) - getPriorityForMP(a),
);
}
}
function getPriorityForMP(mp: MovePhase): number {
const move = mp.move.getMove();
return move.getPriority(mp.pokemon, true);
}

View File

@ -1,20 +1,10 @@
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 */
/** 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);
}
sortInSpeedOrder(this.queue);
}
}

View File

@ -5,6 +5,6 @@ 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);
sortInSpeedOrder(this.queue);
}
}

View File

@ -11,7 +11,7 @@ import { sortInSpeedOrder } from "#app/utils/speed-order";
*/
export class PostSummonPhasePriorityQueue extends PokemonPhasePriorityQueue<PostSummonPhase> {
protected override reorder(): void {
this.queue = sortInSpeedOrder(this.queue, false);
sortInSpeedOrder(this.queue, false);
this.queue.sort((phaseA, phaseB) => phaseB.getPriority() - phaseA.getPriority());
}

View File

@ -1,19 +1,24 @@
/**
* Stores a list of elements.
* Abstract class representing a {@link https://en.wikipedia.org/wiki/Priority_queue#Min-priority_queue | Min-priority queue}.
*
* Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder}.
* Dynamically updates ordering to always return the highest "priority" item,
* based on the implementation of {@linkcode reorder}.
*/
export abstract class PriorityQueue<T> {
/** The items in the queue. */
protected queue: T[] = [];
/**
* Sorts the elements in the queue
* Sort the elements in the queue.
* @remarks
* When sorting, earlier elements will be accessed before later ones.
*/
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
* Reorder the queue before removing and returning the highest priority element.
* @returns The front-most element of the queue after sorting,
* or `undefined` if the queue is empty.
* @sealed
*/
public pop(): T | undefined {
@ -34,7 +39,7 @@ export abstract class PriorityQueue<T> {
}
/**
* Removes all elements from the queue
* Remove all elements from the queue.
* @sealed
*/
public clear(): void {
@ -50,8 +55,8 @@ export abstract class PriorityQueue<T> {
}
/**
* Removes the first element matching the condition
* @param condition - An optional condition function (defaults to a function that always returns `true`)
* Remove the first element matching the condition
* @param condition - If provided, will restrict the removal to only phases matching the condition
* @returns Whether a removal occurred
*/
public remove(condition: (t: T) => boolean = () => true): boolean {
@ -67,12 +72,12 @@ export abstract class PriorityQueue<T> {
}
/** @returns An element matching the condition function */
public find(condition?: (t: T) => boolean): T | undefined {
return this.queue.find(e => !condition || condition(e));
public find(condition: (t: T) => boolean): T | undefined {
return this.queue.find(condition);
}
/** @returns Whether an element matching the condition function exists */
public has(condition?: (t: T) => boolean): boolean {
return this.queue.some(e => !condition || condition(e));
public has(condition: (t: T) => boolean): boolean {
return this.queue.some(condition);
}
}

View File

@ -135,14 +135,17 @@ export function randSeedItem<T>(items: T[]): T {
/**
* Shuffle a list using the seeded rng. Utilises the Fisher-Yates algorithm.
* @param items An array of items.
* @param items - The array of items to shuffle
* @param mutate - Whether to mutate the array in place (`true`) or create a new one
* (`false`); default `false`
* @returns A new shuffled array of items.
*/
export function randSeedShuffle<T>(items: T[]): T[] {
export function randSeedShuffle<T>(items: T[], mutate = false): T[] {
if (items.length <= 1) {
return items;
}
const newArray = items.slice(0);
const newArray = mutate ? items.slice(0) : items;
for (let i = items.length - 1; i > 0; i--) {
const j = Phaser.Math.RND.integerInRange(0, i);
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];

View File

@ -5,7 +5,8 @@ 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
* @param side - The {@linkcode ArenaTagSide | side} of the field to use;
* default `ArenaTagSide.BOTH`
* @returns A {@linkcode Generator} of {@linkcode Pokemon}
*
* @remarks

View File

@ -10,35 +10,36 @@ interface hasPokemon {
}
/**
* 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}
* Sort an array of {@linkcode Pokemon} in speed order, taking Trick Room into account.
* @param pokemonList - An array of `Pokemon` or objects containing `Pokemon` to sort;
* will be mutated and sorted in place.
* @param shuffleFirst - Whether to shuffle the list before sorting (to handle speed ties); default `true`.
* If `false`, will sort speed ties in ascending order of `BattlerIndex`es.
*/
export function sortInSpeedOrder<T extends Pokemon | hasPokemon>(pokemonList: T[], shuffleFirst = true): T[] {
pokemonList = shuffleFirst ? shufflePokemonList(pokemonList) : pokemonList;
export function sortInSpeedOrder<T extends Pokemon | hasPokemon>(pokemonList: T[], shuffleFirst = true): void {
if (shuffleFirst) {
shufflePokemonList(pokemonList);
}
sortBySpeed(pokemonList);
return pokemonList;
}
/**
* @param pokemonList - The array of Pokemon or objects containing Pokemon
* @returns The shuffled array
* Helper function to randomly shuffle an array of Pokemon.
* @param pokemonList - The array of Pokemon or objects containing Pokemon to shuffle
*/
function shufflePokemonList<T extends Pokemon | hasPokemon>(pokemonList: T[]): T[] {
function shufflePokemonList<T extends Pokemon | hasPokemon>(pokemonList: T[]): void {
// 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);
randSeedShuffle(pokemonList, true);
},
globalScene.currentBattle.turn * 1000 + pokemonList.length,
globalScene.waveSeed,
);
return pokemonList;
}
/** Sorts an array of {@linkcode Pokemon} by speed (without shuffling) */
/** Sort an array of {@linkcode Pokemon} in speed order (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);

View File

@ -120,7 +120,6 @@ describe("Abilities - Neutralizing Gas", () => {
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.ENTRAINMENT, BattlerIndex.PLAYER_2);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTag(ArenaTagType.NEUTRALIZING_GAS)).toBeUndefined(); // No neut gas users are left
});

View File

@ -97,7 +97,6 @@ describe("Abilities - Protosynthesis", () => {
true,
);
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
const boosted_dmg = initialHp - enemy.hp;
expect(boosted_dmg).toBeGreaterThan(unboosted_dmg);

View File

@ -1,4 +1,5 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
@ -24,7 +25,7 @@ describe("Abilities - Stall", () => {
game.override
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.REGIELEKI)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STALL)
.enemyMoveset(MoveId.QUICK_ATTACK)
.moveset([MoveId.QUICK_ATTACK, MoveId.TACKLE]);
@ -42,7 +43,7 @@ describe("Abilities - Stall", () => {
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.QUICK_ATTACK);
game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
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.
@ -55,6 +56,7 @@ describe("Abilities - Stall", () => {
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.TACKLE);
game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
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.
@ -69,6 +71,7 @@ describe("Abilities - Stall", () => {
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.TACKLE);
game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase", false);

View File

@ -53,12 +53,10 @@ describe("Abilities - Supreme Overlord", () => {
await game.toNextTurn();
game.move.select(MoveId.EXPLOSION);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(2);
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
expect(move.calculateBattlePower).toHaveReturnedWith(basePower * 1.2);
@ -80,7 +78,6 @@ describe("Abilities - Supreme Overlord", () => {
*/
game.doRevivePokemon(1);
game.move.select(MoveId.EXPLOSION);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
@ -88,12 +85,10 @@ describe("Abilities - Supreme Overlord", () => {
* Bulbasur faints twice
*/
game.move.select(MoveId.EXPLOSION);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(2);
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
expect(move.calculateBattlePower).toHaveReturnedWith(basePower * 1.3);
@ -116,7 +111,6 @@ describe("Abilities - Supreme Overlord", () => {
* Enemy Pokemon faints and new wave is entered.
*/
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
game.move.select(MoveId.TACKLE);
@ -137,7 +131,6 @@ describe("Abilities - Supreme Overlord", () => {
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
game.move.select(MoveId.TACKLE);
@ -158,7 +151,6 @@ describe("Abilities - Supreme Overlord", () => {
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
game.move.select(MoveId.TACKLE);

View File

@ -70,7 +70,6 @@ describe("Items - Multi Lens", () => {
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
expect(playerPokemon.turnData.hitCount).toBe(3);

View File

@ -111,7 +111,6 @@ describe("Moves - Baton Pass", () => {
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined();
game.move.select(MoveId.BATON_PASS);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(1);
await game.toNextTurn();

View File

@ -165,18 +165,20 @@ describe("Moves - Delayed Attacks", () => {
it("should trigger multiple pending attacks in order of creation, even if that order changes later on", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
const [alomomola, blissey] = game.scene.getField();
const [alomomola, blissey, karp1, karp2] = game.scene.getField();
vi.spyOn(karp1, "getNameToRender").mockReturnValue("Karp 1");
vi.spyOn(karp2, "getNameToRender").mockReturnValue("Karp 2");
const oldOrder = game.field.getSpeedOrder();
const oldOrder = game.field.getSpeedOrder(true);
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2);
// Ensure that the moves are used deterministically in speed order (for speed ties)
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.setTurnOrder(oldOrder);
await game.toNextTurn();
expectFutureSightActive(4);
@ -195,7 +197,11 @@ describe("Moves - Delayed Attacks", () => {
const MEPs = game.scene.phaseManager["phaseQueue"].findAll("MoveEffectPhase");
expect(MEPs).toHaveLength(4);
expect(MEPs.map(mep => mep.getPokemon())).toEqual(oldOrder);
expect(
MEPs.map(mep => mep.getPokemon().getBattlerIndex()),
`Expected: ${oldOrder.map(o => game.scene.getField()[o].getNameToRender())}
Actual: ${MEPs.map(mep => mep.getPokemon().getNameToRender())}`,
).toEqual(oldOrder);
});
it("should vanish silently if it would otherwise hit the user", async () => {

View File

@ -17,8 +17,6 @@ describe("Moves - Destiny Bond", () => {
let game: GameManager;
const defaultParty = [SpeciesId.BULBASAUR, SpeciesId.SQUIRTLE];
const enemyFirst = [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
const playerFirst = [BattlerIndex.PLAYER, BattlerIndex.ENEMY];
beforeAll(() => {
phaserGame = new Phaser.Game({
@ -52,7 +50,7 @@ describe("Moves - Destiny Bond", () => {
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(moveToUse);
await game.setTurnOrder(enemyFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.isFainted()).toBe(true);
@ -70,7 +68,7 @@ describe("Moves - Destiny Bond", () => {
// Turn 1: Enemy uses Destiny Bond and doesn't faint
game.move.select(MoveId.SPLASH);
await game.setTurnOrder(playerFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.isFainted()).toBe(false);
@ -78,8 +76,8 @@ describe("Moves - Destiny Bond", () => {
// Turn 2: Player KO's the enemy before the enemy's turn
game.move.select(moveToUse);
await game.setTurnOrder(playerFirst);
await game.phaseInterceptor.to("BerryPhase");
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(enemyPokemon.isFainted()).toBe(true);
expect(playerPokemon.isFainted()).toBe(true);
@ -96,7 +94,7 @@ describe("Moves - Destiny Bond", () => {
// Turn 1: Enemy uses Destiny Bond and doesn't faint
game.move.select(MoveId.SPLASH);
await game.setTurnOrder(enemyFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.isFainted()).toBe(false);
@ -104,7 +102,6 @@ describe("Moves - Destiny Bond", () => {
// Turn 2: Enemy should fail Destiny Bond then get KO'd
game.move.select(moveToUse);
await game.setTurnOrder(enemyFirst);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.isFainted()).toBe(true);
@ -122,7 +119,7 @@ describe("Moves - Destiny Bond", () => {
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(moveToUse);
await game.setTurnOrder(enemyFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.isFainted()).toBe(true);
@ -140,7 +137,7 @@ describe("Moves - Destiny Bond", () => {
// Turn 1: Enemy uses Destiny Bond and doesn't faint
game.move.select(MoveId.SPORE);
await game.setTurnOrder(enemyFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.isFainted()).toBe(false);
@ -149,7 +146,6 @@ describe("Moves - Destiny Bond", () => {
// Turn 2: Enemy should skip a turn due to sleep, then get KO'd
game.move.select(moveToUse);
await game.setTurnOrder(enemyFirst);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.isFainted()).toBe(true);
@ -184,7 +180,7 @@ describe("Moves - Destiny Bond", () => {
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(moveToUse);
await game.setTurnOrder(enemyFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.isFainted()).toBe(true);
@ -238,7 +234,7 @@ describe("Moves - Destiny Bond", () => {
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(moveToUse);
await game.setTurnOrder(enemyFirst);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.isFainted()).toBe(true);

View File

@ -78,8 +78,9 @@ describe("Moves - Encore", () => {
game.move.select(MoveId.ENCORE);
const turnOrder = delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
await game.setTurnOrder(turnOrder);
await game.setTurnOrder(
delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER],
);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
@ -88,25 +89,22 @@ describe("Moves - Encore", () => {
});
it("Pokemon under both Encore and Torment should alternate between Struggle and restricted move", async () => {
const turnOrder = [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
game.override.moveset([MoveId.ENCORE, MoveId.TORMENT, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE);
await game.setTurnOrder(turnOrder);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined();
await game.toNextTurn();
game.move.select(MoveId.TORMENT);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.TORMENT)).toBeDefined();
await game.toNextTurn();
game.move.select(MoveId.SPLASH);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
const lastMove = enemyPokemon.getLastXMoves()[0];
expect(lastMove?.move).toBe(MoveId.STRUGGLE);

View File

@ -64,7 +64,6 @@ describe("Moves - Grudge", () => {
game.move.use(MoveId.GUILLOTINE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(ratatta).toHaveFainted();

View File

@ -343,7 +343,6 @@ describe("Moves - Instruct", () => {
expect(player.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
game.move.select(MoveId.INSTRUCT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(player.getLastXMoves()[0].result).toBe(MoveResult.FAIL);

View File

@ -59,7 +59,6 @@ describe("Moves - Last Respects", () => {
* Charmander faints once
*/
game.move.select(MoveId.EXPLOSION);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(2);
await game.toNextTurn();
@ -86,7 +85,6 @@ describe("Moves - Last Respects", () => {
*/
game.doRevivePokemon(1);
game.move.select(MoveId.EXPLOSION);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
@ -99,7 +97,6 @@ describe("Moves - Last Respects", () => {
await game.toNextTurn();
game.move.select(MoveId.LAST_RESPECTS);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
expect(move.calculateBattlePower).toHaveReturnedWith(basePower + 3 * 50);
@ -127,7 +124,6 @@ describe("Moves - Last Respects", () => {
* Enemy Pokemon faints and new wave is entered.
*/
game.move.select(MoveId.LAST_RESPECTS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
expect(game.scene.arena.playerFaints).toBe(1);
@ -160,7 +156,6 @@ describe("Moves - Last Respects", () => {
* Enemy Pokemon faints and new wave is entered.
*/
game.move.select(MoveId.LAST_RESPECTS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
expect(game.scene.currentBattle.enemyFaints).toBe(0);
@ -184,7 +179,6 @@ describe("Moves - Last Respects", () => {
await game.toNextTurn();
game.move.select(MoveId.LAST_RESPECTS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
game.move.select(MoveId.LAST_RESPECTS);
@ -205,7 +199,6 @@ describe("Moves - Last Respects", () => {
await game.toNextTurn();
game.move.select(MoveId.LAST_RESPECTS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
game.move.select(MoveId.LAST_RESPECTS);

View File

@ -95,17 +95,16 @@ describe("Moves - Metronome", () => {
game.move.select(MoveId.METRONOME);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toNextTurn();
expect(player.getTag(BattlerTagType.CHARGING)).toBeTruthy();
expect(player).toHaveBattlerTag(BattlerTagType.CHARGING);
const turn1PpUsed = metronomeMove.ppUsed;
expect.soft(turn1PpUsed).toBeGreaterThan(1);
expect(solarBeamMove.ppUsed).toBe(0);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(player.getTag(BattlerTagType.CHARGING)).toBeFalsy();
expect(player).not.toHaveBattlerTag(BattlerTagType.CHARGING);
const turn2PpUsed = metronomeMove.ppUsed - turn1PpUsed;
expect(turn2PpUsed).toBeGreaterThan(1);
expect(solarBeamMove.ppUsed).toBe(0);

View File

@ -166,7 +166,6 @@ describe("Moves - Pledge Moves", () => {
game.move.select(MoveId.FIERY_DANCE, 0, BattlerIndex.ENEMY_2);
game.move.select(MoveId.SPLASH, 1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("MoveEndPhase");
// Rainbow effect should increase Fiery Dance's chance of raising Sp. Atk to 100%

View File

@ -85,7 +85,6 @@ describe("Moves - Rage Fist", () => {
// remove substitute and get confused
game.move.select(MoveId.TIDY_UP);
await game.move.selectEnemyMove(MoveId.CONFUSE_RAY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
game.move.select(MoveId.RAGE_FIST);
@ -108,7 +107,6 @@ describe("Moves - Rage Fist", () => {
expect(game.field.getPlayerPokemon().battleData.hitCount).toBe(2);
game.move.select(MoveId.RAGE_FIST);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.field.getPlayerPokemon().battleData.hitCount).toBe(4);
@ -147,7 +145,6 @@ describe("Moves - Rage Fist", () => {
await game.toNextTurn();
game.move.select(MoveId.RAGE_FIST);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(move.calculateBattlePower).toHaveLastReturnedWith(150);

View File

@ -159,7 +159,6 @@ describe("Moves - Roost", () => {
await game.phaseInterceptor.to(TurnEndPhase);
game.move.select(MoveId.ROOST);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
// Should only be typeless type after roost and is grounded
@ -195,7 +194,6 @@ describe("Moves - Roost", () => {
await game.phaseInterceptor.to(TurnEndPhase);
game.move.select(MoveId.ROOST);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
// Should only be typeless type after roost and is grounded

View File

@ -71,7 +71,6 @@ describe("Moves - Sketch", () => {
await game.toNextTurn();
game.move.select(MoveId.SKETCH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.move.forceStatusActivation(true);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);

View File

@ -49,7 +49,6 @@ describe("Moves - Spite", () => {
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(karp).toHaveUsedPP(MoveId.TACKLE, 4 + 1);

View File

@ -109,14 +109,14 @@ describe("Move - Wish", () => {
vi.spyOn(karp1, "getNameToRender").mockReturnValue("Karp 1");
vi.spyOn(karp2, "getNameToRender").mockReturnValue("Karp 2");
const oldOrder = game.field.getSpeedOrder();
const oldOrder = game.field.getSpeedOrder(true);
game.move.use(MoveId.WISH, BattlerIndex.PLAYER);
game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.WISH);
await game.move.forceEnemyMove(MoveId.WISH);
// Ensure that the wishes are used deterministically in speed order (for speed ties)
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.setTurnOrder(oldOrder);
await game.toNextTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH, 4);
@ -137,7 +137,9 @@ describe("Move - Wish", () => {
const healPhases = game.scene.phaseManager["phaseQueue"].findAll("PokemonHealPhase");
expect(healPhases).toHaveLength(4);
expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder);
expect
.soft(healPhases.map(php => php.getPokemon().getBattlerIndex(), "Wishes were not queued in correct order!"))
.toEqual(oldOrder);
await game.toEndOfTurn();

View File

@ -62,7 +62,6 @@ describe("Frenzy Move Reset", () => {
expect(playerPokemon.summonData.moveQueue.length).toBe(2);
expect(playerPokemon.summonData.tags.some(tag => tag.tagType === BattlerTagType.FRENZY)).toBe(true);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceStatusActivation(true);
await game.toNextTurn();

View File

@ -14,7 +14,7 @@ import { PlayerGender } from "#enums/player-gender";
import type { PokeballType } from "#enums/pokeball";
import type { SpeciesId } from "#enums/species-id";
import { UiMode } from "#enums/ui-mode";
import type { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { type EnemyPokemon, type PlayerPokemon, Pokemon } from "#field/pokemon";
import { Trainer } from "#field/trainer";
import { ModifierTypeOption } from "#modifiers/modifier-type";
import { CheckSwitchPhase } from "#phases/check-switch-phase";
@ -52,6 +52,7 @@ import type { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler";
import type { PartyUiHandler } from "#ui/party-ui-handler";
import type { StarterSelectUiHandler } from "#ui/starter-select-ui-handler";
import type { TargetSelectUiHandler } from "#ui/target-select-ui-handler";
import * as speedOrderUtils from "#utils/speed-order";
import fs from "node:fs";
import { AES, enc } from "crypto-js";
import { expect, vi } from "vitest";
@ -536,19 +537,40 @@ export class GameManager {
}
/**
* Modifies the queue manager to return move phases in a particular order
* Used to manually modify Pokemon turn order.
* Note: This *DOES NOT* account for priority.
* @param order - The turn order to set as an array of {@linkcode BattlerIndex}es.
* Override the turn order of the battle's current combatants.
* @param order - The turn order to set, as an array of {@linkcode BattlerIndex}es
* @example
* ```ts
* await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);
* game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);
* ```
* @throws Fails test immediately if `order` does not contain all non-fainted combatants' `BattlerIndex`es.
* @remarks
* This does not account for priority, nor does it change the battlers' speed stats
* (for the purposes of Electro Ball, etc).
* @todo What should happen if the number of active battlers changes mid-test?
* @todo Remove `await`s from existing test files in a follow-up PR
*/
async setTurnOrder(order: BattlerIndex[]): Promise<void> {
await this.phaseInterceptor.to("TurnStartPhase", false);
public setTurnOrder(order: Exclude<BattlerIndex, BattlerIndex.ATTACKER>[]): void {
// TODO: Remove type assertions once `BattlerIndex.ATTACKER` ceases to exist
expect(order).toEqualUnsorted(
this.scene.getField(true).map(p => p.getBattlerIndex() as Exclude<BattlerIndex, BattlerIndex.ATTACKER>),
);
this.scene.phaseManager.dynamicQueueManager.setMoveOrder(order);
// NB: This will need to be changed if `sortInSpeedOrder`'s order is ever changed
vi.spyOn(speedOrderUtils, "sortInSpeedOrder").mockImplementation(list => {
list.sort((a, b) => {
const aBattlerIndex = (a instanceof Pokemon ? a : a.getPokemon()).getBattlerIndex() as Exclude<
BattlerIndex,
BattlerIndex.ATTACKER
>;
const bBattlerIndex = (b instanceof Pokemon ? b : b.getPokemon()).getBattlerIndex() as Exclude<
BattlerIndex,
BattlerIndex.ATTACKER
>;
return order.indexOf(aBattlerIndex) - order.indexOf(bBattlerIndex);
});
});
}
/**

View File

@ -61,14 +61,14 @@ export class FieldHelper extends GameManagerHelper {
* Helper function to return all on-field {@linkcode Pokemon} in speed order (fastest first).
* @param indices - Whether to only return {@linkcode BattlerIndex}es instead of full Pokemon objects
* (such as for comparison with other speed order-related mechanisms); default `false`
* @returns An array containing the {@linkcode BattlerIndex}es of all on-field {@linkcode Pokemon} on the field in order of descending Speed. \
* @returns An array containing the {@linkcode BattlerIndex}es of all on-field `Pokemon` on the field in order of **descending** Speed. \
* Speed ties are returned in increasing order of index.
*
* @remarks
* This does not account for Trick Room as it does not modify the _speed_ of Pokemon on the field,
* only their turn order.
*/
public getSpeedOrder(indices: true): BattlerIndex[];
public getSpeedOrder(indices: true): Exclude<BattlerIndex, BattlerIndex.ATTACKER>[];
public getSpeedOrder(indices = false): BattlerIndex[] | Pokemon[] {
const ret = this.game.scene
.getField(true)