This commit is contained in:
Alex Van Liew 2024-09-17 08:43:44 -07:00 committed by GitHub
commit b28c28ac01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1246 additions and 127 deletions

View File

@ -2490,6 +2490,10 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new SubstituteTag(sourceMove, sourceId);
case BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON:
return new MysteryEncounterPostSummonTag();
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);

View File

@ -1086,8 +1086,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, 0, move.id);
}
}
@ -3887,6 +3886,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;
@ -4438,8 +4452,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 FrenzyAttr extends MoveEffectAttr {
constructor() {
@ -4699,8 +4726,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;
}
@ -5157,6 +5184,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;
@ -5175,26 +5207,76 @@ 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(), "moveEffect", 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)
@ -7364,7 +7446,11 @@ export function initMoves() {
.ignoresSubstitute()
.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(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),
new AttackMove(Moves.RAPID_SPIN, Type.NORMAL, MoveCategory.PHYSICAL, 50, 100, 40, 100, 0, 2)
.attr(StatStageChangeAttr, [ Stat.SPD ], 1, true)
.attr(RemoveBattlerTagAttr, [

View File

@ -80,4 +80,14 @@ export enum BattlerTagType {
BURNED_UP = "BURNED_UP",
DOUBLE_SHOCKED = "DOUBLE_SHOCKED",
MYSTERY_ENCOUNTER_POST_SUMMON = "MYSTERY_ENCOUNTER_POST_SUMMON",
/**
* 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",
}

View File

@ -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,
@ -267,6 +268,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);
@ -440,6 +452,11 @@ 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_OVERRIDE) {
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;

View File

@ -128,6 +128,25 @@ class DefaultOverrides {
*/
readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0;
// --------------------------
// TRAINER/AI OVERRIDES
// --------------------------
/**
* 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
// -------------

View File

@ -50,7 +50,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);

View File

@ -106,7 +106,7 @@ export class FaintPhase extends PokemonPhase {
* If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field,
* push a phase that prompts the player to summon a Pokemon from their party.
*/
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));

View File

@ -95,9 +95,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;

View File

@ -10,52 +10,56 @@ import { SwitchSummonPhase } from "./switch-summon-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 doReturn Indicates if the party member on the field should be
* recalled to ball or has already left the field. Passed to {@linkcode SwitchSummonPhase}.
*/
constructor(scene: BattleScene, fieldIndex: integer, isModal: boolean, doReturn: boolean) {
* @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, 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
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 (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));
}

View File

@ -1,7 +1,11 @@
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";
@ -17,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) {
@ -87,14 +89,22 @@ export class TurnStartPhase extends FieldPhase {
// Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands.
moveOrder = moveOrder.slice(0);
moveOrder.sort((a, b) => {
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];
@ -160,26 +170,54 @@ 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));
}
// 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;

View File

