[Refactor] Improvements on getOrder() (#3547)

* Moved getOrder() into TurnStartPhase

* Cleaned up bypass speed checks

* Revert "Cleaned up bypass speed checks"

This reverts commit 11150254f5.

* Added comments.

* Fixed up some inconsistencies

* changed isSameBracket check

* changed isSameBracket check p2

* changed isSameBracket check p3

* changed isSameBracket check p3

* Fixed up conditionals + stall/M.m

* Seems OK

* Update battle-spec.ts

* Updated tests to use new functions introduced. Less intuitive, but faster.

* Update src/phases.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Moved getOrder() into TurnStartPhase

* Cleaned up bypass speed checks

* Revert "Cleaned up bypass speed checks"

This reverts commit 11150254f5.

* Added comments.

* Fixed up some inconsistencies

* changed isSameBracket check

* changed isSameBracket check p2

* changed isSameBracket check p3

* changed isSameBracket check p3

* Fixed up conditionals + stall/M.m

* Seems OK

* Update battle-spec.ts

* Updated tests to use new functions introduced. Less intuitive, but faster.

* Removed import

* i hate git

* Moved getOrder() into TurnStartPhase

* Seems OK

* missing import

* Added Snooze's review

* Added test fixes and removed unwanted edit.

* fixed dynamax cannon test

* typedocs fixes

* Updating battle-order.test.ts

* merge fixes

* ughhh

* Update src/test/abilities/mycelium_might.test.ts

Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>

* tsdocs :)

* Fixed tests

* Update src/phases/turn-start-phase.ts

Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>

* Update src/phases/turn-start-phase.ts

Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>

* innerthunder's fixes

* commas

* Mocked stats instead of directly changing them

---------

Co-authored-by: Frutescens <info@laptop>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>
This commit is contained in:
Mumble 2024-09-01 15:23:25 -07:00 committed by GitHub
parent 69a9e6a365
commit 55e0d65ac8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 203 additions and 154 deletions

View File

