From f428fd114c9daa6a688b8e6258f70dbbb1ca786d Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Fri, 2 Aug 2024 19:06:40 -0700 Subject: [PATCH 01/36] add override for forcing switches --- src/field/trainer.ts | 5 +++++ src/overrides.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 1348749d964..4147cd3af24 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -21,6 +21,7 @@ import i18next from "i18next"; import { PartyMemberStrength } from "#enums/party-member-strength"; import { Species } from "#enums/species"; import { TrainerType } from "#enums/trainer-type"; +import Overrides from "#app/overrides"; export enum TrainerVariant { DEFAULT, @@ -429,6 +430,10 @@ export default class Trainer extends Phaser.GameObjects.Container { } getPartyMemberMatchupScores(trainerSlot: TrainerSlot = TrainerSlot.NONE, forSwitch: boolean = false): [integer, integer][] { + if (Overrides.TRAINER_ALWAYS_SWITCHES) { + return [[1, 100], [1, 100]]; + } + if (trainerSlot && !this.isDouble()) { trainerSlot = TrainerSlot.NONE; } diff --git a/src/overrides.ts b/src/overrides.ts index 8b3d628e05e..c100c7b3892 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -116,6 +116,11 @@ class DefaultOverrides { readonly OPP_VARIANT_OVERRIDE: Variant = 0; readonly OPP_IVS_OVERRIDE: integer | integer[] = []; + // -------------------------- + // TRAINER/AI OVERRIDES + // -------------------------- + readonly TRAINER_ALWAYS_SWITCHES: boolean = false; + // ------------- // EGG OVERRIDES // ------------- From 939a3f32a802676acad464f75fc3be861add10ed Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Fri, 2 Aug 2024 20:03:39 -0700 Subject: [PATCH 02/36] minor doc improvement --- src/data/move.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/data/move.ts b/src/data/move.ts index 79e67ece581..250e00eb45b 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4889,6 +4889,11 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { private user: boolean; private batonPass: boolean; + /** + * @param user Indicates if this move effect will switch out the user of the + * move (true) or the target of the move (false). + * @param batonPass Indicates if this move is a usage of Baton Pass. + */ constructor(user?: boolean, batonPass?: boolean) { super(false, MoveEffectTrigger.POST_APPLY, false, true); this.user = !!user; From a0f8a4df5bcbea02ac2f1660e51d8b5f2914401b Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Sat, 3 Aug 2024 22:38:53 -0700 Subject: [PATCH 03/36] add new tag types for pursuit --- src/data/battler-tags.ts | 4 ++++ src/enums/battler-tag-type.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index b059b4cf6b2..6102d22317e 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1957,6 +1957,10 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source case BattlerTagType.GULP_MISSILE_ARROKUDA: case BattlerTagType.GULP_MISSILE_PIKACHU: return new GulpMissileTag(tagType, sourceMove); + case BattlerTagType.ANTICIPATING_ACTION: + return new BattlerTag(BattlerTagType.ANTICIPATING_ACTION, BattlerTagLapseType.TURN_END, 1, sourceMove); + case BattlerTagType.ESCAPING: + return new BattlerTag(BattlerTagType.ESCAPING, BattlerTagLapseType.TURN_END, 1, sourceMove); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index b133b442801..ea17b300de2 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -69,5 +69,14 @@ export enum BattlerTagType { GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA", GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU", BEAK_BLAST_CHARGING = "BEAK_BLAST_CHARGING", - SHELL_TRAP = "SHELL_TRAP" + SHELL_TRAP = "SHELL_TRAP", + /** + * Tag which indicates the battler is waiting for their opponent to make some + * sort of action (switch out, use a type of move, make contact, etc) + */ + ANTICIPATING_ACTION = "ANTICIPATING_ACTION", + /** + * Tag which indicates the battler is about to switch out. + */ + ESCAPING = "ESCAPING", } From a3a42931ba350502995bcdc00362f9a8838ba076 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Sat, 3 Aug 2024 22:41:12 -0700 Subject: [PATCH 04/36] add move attrs for pursuit --- src/data/move.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index 250e00eb45b..b9f9ac49814 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -3744,6 +3744,21 @@ export class BlizzardAccuracyAttr extends VariableAccuracyAttr { } } +const isPursuingFunc = (user: Pokemon, target: Pokemon) => + user.getTag(BattlerTagType.ANTICIPATING_ACTION) && target.getTag(BattlerTagType.ESCAPING); + +export class PursuitAccuracyAttr extends VariableAccuracyAttr { + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (isPursuingFunc(user, target)) { + const accuracy = args[0] as Utils.NumberHolder; + accuracy.value = -1; + return true; + } + + return false; + } +} + export class VariableMoveCategoryAttr extends MoveAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { return false; @@ -4482,8 +4497,8 @@ export class LapseBattlerTagAttr extends MoveEffectAttr { export class RemoveBattlerTagAttr extends MoveEffectAttr { public tagTypes: BattlerTagType[]; - constructor(tagTypes: BattlerTagType[], selfTarget: boolean = false) { - super(selfTarget); + constructor(tagTypes: BattlerTagType[], selfTarget: boolean = false, trigger?: MoveEffectTrigger) { + super(selfTarget, trigger); this.tagTypes = tagTypes; } @@ -6811,7 +6826,10 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)), new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) - .partial(), + .attr(PursuitAccuracyAttr) + .attr(AddBattlerTagOnMoveReadyAttr, BattlerTagType.ANTICIPATING_ACTION) + .attr(RemoveBattlerTagAttr, [BattlerTagType.ANTICIPATING_ACTION], true, MoveEffectTrigger.POST_APPLY) + .attr(MovePowerMultiplierAttr, (user, target) => isPursuingFunc(user, target) ? 2 : 1), new AttackMove(Moves.RAPID_SPIN, Type.NORMAL, MoveCategory.PHYSICAL, 50, 100, 40, 100, 0, 2) .attr(StatChangeAttr, BattleStat.SPD, 1, true) .attr(RemoveBattlerTagAttr, [ From 7517e16c844eb5b8db3bf3e27fdd253d29409dd9 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Sat, 3 Aug 2024 23:37:28 -0700 Subject: [PATCH 05/36] mostly implemented (missing some edge cases/interactions) --- src/data/move.ts | 51 ++++++++++++++++++++++++++++++----- src/phases.ts | 69 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index b9f9ac49814..6b7d21d3fe0 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4927,26 +4927,65 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return resolve(false); } - // Move the switch out logic inside the conditional block - // This ensures that the switch out only happens when the conditions are met const switchOutTarget = this.user ? user : target; + + let willBePursued = false; + if (switchOutTarget.hp > 0 && !this.batonPass && this.user && move.id !== Moves.TELEPORT) { + switchOutTarget.addTag(BattlerTagType.ESCAPING); + + const opposingField = user.isPlayer() ? user.scene.getEnemyField() : user.scene.getPlayerField(); + const opposingPursuitUsers = opposingField + .filter((op: Pokemon) => op.getTag(BattlerTagType.ANTICIPATING_ACTION)?.sourceMove === Moves.PURSUIT) + .sort((a, b) => b.turnData.order - a.turnData.order); + if (opposingPursuitUsers.length) { + willBePursued = true; + opposingPursuitUsers.forEach(pursuiter => { + if (user.scene.tryRemovePhase(p => p instanceof MovePhase && p.pokemon.id === pursuiter.id)) { + user.scene.prependToPhase(new MovePhase(user.scene, pursuiter, [switchOutTarget.getBattlerIndex()], pursuiter.getMoveset().find(m => m.moveId === Moves.PURSUIT) || new PokemonMove(Moves.PURSUIT), false, false), MoveEndPhase); + } + }); + } + } + if (switchOutTarget instanceof PlayerPokemon) { - switchOutTarget.leaveField(!this.batonPass); + if (!willBePursued) { + switchOutTarget.leaveField(!this.batonPass); + } if (switchOutTarget.hp > 0) { - user.scene.prependToPhase(new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), true, true), MoveEndPhase); + user.scene.prependToPhase( + new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), true, willBePursued), + MoveEndPhase + ); resolve(true); } else { resolve(false); } + return; } else if (user.scene.currentBattle.battleType !== BattleType.WILD) { + if (!user.scene.currentBattle.trainer) { + return resolve(false); // what are we even doing here + } + // Switch out logic for trainer battles - switchOutTarget.leaveField(!this.batonPass); + if (!willBePursued) { + switchOutTarget.leaveField(!this.batonPass); + } if (switchOutTarget.hp > 0) { // for opponent switching out - user.scene.prependToPhase(new SwitchSummonPhase(user.scene, switchOutTarget.getFieldIndex(), (user.scene.currentBattle.trainer ? user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), false, this.batonPass, false), MoveEndPhase); + user.scene.prependToPhase( + new SwitchSummonPhase( + user.scene, + switchOutTarget.getFieldIndex(), + user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot), + willBePursued, + this.batonPass, + false + ), + MoveEndPhase + ); } } else { // Switch out logic for everything else (eg: WILD battles) diff --git a/src/phases.ts b/src/phases.ts index 2acb054c2ca..466ab4218d5 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -2332,14 +2332,22 @@ export class TurnStartPhase extends FieldPhase { const moveOrder = order.slice(0); moveOrder.sort((a, b) => { - const aCommand = this.scene.currentBattle.turnCommands[a]; - const bCommand = this.scene.currentBattle.turnCommands[b]; + const aCommand = this.scene.currentBattle.turnCommands[a]!; + const bCommand = this.scene.currentBattle.turnCommands[b]!; - if (aCommand?.command !== bCommand?.command) { - if (aCommand?.command === Command.FIGHT) { - return 1; - } else if (bCommand?.command === Command.FIGHT) { - return -1; + if (aCommand.command !== bCommand.command) { + if (aCommand.command === Command.FIGHT) { + if (aCommand.move?.move === Moves.PURSUIT && bCommand.command === Command.POKEMON) { + return -1; + } else { + return 1; + } + } else if (bCommand.command === Command.FIGHT) { + if (bCommand.move?.move === Moves.PURSUIT && aCommand.command === Command.POKEMON) { + return 1; + } else { + return -1; + } } } else if (aCommand?.command === Command.FIGHT) { const aMove = allMoves[aCommand.move!.move];//TODO: is the bang correct here? @@ -2396,22 +2404,50 @@ export class TurnStartPhase extends FieldPhase { if (move.getMove().hasAttr(MoveHeaderAttr)) { this.scene.unshiftPhase(new MoveHeaderPhase(this.scene, pokemon, move)); } + // even though pursuit is ordered before Pokemon commands in the move + // order, the SwitchSummonPhase is unshifted onto the phase list, which + // would cause it to run before pursuit if pursuit was pushed normally. + // the SwitchSummonPhase can't be changed to a push either, because then + // the MoveHeaderPhase for all moves would run prior to the switch-out, + // which is not correct (eg, when focus punching a switching opponent, + // the correct order is switch -> tightening focus message -> attack + // fires, not focus -> switch -> attack). so, we have to specifically + // unshift pursuit when there are other pokemon commands after it, as + // well as order it before any Pokemon commands, otherwise it won't go first. + const remainingMoves = moveOrder.slice(moveOrder.findIndex(mo => mo === o) + 1); + const pendingOpposingPokemonCommands = remainingMoves.filter(o => + this.scene.currentBattle.turnCommands[o]!.command === Command.POKEMON + && (pokemon.isPlayer() ? o >= BattlerIndex.ENEMY : o < BattlerIndex.ENEMY) + ); + const arePokemonCommandsLeftInQueue = Boolean(pendingOpposingPokemonCommands.length); + const addPhase = ( + queuedMove.move === Moves.PURSUIT && arePokemonCommandsLeftInQueue + ? this.scene.unshiftPhase + : this.scene.pushPhase + ).bind(this.scene); + + // pursuit also hits the first pokemon to switch out in doubles, + // regardless of original target + const targets = queuedMove.move === Moves.PURSUIT && arePokemonCommandsLeftInQueue + ? [pendingOpposingPokemonCommands[0]] + : turnCommand.targets || turnCommand.move!.targets; if (pokemon.isPlayer()) { if (turnCommand.cursor === -1) { - this.scene.pushPhase(new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move));//TODO: is the bang correct here? + addPhase(new MovePhase(this.scene, pokemon, targets, move)); } else { - const playerPhase = new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move, false, queuedMove.ignorePP);//TODO: is the bang correct here? - this.scene.pushPhase(playerPhase); + const playerPhase = new MovePhase(this.scene, pokemon, targets, move, false, queuedMove.ignorePP); + addPhase(playerPhase); } } else { - this.scene.pushPhase(new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move, false, queuedMove.ignorePP));//TODO: is the bang correct here? + addPhase(new MovePhase(this.scene, pokemon, targets, move, false, queuedMove.ignorePP)); } break; case Command.BALL: this.scene.unshiftPhase(new AttemptCapturePhase(this.scene, turnCommand.targets![0] % 2, turnCommand.cursor!));//TODO: is the bang correct here? break; case Command.POKEMON: - this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, pokemon.getFieldIndex(), turnCommand.cursor!, true, turnCommand.args![0] as boolean, pokemon.isPlayer()));//TODO: is the bang correct here? + pokemon.addTag(BattlerTagType.ESCAPING); + this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, pokemon.getFieldIndex(), turnCommand.cursor!, true, turnCommand.args![0] as boolean, pokemon.isPlayer())); break; case Command.RUN: let runningPokemon = pokemon; @@ -4694,8 +4730,9 @@ export class SwitchPhase extends BattlePhase { * @param fieldIndex Field index to switch out * @param isModal Indicates if the switch should be forced (true) or is * optional (false). - * @param doReturn Indicates if the party member on the field should be - * recalled to ball or has already left the field. Passed to {@linkcode SwitchSummonPhase}. + * @param doReturn Indicates if this switch should call back the pokemon at + * the {@linkcode fieldIndex} (true), or if the mon has already been recalled + * (false). */ constructor(scene: BattleScene, fieldIndex: integer, isModal: boolean, doReturn: boolean) { super(scene); @@ -4723,7 +4760,9 @@ export class SwitchPhase extends BattlePhase { } // Check if there is any space still in field - if (this.isModal && this.scene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >= this.scene.currentBattle.getBattlerCount()) { + const numActiveBattlers = this.scene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length; + const willReturnModifer = (this.doReturn ? 1 : 0); // need to subtract this if doReturn is true, because the pokemon in the given index hasn't left the field yet. (used for volt switch + pursuit, etc) + if (this.isModal && numActiveBattlers - willReturnModifer >= this.scene.currentBattle.getBattlerCount()) { return super.end(); } From a8b4d6a9de3a323571ac5ba8d34b54e4aa1565ef Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Wed, 7 Aug 2024 11:54:32 -0700 Subject: [PATCH 06/36] minor strict-null issue --- src/data/move.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/data/move.ts b/src/data/move.ts index 6b7d21d3fe0..6a607878e24 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4941,7 +4941,18 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { willBePursued = true; opposingPursuitUsers.forEach(pursuiter => { if (user.scene.tryRemovePhase(p => p instanceof MovePhase && p.pokemon.id === pursuiter.id)) { - user.scene.prependToPhase(new MovePhase(user.scene, pursuiter, [switchOutTarget.getBattlerIndex()], pursuiter.getMoveset().find(m => m.moveId === Moves.PURSUIT) || new PokemonMove(Moves.PURSUIT), false, false), MoveEndPhase); + user.scene.prependToPhase( + new MovePhase( + user.scene, + pursuiter, + [switchOutTarget.getBattlerIndex()], + pursuiter.getMoveset().find(m => + m?.moveId === Moves.PURSUIT) || new PokemonMove(Moves.PURSUIT), + false, + false + ), + MoveEndPhase + ); } }); } From 5e21005fe9cf5913f2e9556762f78d8e3d0f9531 Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Thu, 8 Aug 2024 16:16:37 -0700 Subject: [PATCH 07/36] fail more obviously --- src/test/moves/u_turn.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/moves/u_turn.test.ts b/src/test/moves/u_turn.test.ts index 2c12a4da43b..01a4320d64b 100644 --- a/src/test/moves/u_turn.test.ts +++ b/src/test/moves/u_turn.test.ts @@ -51,9 +51,9 @@ describe("Moves - U-turn", () => { await game.phaseInterceptor.to(TurnEndPhase); // assert + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); expect(game.scene.getParty()[1].hp).toEqual(Math.floor(game.scene.getParty()[1].getMaxHp() * 0.33 + playerHp)); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); }, 20000); it("triggers rough skin on the u-turn user before a new pokemon is switched in", async() => { @@ -71,9 +71,9 @@ describe("Moves - U-turn", () => { // assert const playerPkm = game.scene.getPlayerPokemon()!; + expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp()); expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated - expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); }, 20000); @@ -92,8 +92,8 @@ describe("Moves - U-turn", () => { // assert const playerPkm = game.scene.getPlayerPokemon()!; - expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON); expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); + expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON); expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); }, 20000); From a086b3a0ad03b0acca948b387fdbb5b76759f010 Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Thu, 8 Aug 2024 16:16:50 -0700 Subject: [PATCH 08/36] fix switch moves again --- src/data/move.ts | 2 +- src/phases.ts | 35 ++++++++++++++++++----------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index 6a607878e24..c056e8c7b01 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4965,7 +4965,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { if (switchOutTarget.hp > 0) { user.scene.prependToPhase( - new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), true, willBePursued), + new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), "moveEffect", willBePursued), MoveEndPhase ); resolve(true); diff --git a/src/phases.ts b/src/phases.ts index 466ab4218d5..7a745a386fe 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -1842,7 +1842,7 @@ export class CheckSwitchPhase extends BattlePhase { this.scene.ui.setMode(Mode.CONFIRM, () => { this.scene.ui.setMode(Mode.MESSAGE); this.scene.tryRemovePhase(p => p instanceof PostSummonPhase && p.player && p.fieldIndex === this.fieldIndex); - this.scene.unshiftPhase(new SwitchPhase(this.scene, this.fieldIndex, false, true)); + this.scene.unshiftPhase(new SwitchPhase(this.scene, this.fieldIndex, "switchMode", true)); this.end(); }, () => { this.scene.ui.setMode(Mode.MESSAGE); @@ -4077,7 +4077,7 @@ export class FaintPhase extends PokemonPhase { } else if (nonFaintedPartyMemberCount === 1 && this.scene.currentBattle.double) { this.scene.unshiftPhase(new ToggleDoublePositionPhase(this.scene, true)); } else if (nonFaintedPartyMemberCount >= this.scene.currentBattle.getBattlerCount()) { - this.scene.pushPhase(new SwitchPhase(this.scene, this.fieldIndex, true, false)); + this.scene.pushPhase(new SwitchPhase(this.scene, this.fieldIndex, "faint", false)); } } else { this.scene.unshiftPhase(new VictoryPhase(this.scene, this.battlerIndex)); @@ -4721,55 +4721,56 @@ export class PostGameOverPhase extends Phase { */ export class SwitchPhase extends BattlePhase { protected fieldIndex: integer; - private isModal: boolean; + private switchReason: "faint" | "moveEffect" | "switchMode"; private doReturn: boolean; /** * Creates a new SwitchPhase * @param scene {@linkcode BattleScene} Current battle scene * @param fieldIndex Field index to switch out - * @param isModal Indicates if the switch should be forced (true) or is - * optional (false). + * @param switchReason Indicates why this switch is occurring. The valid options are + * `'faint'` (party member fainted), `'moveEffect'` (uturn, baton pass, dragon + * tail, etc), and `'switchMode'` (start-of-battle optional switch). This + * helps the phase determine both if the switch should be cancellable by the + * user, as well as determine if the party UI should be shown at all. * @param doReturn Indicates if this switch should call back the pokemon at * the {@linkcode fieldIndex} (true), or if the mon has already been recalled * (false). */ - constructor(scene: BattleScene, fieldIndex: integer, isModal: boolean, doReturn: boolean) { + constructor(scene: BattleScene, fieldIndex: integer, switchReason: "faint" | "moveEffect" | "switchMode", doReturn: boolean) { super(scene); this.fieldIndex = fieldIndex; - this.isModal = isModal; + this.switchReason = switchReason; this.doReturn = doReturn; } start() { super.start(); - // Skip modal switch if impossible (no remaining party members that aren't in battle) - if (this.isModal && !this.scene.getParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) { + const isForcedSwitch = this.switchReason !== "switchMode"; + + // Skip forced switch if impossible (no remaining party members that aren't in battle) + if (isForcedSwitch && !this.scene.getParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) { return super.end(); } - // Skip if the fainted party member has been revived already. doReturn is - // only passed as `false` from FaintPhase (as opposed to other usages such - // as ForceSwitchOutAttr or CheckSwitchPhase), so we only want to check this - // if the mon should have already been returned but is still alive and well - // on the field. see also; battle.test.ts - if (this.isModal && !this.doReturn && !this.scene.getParty()[this.fieldIndex].isFainted()) { + // Skip if the fainted party member has been revived already. see also; battle.test.ts + if (this.switchReason === "faint" && !this.scene.getParty()[this.fieldIndex].isFainted()) { return super.end(); } // Check if there is any space still in field const numActiveBattlers = this.scene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length; const willReturnModifer = (this.doReturn ? 1 : 0); // need to subtract this if doReturn is true, because the pokemon in the given index hasn't left the field yet. (used for volt switch + pursuit, etc) - if (this.isModal && numActiveBattlers - willReturnModifer >= this.scene.currentBattle.getBattlerCount()) { + if (isForcedSwitch && numActiveBattlers - willReturnModifer >= this.scene.currentBattle.getBattlerCount()) { return super.end(); } // Override field index to 0 in case of double battle where 2/3 remaining legal party members fainted at once const fieldIndex = this.scene.currentBattle.getBattlerCount() === 1 || this.scene.getParty().filter(p => p.isAllowedInBattle()).length > 1 ? this.fieldIndex : 0; - this.scene.ui.setMode(Mode.PARTY, this.isModal ? PartyUiMode.FAINT_SWITCH : PartyUiMode.POST_BATTLE_SWITCH, fieldIndex, (slotIndex: integer, option: PartyOption) => { + this.scene.ui.setMode(Mode.PARTY, isForcedSwitch ? PartyUiMode.FAINT_SWITCH : PartyUiMode.POST_BATTLE_SWITCH, fieldIndex, (slotIndex: integer, option: PartyOption) => { if (slotIndex >= this.scene.currentBattle.getBattlerCount() && slotIndex < 6) { this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, fieldIndex, slotIndex, this.doReturn, option === PartyOption.PASS_BATON)); } From c1019bac3926021f791235269f6ad0fe27cceb47 Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Thu, 8 Aug 2024 16:34:04 -0700 Subject: [PATCH 09/36] fix switching override in doubles --- src/field/trainer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 4147cd3af24..e8a98e64bc8 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -430,16 +430,17 @@ export default class Trainer extends Phaser.GameObjects.Container { } getPartyMemberMatchupScores(trainerSlot: TrainerSlot = TrainerSlot.NONE, forSwitch: boolean = false): [integer, integer][] { - if (Overrides.TRAINER_ALWAYS_SWITCHES) { - return [[1, 100], [1, 100]]; - } - if (trainerSlot && !this.isDouble()) { trainerSlot = TrainerSlot.NONE; } const party = this.scene.getEnemyParty(); const nonFaintedLegalPartyMembers = party.slice(this.scene.currentBattle.getBattlerCount()).filter(p => p.isAllowedInBattle()).filter(p => !trainerSlot || p.trainerSlot === trainerSlot); + + if (Overrides.TRAINER_ALWAYS_SWITCHES) { + return nonFaintedLegalPartyMembers.map(p => [party.indexOf(p), 100]); + } + const partyMemberScores = nonFaintedLegalPartyMembers.map(p => { const playerField = this.scene.getPlayerField().filter(p => p.isAllowedInBattle()); let score = 0; From e325af1f0bbcf4898a6865781ac5d235959d85be Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Mon, 12 Aug 2024 21:37:54 -0700 Subject: [PATCH 10/36] rename overide to match --- src/field/trainer.ts | 2 +- src/overrides.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/field/trainer.ts b/src/field/trainer.ts index e8a98e64bc8..6dd5eed2a8e 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -437,7 +437,7 @@ export default class Trainer extends Phaser.GameObjects.Container { const party = this.scene.getEnemyParty(); const nonFaintedLegalPartyMembers = party.slice(this.scene.currentBattle.getBattlerCount()).filter(p => p.isAllowedInBattle()).filter(p => !trainerSlot || p.trainerSlot === trainerSlot); - if (Overrides.TRAINER_ALWAYS_SWITCHES) { + if (Overrides.TRAINER_ALWAYS_SWITCHES_OVERRIDE) { return nonFaintedLegalPartyMembers.map(p => [party.indexOf(p), 100]); } diff --git a/src/overrides.ts b/src/overrides.ts index c100c7b3892..00b309f18a7 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -119,7 +119,10 @@ class DefaultOverrides { // -------------------------- // TRAINER/AI OVERRIDES // -------------------------- - readonly TRAINER_ALWAYS_SWITCHES: boolean = false; + /** + * Force enemy AI to always switch pkmn + */ + readonly TRAINER_ALWAYS_SWITCHES_OVERRIDE: boolean = false; // ------------- // EGG OVERRIDES From d6ec1747159ac76dcae14ebec0f8ba94c9c9bba9 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Mon, 12 Aug 2024 21:38:11 -0700 Subject: [PATCH 11/36] add trainer party override --- src/field/trainer.ts | 11 +++++++++++ src/overrides.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 6dd5eed2a8e..c208fa59192 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -263,6 +263,17 @@ export default class Trainer extends Phaser.GameObjects.Container { let ret: EnemyPokemon; this.scene.executeWithSeedOffset(() => { + if (Overrides.TRAINER_PARTY_OVERRIDE?.length) { + ret = this.scene.addEnemyPokemon( + getPokemonSpecies(Overrides.TRAINER_PARTY_OVERRIDE[index % Overrides.TRAINER_PARTY_OVERRIDE.length]), + level, + !this.isDouble() || !(index % 2) + ? TrainerSlot.TRAINER + : TrainerSlot.TRAINER_PARTNER + ); + return; + } + const template = this.getPartyTemplate(); const strength: PartyMemberStrength = template.getStrength(index); diff --git a/src/overrides.ts b/src/overrides.ts index 00b309f18a7..ea1e20aab42 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -123,6 +123,17 @@ class DefaultOverrides { * Force enemy AI to always switch pkmn */ readonly TRAINER_ALWAYS_SWITCHES_OVERRIDE: boolean = false; + /** + * Force enemy trainer battles to always pick pkmn in this order. If the + * trainer would have more Pokemon than in the array, it will wrap around to + * the beginning. If the trainer would have less Pokemon than in the array, + * it will ignore the extras. + * + * Has no effect on wild battles. Only affects newly generated trainers (eg, + * won't work on a saved trainer wave). OPP_SPECIES_OVERRIDE and other OPP_ + * overrides will supercede this value. + */ + readonly TRAINER_PARTY_OVERRIDE: Species[] = []; // ------------- // EGG OVERRIDES From c6e93985ca84a6eae14a67a383ed9fdee5658140 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Mon, 12 Aug 2024 21:38:24 -0700 Subject: [PATCH 12/36] bonus uturn test --- src/test/moves/u_turn.test.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/test/moves/u_turn.test.ts b/src/test/moves/u_turn.test.ts index 01a4320d64b..175e8008472 100644 --- a/src/test/moves/u_turn.test.ts +++ b/src/test/moves/u_turn.test.ts @@ -1,5 +1,5 @@ import { Abilities } from "#app/enums/abilities.js"; -import { SwitchPhase, TurnEndPhase } from "#app/phases"; +import { BerryPhase, SwitchPhase, TurnEndPhase } from "#app/phases"; import GameManager from "#app/test/utils/gameManager"; import { getMovePosition } from "#app/test/utils/gameManagerUtils"; import { Moves } from "#enums/moves"; @@ -8,6 +8,7 @@ import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { StatusEffect } from "#app/enums/status-effect.js"; import { SPLASH_ONLY } from "../utils/testUtils"; +import { Mode } from "#app/ui/ui.js"; describe("Moves - U-turn", () => { let phaserGame: Phaser.Game; @@ -97,4 +98,27 @@ describe("Moves - U-turn", () => { expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); }, 20000); + + it("does not switch out the user if the move fails", async () => { + // arrange + game.override + .enemySpecies(Species.DUGTRIO) + .moveset(Moves.VOLT_SWITCH); // cheating a little here but no types are immune to bug + await game.startBattle([ + Species.RAICHU, + Species.SHUCKLE + ]); + + // act + game.doAttack(getMovePosition(game.scene, 0, Moves.U_TURN)); + game.onNextPrompt("SwitchPhase", Mode.PARTY, () => { + expect.fail("Switch was forced"); + }, () => game.isCurrentPhase(BerryPhase)); + await game.phaseInterceptor.to(BerryPhase, false); + + // assert + const playerPkm = game.scene.getPlayerPokemon()!; + expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + }, 20000); }); From eb1d195866c7759457db8e894707a9b4d6b677cf Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Mon, 12 Aug 2024 21:40:21 -0700 Subject: [PATCH 13/36] add shorthand for setting a moveset to all one move --- src/test/utils/helpers/overridesHelper.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index dbcb02825f2..2e4319b211f 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -111,7 +111,10 @@ export class OverridesHelper extends GameManagerHelper { * @param moveset the {@linkcode Moves | moves}set to set * @returns this */ - moveset(moveset: Moves[]): this { + moveset(moveset: Moves[] | Moves): this { + if (!Array.isArray(moveset)) { + moveset = new Array(4).fill(moveset); + } vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue(moveset); const movesetStr = moveset.map((moveId) => Moves[moveId]).join(", "); this.log(`Player Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`); @@ -230,7 +233,10 @@ export class OverridesHelper extends GameManagerHelper { * @param moveset the {@linkcode Moves | moves}set to set * @returns this */ - enemyMoveset(moveset: Moves[]): this { + enemyMoveset(moveset: Moves[] | Moves): this { + if (!Array.isArray(moveset)) { + moveset = new Array(4).fill(moveset); + } vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); const movesetStr = moveset.map((moveId) => Moves[moveId]).join(", "); this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`); From c080ba0b46e2cdeaeed8f76c65bfdcd3f3704a72 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Mon, 12 Aug 2024 21:40:56 -0700 Subject: [PATCH 14/36] add methods to overridesHelper for new overrides --- src/test/utils/helpers/overridesHelper.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index 2e4319b211f..27509567025 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -276,6 +276,22 @@ export class OverridesHelper extends GameManagerHelper { return this; } + enemyParty(species: Species[]) { + vi.spyOn(Overrides, "TRAINER_PARTY_OVERRIDE", "get").mockReturnValue(species); + this.log("Enemy trainer party set to:", species); + return this; + } + + /** + * Forces the AI to always switch out + * @returns this + */ + forceTrainerSwitches() { + vi.spyOn(Overrides, "TRAINER_ALWAYS_SWITCHES_OVERRIDE", "get").mockReturnValue(true); + this.log("Trainers will always switch out"); + return this; + } + private log(...params: any[]) { console.log("Overrides:", ...params); } From 9d721f6610bebec3fb010d062285e179a3b7205b Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Mon, 12 Aug 2024 21:42:01 -0700 Subject: [PATCH 15/36] basic pursuit functionality tested --- src/test/moves/pursuit.test.ts | 327 +++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/test/moves/pursuit.test.ts diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts new file mode 100644 index 00000000000..f1eb9227ff8 --- /dev/null +++ b/src/test/moves/pursuit.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import GameManager from "../utils/gameManager"; +import { Moves } from "#app/enums/moves.js"; +import { Species } from "#app/enums/species.js"; +import { SPLASH_ONLY } from "../utils/testUtils"; +import { allMoves } from "#app/data/move.js"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase } from "#app/phases.js"; +import Pokemon, { MoveResult } from "#app/field/pokemon.js"; +import { BattleStat } from "#app/data/battle-stat.js"; + +interface PokemonAssertionChainer { + and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer; +} + +function chain(pokemon?: Pokemon): PokemonAssertionChainer { + return { + and: (expectation) => { + return expectation(pokemon); + } + }; +} + +describe("Moves - Pursuit", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + const pursuitMoveDef = allMoves[Moves.PURSUIT]; + + const playerLead = Species.BULBASAUR; + const enemyLead = Species.KANGASKHAN; + + function startBattle() { + return game.startBattle([playerLead, Species.RAICHU, Species.ABSOL]); + } + + function runCombatTurn() { + return game.phaseInterceptor.to(BerryPhase, false); + } + + function playerUsesPursuit(pokemonIndex: 0 | 1 = 0) { + game.doAttack(getMovePosition(game.scene, pokemonIndex, Moves.PURSUIT)); + } + + function playerUsesSwitchMove(pokemonIndex: 0 | 1 = 0, move: Moves.U_TURN | Moves.BATON_PASS | Moves.TELEPORT = Moves.U_TURN) { + game.doAttack(getMovePosition(game.scene, pokemonIndex, move)); + game.doSelectPartyPokemon(2); + } + + function playerSwitches(pokemonIndex: number = 1) { + game.doSwitchPokemon(pokemonIndex); + } + + function enemyUses(move: Moves) { + game.override.enemyMoveset(move); + } + + function enemySwitches() { + game.override.forceTrainerSwitches(); + } + + function forceMovesLast(pokemon?: Pokemon) { + pokemon!.summonData.battleStats[BattleStat.SPD] = -6; + } + + function expectPursuitPowerDoubled() { + expect(pursuitMoveDef.calculateBattlePower).toHaveReturnedWith(80); + } + + function expectPursuitPowerUnchanged() { + expect(pursuitMoveDef.calculateBattlePower).toHaveReturnedWith(40); + } + + function expectPursuitFailed(pokemon?: Pokemon) { + const lastMove = pokemon!.getLastXMoves(0)[0]; + expect(lastMove.result).toBe(MoveResult.FAIL); + return chain(pokemon); + } + + function expectWasHit(pokemon?: Pokemon) { + expect(pokemon!.hp).toBeLessThan(pokemon!.getMaxHp()); + return chain(pokemon); + } + + function expectWasNotHit(pokemon?: Pokemon) { + expect(pokemon!.hp).toBe(pokemon!.getMaxHp()); + return chain(pokemon); + } + + function expectNotOnField(pokemon?: Pokemon) { + expect(pokemon!.isOnField()).toBe(false); + return chain(pokemon); + } + + function expectHasFled(pokemon?: Pokemon) { + expect(pokemon!.wildFlee).toBe(true); + return chain(pokemon); + } + + function findPartyMember(party: Pokemon[], species: Species) { + return party.find(pkmn => pkmn.species.speciesId === species); + } + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .enemyParty([enemyLead, Species.SNORLAX, Species.BASCULIN]) + .startingLevel(20) + .startingWave(25) + .moveset([Moves.PURSUIT, Moves.U_TURN, Moves.BATON_PASS, Moves.TELEPORT]) + .enemyMoveset(SPLASH_ONLY) + .disableCrits(); + + vi.spyOn(pursuitMoveDef, "calculateBattlePower"); + }); + + it("should hit for normal power if the target is not switching", async () => { + // arrange + await startBattle(); + + // act + playerUsesPursuit(); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(game.scene.getEnemyPokemon()); + }); + + it("should hit a hard-switching target for double power (player attacks, enemy switches)", async () => { + // arrange + await startBattle(); + + // act + playerUsesPursuit(); + enemySwitches(); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasNotHit(game.scene.getEnemyPokemon()); + expectNotOnField(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectWasHit); + }); + + it("should hit a hard-switching target for double power (player switches, enemy attacks)", async () => { + // arrange + await startBattle(); + + // act + playerSwitches(); + enemyUses(Moves.PURSUIT); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasNotHit(game.scene.getPlayerPokemon()); + expectNotOnField(findPartyMember(game.scene.getParty(), playerLead)) + .and(expectWasHit); + }); + + it("should hit an outgoing uturning target if pursuiter has not moved yet (player attacks, enemy switches)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasNotHit(game.scene.getEnemyPokemon()); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectNotOnField); + }); + + it("should hit an outgoing uturning target if pursuiter has not moved yet (player switches, enemy attacks)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); + + // act + playerUsesSwitchMove(); + enemyUses(Moves.PURSUIT); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasNotHit(game.scene.getPlayerPokemon()); + expectWasHit(findPartyMember(game.scene.getParty(), playerLead)) + .and(expectNotOnField); + }); + + it("should bypass accuracy checks when hitting a hard-switching target", async () => { + // arrange + await startBattle(); + game.scene.getPlayerPokemon()!.summonData.battleStats[BattleStat.ACC] = -6; + game.scene.getEnemyPokemon()!.summonData.battleStats[BattleStat.EVA] = 6; + + // act + playerUsesPursuit(); + enemySwitches(); + await runCombatTurn(); + + // assert + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); + }); + + it("should bypass accuracy checks when hitting a uturning target", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.getEnemyPokemon()!.summonData.battleStats[BattleStat.ACC] = -6; + game.scene.getPlayerPokemon()!.summonData.battleStats[BattleStat.EVA] = 6; + + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); + + // assert + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); + }); + + it("should not hit a baton pass user", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.getEnemyPokemon()!.summonData.battleStats[BattleStat.ACC] = -6; + game.scene.getPlayerPokemon()!.summonData.battleStats[BattleStat.EVA] = 6; + + // act + playerUsesPursuit(); + enemyUses(Moves.BATON_PASS); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(game.scene.getEnemyPokemon()); + expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectNotOnField); + }); + + it("should not hit a teleport user", () => async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + vi.spyOn(pursuitMoveDef, "priority", "get").mockReturnValue(-6); + + // act + playerUsesPursuit(); + enemyUses(Moves.TELEPORT); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(game.scene.getEnemyPokemon()); + expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectNotOnField); + }); + + it("should not hit a fleeing wild pokemon", async () => { + // arrange + game.override + .startingWave(24) + .disableTrainerWaves(); + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); + + // assert + expectPursuitFailed(game.scene.getPlayerPokemon()); + expectWasNotHit(game.scene.getEnemyParty()[0]) + .and(expectHasFled); + }); + + it.todo("should not hit a switch move user for double damage if the switch move fails and does not switch out the user"); + + it.todo("triggers contact abilities on the pokemon that is switching out (hard-switch)"); + + it.todo("triggers contact abilities on the pokemon that is switching out (switch move, player switching)"); + + it.todo("triggers contact abilities on the pokemon that is switching out (switch move, enemy switching)"); + + it.todo("should bypass follow me when hitting a switching target"); + + it.todo("should bypass substitute when hitting an escaping target"); + + it.todo("should hit a grounded, switching target under Psychic Terrain"); + + describe("doubles interactions", () => { + it.todo("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one"); + + it.todo("should not hit a pokemon being forced out with dragon tail"); + + it.todo("should not hit a uturning target for double power if the pursuiter moves before the uturner"); + + it.todo("should hit the first pokemon to switch out in a double battle regardless of who was targeted"); + + it.todo("should not hit both pokemon in a double battle if both switch out"); + + it.todo("should not hit a switching ally (hard-switch, player field)"); + + it.todo("should not hit a switching ally (hard-switch, enemy field)"); + + it.todo("should not hit a switching ally (switch move, player field)"); + + it.todo("should not hit a switching ally (switch move, enemy field)"); + }); +}); From 8a4a297cd65281c0e557a1bb1de0d56dd5d1020c Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:08 -0700 Subject: [PATCH 16/36] remove whenAboutToRun --- src/test/ui/starter-select.test.ts | 18 +++++++++--------- src/test/utils/gameManager.ts | 2 +- src/test/utils/phaseInterceptor.ts | 15 --------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/test/ui/starter-select.test.ts b/src/test/ui/starter-select.test.ts index 020b26b7f66..ab3783d041d 100644 --- a/src/test/ui/starter-select.test.ts +++ b/src/test/ui/starter-select.test.ts @@ -89,7 +89,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); @@ -153,7 +153,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); @@ -220,7 +220,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); @@ -285,7 +285,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); @@ -345,7 +345,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(false); @@ -406,7 +406,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); @@ -468,7 +468,7 @@ describe("UI - Starter select", () => { resolve(); }); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); @@ -535,7 +535,7 @@ describe("UI - Starter select", () => { const saveSlotSelectUiHandler = game.scene.ui.getHandler() as SaveSlotSelectUiHandler; saveSlotSelectUiHandler.processInput(Button.ACTION); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.CATERPIE); }, 20000); @@ -601,7 +601,7 @@ describe("UI - Starter select", () => { const saveSlotSelectUiHandler = game.scene.ui.getHandler() as SaveSlotSelectUiHandler; saveSlotSelectUiHandler.processInput(Button.ACTION); }); - await game.phaseInterceptor.whenAboutToRun(EncounterPhase); + await game.phaseInterceptor.to(EncounterPhase, false); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.NIDORAN_M); }, 20000); }); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 27ba7a215eb..2d54782b2e8 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -108,7 +108,7 @@ export default class GameManager { * @returns A promise that resolves when the title phase is reached. */ async runToTitle(): Promise { - await this.phaseInterceptor.whenAboutToRun(LoginPhase); + await this.phaseInterceptor.to(LoginPhase, false); this.phaseInterceptor.pop(); await this.phaseInterceptor.run(TitlePhase); diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 34f79f93b6e..8957c8f87a3 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -213,21 +213,6 @@ export default class PhaseInterceptor { }); } - whenAboutToRun(phaseTarget, skipFn?): Promise { - const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; - this.scene.moveAnimations = null; // Mandatory to avoid crash - return new Promise(async (resolve, reject) => { - ErrorInterceptor.getInstance().add(this); - const interval = setInterval(async () => { - const currentPhase = this.onHold[0]; - if (currentPhase?.name === targetName) { - clearInterval(interval); - resolve(); - } - }); - }); - } - pop() { this.onHold.pop(); this.scene.shiftPhase(); From c628759756621b6a13ab2bfa9f78973637de5979 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:09 -0700 Subject: [PATCH 17/36] fix some types --- src/test/utils/gameManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 2d54782b2e8..db46bdaf6fa 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -11,7 +11,7 @@ import { AES, enc } from "crypto-js"; import { updateUserInfo } from "#app/account"; import InputsHandler from "#app/test/utils/inputsHandler"; import ErrorInterceptor from "#app/test/utils/errorInterceptor"; -import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import Pokemon from "#app/field/pokemon"; import { MockClock } from "#app/test/utils/mocks/mockClock"; import PartyUiHandler from "#app/ui/party-ui-handler"; import CommandUiHandler, { Command } from "#app/ui/command-ui-handler"; @@ -181,7 +181,7 @@ export default class GameManager { * Emulate a player attack * @param movePosition the index of the move in the pokemon's moveset array */ - doAttack(movePosition: integer) { + doAttack(movePosition: number) { this.onNextPrompt("CommandPhase", Mode.COMMAND, () => { this.scene.ui.setMode(Mode.FIGHT, (this.scene.getCurrentPhase() as CommandPhase).getFieldIndex()); }); @@ -189,7 +189,7 @@ export default class GameManager { (this.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, movePosition, false); }); - // Confirm target selection if move is multi-target + // Confirm target selection if move hits multiple field members (no target selection available) this.onNextPrompt("SelectTargetPhase", Mode.TARGET_SELECT, () => { const handler = this.scene.ui.getHandler() as TargetSelectUiHandler; const move = (this.scene.getCurrentPhase() as SelectTargetPhase).getPokemon().getMoveset()[movePosition]!.getMove(); // TODO: is the bang correct? @@ -319,7 +319,7 @@ export default class GameManager { return updateUserInfo(); } - async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) { + async killPokemon(pokemon: Pokemon) { (this.scene.time as MockClock).overrideDelay = 0.01; return new Promise(async(resolve, reject) => { pokemon.hp = 0; From 1bb79b87f63192ddd04d21197789589cd788d214 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:09 -0700 Subject: [PATCH 18/36] slightly better overrides --- src/test/utils/helpers/overridesHelper.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index 27509567025..0f398e62604 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -276,6 +276,11 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Overrides the trainer AI's party + * @param species List of pokemon to generate in the party + * @returns this + */ enemyParty(species: Species[]) { vi.spyOn(Overrides, "TRAINER_PARTY_OVERRIDE", "get").mockReturnValue(species); this.log("Enemy trainer party set to:", species); @@ -283,12 +288,12 @@ export class OverridesHelper extends GameManagerHelper { } /** - * Forces the AI to always switch out + * Forces the AI to always switch out, or reset to allow normal switching decisions * @returns this */ - forceTrainerSwitches() { - vi.spyOn(Overrides, "TRAINER_ALWAYS_SWITCHES_OVERRIDE", "get").mockReturnValue(true); - this.log("Trainers will always switch out"); + forceTrainerSwitches(newValue: boolean = true) { + vi.spyOn(Overrides, "TRAINER_ALWAYS_SWITCHES_OVERRIDE", "get").mockReturnValue(newValue); + this.log("Trainers will always switch out set to:", newValue); return this; } From c31809ed3528d99fe1d16ce92ae44f6c6d136af4 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:10 -0700 Subject: [PATCH 19/36] add helper for forcing AI move targetting --- src/test/utils/helpers/moveHelper.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/utils/helpers/moveHelper.ts b/src/test/utils/helpers/moveHelper.ts index 9438952aa92..b4f9cf2d4a5 100644 --- a/src/test/utils/helpers/moveHelper.ts +++ b/src/test/utils/helpers/moveHelper.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; import { MoveEffectPhase } from "#app/phases.js"; import { GameManagerHelper } from "./gameManagerHelper"; +import { BattlerIndex } from "#app/battle.js"; +import { EnemyPokemon } from "#app/field/pokemon.js"; /** * Helper to handle a Pokemon's move @@ -32,4 +34,13 @@ export class MoveHelper extends GameManagerHelper { hitCheck.mockReturnValue(false); } } + + /** + * Forces an enemy Pokemon to attack into a certain slot + * @param pokemon Pokemon to force the attack of + * @param slot BattlerIndex to force the attack into + */ + forceAiTargets(pokemon: EnemyPokemon | undefined, slot: BattlerIndex | BattlerIndex[]) { + vi.spyOn(pokemon!, "getNextTargets").mockReturnValue(Array.isArray(slot) ? slot : [slot]); + } } From b681bc386ae6319739f6f674018869b8014911db Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:10 -0700 Subject: [PATCH 20/36] add better phase logging and a fast timeout for stalled prompts --- src/test/utils/phaseInterceptor.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 8957c8f87a3..0a09dd3be0d 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -290,6 +290,13 @@ export default class PhaseInterceptor { * Method to start the prompt handler. */ startPromptHandler() { + const PROMPT_TIMEOUT = 1000; + + let timeSpentInPrompt = 0; + let lastTime = Date.now(); + let lastPhase, lastPromptPhase, lastMode; + let warned = false; + this.promptInterval = setInterval(() => { if (this.prompts.length) { const actionForNextPrompt = this.prompts[0]; @@ -297,9 +304,30 @@ export default class PhaseInterceptor { const currentMode = this.scene.ui.getMode(); const currentPhase = this.scene.getCurrentPhase().constructor.name; const currentHandler = this.scene.ui.getHandler(); + + if (lastPhase === currentPhase && lastPromptPhase === actionForNextPrompt.phaseTarget && lastMode === currentMode) { + const currentTime = Date.now(); + timeSpentInPrompt += currentTime - lastTime; + lastTime = currentTime; + + if (timeSpentInPrompt > PROMPT_TIMEOUT && !warned) { + console.error("Prompt handling stalled waiting for prompt:", actionForNextPrompt); + expect.fail("Prompt timeout"); + } + } else { + warned = false; + lastMode = currentMode; + lastPhase = currentPhase; + lastPromptPhase = actionForNextPrompt.phaseTarget; + timeSpentInPrompt = 0; + } + + if (expireFn) { this.prompts.shift(); + console.log(`Prompt for ${actionForNextPrompt.phaseTarget} (mode ${actionForNextPrompt.mode}) has expired`); } else if (currentMode === actionForNextPrompt.mode && currentPhase === actionForNextPrompt.phaseTarget && currentHandler.active && (!actionForNextPrompt.awaitingActionInput || (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput))) { + console.log(`Prompt for ${actionForNextPrompt.phaseTarget} (mode ${actionForNextPrompt.mode}) has triggered`); this.prompts.shift().callback(); } } @@ -321,6 +349,7 @@ export default class PhaseInterceptor { expireFn, awaitingActionInput }); + console.log(`Prompt added for ${phaseTarget} (mode ${mode})`); } /** From eb11b951e67b978a0f9d2170f54c57bc52c04cde Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:11 -0700 Subject: [PATCH 21/36] log turn order changes --- src/test/utils/gameManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index db46bdaf6fa..9f481eaed6e 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -384,6 +384,7 @@ export default class GameManager { */ async setTurnOrder(order: BattlerIndex[]): Promise { await this.phaseInterceptor.to(TurnStartPhase, false); + console.log("Base turn order (before priority) set to:", order); vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, "getOrder").mockReturnValue(order); } From d45ed3d9d3d434e928ec93ef6afbdd1e5a2926ed Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:11 -0700 Subject: [PATCH 22/36] add onNextPhase and advance to phaseInterceptor --- src/test/utils/phaseInterceptor.ts | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 0a09dd3be0d..b1a826ee491 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -38,6 +38,9 @@ import UI, { Mode } from "#app/ui/ui"; import { Phase } from "#app/phase"; import ErrorInterceptor from "#app/test/utils/errorInterceptor"; import { QuietFormChangePhase } from "#app/form-change-phase"; +import { expect } from "vitest"; + +type PhaseClassType = (abstract new (...args: any) => Phase); // `typeof Phase` does not work here because of some issue with ctor signatures export default class PhaseInterceptor { public scene; @@ -173,6 +176,14 @@ export default class PhaseInterceptor { }); } + /** + * Advance a single phase + * @returns A promise that resolves when the next phase has started + */ + advance(): Promise { + return this.run(this.onHold[0]); + } + /** * Method to run a phase with an optional skip function. * @param phaseTarget - The phase to run. @@ -213,6 +224,27 @@ export default class PhaseInterceptor { }); } + /** + * The next time a phase of the given type would be run, first run the provided callback. + * The phase instance is passed to the callback, for easier mocking. + * + * This function does not actually start running phases - for that, see {@linkcode to()}. + * @param phaseType Class type of the phase you want to tap + * @param cb callback to run when the phase next arrives + */ + onNextPhase(phaseType: T, cb: (phase: InstanceType) => void) { + const targetName = phaseType.name; + this.scene.moveAnimations = null; // Mandatory to avoid crash + ErrorInterceptor.getInstance().add(this); + const interval = setInterval(async () => { + const currentPhase = this.onHold[0]; + if (currentPhase?.name === targetName) { + clearInterval(interval); + cb(currentPhase); + } + }); + } + pop() { this.onHold.pop(); this.scene.shiftPhase(); From 1b7ccb191216b54cced415ca4e908fdd3eec2ef4 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:11 -0700 Subject: [PATCH 23/36] don't crash if a test faints a pokemon between CommandPhase and TurnStartPhase --- src/phases.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/phases.ts b/src/phases.ts index 7a745a386fe..94a33fa9285 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -2372,8 +2372,8 @@ export class TurnStartPhase extends FieldPhase { } } - if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { - return battlerBypassSpeed[a].value ? -1 : 1; + if (battlerBypassSpeed[a]?.value !== battlerBypassSpeed[b]?.value) { + return battlerBypassSpeed[a]?.value ? -1 : 1; } const aIndex = order.indexOf(a); From daebb3a91c21a19d0f6dcf337fc4e991f41c8778 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:12 -0700 Subject: [PATCH 24/36] fix some issues with prompt timeouts --- src/test/utils/phaseInterceptor.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index b1a826ee491..91fadf0eb86 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -322,10 +322,10 @@ export default class PhaseInterceptor { * Method to start the prompt handler. */ startPromptHandler() { - const PROMPT_TIMEOUT = 1000; + const PROMPT_TIMEOUT = 2000; let timeSpentInPrompt = 0; - let lastTime = Date.now(); + let lastTime: number | undefined = undefined; let lastPhase, lastPromptPhase, lastMode; let warned = false; @@ -337,9 +337,9 @@ export default class PhaseInterceptor { const currentPhase = this.scene.getCurrentPhase().constructor.name; const currentHandler = this.scene.ui.getHandler(); - if (lastPhase === currentPhase && lastPromptPhase === actionForNextPrompt.phaseTarget && lastMode === currentMode) { + if (lastPhase === currentPhase && lastPromptPhase === actionForNextPrompt.phaseTarget && lastMode === currentMode && currentMode !== Mode.MESSAGE) { const currentTime = Date.now(); - timeSpentInPrompt += currentTime - lastTime; + timeSpentInPrompt += lastTime === undefined ? 0 : currentTime - lastTime; lastTime = currentTime; if (timeSpentInPrompt > PROMPT_TIMEOUT && !warned) { @@ -358,8 +358,12 @@ export default class PhaseInterceptor { if (expireFn) { this.prompts.shift(); console.log(`Prompt for ${actionForNextPrompt.phaseTarget} (mode ${actionForNextPrompt.mode}) has expired`); + timeSpentInPrompt = 0; + lastTime = undefined; } else if (currentMode === actionForNextPrompt.mode && currentPhase === actionForNextPrompt.phaseTarget && currentHandler.active && (!actionForNextPrompt.awaitingActionInput || (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput))) { console.log(`Prompt for ${actionForNextPrompt.phaseTarget} (mode ${actionForNextPrompt.mode}) has triggered`); + timeSpentInPrompt = 0; + lastTime = undefined; this.prompts.shift().callback(); } } From 74cbdbb8373494074430bbc0b898fdf78151d354 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:12 -0700 Subject: [PATCH 25/36] finish writing pursuit tests (5 failures to fix) --- src/test/moves/pursuit.test.ts | 412 ++++++++++++++++++++++++++++++--- 1 file changed, 382 insertions(+), 30 deletions(-) diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index f1eb9227ff8..db390beb337 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -5,12 +5,16 @@ import { Species } from "#app/enums/species.js"; import { SPLASH_ONLY } from "../utils/testUtils"; import { allMoves } from "#app/data/move.js"; import { getMovePosition } from "../utils/gameManagerUtils"; -import { BerryPhase } from "#app/phases.js"; +import { BerryPhase, EncounterPhase, EnemyCommandPhase } from "#app/phases.js"; import Pokemon, { MoveResult } from "#app/field/pokemon.js"; import { BattleStat } from "#app/data/battle-stat.js"; +import { BattlerIndex } from "#app/battle.js"; +import { TerrainType } from "#app/data/terrain.js"; +import { Abilities } from "#app/enums/abilities.js"; interface PokemonAssertionChainer { and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer; + and(expectation: (p?: Pokemon) => void): void } function chain(pokemon?: Pokemon): PokemonAssertionChainer { @@ -21,29 +25,63 @@ function chain(pokemon?: Pokemon): PokemonAssertionChainer { }; } +function toArray(a?: T | T[]) { + return Array.isArray(a) ? a : [a!]; +} + describe("Moves - Pursuit", () => { let phaserGame: Phaser.Game; let game: GameManager; + let moveOrder: BattlerIndex[] | undefined; + const pursuitMoveDef = allMoves[Moves.PURSUIT]; - const playerLead = Species.BULBASAUR; + const playerLead = Species.MUDSDALE; const enemyLead = Species.KANGASKHAN; function startBattle() { return game.startBattle([playerLead, Species.RAICHU, Species.ABSOL]); } - function runCombatTurn() { - return game.phaseInterceptor.to(BerryPhase, false); + async function runCombatTurn(kill?: Pokemon | Pokemon[]) { + if (moveOrder?.length) { + // game.phaseInterceptor.tapNextPhase(TurnStartPhase, p => { + // vi.spyOn(p, "getOrder").mockReturnValue(moveOrder!); + // }); + await game.setTurnOrder(moveOrder); + } + if (kill) { + for (const pkmn of toArray(kill)) { + await game.killPokemon(pkmn); + } + } + return await game.phaseInterceptor.to(BerryPhase, false); } - function playerUsesPursuit(pokemonIndex: 0 | 1 = 0) { + function afterFirstSwitch(action: () => void) { + game.phaseInterceptor.onNextPhase(EnemyCommandPhase, () => + game.phaseInterceptor.advance().then(action)); + } + + function playerDoesNothing() { + game.override.moveset(Moves.SPLASH); + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + if (game.scene.currentBattle.double) { + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + } + } + + function playerUsesPursuit(pokemonIndex: 0 | 1 = 0, targetIndex: BattlerIndex = BattlerIndex.ENEMY) { game.doAttack(getMovePosition(game.scene, pokemonIndex, Moves.PURSUIT)); + game.doSelectTarget(targetIndex); } - function playerUsesSwitchMove(pokemonIndex: 0 | 1 = 0, move: Moves.U_TURN | Moves.BATON_PASS | Moves.TELEPORT = Moves.U_TURN) { - game.doAttack(getMovePosition(game.scene, pokemonIndex, move)); + function playerUsesSwitchMove(pokemonIndex: 0 | 1 = 0) { + game.doAttack(getMovePosition(game.scene, pokemonIndex, Moves.U_TURN)); + if (game.scene.currentBattle.double) { + game.doSelectTarget(BattlerIndex.ENEMY); + } game.doSelectPartyPokemon(2); } @@ -55,12 +93,21 @@ describe("Moves - Pursuit", () => { game.override.enemyMoveset(move); } - function enemySwitches() { + function enemySwitches(once?: boolean) { game.override.forceTrainerSwitches(); + if (once) { + afterFirstSwitch(() => game.override.forceTrainerSwitches(false)); + } } - function forceMovesLast(pokemon?: Pokemon) { - pokemon!.summonData.battleStats[BattleStat.SPD] = -6; + function forceMovesLast(pokemon?: Pokemon | Pokemon[]) { + pokemon = toArray(pokemon); + const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p)); + moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex())); + } + + function forceLowestPriorityBracket() { + vi.spyOn(pursuitMoveDef, "priority", "get").mockReturnValue(-6); } function expectPursuitPowerDoubled() { @@ -71,6 +118,13 @@ describe("Moves - Pursuit", () => { expect(pursuitMoveDef.calculateBattlePower).toHaveReturnedWith(40); } + function expectPursuitSucceeded(pokemon?: Pokemon) { + const lastMove = pokemon!.getLastXMoves(0)[0]; + expect(lastMove.move).toBe(Moves.PURSUIT); + expect(lastMove.result).toBe(MoveResult.SUCCESS); + return chain(pokemon); + } + function expectPursuitFailed(pokemon?: Pokemon) { const lastMove = pokemon!.getLastXMoves(0)[0]; expect(lastMove.result).toBe(MoveResult.FAIL); @@ -87,6 +141,11 @@ describe("Moves - Pursuit", () => { return chain(pokemon); } + function expectOnField(pokemon?: Pokemon) { + expect(pokemon!.isOnField()).toBe(true); + return chain(pokemon); + } + function expectNotOnField(pokemon?: Pokemon) { expect(pokemon!.isOnField()).toBe(false); return chain(pokemon); @@ -97,6 +156,13 @@ describe("Moves - Pursuit", () => { return chain(pokemon); } + function expectIsSpecies(species: Species) { + return (pokemon?: Pokemon) => { + expect(pokemon!.species.speciesId).toBe(species); + return chain(pokemon); + }; + } + function findPartyMember(party: Pokemon[], species: Species) { return party.find(pkmn => pkmn.species.speciesId === species); } @@ -112,13 +178,14 @@ describe("Moves - Pursuit", () => { }); beforeEach(() => { + moveOrder = undefined; game = new GameManager(phaserGame); game.override .battleType("single") - .enemyParty([enemyLead, Species.SNORLAX, Species.BASCULIN]) + .enemyParty([enemyLead, Species.SNORLAX, Species.BASCULIN, Species.ALCREMIE]) .startingLevel(20) .startingWave(25) - .moveset([Moves.PURSUIT, Moves.U_TURN, Moves.BATON_PASS, Moves.TELEPORT]) + .moveset([Moves.PURSUIT, Moves.U_TURN, Moves.DRAGON_TAIL, Moves.FOLLOW_ME]) .enemyMoveset(SPLASH_ONLY) .disableCrits(); @@ -138,6 +205,23 @@ describe("Moves - Pursuit", () => { expectWasHit(game.scene.getEnemyPokemon()); }); + it("should not hit a uturning target for double power if the pursuiter moves before the uturner", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); + + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasNotHit(game.scene.getEnemyPokemon()); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectNotOnField); + }); + it("should hit a hard-switching target for double power (player attacks, enemy switches)", async () => { // arrange await startBattle(); @@ -235,6 +319,40 @@ describe("Moves - Pursuit", () => { expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); }); + it.todo("should bypass substitute when hitting an escaping target"); + + it("should hit a grounded, switching target under Psychic Terrain (switch move)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.arena.trySetTerrain(TerrainType.PSYCHIC, false, true); + + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); + }); + + it("should hit a grounded, switching target under Psychic Terrain (hard-switch)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.arena.trySetTerrain(TerrainType.PSYCHIC, false, true); + + // act + playerUsesPursuit(); + enemySwitches(); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); + }); + it("should not hit a baton pass user", async () => { // arrange await startBattle(); @@ -258,7 +376,7 @@ describe("Moves - Pursuit", () => { // arrange await startBattle(); forceMovesLast(game.scene.getPlayerPokemon()); - vi.spyOn(pursuitMoveDef, "priority", "get").mockReturnValue(-6); + forceLowestPriorityBracket(); // act playerUsesPursuit(); @@ -291,37 +409,271 @@ describe("Moves - Pursuit", () => { .and(expectHasFled); }); - it.todo("should not hit a switch move user for double damage if the switch move fails and does not switch out the user"); + it("should not hit a switch move user for double damage if the switch move fails and does not switch out the user", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); - it.todo("triggers contact abilities on the pokemon that is switching out (hard-switch)"); + // act + playerUsesPursuit(); + enemyUses(Moves.VOLT_SWITCH); + await runCombatTurn(); - it.todo("triggers contact abilities on the pokemon that is switching out (switch move, player switching)"); + // assert + expectPursuitPowerUnchanged(); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectOnField); + expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead)); + }); - it.todo("triggers contact abilities on the pokemon that is switching out (switch move, enemy switching)"); + it("triggers contact abilities on the pokemon that is switching out (hard-switch)", async () => { + // arrange + game.override.enemyAbility(Abilities.ROUGH_SKIN); + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); - it.todo("should bypass follow me when hitting a switching target"); + // act + playerUsesPursuit(); + enemySwitches(); + await runCombatTurn(); - it.todo("should bypass substitute when hitting an escaping target"); + // assert + expectPursuitPowerDoubled(); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); + expectWasHit(game.scene.getPlayerPokemon()); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }); - it.todo("should hit a grounded, switching target under Psychic Terrain"); + it("triggers contact abilities on the pokemon that is switching out (switch move, player switching)", async () => { + // arrange + game.override.ability(Abilities.STAMINA); + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); - describe("doubles interactions", () => { - it.todo("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one"); + // act + playerUsesSwitchMove(); + enemyUses(Moves.PURSUIT); + await runCombatTurn(); - it.todo("should not hit a pokemon being forced out with dragon tail"); + // assert + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }); - it.todo("should not hit a uturning target for double power if the pursuiter moves before the uturner"); + it("triggers contact abilities on the pokemon that is switching out (switch move, enemy switching)", async () => { + // arrange + game.override.enemyAbility(Abilities.ROUGH_SKIN); + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); - it.todo("should hit the first pokemon to switch out in a double battle regardless of who was targeted"); + // act + playerUsesSwitchMove(); + enemySwitches(); + await runCombatTurn(); - it.todo("should not hit both pokemon in a double battle if both switch out"); + // assert + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }); - it.todo("should not hit a switching ally (hard-switch, player field)"); + it("should not have a pokemon fainted by a switch move pursue its killer", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.getPlayerPokemon()!.hp = 1; - it.todo("should not hit a switching ally (hard-switch, enemy field)"); + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); - it.todo("should not hit a switching ally (switch move, player field)"); + // assert + expect(game.scene.getParty()[0]!.isFainted()).toBe(true); + expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); + }); - it.todo("should not hit a switching ally (switch move, enemy field)"); + describe("doubles interactions", { timeout: 10000 }, () => { + beforeEach(() => { + game.override.battleType("double"); + }); + + // fails: pursuit does not ignore follow me + it("should bypass follow me when hitting a switching target", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); + + // act + game.doAttack(getMovePosition(game.scene, 0, Moves.FOLLOW_ME)); + playerUsesSwitchMove(1); + enemyUses(Moves.PURSUIT); + game.move.forceAiTargets(game.scene.getEnemyPokemon(), BattlerIndex.PLAYER_2); + await runCombatTurn(game.scene.getEnemyField()[1]); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(findPartyMember(game.scene.getParty(), Species.RAICHU)) + .and(expectNotOnField); + expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead)); + }); + + // fails: fainting a pursuiter still runs the enemy SwitchSummonPhase + it("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + game.scene.getEnemyField()[0]!.hp = 1; + + // act + playerUsesPursuit(0); + playerUsesPursuit(1); + enemySwitches(); + await runCombatTurn(game.scene.getEnemyField()[1]); + + // assert + expect(game.scene.getEnemyParty()[0]!.isFainted()).toBe(true); + expectPursuitSucceeded(game.scene.getPlayerField()[0]); + expectPursuitFailed(game.scene.getPlayerField()[1]); + }); + + it("should not hit a pokemon being forced out with dragon tail", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + + // act + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + playerUsesPursuit(1); + enemyUses(Moves.SPLASH); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(game.scene.getEnemyPokemon()).and(pkmn => { + expect(pkmn?.turnData.attacksReceived[0]).toEqual(expect.objectContaining({ + move: Moves.PURSUIT, + result: MoveResult.SUCCESS, + })); + }); + }); + + // fails: respects original targets + it("should hit the first pokemon to switch out in a double battle regardless of who was targeted", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + + // act + playerUsesPursuit(0, BattlerIndex.ENEMY_2); + playerUsesPursuit(1, BattlerIndex.ENEMY_2); + enemySwitches(true); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); + expectWasNotHit(game.scene.getEnemyField()[0]); + expectWasNotHit(game.scene.getEnemyField()[1]); + expectPursuitSucceeded(game.scene.getPlayerField()[0]); + expectPursuitSucceeded(game.scene.getPlayerField()[1]); + }); + + it("should not hit both pokemon in a double battle if both switch out", async () => { + // arrange + game.phaseInterceptor.onNextPhase(EncounterPhase, () => { + game.scene.currentBattle.enemyLevels = [...game.scene.currentBattle.enemyLevels!, game.scene.currentBattle.enemyLevels![0]]; + }); + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + + // act + playerUsesPursuit(0, BattlerIndex.ENEMY); + playerUsesPursuit(1, BattlerIndex.ENEMY); + enemySwitches(); + afterFirstSwitch(() => { + vi.spyOn(game.scene.currentBattle.trainer!, "getPartyMemberMatchupScores").mockReturnValue([[3, 100]]); + vi.spyOn(game.scene.getPlayerPokemon()!, "getMatchupScore").mockReturnValue(0); + }); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); + expectWasNotHit(game.scene.getEnemyField()[0]).and(expectIsSpecies(Species.BASCULIN)); + expectWasNotHit(game.scene.getEnemyField()[1]).and(expectIsSpecies(Species.ALCREMIE)); + expectPursuitSucceeded(game.scene.getPlayerField()[0]); + expectPursuitSucceeded(game.scene.getPlayerField()[1]); + }); + + // fails: mysterious crash + it("should not hit a switching ally for double damage (hard-switch, player field)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + + // act + playerSwitches(0); + playerUsesPursuit(1, BattlerIndex.PLAYER); + enemyUses(Moves.SPLASH); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(game.scene.getPlayerField()[0]); + expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead)).and(expectNotOnField); + }); + + it("should not hit a switching ally for double damage (hard-switch, enemy field)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyField()); + + // act + playerDoesNothing(); + enemySwitches(true); + enemyUses(Moves.PURSUIT); + game.move.forceAiTargets(game.scene.getEnemyField()[1], BattlerIndex.ENEMY); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); + expectWasHit(game.scene.getEnemyField()[0]); + }); + + it("should not hit a switching ally for double damage (switch move, player field)", async () => { + // arrange + await startBattle(); + forceMovesLast([...game.scene.getPlayerField()].reverse()); + + // act + playerUsesPursuit(0, BattlerIndex.PLAYER_2); + playerUsesSwitchMove(1); // prompts need to be queued in the order of pursuit, then switch move or else they will softlock + enemyUses(Moves.SPLASH); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(game.scene.getPlayerField()[1]); + expectWasNotHit(findPartyMember(game.scene.getParty(), Species.RAICHU)).and(expectNotOnField); + }); + + it("should not hit a switching ally for double damage (switch move, enemy field)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyField()); + + // act + playerDoesNothing(); + enemyUses(Moves.U_TURN); + await game.phaseInterceptor.to(EnemyCommandPhase); + + enemyUses(Moves.PURSUIT); + game.move.forceAiTargets(game.scene.getEnemyField()[1], BattlerIndex.ENEMY); + await runCombatTurn(); + + // assert + expectPursuitPowerUnchanged(); + expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); + expectWasHit(game.scene.getEnemyField()[0]); + }); }); }); From f5fabbb15931a821528fd4caafe091f30e695988 Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:13 -0700 Subject: [PATCH 26/36] fix bad merge --- src/data/move.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index c056e8c7b01..78eeaf1b902 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1034,8 +1034,7 @@ export class AddBattlerTagHeaderAttr extends MoveHeaderAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - user.addTag(this.tagType); - return true; + return user.addTag(this.tagType); } } @@ -6877,7 +6876,7 @@ export function initMoves() { .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)), new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) .attr(PursuitAccuracyAttr) - .attr(AddBattlerTagOnMoveReadyAttr, BattlerTagType.ANTICIPATING_ACTION) + .attr(AddBattlerTagHeaderAttr, BattlerTagType.ANTICIPATING_ACTION) .attr(RemoveBattlerTagAttr, [BattlerTagType.ANTICIPATING_ACTION], true, MoveEffectTrigger.POST_APPLY) .attr(MovePowerMultiplierAttr, (user, target) => isPursuingFunc(user, target) ? 2 : 1), new AttackMove(Moves.RAPID_SPIN, Type.NORMAL, MoveCategory.PHYSICAL, 50, 100, 40, 100, 0, 2) From 88dcaa3d20d4859e9bfdd0566d2d87cf6d18de6a Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:13 -0700 Subject: [PATCH 27/36] fix more bad merge (source move not passed to tag) --- src/data/move.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/move.ts b/src/data/move.ts index 78eeaf1b902..7833bf510c8 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1034,7 +1034,7 @@ export class AddBattlerTagHeaderAttr extends MoveHeaderAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - return user.addTag(this.tagType); + return user.addTag(this.tagType, 0, move.id); } } From f9ea476ef3c94faa0af19e91ca9654c55b6d01bf Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:10:14 -0700 Subject: [PATCH 28/36] fix more test bugs --- src/test/moves/pursuit.test.ts | 3 +-- src/test/utils/gameManager.ts | 4 ++++ src/test/utils/phaseInterceptor.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index db390beb337..d36b249f09a 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -603,14 +603,13 @@ describe("Moves - Pursuit", () => { expectPursuitSucceeded(game.scene.getPlayerField()[1]); }); - // fails: mysterious crash it("should not hit a switching ally for double damage (hard-switch, player field)", async () => { // arrange await startBattle(); forceMovesLast(game.scene.getPlayerField()); // act - playerSwitches(0); + playerSwitches(2); playerUsesPursuit(1, BattlerIndex.PLAYER); enemyUses(Moves.SPLASH); await runCombatTurn(); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 9f481eaed6e..17aca8ae153 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -365,6 +365,10 @@ export default class GameManager { */ doSelectPartyPokemon(slot: number, inPhase = "SwitchPhase") { this.onNextPrompt(inPhase, Mode.PARTY, () => { + if (this.scene.getParty()[slot].isActive(true)) { + throw new Error("Attempting to switch in a party member that is already active"); + } + const partyHandler = this.scene.ui.getHandler() as PartyUiHandler; partyHandler.setCursor(slot); diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 91fadf0eb86..da71b508972 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -343,6 +343,7 @@ export default class PhaseInterceptor { lastTime = currentTime; if (timeSpentInPrompt > PROMPT_TIMEOUT && !warned) { + warned = true; console.error("Prompt handling stalled waiting for prompt:", actionForNextPrompt); expect.fail("Prompt timeout"); } From 113e8ab3bdd5547db4539c1d2362803e843732dc Mon Sep 17 00:00:00 2001 From: snoozbuster Date: Tue, 13 Aug 2024 21:15:02 -0700 Subject: [PATCH 29/36] fix another broken test --- src/test/moves/pursuit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index d36b249f09a..56276ed7f8d 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -541,6 +541,7 @@ describe("Moves - Pursuit", () => { // act game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + game.doSelectTarget(BattlerIndex.ENEMY); playerUsesPursuit(1); enemyUses(Moves.SPLASH); await runCombatTurn(); From f0eac001798cd320ffeac081c82825ff861710cd Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Thu, 15 Aug 2024 15:29:04 -0700 Subject: [PATCH 30/36] fix follow me with pursuit --- src/data/move.ts | 16 +++++++++++++++- src/phases.ts | 6 ++++-- src/test/moves/pursuit.test.ts | 24 +++++++++++++++++++++--- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index 7833bf510c8..fad88e19470 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4203,8 +4203,21 @@ export class TypelessAttr extends MoveAttr { } /** * Attribute used for moves which ignore redirection effects, and always target their original target, i.e. Snipe Shot * Bypasses Storm Drain, Follow Me, Ally Switch, and the like. +* +* Optionally accepts a function to run which can be used to conditionally bypass redirection effects. */ -export class BypassRedirectAttr extends MoveAttr { } +export class BypassRedirectAttr extends MoveAttr { + private bypassConditionFn?: (user: Pokemon | null, target: Pokemon | null, move: Move) => boolean; + + constructor(bypassConditionFn?: (user: Pokemon | null, target: Pokemon | null, move: Move) => boolean) { + super(); + this.bypassConditionFn = bypassConditionFn; + } + + apply(user: Pokemon | null, target: Pokemon | null, move: Move) { + return this.bypassConditionFn?.(user, target, move) ?? true; + } +} export class DisableMoveAttr extends MoveEffectAttr { constructor() { @@ -6876,6 +6889,7 @@ export function initMoves() { .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)), new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) .attr(PursuitAccuracyAttr) + .attr(BypassRedirectAttr, (user, target) => Boolean(user && target && isPursuingFunc(user, target))) .attr(AddBattlerTagHeaderAttr, BattlerTagType.ANTICIPATING_ACTION) .attr(RemoveBattlerTagAttr, [BattlerTagType.ANTICIPATING_ACTION], true, MoveEffectTrigger.POST_APPLY) .attr(MovePowerMultiplierAttr, (user, target) => isPursuingFunc(user, target) ? 2 : 1), diff --git a/src/phases.ts b/src/phases.ts index ba1c9021f4c..0a140e171ac 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -2777,9 +2777,11 @@ export class MovePhase extends BattlePhase { } }); //Check if this move is immune to being redirected, and restore its target to the intended target if it is. - if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) || this.move.getMove().hasAttr(BypassRedirectAttr))) { + const abilityRedirectImmune = this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr); + const moveRedirectImmune = this.move.getMove().getAttrs(BypassRedirectAttr).some(attr => attr.apply(this.pokemon, this.scene.getField(false)[oldTarget], this.move.getMove())); + if (abilityRedirectImmune || moveRedirectImmune) { //If an ability prevented this move from being redirected, display its ability pop up. - if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) && !this.move.getMove().hasAttr(BypassRedirectAttr)) && oldTarget !== moveTarget.value) { + if (abilityRedirectImmune && !moveRedirectImmune && oldTarget !== moveTarget.value) { this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); } moveTarget.value = oldTarget; diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index 56276ed7f8d..b8c877769a6 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -495,7 +495,6 @@ describe("Moves - Pursuit", () => { game.override.battleType("double"); }); - // fails: pursuit does not ignore follow me it("should bypass follow me when hitting a switching target", async () => { // arrange await startBattle(); @@ -509,13 +508,32 @@ describe("Moves - Pursuit", () => { await runCombatTurn(game.scene.getEnemyField()[1]); // assert - expectPursuitPowerUnchanged(); + expectPursuitPowerDoubled(); expectWasHit(findPartyMember(game.scene.getParty(), Species.RAICHU)) .and(expectNotOnField); expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead)); }); - // fails: fainting a pursuiter still runs the enemy SwitchSummonPhase + it("should not bypass follow me when hitting a non-switching target", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); + + // act + game.override.moveset([Moves.FOLLOW_ME, Moves.SPLASH]); + game.doAttack(getMovePosition(game.scene, 0, Moves.FOLLOW_ME)); + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + enemyUses(Moves.PURSUIT); + game.move.forceAiTargets(game.scene.getEnemyPokemon(), BattlerIndex.PLAYER_2); + await runCombatTurn(game.scene.getEnemyField()[1]); + + // assert + expectPursuitPowerUnchanged(); + expectWasHit(findPartyMember(game.scene.getParty(), playerLead)); + expectWasNotHit(findPartyMember(game.scene.getParty(), Species.RAICHU)); + }); + + // fails: fainting an escapee still runs the enemy SwitchSummonPhase it("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one", async () => { // arrange await startBattle(); From 772a6fc079de729b70405b829e9b479a2f9db306 Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Tue, 20 Aug 2024 15:50:16 -0700 Subject: [PATCH 31/36] command order test --- src/test/moves/pursuit.test.ts | 38 +++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index b8c877769a6..c501e556c79 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -5,7 +5,7 @@ import { Species } from "#app/enums/species.js"; import { SPLASH_ONLY } from "../utils/testUtils"; import { allMoves } from "#app/data/move.js"; import { getMovePosition } from "../utils/gameManagerUtils"; -import { BerryPhase, EncounterPhase, EnemyCommandPhase } from "#app/phases.js"; +import { BerryPhase, EncounterPhase, EnemyCommandPhase, SwitchSummonPhase } from "#app/phases.js"; import Pokemon, { MoveResult } from "#app/field/pokemon.js"; import { BattleStat } from "#app/data/battle-stat.js"; import { BattlerIndex } from "#app/battle.js"; @@ -106,6 +106,12 @@ describe("Moves - Pursuit", () => { moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex())); } + function forceMovesFirst(pokemon?: Pokemon | Pokemon[]) { + pokemon = toArray(pokemon); + const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p)); + moveOrder = ([...pokemon, ...otherPkmn].map(pkmn => pkmn.getBattlerIndex())); + } + function forceLowestPriorityBracket() { vi.spyOn(pursuitMoveDef, "priority", "get").mockReturnValue(-6); } @@ -490,7 +496,7 @@ describe("Moves - Pursuit", () => { expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); }); - describe("doubles interactions", { timeout: 10000 }, () => { + describe("doubles interactions", { timeout: 1000000 }, () => { beforeEach(() => { game.override.battleType("double"); }); @@ -574,7 +580,8 @@ describe("Moves - Pursuit", () => { }); }); - // fails: respects original targets + // fails: command re-ordering does not work due to particulars of sort/move ordering; + // pursuit moves after switch it("should hit the first pokemon to switch out in a double battle regardless of who was targeted", async () => { // arrange await startBattle(); @@ -622,6 +629,31 @@ describe("Moves - Pursuit", () => { expectPursuitSucceeded(game.scene.getPlayerField()[1]); }); + // This test is hard to verify, because it's hard to observe independently - + // but depending on exactly how the command ordering is done, it is possible + // for the command order to put one ally's Pursuit move before the other + // ally's Pokemon command, even if the pursuit move does not target a + // pursuer. At this time, this "appears" to work correctly, because of + // nuances in when phases are pushed vs. shifted; but the pursuit + // MoveHeaderPhase is actually run before the switch, which is the only way + // to find the issue at present. Asserting the direct output of the command order + // is probably a better solution. + it("should not move or apply tags before switch when ally switches and not pursuing an enemy", async () => { + // arrange + await startBattle(); + forceMovesFirst(game.scene.getPlayerField().reverse()); + + // act + playerSwitches(2); + playerUsesPursuit(0); + enemyUses(Moves.SPLASH); + await game.phaseInterceptor.to(SwitchSummonPhase); + + // assert + expect(game.phaseInterceptor.log).not.toContain("MovePhase"); + expect(game.phaseInterceptor.log).not.toContain("MoveHeaderPhase"); + }); + it("should not hit a switching ally for double damage (hard-switch, player field)", async () => { // arrange await startBattle(); From e80f1586914e730bdf59affada9c4c52e8a9c9e0 Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Tue, 20 Aug 2024 16:00:34 -0700 Subject: [PATCH 32/36] fix imports --- src/test/moves/pursuit.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index c501e556c79..12ae4410631 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -5,12 +5,15 @@ import { Species } from "#app/enums/species.js"; import { SPLASH_ONLY } from "../utils/testUtils"; import { allMoves } from "#app/data/move.js"; import { getMovePosition } from "../utils/gameManagerUtils"; -import { BerryPhase, EncounterPhase, EnemyCommandPhase, SwitchSummonPhase } from "#app/phases.js"; import Pokemon, { MoveResult } from "#app/field/pokemon.js"; import { BattleStat } from "#app/data/battle-stat.js"; import { BattlerIndex } from "#app/battle.js"; import { TerrainType } from "#app/data/terrain.js"; import { Abilities } from "#app/enums/abilities.js"; +import { BerryPhase } from "#app/phases/berry-phase.js"; +import { EnemyCommandPhase } from "#app/phases/enemy-command-phase.js"; +import { SwitchSummonPhase } from "#app/phases/switch-summon-phase.js"; +import { EncounterPhase } from "#app/phases/encounter-phase.js"; interface PokemonAssertionChainer { and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer; From c47c82e634c0e840797cb0156d1cff7afa3e8b6b Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Tue, 20 Aug 2024 19:13:14 -0700 Subject: [PATCH 33/36] fix onNextPhase callback --- src/test/utils/phaseInterceptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 9a2d4dc5007..e6f39650756 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -240,7 +240,7 @@ export default class PhaseInterceptor { const currentPhase = this.onHold[0]; if (currentPhase?.name === targetName) { clearInterval(interval); - cb(currentPhase); + cb(this.scene.getCurrentPhase()); } }); } From 5a582db1481ca1e9a65b05b096cb32216f8ae0cb Mon Sep 17 00:00:00 2001 From: Alex Van Liew Date: Tue, 20 Aug 2024 19:22:03 -0700 Subject: [PATCH 34/36] add more failing tests cause I found more stuff that's broke --- src/test/moves/pursuit.test.ts | 190 +++++++++++++++++++++++++++------ 1 file changed, 160 insertions(+), 30 deletions(-) diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index 12ae4410631..40f2a9c8c9a 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -10,10 +10,10 @@ import { BattleStat } from "#app/data/battle-stat.js"; import { BattlerIndex } from "#app/battle.js"; import { TerrainType } from "#app/data/terrain.js"; import { Abilities } from "#app/enums/abilities.js"; -import { BerryPhase } from "#app/phases/berry-phase.js"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase.js"; import { SwitchSummonPhase } from "#app/phases/switch-summon-phase.js"; import { EncounterPhase } from "#app/phases/encounter-phase.js"; +import { TurnStartPhase } from "#app/phases/turn-start-phase.js"; interface PokemonAssertionChainer { and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer; @@ -32,12 +32,10 @@ function toArray(a?: T | T[]) { return Array.isArray(a) ? a : [a!]; } -describe("Moves - Pursuit", () => { +describe("Moves - Pursuit", { timeout: 10000 }, () => { let phaserGame: Phaser.Game; let game: GameManager; - let moveOrder: BattlerIndex[] | undefined; - const pursuitMoveDef = allMoves[Moves.PURSUIT]; const playerLead = Species.MUDSDALE; @@ -47,19 +45,12 @@ describe("Moves - Pursuit", () => { return game.startBattle([playerLead, Species.RAICHU, Species.ABSOL]); } - async function runCombatTurn(kill?: Pokemon | Pokemon[]) { - if (moveOrder?.length) { - // game.phaseInterceptor.tapNextPhase(TurnStartPhase, p => { - // vi.spyOn(p, "getOrder").mockReturnValue(moveOrder!); - // }); - await game.setTurnOrder(moveOrder); - } - if (kill) { - for (const pkmn of toArray(kill)) { - await game.killPokemon(pkmn); - } - } - return await game.phaseInterceptor.to(BerryPhase, false); + async function runCombatTurn(to: string = "TurnInitPhase") { + // many of these tests can't run only to BerryPhase because of interactions with fainting + // during switches. we need to run tests all the way through to the start of the next turn + // to ensure that, for example, the game doesn't attempt to switch two pokemon into the same + // slot (once in the originally queued summon phase, and the next as a result of FaintPhase) + return await game.phaseInterceptor.to(to, false); } function afterFirstSwitch(action: () => void) { @@ -106,13 +97,29 @@ describe("Moves - Pursuit", () => { function forceMovesLast(pokemon?: Pokemon | Pokemon[]) { pokemon = toArray(pokemon); const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p)); - moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex())); + const moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex())); + + game.phaseInterceptor.onNextPhase(TurnStartPhase, p => { + vi.spyOn(p, "getOrder").mockReturnValue( + // TurnStartPhase crashes if a BI returned by getOrder() is fainted. + // not an issue normally but some of the test setups can cause this + moveOrder!.filter(i => game.scene.getField(false)[i]?.isActive(true)) + ); + }); } function forceMovesFirst(pokemon?: Pokemon | Pokemon[]) { pokemon = toArray(pokemon); const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p)); - moveOrder = ([...pokemon, ...otherPkmn].map(pkmn => pkmn.getBattlerIndex())); + const moveOrder = ([...pokemon, ...otherPkmn].map(pkmn => pkmn.getBattlerIndex())); + + game.phaseInterceptor.onNextPhase(TurnStartPhase, p => { + vi.spyOn(p, "getOrder").mockReturnValue( + // TurnStartPhase crashes if a BI returned by getOrder() is fainted. + // not an issue normally but some of the test setups can cause this + moveOrder!.filter(i => game.scene.getField(false)[i]?.isActive(true)) + ); + }); } function forceLowestPriorityBracket() { @@ -187,7 +194,6 @@ describe("Moves - Pursuit", () => { }); beforeEach(() => { - moveOrder = undefined; game = new GameManager(phaserGame); game.override .battleType("single") @@ -328,7 +334,11 @@ describe("Moves - Pursuit", () => { expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)); }); - it.todo("should bypass substitute when hitting an escaping target"); + it.todo("should bypass substitute when hitting an escaping target (hard switch)"); + + it.todo("should bypass substitute when hitting an escaping target (switch move)"); + + it.todo("should not bypass substitute when hitting a non-escaping target"); it("should hit a grounded, switching target under Psychic Terrain (switch move)", async () => { // arrange @@ -410,7 +420,7 @@ describe("Moves - Pursuit", () => { // act playerUsesPursuit(); enemyUses(Moves.U_TURN); - await runCombatTurn(); + await runCombatTurn("BerryPhase"); // assert expectPursuitFailed(game.scene.getPlayerPokemon()); @@ -496,10 +506,79 @@ describe("Moves - Pursuit", () => { // assert expect(game.scene.getParty()[0]!.isFainted()).toBe(true); - expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); + expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(expectNotOnField); }); - describe("doubles interactions", { timeout: 1000000 }, () => { + it("should not cause two pokemon to enter the field if a switching-out pokemon is fainted by pursuit (hard-switch, enemy faints)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.getEnemyPokemon()!.hp = 1; + + // act + playerUsesPursuit(); + enemySwitches(); + await runCombatTurn(); + + // assert + expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(1); + }); + + it("should not cause two pokemon to enter the field if a switching-out pokemon is fainted by pursuit (u-turn, enemy faints)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerPokemon()); + game.scene.getEnemyPokemon()!.hp = 1; + + // act + playerUsesPursuit(); + enemyUses(Moves.U_TURN); + await runCombatTurn(); + + // assert + expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(1); + }); + + it("should not cause two pokemon to enter the field or a premature switch if a switching-out pokemon is fainted by pursuit (hard-switch, player faints)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); + game.scene.getPlayerPokemon()!.hp = 1; + + // act + playerSwitches(); + enemyUses(Moves.PURSUIT); + await runCombatTurn("TurnEndPhase"); + + // assert + expect(game.scene.getParty().filter(p => p.isOnField())).toHaveLength(1); + // SwitchPhase for fainted pokemon happens after TurnEndPhase - if we + // skipped the uturn switch, we shouldn't have executed a switchout by this point + expect(game.scene.getPlayerField()[0]?.isFainted()).toBeTruthy(); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + }); + + it("should not cause two pokemon to enter the field or a premature switch if a switching-out pokemon is fainted by pursuit (u-turn, player faints)", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getEnemyPokemon()); + game.scene.getPlayerPokemon()!.hp = 1; + + // act + playerUsesSwitchMove(); + enemyUses(Moves.PURSUIT); + await runCombatTurn("TurnEndPhase"); + + // assert + expect(game.scene.getParty().filter(p => p.isOnField())).toHaveLength(1); + // SwitchPhase for fainted pokemon happens after TurnEndPhase - if we + // skipped the uturn switch, we shouldn't have executed a switchout by this point + expect(game.scene.getPlayerField()[0]?.isFainted()).toBeTruthy(); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + }); + + describe("doubles interactions", () => { beforeEach(() => { game.override.battleType("double"); }); @@ -514,7 +593,8 @@ describe("Moves - Pursuit", () => { playerUsesSwitchMove(1); enemyUses(Moves.PURSUIT); game.move.forceAiTargets(game.scene.getEnemyPokemon(), BattlerIndex.PLAYER_2); - await runCombatTurn(game.scene.getEnemyField()[1]); + await game.killPokemon(game.scene.getEnemyField()[1]); + await runCombatTurn(); // assert expectPursuitPowerDoubled(); @@ -534,7 +614,8 @@ describe("Moves - Pursuit", () => { game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); enemyUses(Moves.PURSUIT); game.move.forceAiTargets(game.scene.getEnemyPokemon(), BattlerIndex.PLAYER_2); - await runCombatTurn(game.scene.getEnemyField()[1]); + await game.killPokemon(game.scene.getEnemyField()[1]); + await runCombatTurn(); // assert expectPursuitPowerUnchanged(); @@ -542,8 +623,7 @@ describe("Moves - Pursuit", () => { expectWasNotHit(findPartyMember(game.scene.getParty(), Species.RAICHU)); }); - // fails: fainting an escapee still runs the enemy SwitchSummonPhase - it("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one", async () => { + it("should not cause the enemy AI to send out a fainted pokemon if they command 2 switches and one of the outgoing pokemon faints to pursuit", async () => { // arrange await startBattle(); forceMovesLast(game.scene.getPlayerField()); @@ -553,14 +633,64 @@ describe("Moves - Pursuit", () => { playerUsesPursuit(0); playerUsesPursuit(1); enemySwitches(); - await runCombatTurn(game.scene.getEnemyField()[1]); + await runCombatTurn(); // assert - expect(game.scene.getEnemyParty()[0]!.isFainted()).toBe(true); + expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(2); + expect(game.scene.getEnemyParty().filter(p => p.isOnField() && p.isFainted())).toHaveLength(0); + }); + + // TODO: is this correct behavior? + it("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one with no other targets on field", async () => { + // arrange + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + game.scene.getEnemyField()[0]!.hp = 1; + + // act + playerUsesPursuit(0); + playerUsesPursuit(1); + enemySwitches(); + await game.killPokemon(game.scene.getEnemyField()[1]); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); expectPursuitSucceeded(game.scene.getPlayerField()[0]); expectPursuitFailed(game.scene.getPlayerField()[1]); }); + // TODO: is this correct behavior? + it("should attack the second switching pokemon if both pokemon switch and the first is KOd", async () => { + // arrange + game.phaseInterceptor.onNextPhase(EncounterPhase, () => { + game.scene.currentBattle.enemyLevels = [...game.scene.currentBattle.enemyLevels!, game.scene.currentBattle.enemyLevels![0]]; + }); + await startBattle(); + forceMovesLast(game.scene.getPlayerField()); + game.scene.getEnemyField()[0]!.hp = 1; + + // act + playerUsesPursuit(0); + playerUsesPursuit(1); + enemySwitches(); + afterFirstSwitch(() => { + vi.spyOn(game.scene.currentBattle.trainer!, "getPartyMemberMatchupScores").mockReturnValue([[3, 100]]); + vi.spyOn(game.scene.getPlayerPokemon()!, "getMatchupScore").mockReturnValue(0); + }); + await runCombatTurn(); + + // assert + expectPursuitPowerDoubled(); + expectPursuitSucceeded(game.scene.getPlayerField()[0]); + expectPursuitSucceeded(game.scene.getPlayerField()[1]); + expectWasHit(findPartyMember(game.scene.getEnemyParty(), Species.SNORLAX)); + expectNotOnField(findPartyMember(game.scene.getEnemyParty(), enemyLead)) + .and(p => expect(p?.isFainted())); + }); + + // TODO: confirm correct behavior and add tests for other pursuit/switch combos in doubles + it("should not hit a pokemon being forced out with dragon tail", async () => { // arrange await startBattle(); From b400cbf0c1b4aa02e9ee009b06eccf7f69bc9bfa Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Tue, 17 Sep 2024 04:34:10 -0700 Subject: [PATCH 35/36] Test updating --- src/test/moves/u_turn.test.ts | 48 ++-------------------- src/test/moves/volt_switch.test.ts | 50 +++++++++++++++++++++++ src/test/utils/gameManager.ts | 25 ++++++------ src/test/utils/helpers/overridesHelper.ts | 15 ++++++- src/test/utils/phaseInterceptor.ts | 27 ++++++------ 5 files changed, 92 insertions(+), 73 deletions(-) create mode 100644 src/test/moves/volt_switch.test.ts diff --git a/src/test/moves/u_turn.test.ts b/src/test/moves/u_turn.test.ts index ce8942a348c..84bd5eeeb4e 100644 --- a/src/test/moves/u_turn.test.ts +++ b/src/test/moves/u_turn.test.ts @@ -7,7 +7,6 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { BerryPhase } from "#app/phases/berry-phase.js"; describe("Moves - U-turn", () => { let phaserGame: Phaser.Game; @@ -36,40 +35,28 @@ describe("Moves - U-turn", () => { }); it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => { - // arrange const playerHp = 1; game.override.ability(Abilities.REGENERATOR); - await game.startBattle([ - Species.RAICHU, - Species.SHUCKLE - ]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); game.scene.getPlayerPokemon()!.hp = playerHp; - // act game.move.select(Moves.U_TURN); game.doSelectPartyPokemon(1); await game.phaseInterceptor.to(TurnEndPhase); - // assert expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); expect(game.scene.getParty()[1].hp).toEqual(Math.floor(game.scene.getParty()[1].getMaxHp() * 0.33 + playerHp)); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); }, 20000); it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => { - // arrange game.override.enemyAbility(Abilities.ROUGH_SKIN); - await game.startBattle([ - Species.RAICHU, - Species.SHUCKLE - ]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - // act game.move.select(Moves.U_TURN); game.doSelectPartyPokemon(1); await game.phaseInterceptor.to(SwitchPhase, false); - // assert const playerPkm = game.scene.getPlayerPokemon()!; expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp()); @@ -78,46 +65,17 @@ describe("Moves - U-turn", () => { }, 20000); it("triggers contact abilities on the u-turn user (eg poison point) before a new pokemon is switched in", async () => { - // arrange game.override.enemyAbility(Abilities.POISON_POINT); - await game.startBattle([ - Species.RAICHU, - Species.SHUCKLE - ]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); vi.spyOn(game.scene.getEnemyPokemon()!, "randSeedInt").mockReturnValue(0); - // act game.move.select(Moves.U_TURN); await game.phaseInterceptor.to(SwitchPhase, false); - // assert const playerPkm = game.scene.getPlayerPokemon()!; expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON); expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); }, 20000); - - it("does not switch out the user if the move fails", async () => { - // arrange - game.override - .enemySpecies(Species.DUGTRIO) - .moveset(Moves.VOLT_SWITCH); // cheating a little here but no types are immune to bug - await game.startBattle([ - Species.RAICHU, - Species.SHUCKLE - ]); - - // act - game.doAttack(getMovePosition(game.scene, 0, Moves.U_TURN)); - game.onNextPrompt("SwitchPhase", Mode.PARTY, () => { - expect.fail("Switch was forced"); - }, () => game.isCurrentPhase(BerryPhase)); - await game.phaseInterceptor.to(BerryPhase, false); - - // assert - const playerPkm = game.scene.getPlayerPokemon()!; - expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - }, 20000); }); diff --git a/src/test/moves/volt_switch.test.ts b/src/test/moves/volt_switch.test.ts new file mode 100644 index 00000000000..6c137df260a --- /dev/null +++ b/src/test/moves/volt_switch.test.ts @@ -0,0 +1,50 @@ +import { BerryPhase } from "#app/phases/berry-phase"; +import { Mode } from "#app/ui/ui"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Volt Switch", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const TIMEOUT = 20 * 1000; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset([Moves.SPLASH]) + .battleType("single") + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("does not switch out the user if the move fails", async () => { + game.override + .enemySpecies(Species.DUGTRIO) + .moveset(Moves.VOLT_SWITCH); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + game.move.select(Moves.VOLT_SWITCH); + game.onNextPrompt("SwitchPhase", Mode.PARTY, () => { + expect.fail("Switch was forced"); + }, () => game.isCurrentPhase(BerryPhase)); + await game.phaseInterceptor.to(BerryPhase, false); + + const playerPkm = game.scene.getPlayerPokemon()!; + expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + }, TIMEOUT); +}); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 088875b090d..87f8cf00fff 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -1,20 +1,23 @@ import { updateUserInfo } from "#app/account"; import { BattlerIndex } from "#app/battle"; import BattleScene from "#app/battle-scene"; -import { BattleStyle } from "#app/enums/battle-style"; -import { Moves } from "#app/enums/moves"; import { getMoveTargets } from "#app/data/move"; -import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import { BattleStyle } from "#app/enums/battle-style"; +import { ExpGainsSpeed } from "#app/enums/exp-gains-speed"; +import { Moves } from "#app/enums/moves"; +import Pokemon from "#app/field/pokemon"; import Trainer from "#app/field/trainer"; import { GameModes, getGameMode } from "#app/game-mode"; import { ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type"; import overrides from "#app/overrides"; +import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; import { CommandPhase } from "#app/phases/command-phase"; import { EncounterPhase } from "#app/phases/encounter-phase"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { FaintPhase } from "#app/phases/faint-phase"; import { LoginPhase } from "#app/phases/login-phase"; import { MovePhase } from "#app/phases/move-phase"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { SelectStarterPhase } from "#app/phases/select-starter-phase"; import { SelectTargetPhase } from "#app/phases/select-target-phase"; @@ -24,13 +27,16 @@ import { TurnInitPhase } from "#app/phases/turn-init-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase"; import ErrorInterceptor from "#app/test/utils/errorInterceptor"; import InputsHandler from "#app/test/utils/inputsHandler"; +import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler"; import CommandUiHandler from "#app/ui/command-ui-handler"; import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; import PartyUiHandler from "#app/ui/party-ui-handler"; import TargetSelectUiHandler from "#app/ui/target-select-ui-handler"; import { Mode } from "#app/ui/ui"; +import { isNullOrUndefined } from "#app/utils"; import { Button } from "#enums/buttons"; import { ExpNotification } from "#enums/exp-notification"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import { generateStarter, waitUntil } from "#test/utils/gameManagerUtils"; @@ -39,21 +45,14 @@ import PhaseInterceptor from "#test/utils/phaseInterceptor"; import TextInterceptor from "#test/utils/TextInterceptor"; import { AES, enc } from "crypto-js"; import fs from "fs"; -import { vi } from "vitest"; +import { expect, vi } from "vitest"; +import { ChallengeModeHelper } from "./helpers/challengeModeHelper"; import { ClassicModeHelper } from "./helpers/classicModeHelper"; import { DailyModeHelper } from "./helpers/dailyModeHelper"; -import { ChallengeModeHelper } from "./helpers/challengeModeHelper"; import { MoveHelper } from "./helpers/moveHelper"; import { OverridesHelper } from "./helpers/overridesHelper"; -import { SettingsHelper } from "./helpers/settingsHelper"; import { ReloadHelper } from "./helpers/reloadHelper"; -import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; -import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler"; -import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; -import { expect } from "vitest"; -import { MysteryEncounterType } from "#enums/mystery-encounter-type"; -import { isNullOrUndefined } from "#app/utils"; -import { ExpGainsSpeed } from "#app/enums/exp-gains-speed"; +import { SettingsHelper } from "./helpers/settingsHelper"; /** * Class to manage the game state and transitions between phases. diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index 686de58e874..b23caecfc4c 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -8,10 +8,10 @@ import * as GameMode from "#app/game-mode"; import { GameModes, getGameMode } from "#app/game-mode"; import { ModifierOverride } from "#app/modifier/modifier-type"; import Overrides from "#app/overrides"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { vi } from "vitest"; import { GameManagerHelper } from "./gameManagerHelper"; -import { MysteryEncounterType } from "#enums/mystery-encounter-type"; -import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; /** * Helper to handle overrides in tests @@ -325,6 +325,17 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Overrides the trainer AI's party + * @param species List of pokemon to generate in the party + * @returns this + */ + enemyParty(species: Species[]) { + vi.spyOn(Overrides, "TRAINER_PARTY_OVERRIDE", "get").mockReturnValue(species); + this.log("Enemy trainer party set to:", species); + return this; + } + /** * Override the encounter chance for a mystery encounter. * @param percentage the encounter chance in % diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 92d4a442fad..13336b5ce48 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -1,5 +1,4 @@ import { Phase } from "#app/phase"; -import ErrorInterceptor from "#app/test/utils/errorInterceptor"; import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; import { BattleEndPhase } from "#app/phases/battle-end-phase"; import { BerryPhase } from "#app/phases/berry-phase"; @@ -11,17 +10,28 @@ import { EncounterPhase } from "#app/phases/encounter-phase"; import { EndEvolutionPhase } from "#app/phases/end-evolution-phase"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { EvolutionPhase } from "#app/phases/evolution-phase"; +import { ExpPhase } from "#app/phases/exp-phase"; import { FaintPhase } from "#app/phases/faint-phase"; import { LearnMovePhase } from "#app/phases/learn-move-phase"; import { LevelCapPhase } from "#app/phases/level-cap-phase"; import { LoginPhase } from "#app/phases/login-phase"; import { MessagePhase } from "#app/phases/message-phase"; +import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; import { MovePhase } from "#app/phases/move-phase"; +import { + MysteryEncounterBattlePhase, + MysteryEncounterOptionSelectedPhase, + MysteryEncounterPhase, + MysteryEncounterRewardsPhase, + PostMysteryEncounterPhase +} from "#app/phases/mystery-encounter-phases"; import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { NewBiomeEncounterPhase } from "#app/phases/new-biome-encounter-phase"; import { NextEncounterPhase } from "#app/phases/next-encounter-phase"; +import { PartyExpPhase } from "#app/phases/party-exp-phase"; +import { PartyHealPhase } from "#app/phases/party-heal-phase"; import { PostSummonPhase } from "#app/phases/post-summon-phase"; import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase"; import { SelectGenderPhase } from "#app/phases/select-gender-phase"; @@ -41,17 +51,9 @@ import { TurnInitPhase } from "#app/phases/turn-init-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase"; import { UnavailablePhase } from "#app/phases/unavailable-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; -import { PartyHealPhase } from "#app/phases/party-heal-phase"; +import ErrorInterceptor from "#app/test/utils/errorInterceptor"; import UI, { Mode } from "#app/ui/ui"; -import { - MysteryEncounterBattlePhase, - MysteryEncounterOptionSelectedPhase, - MysteryEncounterPhase, - MysteryEncounterRewardsPhase, - PostMysteryEncounterPhase -} from "#app/phases/mystery-encounter-phases"; -import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; -import { PartyExpPhase } from "#app/phases/party-exp-phase"; +import { expect } from "vitest"; type PhaseClassType = (abstract new (...args: any) => Phase); // `typeof Phase` does not work here because of some issue with ctor signatures @@ -62,7 +64,6 @@ export interface PromptHandler { expireFn?: () => void; awaitingActionInput?: boolean; } -import { ExpPhase } from "#app/phases/exp-phase"; export default class PhaseInterceptor { public scene; @@ -227,7 +228,7 @@ export default class PhaseInterceptor { * @param skipFn - Optional skip function. * @returns A promise that resolves when the phase is run. */ - run(phaseTarget, skipFn?): Promise { + async run(phaseTarget, skipFn?): Promise { const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; this.scene.moveAnimations = null; // Mandatory to avoid crash return new Promise(async (resolve, reject) => { From 559097d3099f802aa670cc2ee53da887510c3959 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Tue, 17 Sep 2024 05:11:01 -0700 Subject: [PATCH 36/36] More cleanup --- src/phases/turn-start-phase.ts | 15 ++- src/test/moves/pursuit.test.ts | 120 +++++++++------------- src/test/utils/gameManager.ts | 6 +- src/test/utils/helpers/overridesHelper.ts | 10 ++ 4 files changed, 65 insertions(+), 86 deletions(-) diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 5dfe346a840..811c69ed671 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -1,17 +1,16 @@ +import { BattlerIndex } from "#app/battle"; import BattleScene from "#app/battle-scene"; -import { applyAbAttrs, BypassSpeedChanceAbAttr, PreventBypassSpeedChanceAbAttr, ChangeMovePriorityAbAttr } from "#app/data/ability"; +import { applyAbAttrs, BypassSpeedChanceAbAttr, ChangeMovePriorityAbAttr, PreventBypassSpeedChanceAbAttr } from "#app/data/ability"; +import { TrickRoomTag } from "#app/data/arena-tag"; import { allMoves, applyMoveAttrs, IncrementMovePriorityAttr, MoveHeaderAttr } from "#app/data/move"; import { Abilities } from "#app/enums/abilities"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { Moves } from "#app/enums/moves"; import { Stat } from "#app/enums/stat"; import Pokemon, { PokemonMove } from "#app/field/pokemon"; import { BypassSpeedChanceModifier } from "#app/modifier/modifier"; import { Command } from "#app/ui/command-ui-handler"; import * as Utils from "#app/utils"; - -import { BattlerIndex } from "#app/battle"; - -import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { Moves } from "#app/enums/moves"; import { AttemptCapturePhase } from "./attempt-capture-phase"; import { AttemptRunPhase } from "./attempt-run-phase"; import { BerryPhase } from "./berry-phase"; @@ -22,8 +21,6 @@ import { PostTurnStatusEffectPhase } from "./post-turn-status-effect-phase"; import { SwitchSummonPhase } from "./switch-summon-phase"; import { TurnEndPhase } from "./turn-end-phase"; import { WeatherEffectPhase } from "./weather-effect-phase"; -import { BattlerIndex } from "#app/battle"; -import { TrickRoomTag } from "#app/data/arena-tag"; export class TurnStartPhase extends FieldPhase { constructor(scene: BattleScene) { @@ -173,7 +170,7 @@ export class TurnStartPhase extends FieldPhase { if (!queuedMove) { continue; } - const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move) || new PokemonMove(queuedMove.move); + const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move) ?? new PokemonMove(queuedMove.move); if (move.getMove().hasAttr(MoveHeaderAttr)) { this.scene.unshiftPhase(new MoveHeaderPhase(this.scene, pokemon, move)); } diff --git a/src/test/moves/pursuit.test.ts b/src/test/moves/pursuit.test.ts index 40f2a9c8c9a..cad31aa6f2c 100644 --- a/src/test/moves/pursuit.test.ts +++ b/src/test/moves/pursuit.test.ts @@ -1,19 +1,17 @@ +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/move"; +import { TerrainType } from "#app/data/terrain"; +import { Abilities } from "#app/enums/abilities"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import { Stat } from "#app/enums/stat"; +import Pokemon, { MoveResult } from "#app/field/pokemon"; +import { EncounterPhase } from "#app/phases/encounter-phase"; +import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; +import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { TurnStartPhase } from "#app/phases/turn-start-phase"; +import GameManager from "#test/utils/gameManager"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import GameManager from "../utils/gameManager"; -import { Moves } from "#app/enums/moves.js"; -import { Species } from "#app/enums/species.js"; -import { SPLASH_ONLY } from "../utils/testUtils"; -import { allMoves } from "#app/data/move.js"; -import { getMovePosition } from "../utils/gameManagerUtils"; -import Pokemon, { MoveResult } from "#app/field/pokemon.js"; -import { BattleStat } from "#app/data/battle-stat.js"; -import { BattlerIndex } from "#app/battle.js"; -import { TerrainType } from "#app/data/terrain.js"; -import { Abilities } from "#app/enums/abilities.js"; -import { EnemyCommandPhase } from "#app/phases/enemy-command-phase.js"; -import { SwitchSummonPhase } from "#app/phases/switch-summon-phase.js"; -import { EncounterPhase } from "#app/phases/encounter-phase.js"; -import { TurnStartPhase } from "#app/phases/turn-start-phase.js"; interface PokemonAssertionChainer { and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer; @@ -45,11 +43,14 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { return game.startBattle([playerLead, Species.RAICHU, Species.ABSOL]); } - async function runCombatTurn(to: string = "TurnInitPhase") { - // many of these tests can't run only to BerryPhase because of interactions with fainting - // during switches. we need to run tests all the way through to the start of the next turn - // to ensure that, for example, the game doesn't attempt to switch two pokemon into the same - // slot (once in the originally queued summon phase, and the next as a result of FaintPhase) + /** + * Many of these tests can't run only to {@linkcode BerryPhase} because of interactions with fainting + * during switches. We need to run tests all the way through to the start of the next turn + * to ensure that, for example, the game doesn't attempt to switch two pokemon into the same + * slot (once in the originally queued summon phase, and the next as a result of {@linkcode FaintPhase}) + * @param to Phase to run to (default {@linkcode TurnInitPhase}) + */ + async function runCombatTurn(to: string = "TurnInitPhase"): Promise { return await game.phaseInterceptor.to(to, false); } @@ -60,22 +61,18 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { function playerDoesNothing() { game.override.moveset(Moves.SPLASH); - game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + game.move.select(Moves.SPLASH); if (game.scene.currentBattle.double) { - game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + game.move.select(Moves.SPLASH, 1); } } function playerUsesPursuit(pokemonIndex: 0 | 1 = 0, targetIndex: BattlerIndex = BattlerIndex.ENEMY) { - game.doAttack(getMovePosition(game.scene, pokemonIndex, Moves.PURSUIT)); - game.doSelectTarget(targetIndex); + game.move.select(Moves.PURSUIT, pokemonIndex, targetIndex); } function playerUsesSwitchMove(pokemonIndex: 0 | 1 = 0) { - game.doAttack(getMovePosition(game.scene, pokemonIndex, Moves.U_TURN)); - if (game.scene.currentBattle.double) { - game.doSelectTarget(BattlerIndex.ENEMY); - } + game.move.select(Moves.U_TURN, pokemonIndex); game.doSelectPartyPokemon(2); } @@ -100,7 +97,7 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { const moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex())); game.phaseInterceptor.onNextPhase(TurnStartPhase, p => { - vi.spyOn(p, "getOrder").mockReturnValue( + vi.spyOn(p, "getCommandOrder").mockReturnValue( // TurnStartPhase crashes if a BI returned by getOrder() is fainted. // not an issue normally but some of the test setups can cause this moveOrder!.filter(i => game.scene.getField(false)[i]?.isActive(true)) @@ -114,7 +111,7 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { const moveOrder = ([...pokemon, ...otherPkmn].map(pkmn => pkmn.getBattlerIndex())); game.phaseInterceptor.onNextPhase(TurnStartPhase, p => { - vi.spyOn(p, "getOrder").mockReturnValue( + vi.spyOn(p, "getCommandOrder").mockReturnValue( // TurnStartPhase crashes if a BI returned by getOrder() is fainted. // not an issue normally but some of the test setups can cause this moveOrder!.filter(i => game.scene.getField(false)[i]?.isActive(true)) @@ -201,7 +198,7 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { .startingLevel(20) .startingWave(25) .moveset([Moves.PURSUIT, Moves.U_TURN, Moves.DRAGON_TAIL, Moves.FOLLOW_ME]) - .enemyMoveset(SPLASH_ONLY) + .enemyMoveset(Moves.SPLASH) .disableCrits(); vi.spyOn(pursuitMoveDef, "calculateBattlePower"); @@ -306,8 +303,8 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { it("should bypass accuracy checks when hitting a hard-switching target", async () => { // arrange await startBattle(); - game.scene.getPlayerPokemon()!.summonData.battleStats[BattleStat.ACC] = -6; - game.scene.getEnemyPokemon()!.summonData.battleStats[BattleStat.EVA] = 6; + game.scene.getPlayerPokemon()!.summonData.statStages[Stat.ACC] = -6; + game.scene.getEnemyPokemon()!.summonData.statStages[Stat.EVA] = 6; // act playerUsesPursuit(); @@ -322,8 +319,8 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { // arrange await startBattle(); forceMovesLast(game.scene.getPlayerPokemon()); - game.scene.getEnemyPokemon()!.summonData.battleStats[BattleStat.ACC] = -6; - game.scene.getPlayerPokemon()!.summonData.battleStats[BattleStat.EVA] = 6; + game.scene.getEnemyPokemon()!.summonData.statStages[Stat.ACC] = -6; + game.scene.getPlayerPokemon()!.summonData.statStages[Stat.EVA] = 6; // act playerUsesPursuit(); @@ -376,8 +373,8 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { // arrange await startBattle(); forceMovesLast(game.scene.getPlayerPokemon()); - game.scene.getEnemyPokemon()!.summonData.battleStats[BattleStat.ACC] = -6; - game.scene.getPlayerPokemon()!.summonData.battleStats[BattleStat.EVA] = 6; + game.scene.getEnemyPokemon()!.summonData.statStages[Stat.ACC] = -6; + game.scene.getPlayerPokemon()!.summonData.statStages[Stat.EVA] = 6; // act playerUsesPursuit(); @@ -580,23 +577,21 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { describe("doubles interactions", () => { beforeEach(() => { - game.override.battleType("double"); + game.override + .battleType("double") + .enemyMoveset([Moves.PURSUIT, Moves.U_TURN]); }); it("should bypass follow me when hitting a switching target", async () => { - // arrange await startBattle(); forceMovesLast(game.scene.getEnemyPokemon()); - // act - game.doAttack(getMovePosition(game.scene, 0, Moves.FOLLOW_ME)); + game.move.select(Moves.FOLLOW_ME); playerUsesSwitchMove(1); - enemyUses(Moves.PURSUIT); - game.move.forceAiTargets(game.scene.getEnemyPokemon(), BattlerIndex.PLAYER_2); + game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.PLAYER_2); await game.killPokemon(game.scene.getEnemyField()[1]); await runCombatTurn(); - // assert expectPursuitPowerDoubled(); expectWasHit(findPartyMember(game.scene.getParty(), Species.RAICHU)) .and(expectNotOnField); @@ -604,57 +599,47 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { }); it("should not bypass follow me when hitting a non-switching target", async () => { - // arrange await startBattle(); forceMovesLast(game.scene.getEnemyPokemon()); - // act game.override.moveset([Moves.FOLLOW_ME, Moves.SPLASH]); - game.doAttack(getMovePosition(game.scene, 0, Moves.FOLLOW_ME)); - game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); - enemyUses(Moves.PURSUIT); - game.move.forceAiTargets(game.scene.getEnemyPokemon(), BattlerIndex.PLAYER_2); + game.move.select(Moves.FOLLOW_ME); + game.move.select(Moves.SPLASH, 1); + game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.PLAYER_2); await game.killPokemon(game.scene.getEnemyField()[1]); await runCombatTurn(); - // assert expectPursuitPowerUnchanged(); expectWasHit(findPartyMember(game.scene.getParty(), playerLead)); expectWasNotHit(findPartyMember(game.scene.getParty(), Species.RAICHU)); }); it("should not cause the enemy AI to send out a fainted pokemon if they command 2 switches and one of the outgoing pokemon faints to pursuit", async () => { - // arrange await startBattle(); forceMovesLast(game.scene.getPlayerField()); game.scene.getEnemyField()[0]!.hp = 1; - // act playerUsesPursuit(0); playerUsesPursuit(1); enemySwitches(); await runCombatTurn(); - // assert expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(2); expect(game.scene.getEnemyParty().filter(p => p.isOnField() && p.isFainted())).toHaveLength(0); }); // TODO: is this correct behavior? it("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one with no other targets on field", async () => { - // arrange await startBattle(); forceMovesLast(game.scene.getPlayerField()); game.scene.getEnemyField()[0]!.hp = 1; - // act playerUsesPursuit(0); playerUsesPursuit(1); enemySwitches(); await game.killPokemon(game.scene.getEnemyField()[1]); await runCombatTurn(); - // assert expectPursuitPowerDoubled(); expectPursuitSucceeded(game.scene.getPlayerField()[0]); expectPursuitFailed(game.scene.getPlayerField()[1]); @@ -662,7 +647,6 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { // TODO: is this correct behavior? it("should attack the second switching pokemon if both pokemon switch and the first is KOd", async () => { - // arrange game.phaseInterceptor.onNextPhase(EncounterPhase, () => { game.scene.currentBattle.enemyLevels = [...game.scene.currentBattle.enemyLevels!, game.scene.currentBattle.enemyLevels![0]]; }); @@ -670,7 +654,6 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { forceMovesLast(game.scene.getPlayerField()); game.scene.getEnemyField()[0]!.hp = 1; - // act playerUsesPursuit(0); playerUsesPursuit(1); enemySwitches(); @@ -680,7 +663,6 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { }); await runCombatTurn(); - // assert expectPursuitPowerDoubled(); expectPursuitSucceeded(game.scene.getPlayerField()[0]); expectPursuitSucceeded(game.scene.getPlayerField()[1]); @@ -692,13 +674,10 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { // TODO: confirm correct behavior and add tests for other pursuit/switch combos in doubles it("should not hit a pokemon being forced out with dragon tail", async () => { - // arrange await startBattle(); forceMovesLast(game.scene.getPlayerField()); - // act - game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); - game.doSelectTarget(BattlerIndex.ENEMY); + game.move.select(Moves.DRAGON_TAIL); playerUsesPursuit(1); enemyUses(Moves.SPLASH); await runCombatTurn(); @@ -805,18 +784,14 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { }); it("should not hit a switching ally for double damage (hard-switch, enemy field)", async () => { - // arrange await startBattle(); forceMovesLast(game.scene.getEnemyField()); - // act playerDoesNothing(); enemySwitches(true); - enemyUses(Moves.PURSUIT); - game.move.forceAiTargets(game.scene.getEnemyField()[1], BattlerIndex.ENEMY); + game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.ENEMY); await runCombatTurn(); - // assert expectPursuitPowerUnchanged(); expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField); expectWasHit(game.scene.getEnemyField()[0]); @@ -846,11 +821,8 @@ describe("Moves - Pursuit", { timeout: 10000 }, () => { // act playerDoesNothing(); - enemyUses(Moves.U_TURN); - await game.phaseInterceptor.to(EnemyCommandPhase); - - enemyUses(Moves.PURSUIT); - game.move.forceAiTargets(game.scene.getEnemyField()[1], BattlerIndex.ENEMY); + game.forceEnemyMove(Moves.U_TURN); + game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.ENEMY); await runCombatTurn(); // assert diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 87f8cf00fff..468a44fd6ef 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -461,11 +461,11 @@ export default class GameManager { * await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]); * ``` */ - async setTurnOrder(order: BattlerIndex[]): Promise { + async setTurnOrder(order: BattlerIndex[], modifyPriority: boolean = false): Promise { await this.phaseInterceptor.to(TurnStartPhase, false); - console.log("Base turn order (before priority) set to:", order); + console.log(`${modifyPriority ? "Turn" : "Speed"} order modified to: `, order); - vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, "getSpeedOrder").mockReturnValue(order); + vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, (modifyPriority ? "getCommandOrder" : "getSpeedOrder")).mockReturnValue(order); } /** diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index b23caecfc4c..45c0d61107e 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -336,6 +336,16 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Forces the AI to always switch out, or reset to allow normal switching decisions + * @returns this + */ + forceTrainerSwitches(newValue: boolean = true) { + vi.spyOn(Overrides, "TRAINER_ALWAYS_SWITCHES_OVERRIDE", "get").mockReturnValue(newValue); + this.log("Trainers will always switch out set to:", newValue); + return this; + } + /** * Override the encounter chance for a mystery encounter. * @param percentage the encounter chance in %