@ -0,0 +1,834 @@
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";
interface PokemonAssertionChainer {
and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer;
and(expectation: (p?: Pokemon) => void): void
}
function chain(pokemon?: Pokemon): PokemonAssertionChainer {
return {
and: (expectation) => {
return expectation(pokemon);
}
};
}
function toArray<T>(a?: T | T[]) {
return Array.isArray(a) ? a : [a!];
}
describe("Moves - Pursuit", { timeout: 10000 }, () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const pursuitMoveDef = allMoves[Moves.PURSUIT];
const playerLead = Species.MUDSDALE;
const enemyLead = Species.KANGASKHAN;
function startBattle() {
return game.startBattle([playerLead, Species.RAICHU, Species.ABSOL]);
}
/**
* 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<void> {
return await game.phaseInterceptor.to(to, false);
}
function afterFirstSwitch(action: () => void) {
game.phaseInterceptor.onNextPhase(EnemyCommandPhase, () =>
game.phaseInterceptor.advance().then(action));
}
function playerDoesNothing() {
game.override.moveset(Moves.SPLASH);
game.move.select(Moves.SPLASH);
if (game.scene.currentBattle.double) {
game.move.select(Moves.SPLASH, 1);
}
}
function playerUsesPursuit(pokemonIndex: 0 | 1 = 0, targetIndex: BattlerIndex = BattlerIndex.ENEMY) {
game.move.select(Moves.PURSUIT, pokemonIndex, targetIndex);
}
function playerUsesSwitchMove(pokemonIndex: 0 | 1 = 0) {
game.move.select(Moves.U_TURN, pokemonIndex);
game.doSelectPartyPokemon(2);
}
function playerSwitches(pokemonIndex: number = 1) {
game.doSwitchPokemon(pokemonIndex);
}
function enemyUses(move: Moves) {
game.override.enemyMoveset(move);
}
function enemySwitches(once?: boolean) {
game.override.forceTrainerSwitches();
if (once) {
afterFirstSwitch(() => game.override.forceTrainerSwitches(false));
}
}
function forceMovesLast(pokemon?: Pokemon | Pokemon[]) {
pokemon = toArray(pokemon);
const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p));
const moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex()));
game.phaseInterceptor.onNextPhase(TurnStartPhase, p => {
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))
);
});
}
function forceMovesFirst(pokemon?: Pokemon | Pokemon[]) {
pokemon = toArray(pokemon);
const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p));
const moveOrder = ([...pokemon, ...otherPkmn].map(pkmn => pkmn.getBattlerIndex()));
game.phaseInterceptor.onNextPhase(TurnStartPhase, p => {
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))
);
});
}
function forceLowestPriorityBracket() {
vi.spyOn(pursuitMoveDef, "priority", "get").mockReturnValue(-6);
}
function expectPursuitPowerDoubled() {
expect(pursuitMoveDef.calculateBattlePower).toHaveReturnedWith(80);
}
function expectPursuitPowerUnchanged() {
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);
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 expectOnField(pokemon?: Pokemon) {
expect(pokemon!.isOnField()).toBe(true);
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 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);
}
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, Species.ALCREMIE])
.startingLevel(20)
.startingWave(25)
.moveset([Moves.PURSUIT, Moves.U_TURN, Moves.DRAGON_TAIL, Moves.FOLLOW_ME])
.enemyMoveset(Moves.SPLASH)
.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 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();
// 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.statStages[Stat.ACC] = -6;
game.scene.getEnemyPokemon()!.summonData.statStages[Stat.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.statStages[Stat.ACC] = -6;
game.scene.getPlayerPokemon()!.summonData.statStages[Stat.EVA] = 6;
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
});
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
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();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.getEnemyPokemon()!.summonData.statStages[Stat.ACC] = -6;
game.scene.getPlayerPokemon()!.summonData.statStages[Stat.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());
forceLowestPriorityBracket();
// 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("BerryPhase");
// assert
expectPursuitFailed(game.scene.getPlayerPokemon());
expectWasNotHit(game.scene.getEnemyParty()[0])
.and(expectHasFled);
});
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());
// act
playerUsesPursuit();
enemyUses(Moves.VOLT_SWITCH);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectOnField);
expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead));
});
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());
// act
playerUsesPursuit();
enemySwitches();
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
expectWasHit(game.scene.getPlayerPokemon());
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
});
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());
// act
playerUsesSwitchMove();
enemyUses(Moves.PURSUIT);
await runCombatTurn();
// assert
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
});
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());
// act
playerUsesSwitchMove();
enemySwitches();
await runCombatTurn();
// assert
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
});
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;
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expect(game.scene.getParty()[0]!.isFainted()).toBe(true);
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectNotOnField);
});
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")
.enemyMoveset([Moves.PURSUIT, Moves.U_TURN]);
});
it("should bypass follow me when hitting a switching target", async () => {
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
game.move.select(Moves.FOLLOW_ME);
playerUsesSwitchMove(1);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.PLAYER_2);
await game.killPokemon(game.scene.getEnemyField()[1]);
await runCombatTurn();
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getParty(), Species.RAICHU))
.and(expectNotOnField);
expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead));
});
it("should not bypass follow me when hitting a non-switching target", async () => {
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
game.override.moveset([Moves.FOLLOW_ME, Moves.SPLASH]);
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();
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 () => {
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.scene.getEnemyField()[0]!.hp = 1;
playerUsesPursuit(0);
playerUsesPursuit(1);
enemySwitches();
await runCombatTurn();
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 () => {
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.scene.getEnemyField()[0]!.hp = 1;
playerUsesPursuit(0);
playerUsesPursuit(1);
enemySwitches();
await game.killPokemon(game.scene.getEnemyField()[1]);
await runCombatTurn();
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 () => {
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;
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();
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 () => {
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.move.select(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: 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();
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]);
});
// 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();
forceMovesLast(game.scene.getPlayerField());
// act
playerSwitches(2);
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 () => {
await startBattle();
forceMovesLast(game.scene.getEnemyField());
playerDoesNothing();
enemySwitches(true);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.ENEMY);
await runCombatTurn();
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();
game.forceEnemyMove(Moves.U_TURN);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.ENEMY);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField);
expectWasHit(game.scene.getEnemyField()[0]);
});
});
});

View File

@ -35,64 +35,46 @@ 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");
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 () => {
// 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());
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);
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.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);

View File

@ -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);
});

View File

@ -88,7 +88,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);
@ -149,7 +149,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);
@ -213,7 +213,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);
@ -276,7 +276,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);
@ -337,7 +337,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);
@ -398,7 +398,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);
@ -458,7 +458,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);
@ -525,7 +525,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);
@ -591,7 +591,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);
});

View File

@ -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.
@ -141,7 +140,7 @@ export default class GameManager {
* @returns A promise that resolves when the title phase is reached.
*/
async runToTitle(): Promise<void> {
await this.phaseInterceptor.whenAboutToRun(LoginPhase);
await this.phaseInterceptor.to(LoginPhase, false);
this.phaseInterceptor.pop();
await this.phaseInterceptor.run(TitlePhase);
@ -251,7 +250,7 @@ export default class GameManager {
* @param {BattlerIndex} targetIndex The index of the attack target, or `undefined` for multi-target attacks
* @param movePosition The index of the move in the pokemon's moveset array
*/
selectTarget(movePosition: integer, targetIndex?: BattlerIndex) {
selectTarget(movePosition: number, targetIndex?: BattlerIndex) {
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?
@ -397,7 +396,7 @@ export default class GameManager {
return updateUserInfo();
}
async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) {
async killPokemon(pokemon: Pokemon) {
return new Promise<void>(async (resolve, reject) => {
pokemon.hp = 0;
this.scene.pushPhase(new FaintPhase(this.scene, pokemon.getBattlerIndex(), true));
@ -441,6 +440,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);
@ -458,10 +461,11 @@ export default class GameManager {
* await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);
* ```
*/
async setTurnOrder(order: BattlerIndex[]): Promise<void> {
async setTurnOrder(order: BattlerIndex[], modifyPriority: boolean = false): Promise<void> {
await this.phaseInterceptor.to(TurnStartPhase, false);
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);
}
/**

View File

@ -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,27 @@ 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;
}
/**
* 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 %

View File

@ -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,11 @@ 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
export interface PromptHandler {
phaseTarget?: string;
@ -60,7 +64,6 @@ export interface PromptHandler {
expireFn?: () => void;
awaitingActionInput?: boolean;
}
import { ExpPhase } from "#app/phases/exp-phase";
export default class PhaseInterceptor {
public scene;
@ -211,13 +214,21 @@ export default class PhaseInterceptor {
});
}
/**
* Advance a single phase
* @returns A promise that resolves when the next phase has started
*/
advance(): Promise<void> {
return this.run(this.onHold[0]);
}
/**
* Method to run a phase with an optional skip function.
* @param phaseTarget - The phase to run.
* @param skipFn - Optional skip function.
* @returns A promise that resolves when the phase is run.
*/
run(phaseTarget, skipFn?): Promise<void> {
async run(phaseTarget, skipFn?): Promise<void> {
const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
this.scene.moveAnimations = null; // Mandatory to avoid crash
return new Promise(async (resolve, reject) => {
@ -251,18 +262,24 @@ export default class PhaseInterceptor {
});
}
whenAboutToRun(phaseTarget, skipFn?): Promise<void> {
const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
/**
* 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<T extends PhaseClassType>(phaseType: T, cb: (phase: InstanceType<T>) => void) {
const targetName = phaseType.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();
}
});
ErrorInterceptor.getInstance().add(this);
const interval = setInterval(async () => {
const currentPhase = this.onHold[0];
if (currentPhase?.name === targetName) {
clearInterval(interval);
cb(this.scene.getCurrentPhase());
}
});
}
@ -359,6 +376,13 @@ export default class PhaseInterceptor {
* Method to start the prompt handler.
*/
startPromptHandler() {
const PROMPT_TIMEOUT = 2000;
let timeSpentInPrompt = 0;
let lastTime: number | undefined = undefined;
let lastPhase, lastPromptPhase, lastMode;
let warned = false;
this.promptInterval = setInterval(() => {
if (this.prompts.length) {
const actionForNextPrompt = this.prompts[0];
@ -366,8 +390,31 @@ 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 && currentMode !== Mode.MESSAGE) {
const currentTime = Date.now();
timeSpentInPrompt += lastTime === undefined ? 0 : currentTime - lastTime;
lastTime = currentTime;
if (timeSpentInPrompt > PROMPT_TIMEOUT && !warned) {
warned = true;
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`);
timeSpentInPrompt = 0;
lastTime = undefined;
} else if (currentMode === actionForNextPrompt.mode && currentPhase === actionForNextPrompt.phaseTarget && currentHandler.active && (!actionForNextPrompt.awaitingActionInput || (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput))) {
const prompt = this.prompts.shift();
if (prompt?.callback) {
@ -394,6 +441,7 @@ export default class PhaseInterceptor {
expireFn,
awaitingActionInput
});
console.log(`Prompt added for ${phaseTarget} (mode ${mode})`);
}
/**