@ -5025,7 +5025,7 @@ export function initAbilities() {
.attr(AlwaysHitAbAttr) .attr(AlwaysHitAbAttr)
.attr(DoubleBattleChanceAbAttr), .attr(DoubleBattleChanceAbAttr),
new Ability(Abilities.STALL, 4) new Ability(Abilities.STALL, 4)
.attr(ChangeMovePriorityAbAttr, (pokemon, move: Move) => true, -0.5), .attr(ChangeMovePriorityAbAttr, (pokemon, move: Move) => true, -0.2),
new Ability(Abilities.TECHNICIAN, 4) new Ability(Abilities.TECHNICIAN, 4)
.attr(MovePowerBoostAbAttr, (user, target, move) => { .attr(MovePowerBoostAbAttr, (user, target, move) => {
const power = new Utils.NumberHolder(move.power); const power = new Utils.NumberHolder(move.power);
@ -5713,7 +5713,7 @@ export function initAbilities() {
.partial() // Healing not blocked by Heal Block .partial() // Healing not blocked by Heal Block
.ignorable(), .ignorable(),
new Ability(Abilities.MYCELIUM_MIGHT, 9) new Ability(Abilities.MYCELIUM_MIGHT, 9)
.attr(ChangeMovePriorityAbAttr, (pokemon, move) => move.category === MoveCategory.STATUS, -0.5) .attr(ChangeMovePriorityAbAttr, (pokemon, move) => move.category === MoveCategory.STATUS, -0.2)
.attr(PreventBypassSpeedChanceAbAttr, (pokemon, move) => move.category === MoveCategory.STATUS) .attr(PreventBypassSpeedChanceAbAttr, (pokemon, move) => move.category === MoveCategory.STATUS)
.attr(MoveAbilityBypassAbAttr, (pokemon, move: Move) => move.category === MoveCategory.STATUS), .attr(MoveAbilityBypassAbAttr, (pokemon, move: Move) => move.category === MoveCategory.STATUS),
new Ability(Abilities.MINDS_EYE, 9) new Ability(Abilities.MINDS_EYE, 9)

View File

@ -1,42 +1,9 @@
import { BattlerIndex } from "#app/battle.js";
import { TrickRoomTag } from "#app/data/arena-tag.js";
import { Stat } from "#app/enums/stat.js";
import Pokemon from "#app/field/pokemon.js";
import { BattlePhase } from "./battle-phase"; import { BattlePhase } from "./battle-phase";
import * as Utils from "#app/utils.js"; import Pokemon from "#app/field/pokemon";
type PokemonFunc = (pokemon: Pokemon) => void; type PokemonFunc = (pokemon: Pokemon) => void;
export abstract class FieldPhase extends BattlePhase { export abstract class FieldPhase extends BattlePhase {
getOrder(): BattlerIndex[] {
const playerField = this.scene.getPlayerField().filter(p => p.isActive()) as Pokemon[];
const enemyField = this.scene.getEnemyField().filter(p => p.isActive()) as Pokemon[];
// We shuffle the list before sorting so speed ties produce random results
let orderedTargets: Pokemon[] = playerField.concat(enemyField);
// We seed it with the current turn to prevent an inconsistency where it
// was varying based on how long since you last reloaded
this.scene.executeWithSeedOffset(() => {
orderedTargets = Utils.randSeedShuffle(orderedTargets);
}, this.scene.currentBattle.turn, this.scene.waveSeed);
orderedTargets.sort((a: Pokemon, b: Pokemon) => {
const aSpeed = a?.getBattleStat(Stat.SPD) || 0;
const bSpeed = b?.getBattleStat(Stat.SPD) || 0;
return bSpeed - aSpeed;
});
const speedReversed = new Utils.BooleanHolder(false);
this.scene.arena.applyTags(TrickRoomTag, speedReversed);
if (speedReversed.value) {
orderedTargets = orderedTargets.reverse();
}
return orderedTargets.map(t => t.getFieldIndex() + (!t.isPlayer() ? BattlerIndex.ENEMY : 0));
}
executeForAll(func: PokemonFunc): void { executeForAll(func: PokemonFunc): void {
const field = this.scene.getField(true).filter(p => p.summonData); const field = this.scene.getField(true).filter(p => p.summonData);
field.forEach(pokemon => func(pokemon)); field.forEach(pokemon => func(pokemon));

View File

@ -1,12 +1,12 @@
import BattleScene from "#app/battle-scene.js"; import BattleScene from "#app/battle-scene";
import { applyAbAttrs, BypassSpeedChanceAbAttr, PreventBypassSpeedChanceAbAttr, ChangeMovePriorityAbAttr } from "#app/data/ability.js"; import { applyAbAttrs, BypassSpeedChanceAbAttr, PreventBypassSpeedChanceAbAttr, ChangeMovePriorityAbAttr } from "#app/data/ability";
import { allMoves, applyMoveAttrs, IncrementMovePriorityAttr, MoveHeaderAttr } from "#app/data/move.js"; import { allMoves, applyMoveAttrs, IncrementMovePriorityAttr, MoveHeaderAttr } from "#app/data/move";
import { Abilities } from "#app/enums/abilities.js"; import { Abilities } from "#app/enums/abilities";
import { Stat } from "#app/enums/stat.js"; import { Stat } from "#app/enums/stat";
import { PokemonMove } from "#app/field/pokemon.js"; import Pokemon, { PokemonMove } from "#app/field/pokemon";
import { BypassSpeedChanceModifier } from "#app/modifier/modifier.js"; import { BypassSpeedChanceModifier } from "#app/modifier/modifier";
import { Command } from "#app/ui/command-ui-handler.js"; import { Command } from "#app/ui/command-ui-handler";
import * as Utils from "#app/utils.js"; import * as Utils from "#app/utils";
import { AttemptCapturePhase } from "./attempt-capture-phase"; import { AttemptCapturePhase } from "./attempt-capture-phase";
import { AttemptRunPhase } from "./attempt-run-phase"; import { AttemptRunPhase } from "./attempt-run-phase";
import { BerryPhase } from "./berry-phase"; import { BerryPhase } from "./berry-phase";
@ -17,18 +17,59 @@ import { PostTurnStatusEffectPhase } from "./post-turn-status-effect-phase";
import { SwitchSummonPhase } from "./switch-summon-phase"; import { SwitchSummonPhase } from "./switch-summon-phase";
import { TurnEndPhase } from "./turn-end-phase"; import { TurnEndPhase } from "./turn-end-phase";
import { WeatherEffectPhase } from "./weather-effect-phase"; import { WeatherEffectPhase } from "./weather-effect-phase";
import { BattlerIndex } from "#app/battle";
import { TrickRoomTag } from "#app/data/arena-tag";
export class TurnStartPhase extends FieldPhase { export class TurnStartPhase extends FieldPhase {
constructor(scene: BattleScene) { constructor(scene: BattleScene) {
super(scene); super(scene);
} }
start() { /**
super.start(); * This orders the active Pokemon on the field by speed into an BattlerIndex array and returns that array.
* It also checks for Trick Room and reverses the array if it is present.
* @returns {@linkcode BattlerIndex[]} the battle indices of all pokemon on the field ordered by speed
*/
getSpeedOrder(): BattlerIndex[] {
const playerField = this.scene.getPlayerField().filter(p => p.isActive()) as Pokemon[];
const enemyField = this.scene.getEnemyField().filter(p => p.isActive()) as Pokemon[];
const field = this.scene.getField(); // We shuffle the list before sorting so speed ties produce random results
const order = this.getOrder(); let orderedTargets: Pokemon[] = playerField.concat(enemyField);
// We seed it with the current turn to prevent an inconsistency where it
// was varying based on how long since you last reloaded
this.scene.executeWithSeedOffset(() => {
orderedTargets = Utils.randSeedShuffle(orderedTargets);
}, this.scene.currentBattle.turn, this.scene.waveSeed);
orderedTargets.sort((a: Pokemon, b: Pokemon) => {
const aSpeed = a?.getBattleStat(Stat.SPD) || 0;
const bSpeed = b?.getBattleStat(Stat.SPD) || 0;
return bSpeed - aSpeed;
});
// Next, a check for Trick Room is applied. If Trick Room is present, the order is reversed.
const speedReversed = new Utils.BooleanHolder(false);
this.scene.arena.applyTags(TrickRoomTag, speedReversed);
if (speedReversed.value) {
orderedTargets = orderedTargets.reverse();
}
return orderedTargets.map(t => t.getFieldIndex() + (!t.isPlayer() ? BattlerIndex.ENEMY : BattlerIndex.PLAYER));
}
/**
* This takes the result of getSpeedOrder and applies priority / bypass speed attributes to it.
* This also considers the priority levels of various commands and changes the result of getSpeedOrder based on such.
* @returns {@linkcode BattlerIndex[]} the final sequence of commands for this turn
*/
getCommandOrder(): BattlerIndex[] {
let moveOrder = this.getSpeedOrder();
// The creation of the battlerBypassSpeed object contains checks for the ability Quick Draw and the held item Quick Claw
// The ability Mycelium Might disables Quick Claw's activation when using a status move
// This occurs before the main loop because of battles with more than two Pokemon
const battlerBypassSpeed = {}; const battlerBypassSpeed = {};
this.scene.getField(true).filter(p => p.summonData).map(p => { this.scene.getField(true).filter(p => p.summonData).map(p => {
@ -42,8 +83,9 @@ export class TurnStartPhase extends FieldPhase {
battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed; battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed;
}); });
const moveOrder = order.slice(0); // The function begins sorting orderedTargets based on command priority, move priority, and possible speed bypasses.
// Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands.
moveOrder = moveOrder.slice(0);
moveOrder.sort((a, b) => { moveOrder.sort((a, b) => {
const aCommand = this.scene.currentBattle.turnCommands[a]; const aCommand = this.scene.currentBattle.turnCommands[a];
const bCommand = this.scene.currentBattle.turnCommands[b]; const bCommand = this.scene.currentBattle.turnCommands[b];
@ -55,37 +97,50 @@ export class TurnStartPhase extends FieldPhase {
return -1; return -1;
} }
} else if (aCommand?.command === Command.FIGHT) { } else if (aCommand?.command === Command.FIGHT) {
const aMove = allMoves[aCommand.move!.move];//TODO: is the bang correct here? const aMove = allMoves[aCommand.move!.move];
const bMove = allMoves[bCommand!.move!.move];//TODO: is the bang correct here? const bMove = allMoves[bCommand!.move!.move];
// The game now considers priority and applies the relevant move and ability attributes
const aPriority = new Utils.IntegerHolder(aMove.priority); const aPriority = new Utils.IntegerHolder(aMove.priority);
const bPriority = new Utils.IntegerHolder(bMove.priority); const bPriority = new Utils.IntegerHolder(bMove.priority);
applyMoveAttrs(IncrementMovePriorityAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, aMove, aPriority); //TODO: is the bang correct here? applyMoveAttrs(IncrementMovePriorityAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, aMove, aPriority);
applyMoveAttrs(IncrementMovePriorityAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, bMove, bPriority); //TODO: is the bang correct here? applyMoveAttrs(IncrementMovePriorityAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, bMove, bPriority);
applyAbAttrs(ChangeMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, false, aMove, aPriority); //TODO: is the bang correct here? applyAbAttrs(ChangeMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, false, aMove, aPriority);
applyAbAttrs(ChangeMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, false, bMove, bPriority); //TODO: is the bang correct here? applyAbAttrs(ChangeMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, false, bMove, bPriority);
// The game now checks for differences in priority levels.
// If the moves share the same original priority bracket, it can check for differences in battlerBypassSpeed and return the result.
// This conditional is used to ensure that Quick Claw can still activate with abilities like Stall and Mycelium Might (attack moves only)
// Otherwise, the game returns the user of the move with the highest priority.
const isSameBracket = Math.ceil(aPriority.value) - Math.ceil(bPriority.value) === 0;
if (aPriority.value !== bPriority.value) { if (aPriority.value !== bPriority.value) {
const bracketDifference = Math.ceil(aPriority.value) - Math.ceil(bPriority.value); if (isSameBracket && battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) {
const hasSpeedDifference = battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value;
if (bracketDifference === 0 && hasSpeedDifference) {
return battlerBypassSpeed[a].value ? -1 : 1; return battlerBypassSpeed[a].value ? -1 : 1;
} }
return aPriority.value < bPriority.value ? 1 : -1; return aPriority.value < bPriority.value ? 1 : -1;
} }
} }
// If there is no difference between the move's calculated priorities, the game checks for differences in battlerBypassSpeed and returns the result.
if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) {
return battlerBypassSpeed[a].value ? -1 : 1; return battlerBypassSpeed[a].value ? -1 : 1;
} }
const aIndex = order.indexOf(a); const aIndex = moveOrder.indexOf(a);
const bIndex = order.indexOf(b); const bIndex = moveOrder.indexOf(b);
return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0; return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0;
}); });
return moveOrder;
}
start() {
super.start();
const field = this.scene.getField();
const moveOrder = this.getCommandOrder();
let orderIndex = 0; let orderIndex = 0;
@ -150,10 +205,9 @@ export class TurnStartPhase extends FieldPhase {
} }
} }
this.scene.pushPhase(new WeatherEffectPhase(this.scene)); this.scene.pushPhase(new WeatherEffectPhase(this.scene));
for (const o of order) { for (const o of moveOrder) {
if (field[o].status && field[o].status.isPostTurn()) { if (field[o].status && field[o].status.isPostTurn()) {
this.scene.pushPhase(new PostTurnStatusEffectPhase(this.scene, o)); this.scene.pushPhase(new PostTurnStatusEffectPhase(this.scene, o));
} }

View File

@ -1,6 +1,6 @@
import { BattleStat } from "#app/data/battle-stat"; import { BattleStat } from "#app/data/battle-stat";
import { MovePhase } from "#app/phases/move-phase";
import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { TurnStartPhase } from "#app/phases/turn-start-phase";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
@ -8,7 +8,6 @@ import GameManager from "#test/utils/gameManager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Mycelium Might", () => { describe("Abilities - Mycelium Might", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
@ -35,7 +34,7 @@ describe("Abilities - Mycelium Might", () => {
}); });
/** /**
* Bulbapedia References: * References:
* https://bulbapedia.bulbagarden.net/wiki/Mycelium_Might_(Ability) * https://bulbapedia.bulbagarden.net/wiki/Mycelium_Might_(Ability)
* https://bulbapedia.bulbagarden.net/wiki/Priority * https://bulbapedia.bulbagarden.net/wiki/Priority
* https://www.smogon.com/forums/threads/scarlet-violet-battle-mechanics-research.3709545/page-24 * https://www.smogon.com/forums/threads/scarlet-violet-battle-mechanics-research.3709545/page-24
@ -44,22 +43,22 @@ describe("Abilities - Mycelium Might", () => {
it("will move last in its priority bracket and ignore protective abilities", async () => { it("will move last in its priority bracket and ignore protective abilities", async () => {
await game.startBattle([Species.REGIELEKI]); await game.startBattle([Species.REGIELEKI]);
const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex();
const enemyPokemon = game.scene.getEnemyPokemon(); const enemyPokemon = game.scene.getEnemyPokemon();
const playerIndex = game.scene.getPlayerPokemon()?.getBattlerIndex();
const enemyIndex = enemyPokemon?.getBattlerIndex(); const enemyIndex = enemyPokemon?.getBattlerIndex();
game.move.select(Moves.BABY_DOLL_EYES); game.move.select(Moves.BABY_DOLL_EYES);
await game.phaseInterceptor.to(MovePhase, false); await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
// The opponent Pokemon (without Mycelium Might) goes first despite having lower speed than the player Pokemon. // The opponent Pokemon (without Mycelium Might) goes first despite having lower speed than the player Pokemon.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex);
await game.phaseInterceptor.run(MovePhase);
await game.phaseInterceptor.to(MovePhase, false);
// The player Pokemon (with Mycelium Might) goes last despite having higher speed than the opponent. // The player Pokemon (with Mycelium Might) goes last despite having higher speed than the opponent.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); expect(speedOrder).toEqual([playerIndex, enemyIndex]);
expect(commandOrder).toEqual([enemyIndex, playerIndex]);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
// Despite the opponent's ability (Clear Body), its attack stat is still reduced.
expect(enemyPokemon?.summonData.battleStats[BattleStat.ATK]).toBe(-1); expect(enemyPokemon?.summonData.battleStats[BattleStat.ATK]).toBe(-1);
}, 20000); }, 20000);
@ -67,39 +66,41 @@ describe("Abilities - Mycelium Might", () => {
game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]);
await game.startBattle([Species.REGIELEKI]); await game.startBattle([Species.REGIELEKI]);
const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex();
const enemyPokemon = game.scene.getEnemyPokemon(); const enemyPokemon = game.scene.getEnemyPokemon();
const playerIndex = game.scene.getPlayerPokemon()?.getBattlerIndex();
const enemyIndex = enemyPokemon?.getBattlerIndex(); const enemyIndex = enemyPokemon?.getBattlerIndex();
game.move.select(Moves.BABY_DOLL_EYES); game.move.select(Moves.BABY_DOLL_EYES);
await game.phaseInterceptor.to(MovePhase, false); await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
// The player Pokemon (with M.M.) goes first because its move is still within a higher priority bracket than its opponent. // The player Pokemon (with M.M.) goes first because its move is still within a higher priority bracket than its opponent.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex);
await game.phaseInterceptor.run(MovePhase);
await game.phaseInterceptor.to(MovePhase, false);
// The enemy Pokemon goes second because its move is in a lower priority bracket. // The enemy Pokemon goes second because its move is in a lower priority bracket.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); expect(speedOrder).toEqual([playerIndex, enemyIndex]);
expect(commandOrder).toEqual([playerIndex, enemyIndex]);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
// Despite the opponent's ability (Clear Body), its attack stat is still reduced.
expect(enemyPokemon?.summonData.battleStats[BattleStat.ATK]).toBe(-1); expect(enemyPokemon?.summonData.battleStats[BattleStat.ATK]).toBe(-1);
}, 20000); }, 20000);
it("will not affect non-status moves", async () => { it("will not affect non-status moves", async () => {
await game.startBattle([Species.REGIELEKI]); await game.startBattle([Species.REGIELEKI]);
const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); const playerIndex = game.scene.getPlayerPokemon()!.getBattlerIndex();
const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex();
game.move.select(Moves.QUICK_ATTACK); game.move.select(Moves.QUICK_ATTACK);
await game.phaseInterceptor.to(MovePhase, false); await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
// The player Pokemon (with M.M.) goes first because it has a higher speed and did not use a status move. // The player Pokemon (with M.M.) goes first because it has a higher speed and did not use a status move.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex);
await game.phaseInterceptor.run(MovePhase);
await game.phaseInterceptor.to(MovePhase, false);
// The enemy Pokemon (without M.M.) goes second because its speed is lower. // The enemy Pokemon (without M.M.) goes second because its speed is lower.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); // This means that the commandOrder should be identical to the speedOrder
expect(speedOrder).toEqual([playerIndex, enemyIndex]);
expect(commandOrder).toEqual([playerIndex, enemyIndex]);
}, 20000); }, 20000);
}); });

View File

@ -1,11 +1,10 @@
import { MovePhase } from "#app/phases/move-phase";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager"; import GameManager from "#test/utils/gameManager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { TurnStartPhase } from "#app/phases/turn-start-phase";
describe("Abilities - Stall", () => { describe("Abilities - Stall", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -32,7 +31,7 @@ describe("Abilities - Stall", () => {
}); });
/** /**
* Bulbapedia References: * References:
* https://bulbapedia.bulbagarden.net/wiki/Stall_(Ability) * https://bulbapedia.bulbagarden.net/wiki/Stall_(Ability)
* https://bulbapedia.bulbagarden.net/wiki/Priority * https://bulbapedia.bulbagarden.net/wiki/Priority
**/ **/
@ -40,55 +39,56 @@ describe("Abilities - Stall", () => {
it("Pokemon with Stall should move last in its priority bracket regardless of speed", async () => { it("Pokemon with Stall should move last in its priority bracket regardless of speed", async () => {
await game.startBattle([Species.SHUCKLE]); await game.startBattle([Species.SHUCKLE]);
const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); const playerIndex = game.scene.getPlayerPokemon()!.getBattlerIndex();
const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex();
game.move.select(Moves.QUICK_ATTACK); game.move.select(Moves.QUICK_ATTACK);
await game.phaseInterceptor.to(MovePhase, false); await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
// The player Pokemon (without Stall) goes first despite having lower speed than the opponent. // The player Pokemon (without Stall) goes first despite having lower speed than the opponent.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex);
await game.phaseInterceptor.run(MovePhase);
await game.phaseInterceptor.to(MovePhase, false);
// The opponent Pokemon (with Stall) goes last despite having higher speed than the player Pokemon. // The opponent Pokemon (with Stall) goes last despite having higher speed than the player Pokemon.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); expect(speedOrder).toEqual([enemyIndex, playerIndex]);
expect(commandOrder).toEqual([playerIndex, enemyIndex]);
}, 20000); }, 20000);
it("Pokemon with Stall will go first if a move that is in a higher priority bracket than the opponent's move is used", async () => { it("Pokemon with Stall will go first if a move that is in a higher priority bracket than the opponent's move is used", async () => {
await game.startBattle([Species.SHUCKLE]); await game.startBattle([Species.SHUCKLE]);
const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); const playerIndex = game.scene.getPlayerPokemon()!.getBattlerIndex();
const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex();
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to(MovePhase, false); await game.phaseInterceptor.to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
// The opponent Pokemon (with Stall) goes first because its move is still within a higher priority bracket than its opponent. // The opponent Pokemon (with Stall) goes first because its move is still within a higher priority bracket than its opponent.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex);
await game.phaseInterceptor.run(MovePhase);
await game.phaseInterceptor.to(MovePhase, false);
// The player Pokemon goes second because its move is in a lower priority bracket. // The player Pokemon goes second because its move is in a lower priority bracket.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); expect(speedOrder).toEqual([enemyIndex, playerIndex]);
expect(commandOrder).toEqual([enemyIndex, playerIndex]);
}, 20000); }, 20000);
it("If both Pokemon have stall and use the same move, speed is used to determine who goes first.", async () => { it("If both Pokemon have stall and use the same move, speed is used to determine who goes first.", async () => {
game.override.ability(Abilities.STALL); game.override.ability(Abilities.STALL);
await game.startBattle([Species.SHUCKLE]); await game.startBattle([Species.SHUCKLE]);
const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); const playerIndex = game.scene.getPlayerPokemon()!.getBattlerIndex();
const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex();
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to(MovePhase, false); await game.phaseInterceptor.to(TurnStartPhase, false);
// The opponent Pokemon (with Stall) goes first because it has a higher speed. const phase = game.scene.getCurrentPhase() as TurnStartPhase;
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); const speedOrder = phase.getSpeedOrder();
const commandOrder = phase.getCommandOrder();
await game.phaseInterceptor.run(MovePhase); // The opponent Pokemon (with Stall) goes first because it has a higher speed.
await game.phaseInterceptor.to(MovePhase, false);
// The player Pokemon (with Stall) goes second because its speed is lower. // The player Pokemon (with Stall) goes second because its speed is lower.
expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); expect(speedOrder).toEqual([enemyIndex, playerIndex]);
expect(commandOrder).toEqual([enemyIndex, playerIndex]);
}, 20000); }, 20000);
}); });

View File

@ -1,4 +1,3 @@
import { Stat } from "#app/data/pokemon-stat";
import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
import { SelectTargetPhase } from "#app/phases/select-target-phase"; import { SelectTargetPhase } from "#app/phases/select-target-phase";
import { TurnStartPhase } from "#app/phases/turn-start-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase";
@ -7,8 +6,7 @@ import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager"; import GameManager from "#test/utils/gameManager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Battle order", () => { describe("Battle order", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -37,30 +35,42 @@ describe("Battle order", () => {
await game.startBattle([ await game.startBattle([
Species.BULBASAUR, Species.BULBASAUR,
]); ]);
game.scene.getParty()[0].stats[Stat.SPD] = 50;
game.scene.currentBattle.enemyParty[0].stats[Stat.SPD] = 150; const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set playerPokemon's speed to 50
vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.run(EnemyCommandPhase); await game.phaseInterceptor.run(EnemyCommandPhase);
const playerPokemonIndex = playerPokemon.getBattlerIndex();
const enemyPokemonIndex = enemyPokemon.getBattlerIndex();
const phase = game.scene.getCurrentPhase() as TurnStartPhase; const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const order = phase.getOrder(); const order = phase.getCommandOrder();
expect(order[0]).toBe(2); expect(order[0]).toBe(enemyPokemonIndex);
expect(order[1]).toBe(0); expect(order[1]).toBe(playerPokemonIndex);
}, 20000); }, 20000);
it("Player faster than opponent 150 vs 50", async () => { it("Player faster than opponent 150 vs 50", async () => {
await game.startBattle([ await game.startBattle([
Species.BULBASAUR, Species.BULBASAUR,
]); ]);
game.scene.getParty()[0].stats[Stat.SPD] = 150;
game.scene.currentBattle.enemyParty[0].stats[Stat.SPD] = 50; const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set playerPokemon's speed to 150
vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set enemyPokemon's speed to 50
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.run(EnemyCommandPhase); await game.phaseInterceptor.run(EnemyCommandPhase);
const playerPokemonIndex = playerPokemon.getBattlerIndex();
const enemyPokemonIndex = enemyPokemon.getBattlerIndex();
const phase = game.scene.getCurrentPhase() as TurnStartPhase; const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const order = phase.getOrder(); const order = phase.getCommandOrder();
expect(order[0]).toBe(0); expect(order[0]).toBe(playerPokemonIndex);
expect(order[1]).toBe(2); expect(order[1]).toBe(enemyPokemonIndex);
}, 20000); }, 20000);
it("double - both opponents faster than player 50/50 vs 150/150", async () => { it("double - both opponents faster than player 50/50 vs 150/150", async () => {
@ -69,20 +79,25 @@ describe("Battle order", () => {
Species.BULBASAUR, Species.BULBASAUR,
Species.BLASTOISE, Species.BLASTOISE,
]); ]);
game.scene.getParty()[0].stats[Stat.SPD] = 50;
game.scene.getParty()[1].stats[Stat.SPD] = 50; const playerPokemon = game.scene.getPlayerField();
game.scene.currentBattle.enemyParty[0].stats[Stat.SPD] = 150; const enemyPokemon = game.scene.getEnemyField();
game.scene.currentBattle.enemyParty[1].stats[Stat.SPD] = 150;
playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50])); // set both playerPokemons' speed to 50
enemyPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150])); // set both enemyPokemons' speed to 150
const playerIndices = playerPokemon.map(p => p?.getBattlerIndex());
const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex());
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
game.move.select(Moves.TACKLE, 1); game.move.select(Moves.TACKLE, 1);
await game.phaseInterceptor.runFrom(SelectTargetPhase).to(TurnStartPhase, false); await game.phaseInterceptor.runFrom(SelectTargetPhase).to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase; const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const order = phase.getOrder(); const order = phase.getCommandOrder();
expect(order.indexOf(0)).toBeGreaterThan(order.indexOf(2)); expect(order.slice(0, 2).includes(enemyIndices[0])).toBe(true);
expect(order.indexOf(0)).toBeGreaterThan(order.indexOf(3)); expect(order.slice(0, 2).includes(enemyIndices[1])).toBe(true);
expect(order.indexOf(1)).toBeGreaterThan(order.indexOf(2)); expect(order.slice(2, 4).includes(playerIndices[0])).toBe(true);
expect(order.indexOf(1)).toBeGreaterThan(order.indexOf(3)); expect(order.slice(2, 4).includes(playerIndices[1])).toBe(true);
}, 20000); }, 20000);
it("double - speed tie except 1 - 100/100 vs 100/150", async () => { it("double - speed tie except 1 - 100/100 vs 100/150", async () => {
@ -91,19 +106,25 @@ describe("Battle order", () => {
Species.BULBASAUR, Species.BULBASAUR,
Species.BLASTOISE, Species.BLASTOISE,
]); ]);
game.scene.getParty()[0].stats[Stat.SPD] = 100;
game.scene.getParty()[1].stats[Stat.SPD] = 100; const playerPokemon = game.scene.getPlayerField();
game.scene.currentBattle.enemyParty[0].stats[Stat.SPD] = 100; const enemyPokemon = game.scene.getEnemyField();
game.scene.currentBattle.enemyParty[1].stats[Stat.SPD] = 150; playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100])); //set both playerPokemons' speed to 100
vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set enemyPokemon's speed to 100
vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150
const playerIndices = playerPokemon.map(p => p?.getBattlerIndex());
const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex());
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
game.move.select(Moves.TACKLE, 1); game.move.select(Moves.TACKLE, 1);
await game.phaseInterceptor.runFrom(SelectTargetPhase).to(TurnStartPhase, false); await game.phaseInterceptor.runFrom(SelectTargetPhase).to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase; const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const order = phase.getOrder(); const order = phase.getCommandOrder();
expect(order.indexOf(3)).toBeLessThan(order.indexOf(0)); expect(order[0]).toBe(enemyIndices[1]);
expect(order.indexOf(3)).toBeLessThan(order.indexOf(1)); expect(order.slice(1, 4).includes(enemyIndices[0])).toBe(true);
expect(order.indexOf(3)).toBeLessThan(order.indexOf(2)); expect(order.slice(1, 4).includes(playerIndices[0])).toBe(true);
expect(order.slice(1, 4).includes(playerIndices[1])).toBe(true);
}, 20000); }, 20000);
it("double - speed tie 100/150 vs 100/150", async () => { it("double - speed tie 100/150 vs 100/150", async () => {
@ -112,19 +133,25 @@ describe("Battle order", () => {
Species.BULBASAUR, Species.BULBASAUR,
Species.BLASTOISE, Species.BLASTOISE,
]); ]);
game.scene.getParty()[0].stats[Stat.SPD] = 100;
game.scene.getParty()[1].stats[Stat.SPD] = 150; const playerPokemon = game.scene.getPlayerField();
game.scene.currentBattle.enemyParty[0].stats[Stat.SPD] = 100; const enemyPokemon = game.scene.getEnemyField();
game.scene.currentBattle.enemyParty[1].stats[Stat.SPD] = 150; vi.spyOn(playerPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set one playerPokemon's speed to 100
vi.spyOn(playerPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other playerPokemon's speed to 150
vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set one enemyPokemon's speed to 100
vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other enemyPokemon's speed to 150
const playerIndices = playerPokemon.map(p => p?.getBattlerIndex());
const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex());
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
game.move.select(Moves.TACKLE, 1); game.move.select(Moves.TACKLE, 1);
await game.phaseInterceptor.runFrom(SelectTargetPhase).to(TurnStartPhase, false); await game.phaseInterceptor.runFrom(SelectTargetPhase).to(TurnStartPhase, false);
const phase = game.scene.getCurrentPhase() as TurnStartPhase; const phase = game.scene.getCurrentPhase() as TurnStartPhase;
const order = phase.getOrder(); const order = phase.getCommandOrder();
expect(order.indexOf(1)).toBeLessThan(order.indexOf(0)); expect(order.slice(0, 2).includes(playerIndices[1])).toBe(true);
expect(order.indexOf(1)).toBeLessThan(order.indexOf(2)); expect(order.slice(0, 2).includes(enemyIndices[1])).toBe(true);
expect(order.indexOf(3)).toBeLessThan(order.indexOf(0)); expect(order.slice(2, 4).includes(playerIndices[0])).toBe(true);
expect(order.indexOf(3)).toBeLessThan(order.indexOf(2)); expect(order.slice(2, 4).includes(enemyIndices[0])).toBe(true);
}, 20000); }, 20000);
}); });

View File

@ -381,7 +381,7 @@ export default class GameManager {
} }
/** /**
* Intercepts `TurnStartPhase` and mocks the getOrder's return value {@linkcode TurnStartPhase.getOrder} * Intercepts `TurnStartPhase` and mocks the getSpeedOrder's return value {@linkcode TurnStartPhase.getSpeedOrder}
* Used to modify the turn order. * Used to modify the turn order.
* @param {BattlerIndex[]} order The turn order to set * @param {BattlerIndex[]} order The turn order to set
* @example * @example
@ -392,7 +392,7 @@ export default class GameManager {
async setTurnOrder(order: BattlerIndex[]): Promise<void> { async setTurnOrder(order: BattlerIndex[]): Promise<void> {
await this.phaseInterceptor.to(TurnStartPhase, false); await this.phaseInterceptor.to(TurnStartPhase, false);
vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, "getOrder").mockReturnValue(order); vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, "getSpeedOrder").mockReturnValue(order);
} }
/** /**