This commit is contained in:
Bertie690 2025-08-04 19:44:11 -04:00 committed by GitHub
commit dc99017968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1586 additions and 1894 deletions

View File

@ -810,6 +810,58 @@ export class BattleScene extends SceneBase {
return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
}
/**
* Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
* but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
*
* Used for switch out logic checks.
* @param pokemon - A {@linkcode Pokemon} on the desired side of the field, used to infer the side and trainer slot (as applicable)
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
* @overload
*/
public getBackupPartyMemberIndices(pokemon: Pokemon): number[];
/**
* Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
* but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
*
* Used for switch out logic checks.
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true`
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
* @overload
*/
public getBackupPartyMemberIndices(player: true): number[];
/**
* Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
* but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
*
* Used for switch out logic checks.
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true`
* @param trainerSlot - The {@linkcode TrainerSlot | trainer slot} to check against for enemy trainers;
* used to verify ownership in multi battles
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
* @overload
*/
public getBackupPartyMemberIndices(player: false, trainerSlot: TrainerSlot): number[];
public getBackupPartyMemberIndices(player: boolean | Pokemon, trainerSlot?: number): number[] {
// Note: We return the indices instead of the actual Pokemon because `SwitchSummonPhase` and co. take an index instead of a pokemon.
// If this is ever changed, this can be replaced with a simpler version involving `filter` and conditional type annotations.
if (typeof player === "object") {
// Marginally faster than using a ternary
trainerSlot = (player as unknown as EnemyPokemon)["trainerSlot"];
player = player.isPlayer();
}
const indices: number[] = [];
const party = player ? this.getPlayerParty() : this.getEnemyParty();
party.forEach((p: PlayerPokemon | EnemyPokemon, i: number) => {
if (p.isAllowedInBattle() && !p.isOnField() && (player || (p as EnemyPokemon).trainerSlot === trainerSlot)) {
indices.push(i);
}
});
return indices;
}
/**
* Returns an array of Pokemon on both sides of the battle - player first, then enemy.
* Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type.
@ -836,6 +888,7 @@ export class BattleScene extends SceneBase {
if (this.currentBattle.double === false) {
return;
}
// TODO: Remove while loop
if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase;
do {

View File

@ -13,6 +13,7 @@ import { getBerryEffectFunc } from "#data/berry";
import { allAbilities, allMoves } from "#data/data-lists";
import { SpeciesFormChangeAbilityTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
import { Gender } from "#data/gender";
import { ForceSwitchOutHelper } from "#data/helpers/force-switch";
import { getPokeballName } from "#data/pokeball";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
@ -22,7 +23,6 @@ import type { Weather } from "#data/weather";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-type";
@ -42,16 +42,17 @@ import { SpeciesId } from "#enums/species-id";
import type { BattleStat, EffectiveStat } from "#enums/stat";
import { BATTLE_STATS, EFFECTIVE_STATS, getStatKey, Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { SwitchType } from "#enums/switch-type";
import { type NormalSwitchType, SwitchType } from "#enums/switch-type";
import { WeatherType } from "#enums/weather-type";
import { BerryUsedEvent } from "#events/battle-scene";
import type { EnemyPokemon, Pokemon } from "#field/pokemon";
import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "#modifiers/modifier";
import type { Pokemon } from "#field/pokemon";
import { BerryModifier, PokemonHeldItemModifier } from "#modifiers/modifier";
import { BerryModifierType } from "#modifiers/modifier-type";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move";
import type { PokemonMove } from "#moves/pokemon-move";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import type { StatStageChangePhase } from "#phases/stat-stage-change-phase";
import type {
AbAttrCondition,
@ -3254,9 +3255,9 @@ export class CommanderAbAttr extends AbAttr {
const ally = pokemon.getAlly();
return (
globalScene.currentBattle?.double &&
!isNullOrUndefined(ally) &&
ally.species.speciesId === SpeciesId.DONDOZO &&
!(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED))
ally?.species.speciesId === SpeciesId.DONDOZO &&
!ally.isFainted() &&
!ally.getTag(BattlerTagType.COMMANDED)
);
}
@ -4159,7 +4160,7 @@ export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr {
/**
* Condition function to applied to abilities related to Sheer Force.
* Checks if last move used against target was affected by a Sheer Force user and:
* Disables: Color Change, Pickpocket, Berserk, Anger Shell
* Disables: Color Change, Pickpocket, Berserk, Anger Shell, Wimp Out and Emergency Exit.
* @returns An {@linkcode AbAttrCondition} to disable the ability under the proper conditions.
*/
function getSheerForceHitDisableAbCondition(): AbAttrCondition {
@ -6229,187 +6230,13 @@ export class TerrainEventTypeChangeAbAttr extends PostSummonAbAttr {
}
}
class ForceSwitchOutHelper {
constructor(private switchType: SwitchType) {}
/**
* Handles the logic for switching out a Pokémon based on battle conditions, HP, and the switch type.
*
* @param pokemon The {@linkcode Pokemon} attempting to switch out.
* @returns `true` if the switch is successful
*/
// TODO: Make this cancel pending move phases on the switched out target
public switchOutLogic(pokemon: Pokemon): boolean {
const switchOutTarget = pokemon;
/**
* If the switch-out target is a player-controlled Pokémon, the function checks:
* - Whether there are available party members to switch in.
* - If the Pokémon is still alive (hp > 0), and if so, it leaves the field and a new SwitchPhase is initiated.
*/
if (switchOutTarget.isPlayer()) {
if (globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isOnField()).length < 1) {
return false;
}
if (switchOutTarget.hp > 0) {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
"SwitchPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
true,
true,
);
return true;
}
/**
* For non-wild battles, it checks if the opposing party has any available Pokémon to switch in.
* If yes, the Pokémon leaves the field and a new SwitchSummonPhase is initiated.
*/
} else if (globalScene.currentBattle.battleType !== BattleType.WILD) {
if (globalScene.getEnemyParty().filter(p => p.isAllowedInBattle() && !p.isOnField()).length < 1) {
return false;
}
if (switchOutTarget.hp > 0) {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
const summonIndex = globalScene.currentBattle.trainer
? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot)
: 0;
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
summonIndex,
false,
false,
);
return true;
}
/**
* For wild Pokémon battles, the Pokémon will flee if the conditions are met (waveIndex and double battles).
* It will not flee if it is a Mystery Encounter with fleeing disabled (checked in `getSwitchOutCondition()`) or if it is a wave 10x wild boss
*/
} else {
const allyPokemon = switchOutTarget.getAlly();
if (!globalScene.currentBattle.waveIndex || globalScene.currentBattle.waveIndex % 10 === 0) {
return false;
}
if (switchOutTarget.hp > 0) {
switchOutTarget.leaveField(false);
globalScene.phaseManager.queueMessage(
i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }),
null,
true,
500,
);
if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) {
globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon);
}
}
if (!allyPokemon?.isActive(true)) {
globalScene.clearEnemyHeldItemModifiers();
if (switchOutTarget.hp) {
globalScene.phaseManager.pushNew("BattleEndPhase", false);
if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) {
globalScene.phaseManager.pushNew("SelectBiomePhase");
}
globalScene.phaseManager.pushNew("NewBattlePhase");
}
}
}
return false;
}
/**
* Determines if a Pokémon can switch out based on its status, the opponent's status, and battle conditions.
*
* @param pokemon The Pokémon attempting to switch out.
* @param opponent The opponent Pokémon.
* @returns `true` if the switch-out condition is met
*/
public getSwitchOutCondition(pokemon: Pokemon, opponent: Pokemon): boolean {
const switchOutTarget = pokemon;
const player = switchOutTarget.isPlayer();
if (player) {
const blockedByAbility = new BooleanHolder(false);
applyAbAttrs("ForceSwitchOutImmunityAbAttr", { pokemon: opponent, cancelled: blockedByAbility });
return !blockedByAbility.value;
}
if (!player && globalScene.currentBattle.battleType === BattleType.WILD) {
if (!globalScene.currentBattle.waveIndex && globalScene.currentBattle.waveIndex % 10 === 0) {
return false;
}
}
if (
!player &&
globalScene.currentBattle.isBattleMysteryEncounter() &&
!globalScene.currentBattle.mysteryEncounter?.fleeAllowed
) {
return false;
}
const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
return (
(!player && globalScene.currentBattle.battleType === BattleType.WILD) ||
party.filter(
p =>
p.isAllowedInBattle() &&
!p.isOnField() &&
(player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot),
).length > 0
);
}
/**
* Returns a message if the switch-out attempt fails due to ability effects.
*
* @param target The target Pokémon.
* @returns The failure message, or `null` if no failure.
*/
public getFailedText(target: Pokemon): string | null {
const blockedByAbility = new BooleanHolder(false);
applyAbAttrs("ForceSwitchOutImmunityAbAttr", { pokemon: target, cancelled: blockedByAbility });
return blockedByAbility.value
? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) })
: null;
}
}
/**
* Calculates the amount of recovery from the Shell Bell item.
*
* If the Pokémon is holding a Shell Bell, this function computes the amount of health
* recovered based on the damage dealt in the current turn. The recovery is multiplied by the
* Shell Bell's modifier (if any).
*
* @param pokemon - The Pokémon whose Shell Bell recovery is being calculated.
* @returns The amount of health recovered by Shell Bell.
*/
function calculateShellBellRecovery(pokemon: Pokemon): number {
const shellBellModifier = pokemon.getHeldItems().find(m => m instanceof HitHealModifier);
if (shellBellModifier) {
return toDmgValue(pokemon.turnData.totalDamageDealt / 8) * shellBellModifier.stackCount;
}
return 0;
}
export interface PostDamageAbAttrParams extends AbAttrBaseParams {
/** The pokemon that caused the damage; omitted if the damage was not from a pokemon */
source?: Pokemon;
readonly source?: Pokemon;
/** The amount of damage that was dealt */
readonly damage: number;
}
/**
* Triggers after the Pokemon takes any damage
*/
@ -6426,83 +6253,101 @@ export class PostDamageAbAttr extends AbAttr {
* This attribute checks various conditions related to the damage received, the moves used by the Pokémon
* and its opponents, and determines whether a forced switch-out should occur.
*
* Used by Wimp Out and Emergency Exit
*
* @see {@linkcode applyPostDamage}
* @sealed
* Used for {@linkcode AbilityId.WIMP_OUT} and {@linkcode AbilityId.EMERGENCY_EXIT}.
*/
export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr {
private helper: ForceSwitchOutHelper = new ForceSwitchOutHelper(SwitchType.SWITCH);
private hpRatio: number;
private helper: ForceSwitchOutHelper;
constructor(hpRatio = 0.5) {
constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) {
super();
this.hpRatio = hpRatio;
this.helper = new ForceSwitchOutHelper({ selfSwitch: true, switchType, allowFlee: true });
// TODO: change if any force switch abilities with red card-like effects are added
}
// TODO: Refactor to use more early returns
/**
* Check to see if the user should be switched out after taking damage.
* @param pokemon - The {@linkcode Pokemon} with this ability; will be switched out if conditions are met
* @param damage - The amount of damage dealt by the triggering damage instance
* @param source - The {@linkcode Pokemon} having damaged the user with an attack, or `undefined`
* if the damage source was indirect
* @returns Whether `pokemon` should be switched out upon move conclusion.
*/
public override canApply({ pokemon, source, damage }: PostDamageAbAttrParams): boolean {
const moveHistory = pokemon.getMoveHistory();
// Will not activate when the Pokémon's HP is lowered by cutting its own HP
const fordbiddenAttackingMoves = [MoveId.BELLY_DRUM, MoveId.SUBSTITUTE, MoveId.CURSE, MoveId.PAIN_SPLIT];
if (moveHistory.length > 0) {
const lastMoveUsed = moveHistory[moveHistory.length - 1];
if (fordbiddenAttackingMoves.includes(lastMoveUsed.move)) {
return false;
}
// Skip move checks for damage not occurring due to a move (eg: hazards)
const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase?.is("MoveEffectPhase") && !this.passesMoveChecks(source)) {
return false;
}
// Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.
const fordbiddenDefendingMoves = [MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW];
if (source) {
const enemyMoveHistory = source.getMoveHistory();
if (enemyMoveHistory.length > 0) {
const enemyLastMoveUsed = enemyMoveHistory[enemyMoveHistory.length - 1];
// Will not activate if the Pokémon's HP falls below half while it is in the air during Sky Drop.
if (
fordbiddenDefendingMoves.includes(enemyLastMoveUsed.move) ||
(enemyLastMoveUsed.move === MoveId.SKY_DROP && enemyLastMoveUsed.result === MoveResult.OTHER)
) {
return false;
// Will not activate if the Pokémon's HP falls below half by a move affected by Sheer Force.
// TODO: Make this use the sheer force disable condition
}
if (allMoves[enemyLastMoveUsed.move].chance >= 0 && source.hasAbility(AbilityId.SHEER_FORCE)) {
return false;
}
// Activate only after the last hit of multistrike moves
if (source.turnData.hitsLeft > 1) {
return false;
}
if (source.turnData.hitCount > 1) {
damage = pokemon.turnData.damageTaken;
}
}
if (!this.wasKnockedBelowHalf(pokemon, damage)) {
return false;
}
if (pokemon.hp + damage >= pokemon.getMaxHp() * this.hpRatio) {
const shellBellHeal = calculateShellBellRecovery(pokemon);
if (pokemon.hp - shellBellHeal < pokemon.getMaxHp() * this.hpRatio) {
for (const opponent of pokemon.getOpponents()) {
if (!this.helper.getSwitchOutCondition(pokemon, opponent)) {
return false;
}
}
return true;
}
}
return this.helper.canSwitchOut(pokemon);
}
return false;
/**
* Perform move checks to determine if this pokemon should switch out.
* @param source - The {@linkcode Pokemon} whose attack caused the user to switch out,
* or `undefined` if the damage source was indirect.
* @returns `true` if this Pokemon should be allowed to switch out.
*/
private passesMoveChecks(source: Pokemon | undefined): boolean {
// Wimp Out and Emergency Exit...
const currentPhase = globalScene.phaseManager.getCurrentPhase() as MoveEffectPhase;
const currentMove = currentPhase.move;
// will not activate from self-induced HP cutting,
// TODO: Verify that Fillet Away and Clangorous Soul do not proc wimp out
const hpCutMoves = new Set<MoveId>([
MoveId.CURSE,
MoveId.BELLY_DRUM,
MoveId.SUBSTITUTE,
MoveId.PAIN_SPLIT,
MoveId.CLANGOROUS_SOUL,
MoveId.FILLET_AWAY,
]);
// NB: Given this attribute is only applied after _taking damage_ or recieving a damaging attack,
// a failed Substitute or non-Ghost type Curse will not trigger this code.
const notHpCut = !hpCutMoves.has(currentMove.id);
// will not activate for forced switch moves (which trigger before wimp out activates),
const notForceSwitched = ![MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW].includes(currentMove.id);
// and will not activate if the Pokemon is currently in the air from Sky Drop.
// TODO: Make this check the user's tags and move to main `canApply` block once Sky Drop is fully implemented -
// we could be sky dropped by another Pokemon or take indirect damage while skybound (both of which render this check useless)
const lastMove = source?.getLastXMoves()[0];
const notSkyDropped = !(lastMove?.move === MoveId.SKY_DROP && lastMove.result === MoveResult.OTHER);
return notHpCut && notForceSwitched && notSkyDropped;
}
/**
* Perform HP checks to determine if this pokemon should switch out.
* The switch fails if the pokemon was below {@linkcode hpRatio} before being hit
* or is still above it after the hit.
* @param pokemon - The {@linkcode Pokemon} with this ability
* @param damage - The amount of damage taken
* @returns Whether the Pokemon was knocked below half after `damage` was applied
*/
private wasKnockedBelowHalf(pokemon: Pokemon, damage: number) {
// NB: This occurs _after_ the damage instance has been dealt,
// so `pokemon.hp` contains the post-taking damage hp value.
const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio;
return pokemon.hp < hpNeededToSwitch && pokemon.hp + damage >= hpNeededToSwitch;
}
/**
* Applies the switch-out logic after the Pokémon takes damage.
* Checks various conditions based on the moves used by the Pokémon, the opponents' moves, and
* the Pokémon's health after damage to determine whether the switch-out should occur.
*/
public override apply({ pokemon }: PostDamageAbAttrParams): void {
// TODO: Consider respecting the `simulated` flag here
this.helper.switchOutLogic(pokemon);
public override apply({ pokemon, simulated }: PostDamageAbAttrParams): void {
if (simulated) {
return;
}
this.helper.doSwitch(pokemon);
}
}
@ -7385,10 +7230,10 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(AbilityId.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr)
.edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode
.condition(getSheerForceHitDisableAbCondition()),
new Ability(AbilityId.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr)
.edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode
.condition(getSheerForceHitDisableAbCondition()),
new Ability(AbilityId.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(AbilityId.MERCILESS, 7)

View File

@ -813,10 +813,16 @@ export class ConfusedTag extends SerializableBattlerTag {
);
}
/**
* Tick down this Pokemon's confusion duration, randomly interrupting its move if not cured.
* @param pokemon - The {@linkcode Pokemon} with this tag
* @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} triggering this tag's effects.
* @returns Whether the tag should be kept.
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType);
const shouldRemain = super.lapse(pokemon, lapseType);
if (!shouldLapse) {
if (!shouldRemain) {
return false;
}
@ -830,7 +836,9 @@ export class ConfusedTag extends SerializableBattlerTag {
phaseManager.unshiftNew("CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION);
// 1/3 chance of hitting self with a 40 base power move
if (pokemon.randBattleSeedInt(3) === 0 || Overrides.CONFUSION_ACTIVATION_OVERRIDE === true) {
const shouldInterruptMove = Overrides.CONFUSION_ACTIVATION_OVERRIDE ?? pokemon.randBattleSeedInt(3) === 0;
if (shouldInterruptMove) {
// TODO: Are these calculations correct? We probably shouldn't hardcode the damage formula here...
const atk = pokemon.getEffectiveStat(Stat.ATK);
const def = pokemon.getEffectiveStat(Stat.DEF);
const damage = toDmgValue(

View File

@ -0,0 +1,221 @@
import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#app/field/pokemon";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { BooleanHolder, isNullOrUndefined } from "#app/utils/common";
import { BattleType } from "#enums/battle-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import type { NormalSwitchType } from "#enums/switch-type";
import { SwitchType } from "#enums/switch-type";
import i18next from "i18next";
export interface ForceSwitchOutHelperArgs {
/**
* Whether to switch out the user (`true`) or target (`false`).
* If `true`, will ignore certain effects that would otherwise block forced switches.
* @defaultValue `false`
*/
selfSwitch?: boolean;
/**
* The {@linkcode NormalSwitchType} corresponding to the type of switch logic to implement.
* @defaultValue {@linkcode SwitchType.SWITCH}
*/
switchType?: NormalSwitchType;
/**
* Whether to allow non-boss wild Pokemon to flee from this effect's activation.
* @defaultValue `false`
*/
allowFlee?: boolean;
}
/**
* Helper class to handle shared logic for force switching effects.
*/
export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs {
public readonly selfSwitch: boolean;
public readonly switchType: NormalSwitchType;
public readonly allowFlee: boolean;
constructor({ selfSwitch = false, switchType = SwitchType.SWITCH, allowFlee = false }: ForceSwitchOutHelperArgs) {
this.selfSwitch = selfSwitch;
this.switchType = switchType;
this.allowFlee = allowFlee;
}
/**
* Determine if a Pokémon can be forcibly switched out based on its status and battle conditions.
* @param switchOutTarget - The {@linkcode Pokemon} being switched out
* @returns Whether {@linkcode switchOutTarget} can be switched out by the current effect.
*/
public canSwitchOut(switchOutTarget: Pokemon): boolean {
if (switchOutTarget.isFainted()) {
// Fainted Pokemon cannot be switched out by any means.
// This is already checked in `MoveEffectAttr.canApply`, but better safe than sorry
return false;
}
// If we aren't switching ourself out, ensure the target in question can actually be switched out by us
if (!this.selfSwitch && !this.performForceSwitchChecks(switchOutTarget)) {
return false;
}
// Wild enemies should not be allowed to flee with ineligible fleeing moves, nor by any means on X0 waves (don't want easy boss wins)
// TODO: Do we want to show a message for wave X0 failures?
const isPlayer = switchOutTarget.isPlayer();
if (!isPlayer && globalScene.currentBattle.battleType === BattleType.WILD) {
return this.allowFlee && globalScene.currentBattle.waveIndex % 10 !== 0;
}
// Finally, ensure that a trainer switching out has at least 1 valid reserve member to send in.
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(switchOutTarget);
return reservePartyMembers.length > 0;
}
/**
* Perform various checks to confirm the switched out target can be forcibly removed from the field
* by another Pokemon.
* @param switchOutTarget - The {@linkcode Pokemon} being switched out
* @returns Whether {@linkcode switchOutTarget} can be switched out by another Pokemon.
*/
private performForceSwitchChecks(switchOutTarget: Pokemon): boolean {
// Dondozo with an allied Tatsugiri in its mouth cannot be forced out by enemies
const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED);
if (commandedTag?.getSourcePokemon()?.isActive(true)) {
return false;
}
// Check for opposing switch block abilities (Suction Cups and co)
const blockedByAbility = new BooleanHolder(false);
applyAbAttrs("ForceSwitchOutImmunityAbAttr", { pokemon: switchOutTarget, cancelled: blockedByAbility });
if (blockedByAbility.value) {
return false;
}
// Finally, wild opponents cannot be force switched during MEs with flee disabled
return !(
switchOutTarget.isEnemy() &&
globalScene.currentBattle.isBattleMysteryEncounter() &&
!globalScene.currentBattle.mysteryEncounter?.fleeAllowed
);
}
/**
* Wrapper function to handle the actual "switching out" of Pokemon.
* @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) to be switched out
*/
public doSwitch(switchOutTarget: Pokemon): void {
if (switchOutTarget.isPlayer()) {
this.trySwitchPlayerPokemon(switchOutTarget);
return;
}
if (globalScene.currentBattle.battleType === BattleType.TRAINER) {
this.trySwitchTrainerPokemon(switchOutTarget as unknown as EnemyPokemon);
} else {
this.tryFleeWildPokemon(switchOutTarget as unknown as EnemyPokemon);
}
// Hide the info container as soon as the switch out occurs.
// Effects are kept to ensure correct Shell Bell interactions.
// TODO: Should we hide the info container for wild fleeing?
// Currently keeping it same as prior logic for consistency
if (globalScene.currentBattle.battleType === BattleType.WILD) {
switchOutTarget.hideInfo();
}
}
/**
* Method to handle switching out a player Pokemon.
* @param switchOutTarget - The {@linkcode PlayerPokemon} to be switched out
*/
private trySwitchPlayerPokemon(switchOutTarget: PlayerPokemon): void {
// If not forced to switch, add a SwitchPhase to allow picking the next switched in Pokemon.
if (this.switchType !== SwitchType.FORCE_SWITCH) {
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
"SwitchPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
true,
true,
);
return;
}
// Pick a random eligible player pokemon to replace the switched out one.
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(true);
// TODO: Change to use rand seed item
const switchInIndex = reservePartyMembers[switchOutTarget.randBattleSeedInt(reservePartyMembers.length)];
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
switchInIndex,
false,
true,
);
}
/**
* Method to handle switching out an opposing trainer's Pokemon.
* @param switchOutTarget - The {@linkcode EnemyPokemon} to be switched out
*/
private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void {
// fallback for no trainer
if (!globalScene.currentBattle.trainer) {
console.warn("Enemy trainer switch logic triggered without a trainer!");
return;
}
// Forced switches will pick a random eligible pokemon from this trainer's side, while
// choice-based switching uses the trainer's default switch behavior.
const reservePartyIndices = globalScene.getBackupPartyMemberIndices(false, switchOutTarget.trainerSlot);
const summonIndex =
this.switchType === SwitchType.FORCE_SWITCH
? reservePartyIndices[switchOutTarget.randBattleSeedInt(reservePartyIndices.length)]
: globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot);
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
summonIndex,
false,
true,
);
}
/**
* Method to handle fleeing a wild enemy Pokemon, redirecting incoming moves to its ally as applicable.
* @param switchOutTarget - The {@linkcode EnemyPokemon} fleeing the battle
*/
private tryFleeWildPokemon(switchOutTarget: EnemyPokemon): void {
switchOutTarget.leaveField(true);
globalScene.phaseManager.queueMessage(
i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }),
null,
true,
500,
);
const allyPokemon = switchOutTarget.getAlly();
if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) {
globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon);
}
// End battle if no enemies are active and enemy wasn't already KO'd
if (!allyPokemon?.isActive(true) && !switchOutTarget.isFainted()) {
globalScene.clearEnemyHeldItemModifiers();
globalScene.phaseManager.pushNew("BattleEndPhase", false);
if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) {
globalScene.phaseManager.pushNew("SelectBiomePhase");
}
globalScene.phaseManager.pushNew("NewBattlePhase");
}
}
}

View File

@ -25,6 +25,7 @@ import { getBerryEffectFunc } from "#data/berry";
import { applyChallenges } from "#data/challenge";
import { allAbilities, allMoves } from "#data/data-lists";
import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
import { ForceSwitchOutHelper, ForceSwitchOutHelperArgs } from "#data/helpers/force-switch";
import { DelayedAttackTag } from "#data/positional-tags/positional-tag";
import {
getNonVolatileStatusEffects,
@ -1303,10 +1304,12 @@ export class MoveEffectAttr extends MoveAttr {
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @param args Set of unique arguments needed by this attribute
* @returns true if basic application of the ability attribute should be possible
* @param args - Any unique arguments needed by this attribute
* @returns `true` if basic application of the ability attribute should be possible.
* By default, checks that the target is not fainted and (for non self-targeting moves) not protected by an effect.
*/
canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) {
// TODO: why do we check frenzy tag here?
return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp)
&& (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) ||
move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target }));
@ -1628,6 +1631,7 @@ export class MatchHpAttr extends FixedDamageAttr {
type MoveFilter = (move: Move) => boolean;
// TODO: fix this to check the last direct damage instance taken
export class CounterDamageAttr extends FixedDamageAttr {
private moveFilter: MoveFilter;
private multiplier: number;
@ -1709,6 +1713,7 @@ export class CelebrateAttr extends MoveEffectAttr {
}
export class RecoilAttr extends MoveEffectAttr {
/** Whether the recoil damage should use the user's max HP (`true`) or damage dealt `false`. */
private useHp: boolean;
private damageRatio: number;
private unblockable: boolean;
@ -1742,8 +1747,8 @@ export class RecoilAttr extends MoveEffectAttr {
return false;
}
const damageValue = (!this.useHp ? user.turnData.totalDamageDealt : user.getMaxHp()) * this.damageRatio;
const minValue = user.turnData.totalDamageDealt ? 1 : 0;
const damageValue = (!this.useHp ? user.turnData.singleHitDamageDealt : user.getMaxHp()) * this.damageRatio;
const minValue = user.turnData.singleHitDamageDealt ? 1 : 0;
const recoilDamage = toDmgValue(damageValue, minValue);
if (!recoilDamage) {
return false;
@ -1896,8 +1901,8 @@ export class AddSubstituteAttr extends MoveEffectAttr {
}
/**
* Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user
* @param user - The {@linkcode Pokemon} that used the move.
* Removes a fraction of the user's maximum HP to create a substitute.
* @param user - The {@linkcode Pokemon} using the move.
* @param target - n/a
* @param move - The {@linkcode Move} with this attribute.
* @param args - n/a
@ -1908,7 +1913,7 @@ export class AddSubstituteAttr extends MoveEffectAttr {
return false;
}
const damageTaken = this.roundUp ? Math.ceil(user.getMaxHp() * this.hpCost) : Math.floor(user.getMaxHp() * this.hpCost);
const damageTaken = (this.roundUp ? Math.ceil : Math.floor)(user.getMaxHp() * this.hpCost);
user.damageAndUpdate(damageTaken, { result: HitResult.INDIRECT, ignoreSegments: true, ignoreFaintPhase: true });
user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id);
return true;
@ -2078,6 +2083,13 @@ export class FlameBurstAttr extends MoveEffectAttr {
}
}
/**
* Attribute to KO the user while fully restoring HP/status of the next switched in Pokemon.
*
* Used for {@linkcode Moves.HEALING_WISH} and {@linkcode Moves.LUNAR_DANCE}.
* TODO: Implement "heal storing" if switched in pokemon is at full HP (likely with an end-of-turn ArenaTag).
* Will likely be blocked by the need for a "slot dependent ArenaTag" similar to Future Sight
*/
export class SacrificialFullRestoreAttr extends SacrificialAttr {
protected restorePP: boolean;
protected moveMessage: string;
@ -2095,8 +2107,8 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
}
// We don't know which party member will be chosen, so pick the highest max HP in the party
const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0);
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
const maxPartyMemberHp = Math.max(...party.map(p => p.getMaxHp()));
const pm = globalScene.phaseManager;
@ -2121,7 +2133,10 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
}
getCondition(): MoveConditionFunc {
return (user, _target, _move) => globalScene.getPlayerParty().filter(p => p.isActive()).length > globalScene.currentBattle.getBattlerCount();
return (user) => {
const otherPartyIndices = globalScene.getBackupPartyMemberIndices(user)
return otherPartyIndices.length > 0;
}
}
}
@ -6339,168 +6354,61 @@ export class RevivalBlessingAttr extends MoveEffectAttr {
}
}
/**
* Attribute to forcibly switch out the user or target of a Move.
*/
// TODO: Add locales for forced recall moves
export class ForceSwitchOutAttr extends MoveEffectAttr {
constructor(
private selfSwitch: boolean = false,
private switchType: SwitchType = SwitchType.SWITCH
) {
private readonly helper: ForceSwitchOutHelper;
constructor(args: ForceSwitchOutHelperArgs) {
super(false, { lastHitOnly: true });
this.helper = new ForceSwitchOutHelper(args);
}
isBatonPass() {
return this.switchType === SwitchType.BATON_PASS;
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
if (!super.apply(user, target, move, _args)) {
return false;
};
this.helper.doSwitch(this.helper.selfSwitch ? user : target)
return true;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// Check if the move category is not STATUS or if the switch out condition is not met
if (!this.getSwitchOutCondition()(user, target, move)) {
/**
* Check whether the target can be switched out.
*/
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]) {
if (!super.canApply(user, target, _move, _args)) {
return false;
}
/** The {@linkcode Pokemon} to be switched out with this effect */
const switchOutTarget = this.selfSwitch ? user : target;
const switchOutTarget = this.helper.selfSwitch ? user : target;
// If the switch-out target is a Dondozo with a Tatsugiri in its mouth
// (e.g. when it uses Flip Turn), make it spit out the Tatsugiri before switching out.
switchOutTarget.lapseTag(BattlerTagType.COMMANDED);
if (switchOutTarget.isPlayer()) {
/**
* Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch
* If it did, the user of U-turn or Volt Switch will not be switched out.
*/
if (target.getAbility().hasAttr("PostDamageForceSwitchAbAttr")
&& [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id)
// Check for Wimp Out edge case - self-switching moves cannot proc if the attack also triggers Wimp Out/EE
// TODO: This can be improved with a move in flight global object
const moveDmgDealt = user.turnData.lastMoveDamageDealt[target.getBattlerIndex()]
if (
this.helper.selfSwitch
&& moveDmgDealt
&& target.getAbilityAttrs("PostDamageForceSwitchAbAttr").some(
p => p.canApply({pokemon: target, damage: moveDmgDealt, simulated: false, source: user}))
) {
if (this.hpDroppedBelowHalf(target)) {
return false;
}
}
// Find indices of off-field Pokemon that are eligible to be switched into
const eligibleNewIndices: number[] = [];
globalScene.getPlayerParty().forEach((pokemon, index) => {
if (pokemon.isAllowedInBattle() && !pokemon.isOnField()) {
eligibleNewIndices.push(index);
}
});
if (eligibleNewIndices.length < 1) {
return false;
}
if (switchOutTarget.hp > 0) {
if (this.switchType === SwitchType.FORCE_SWITCH) {
switchOutTarget.leaveField(true);
const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)];
globalScene.phaseManager.prependNewToPhase(
"MoveEndPhase",
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
slotIndex,
false,
true
);
} else {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
"SwitchPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
true,
true
);
return true;
}
}
return false;
} else if (globalScene.currentBattle.battleType !== BattleType.WILD) { // Switch out logic for enemy trainers
// Find indices of off-field Pokemon that are eligible to be switched into
const isPartnerTrainer = globalScene.currentBattle.trainer?.isPartner();
const eligibleNewIndices: number[] = [];
globalScene.getEnemyParty().forEach((pokemon, index) => {
if (pokemon.isAllowedInBattle() && !pokemon.isOnField() && (!isPartnerTrainer || pokemon.trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)) {
eligibleNewIndices.push(index);
}
});
if (eligibleNewIndices.length < 1) {
return false;
}
if (switchOutTarget.hp > 0) {
if (this.switchType === SwitchType.FORCE_SWITCH) {
switchOutTarget.leaveField(true);
const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)];
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
slotIndex,
false,
false
);
} else {
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
"SwitchSummonPhase",
this.switchType,
switchOutTarget.getFieldIndex(),
(globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0),
false,
false
);
}
}
} else { // Switch out logic for wild pokemon
/**
* Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch
* If it did, the user of U-turn or Volt Switch will not be switched out.
*/
if (target.getAbility().hasAttr("PostDamageForceSwitchAbAttr")
&& [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id)
) {
if (this.hpDroppedBelowHalf(target)) {
return false;
}
}
const allyPokemon = switchOutTarget.getAlly();
if (switchOutTarget.hp > 0) {
switchOutTarget.leaveField(false);
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500);
// in double battles redirect potential moves off fled pokemon
if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) {
globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon);
}
}
// clear out enemy held item modifiers of the switch out target
globalScene.clearEnemyHeldItemModifiers(switchOutTarget);
if (!allyPokemon?.isActive(true) && switchOutTarget.hp) {
globalScene.phaseManager.pushNew("BattleEndPhase", false);
if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) {
globalScene.phaseManager.pushNew("SelectBiomePhase");
}
globalScene.phaseManager.pushNew("NewBattlePhase");
}
}
return true;
return this.helper.canSwitchOut(switchOutTarget)
}
getCondition(): MoveConditionFunc {
return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move));
// Damaging switch moves and ones w/o a secondary effect do not "fail"
// upon an unsuccessful switch - they still succeed and perform secondary effects
// (just without actually switching out).
// TODO: Remove attr check once move attribute application is cleaned up
return (user, target, move) => (move.category !== MoveCategory.STATUS || move.attrs.length > 1 || this.canApply(user, target, move, []));
}
getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined {
getFailedText(_user: Pokemon, target: Pokemon): string | undefined {
const cancelled = new BooleanHolder(false);
applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled});
if (cancelled.value) {
@ -6508,86 +6416,24 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
}
}
getSwitchOutCondition(): MoveConditionFunc {
return (user, target, move) => {
const switchOutTarget = (this.selfSwitch ? user : target);
const player = switchOutTarget.isPlayer();
const forceSwitchAttr = move.getAttrs("ForceSwitchOutAttr").find(attr => attr.switchType === SwitchType.FORCE_SWITCH);
if (!this.selfSwitch) {
if (move.hitsSubstitute(user, target)) {
return false;
}
// Check if the move is Roar or Whirlwind and if there is a trainer with only Pokémon left.
if (forceSwitchAttr && globalScene.currentBattle.trainer) {
const enemyParty = globalScene.getEnemyParty();
// Filter out any Pokémon that are not allowed in battle (e.g. fainted ones)
const remainingPokemon = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle());
if (remainingPokemon.length <= 1) {
return false;
}
}
// Dondozo with an allied Tatsugiri in its mouth cannot be forced out
const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED);
if (commandedTag?.getSourcePokemon()?.isActive(true)) {
return false;
}
if (!player && globalScene.currentBattle.isBattleMysteryEncounter() && !globalScene.currentBattle.mysteryEncounter?.fleeAllowed) {
// Don't allow wild opponents to be force switched during MEs with flee disabled
return false;
}
const blockedByAbility = new BooleanHolder(false);
applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled: blockedByAbility});
if (blockedByAbility.value) {
return false;
}
}
if (!player && globalScene.currentBattle.battleType === BattleType.WILD) {
// wild pokemon cannot switch out with baton pass.
return !this.isBatonPass()
&& globalScene.currentBattle.waveIndex % 10 !== 0
// Don't allow wild mons to flee with U-turn et al.
&& !(this.selfSwitch && MoveCategory.STATUS !== move.category);
}
const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
return party.filter(p => p.isAllowedInBattle() && !p.isOnField()
&& (player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)).length > 0;
};
}
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
if (!globalScene.getEnemyParty().find(p => p.isActive() && !p.isOnField())) {
const switchOutTarget = this.helper.selfSwitch ? user : target;
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(switchOutTarget)
if (reservePartyMembers.length === 0) {
return -20;
}
let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
if (this.selfSwitch && this.isBatonPass()) {
const statStageTotal = user.getStatStages().reduce((s: number, total: number) => total += s, 0);
let ret = this.helper.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
if (this.helper.selfSwitch && this.isBatonPass()) {
const statStageTotal = user.getStatStages().reduce((total, s) => total + s, 0);
// TODO: Why do we use a sine tween?
ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10));
}
return ret;
}
/**
* Helper function to check if the Pokémon's health is below half after taking damage.
* Used for an edge case interaction with Wimp Out/Emergency Exit.
* If the Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out.
*/
hpDroppedBelowHalf(target: Pokemon): boolean {
const pokemonHealth = target.hp;
const maxPokemonHealth = target.getMaxHp();
const damageTaken = target.turnData.damageTaken;
const initialHealth = pokemonHealth + damageTaken;
// Check if the Pokémon's health has dropped below half after the damage
return initialHealth >= maxPokemonHealth / 2 && pokemonHealth < maxPokemonHealth / 2;
public isBatonPass(): boolean {
return this.helper.switchType === SwitchType.BATON_PASS;
}
}
@ -6598,10 +6444,12 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr {
}
getCondition(): MoveConditionFunc {
// chilly reception move will go through if the weather is change-able to snow, or the user can switch out, else move will fail
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move);
// chilly reception will succeed if the weather is changeable to snow OR the user can be switched out,
// only failing if neither is the case.
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getCondition()(user, target, move);
}
}
export class RemoveTypeAttr extends MoveEffectAttr {
private removedType: PokemonType;
@ -8069,11 +7917,6 @@ const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target
const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined;
const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
return party.some(pokemon => pokemon.isActive() && !pokemon.isOnField());
};
const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => !target.isOfType(PokemonType.GHOST);
const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0;
@ -8540,7 +8383,7 @@ export function initMoves() {
.windMove(),
new AttackMove(MoveId.WING_ATTACK, PokemonType.FLYING, MoveCategory.PHYSICAL, 60, 100, 35, -1, 0, 1),
new StatusMove(MoveId.WHIRLWIND, PokemonType.NORMAL, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
.attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
.ignoresSubstitute()
.hidesTarget()
.windMove()
@ -8623,7 +8466,7 @@ export function initMoves() {
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new StatusMove(MoveId.ROAR, PokemonType.NORMAL, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
.soundBased()
.hidesTarget()
.reflectable(),
@ -8783,7 +8626,7 @@ export function initMoves() {
new AttackMove(MoveId.RAGE, PokemonType.NORMAL, MoveCategory.PHYSICAL, 20, 100, 20, -1, 0, 1)
.partial(), // No effect implemented
new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr, true)
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SWITCH, allowFlee: true})
.hidesUser(),
new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
.attr(LevelDamageAttr),
@ -9183,8 +9026,7 @@ export function initMoves() {
new AttackMove(MoveId.DRAGON_BREATH, PokemonType.DRAGON, MoveCategory.SPECIAL, 60, 100, 20, 30, 0, 2)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS),
new SelfStatusMove(MoveId.BATON_PASS, PokemonType.NORMAL, -1, 40, -1, 0, 2)
.attr(ForceSwitchOutAttr, true, SwitchType.BATON_PASS)
.condition(failIfLastInPartyCondition)
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.BATON_PASS})
.hidesUser(),
new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
@ -9633,8 +9475,7 @@ export function initMoves() {
.ballBombMove(),
new SelfStatusMove(MoveId.HEALING_WISH, PokemonType.PSYCHIC, -1, 10, -1, 0, 4)
.attr(SacrificialFullRestoreAttr, false, "moveTriggers:sacrificialFullRestore")
.triageMove()
.condition(failIfLastInPartyCondition),
.triageMove(),
new AttackMove(MoveId.BRINE, PokemonType.WATER, MoveCategory.SPECIAL, 65, 100, 10, -1, 0, 4)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getHpRatio() < 0.5 ? 2 : 1),
new AttackMove(MoveId.NATURAL_GIFT, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 4)
@ -9665,7 +9506,7 @@ export function initMoves() {
.makesContact(false)
.target(MoveTarget.ATTACKER),
new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4)
.attr(ForceSwitchOutAttr, true),
.attr(ForceSwitchOutAttr, {selfSwitch: true}),
new AttackMove(MoveId.CLOSE_COMBAT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4)
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true),
new AttackMove(MoveId.PAYBACK, PokemonType.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4)
@ -9932,8 +9773,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.LUNAR_DANCE, PokemonType.PSYCHIC, -1, 10, -1, 0, 4)
.attr(SacrificialFullRestoreAttr, true, "moveTriggers:lunarDanceRestore")
.danceMove()
.triageMove()
.condition(failIfLastInPartyCondition),
.triageMove(),
new AttackMove(MoveId.CRUSH_GRIP, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4)
.attr(OpponentHighHpPowerAttr, 120),
new AttackMove(MoveId.MAGMA_STORM, PokemonType.FIRE, MoveCategory.SPECIAL, 100, 75, 5, -1, 0, 4)
@ -10094,7 +9934,7 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true)
.attr(StatStageChangeAttr, [ Stat.SPD ], 2, true),
new AttackMove(MoveId.CIRCLE_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5)
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
.attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
.hidesTarget(),
new AttackMove(MoveId.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5)
.target(MoveTarget.ALL_NEAR_ENEMIES)
@ -10154,7 +9994,7 @@ export function initMoves() {
.attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, MoveId.FIRE_PLEDGE)
.attr(BypassRedirectAttr, true),
new AttackMove(MoveId.VOLT_SWITCH, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 20, -1, 0, 5)
.attr(ForceSwitchOutAttr, true),
.attr(ForceSwitchOutAttr, {selfSwitch: true}),
new AttackMove(MoveId.STRUGGLE_BUG, PokemonType.BUG, MoveCategory.SPECIAL, 50, 100, 20, 100, 0, 5)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -1)
.target(MoveTarget.ALL_NEAR_ENEMIES),
@ -10166,7 +10006,7 @@ export function initMoves() {
new AttackMove(MoveId.FROST_BREATH, PokemonType.ICE, MoveCategory.SPECIAL, 60, 90, 10, -1, 0, 5)
.attr(CritOnlyAttr),
new AttackMove(MoveId.DRAGON_TAIL, PokemonType.DRAGON, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5)
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
.attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
.hidesTarget(),
new SelfStatusMove(MoveId.WORK_UP, PokemonType.NORMAL, -1, 30, -1, 0, 5)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, true),
@ -10322,9 +10162,10 @@ export function initMoves() {
.target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(MoveId.PARTING_SHOT, PokemonType.DARK, 100, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, { trigger: MoveEffectTrigger.PRE_APPLY })
.attr(ForceSwitchOutAttr, true)
.attr(ForceSwitchOutAttr, {selfSwitch: true})
.soundBased()
.reflectable(),
.reflectable()
.edgeCase(), // should not fail if no target is switched out
new StatusMove(MoveId.TOPSY_TURVY, PokemonType.DARK, -1, 20, -1, 0, 6)
.attr(InvertStatsAttr)
.reflectable(),
@ -11055,7 +10896,7 @@ export function initMoves() {
.target(MoveTarget.NEAR_ALLY)
.condition(failIfSingleBattle),
new AttackMove(MoveId.FLIP_TURN, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 8)
.attr(ForceSwitchOutAttr, true),
.attr(ForceSwitchOutAttr, {selfSwitch: true}),
new AttackMove(MoveId.TRIPLE_AXEL, PokemonType.ICE, MoveCategory.PHYSICAL, 20, 90, 10, -1, 0, 8)
.attr(MultiHitAttr, MultiHitType._3)
.attr(MultiHitPowerIncrementAttr, 3)
@ -11389,15 +11230,14 @@ export function initMoves() {
.makesContact(),
new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(AddSubstituteAttr, 0.5, true)
.attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL)
.condition(failIfLastInPartyCondition),
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SHED_TAIL}),
new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9)
.attr(PreMoveMessageAttr, (user, _target, _move) =>
// Don't display text if current move phase is follow up (ie move called indirectly)
isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode)
? ""
: i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) }))
.attr(ChillyReceptionAttr, true),
.attr(ChillyReceptionAttr, {selfSwitch: true}),
new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true)
.attr(RemoveArenaTrapAttr, true)

View File

@ -341,16 +341,14 @@ export class MysteryEncounter implements IMysteryEncounter {
* can cause scenarios where there are not enough Pokemon that are sufficient for all requirements.
*/
private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean {
if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) {
const activeMon = globalScene.getPlayerParty().filter(p => p.isActive(true));
if (activeMon.length > 0) {
this.primaryPokemon = activeMon[0];
} else {
this.primaryPokemon = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle())[0];
}
let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
if (!this.primaryPokemonRequirements?.length) {
// If we lack specified criterion, grab the first on-field pokemon, or else the first pokemon allowed in battle
const activeMons = qualified.filter(p => p.isAllowedInBattle());
this.primaryPokemon = activeMons.find(p => p.isOnField()) ?? activeMons[0];
return true;
}
let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
for (const req of this.primaryPokemonRequirements) {
if (req.meetsRequirement()) {
qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn));

View File

@ -315,7 +315,14 @@ export class PokemonTurnData {
* - `0` = Move is finished
*/
public hitsLeft = -1;
public totalDamageDealt = 0;
/**
* The final amount of damage dealt by this Pokemon's last attack against each of its targets,
* indexed by their respective `BattlerIndex`es. \
* Reset to an empty array upon attempting to use a move,
* and is used to calculate various damage-related effects (Shell Bell, U-Turn + Wimp Out interactions, etc.).
*/
// TODO: move this or something like it to some sort of "move in flight" object
public lastMoveDamageDealt: number[] = [0, 0, 0, 0];
public singleHitDamageDealt = 0;
public damageTaken = 0;
public attacksReceived: AttackMoveResult[] = [];

View File

@ -14,3 +14,6 @@ export enum SwitchType {
/** Force switchout to a random party member */
FORCE_SWITCH,
}
/** Union type of all "normal" switch types that can be used by force switch moves. */
export type NormalSwitchType = Exclude<SwitchType, SwitchType.INITIAL_SWITCH>

View File

@ -243,6 +243,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
public luck: number;
public pauseEvolutions: boolean;
public pokerus: boolean;
/** Whether this Pokemon is currently attempting to switch in. */
public switchOutStatus = false;
public evoCounter: number;
public teraType: PokemonType;
@ -1228,7 +1229,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @see {@linkcode SubstituteTag}
* @see {@linkcode getFieldPositionOffset}
*/
getSubstituteOffset(): [number, number] {
getSubstituteOffset(): [x: number, y: number] {
return this.isPlayer() ? [-30, 10] : [30, -10];
}
@ -1640,6 +1641,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.getMaxHp() - this.hp;
}
/**
* Return this Pokemon's current HP as a fraction of its maximum HP.
* @param precise - Whether to return the exact HP ratio (`true`) or rounded to the nearest 1% (`false`); default `false`
* @returns This pokemon's current HP ratio (current / max).
*/
getHpRatio(precise = false): number {
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100;
}
@ -4093,13 +4099,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* Given the damage, adds a new DamagePhase and update HP values, etc.
*
* Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly
* @param damage integer - passed to damage()
* @param result an enum if it's super effective, not very, etc.
* @param isCritical boolean if move is a critical hit
* @param ignoreSegments boolean, passed to damage() and not used currently
* @param preventEndure boolean, ignore endure properties of pokemon, passed to damage()
* @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage()
* @returns integer of damage done
* @param damage - Amount of damage to deal
* @param result - The {@linkcode HitResult} of the damage instance; default `HitResult.EFFECTIVE`
* @param isCritical - Whether the move being used (if any) was a critical hit; default `false`
* @param ignoreSegments - Whether to ignore boss segments; default `false` and currently unused
* @param preventEndure - Whether to ignore {@linkcode Moves.ENDURE} and similar effects when applying damage; default `false`
* @param ignoreFaintPhase - Whether to ignore adding a faint phase if the damage causes the target to faint; default `false`
* @returns The amount of damage actually dealt.
* @remarks
* This will not trigger "on damage" effects for direct damage moves, instead occuring at the end of `MoveEffectPhase`.
*/
damageAndUpdate(
damage: number,
@ -4108,13 +4116,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
isCritical = false,
ignoreSegments = false,
ignoreFaintPhase = false,
source = undefined,
}: {
result?: DamageResult;
isCritical?: boolean;
ignoreSegments?: boolean;
ignoreFaintPhase?: boolean;
source?: Pokemon;
} = {},
): number {
const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result);
@ -4122,27 +4128,30 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
"DamageAnimPhase",
this.getBattlerIndex(),
damage,
result as DamageResult,
result,
isCritical,
);
globalScene.phaseManager.unshiftPhase(damagePhase);
if (this.switchOutStatus && source) {
// Prevent enemies not on field from taking damage.
// TODO: Review if wimp out actually needs this anymore
if (this.switchOutStatus) {
damage = 0;
}
damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase);
// Ensure the battle-info bar's HP is updated, though only if the battle info is visible
// TODO: When battle-info UI is refactored, make this only update the HP bar
if (this.battleInfo.visible) {
this.updateInfo();
}
// Damage amount may have changed, but needed to be queued before calling damage function
damagePhase.updateAmount(damage);
/**
* Run PostDamageAbAttr from any source of damage that is not from a multi-hit
* Multi-hits are handled in move-effect-phase.ts for PostDamageAbAttr
*/
if (!source || source.turnData.hitCount <= 1) {
applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage, source });
// Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances.
if (isIndirectDamage && result !== HitResult.CONFUSION) {
applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage });
}
return damage;
}
@ -4356,6 +4365,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
for (const tag of source.summonData.tags) {
// Skip non-Baton Passable tags (or telekinesis for mega gengar; cf. https://bulbapedia.bulbagarden.net/wiki/Telekinesis_(move))
if (
!tag.isBatonPassable ||
(tag.tagType === BattlerTagType.TELEKINESIS &&
@ -5064,8 +5074,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Reset this Pokemon's {@linkcode PokemonSummonData | SummonData} and {@linkcode PokemonTempSummonData | TempSummonData}
* in preparation for switching pokemon, as well as removing any relevant on-switch tags.
* @remarks
* This **SHOULD NOT** be called when {@linkcode leaveField} is already being called,
* which already calls this function.
*/
resetSummonData(): void {
console.log(`resetSummonData called on Pokemon ${this.name}`);
const illusion: IllusionData | null = this.summonData.illusion;
if (this.summonData.speciesForm) {
this.summonData.speciesForm = null;
@ -5107,6 +5121,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
resetTurnData(): void {
console.log(`resetTurnData called on Pokemon ${this.name}`);
this.turnData = new PokemonTurnData();
}
@ -5549,15 +5564,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Causes a Pokemon to leave the field (such as in preparation for a switch out/escape).
* @param clearEffects Indicates if effects should be cleared (true) or passed
* to the next pokemon, such as during a baton pass (false)
* @param hideInfo Indicates if this should also play the animation to hide the Pokemon's
* info container.
* Cause this {@linkcode Pokemon} to leave the field (such as in preparation for a switch out/escape).
* @param clearEffects - Whether to clear (`true`) or transfer (`false`) transient effects upon switching; default `true`
* @param hideInfo - Whether to play the animation to hide the Pokemon's info container; default `true`.
* @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false`
* @remarks
* This **SHOULD NOT** be called with `clearEffects=true` when a `SummonPhase` or `SwitchSummonPhase` is already being added,
* both of which do so already and can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}.
*/
// TODO: Review where this is being called and where it is necessary to call it
leaveField(clearEffects = true, hideInfo = true, destroy = false) {
console.log(`leaveField called on Pokemon ${this.name}`);
this.resetSprite();
this.resetTurnData();
globalScene
.getField(true)
.filter(p => p !== this)
@ -5566,6 +5584,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (clearEffects) {
this.destroySubstitute();
this.resetSummonData();
this.resetTurnData();
}
if (hideInfo) {
this.hideInfo();

View File

@ -1765,20 +1765,26 @@ export class HitHealModifier extends PokemonHeldItemModifier {
* @returns `true` if the {@linkcode Pokemon} was healed
*/
override apply(pokemon: Pokemon): boolean {
if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) {
// TODO: this shouldn't be undefined AFAIK
globalScene.phaseManager.unshiftNew(
"PokemonHealPhase",
pokemon.getBattlerIndex(),
toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount,
i18next.t("modifier:hitHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name,
}),
true,
);
if (pokemon.isFullHp()) {
return false;
}
// Collate the amount of damage this attack did against all its targets.
const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0);
if (totalDmgDealt === 0) {
return false;
}
globalScene.phaseManager.unshiftNew(
"PokemonHealPhase",
pokemon.getBattlerIndex(),
toDmgValue(totalDmgDealt / 8) * this.stackCount,
i18next.t("modifier:hitHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name,
}),
true,
);
return true;
}

View File

@ -38,16 +38,12 @@ export class CheckSwitchPhase extends BattlePhase {
}
// ...if there are no other allowed Pokemon in the player's party to switch with
if (
!globalScene
.getPlayerParty()
.slice(1)
.filter(p => p.isActive()).length
) {
if (globalScene.getBackupPartyMemberIndices(true).length === 0) {
return super.end();
}
// ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching
// TODO: Ignore trapping check if baton item is held (since those bypass trapping)
if (
pokemon.getTag(BattlerTagType.FRENZY) ||
pokemon.isTrapped() ||

View File

@ -49,8 +49,6 @@ export class FaintPhase extends PokemonPhase {
faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source);
}
faintPokemon.resetSummonData();
if (!this.preventInstantRevive) {
const instantReviveModifier = globalScene.applyModifier(
PokemonInstantReviveModifier,
@ -59,6 +57,7 @@ export class FaintPhase extends PokemonPhase {
) as PokemonInstantReviveModifier;
if (instantReviveModifier) {
faintPokemon.resetSummonData();
faintPokemon.loseHeldItem(instantReviveModifier);
globalScene.updateModifiers(this.player);
return this.end();
@ -146,41 +145,32 @@ export class FaintPhase extends PokemonPhase {
}
}
const legalBackupPokemon = globalScene.getBackupPartyMemberIndices(pokemon);
if (this.player) {
/** The total number of Pokemon in the player's party that can legally fight */
/** An array of Pokemon in the player's party that can legally fight. */
const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle();
/** The total number of legal player Pokemon that aren't currently on the field */
const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true));
if (!legalPlayerPokemon.length) {
/** If the player doesn't have any legal Pokemon, end the game */
if (legalPlayerPokemon.length === 0) {
// If the player doesn't have any legal Pokemon left in their party, end the game.
globalScene.phaseManager.unshiftNew("GameOverPhase");
} else if (
globalScene.currentBattle.double &&
legalPlayerPokemon.length === 1 &&
legalPlayerPartyPokemon.length === 0
) {
/**
* If the player has exactly one Pokemon in total at this point in a double battle, and that Pokemon
* is already on the field, unshift a phase that moves that Pokemon to center position.
*/
} else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) {
/*
Otherwise, if the player has no reserve members left to switch in,
unshift a phase to move the other on-field pokemon to center position.
*/
globalScene.phaseManager.unshiftNew("ToggleDoublePositionPhase", true);
} else if (legalPlayerPartyPokemon.length > 0) {
/**
* 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.
*/
} else {
// If previous conditions weren't met, push a phase to prompt the player to select a new pokemon from their party.
globalScene.phaseManager.pushNew("SwitchPhase", SwitchType.SWITCH, this.fieldIndex, true, false);
}
} else {
// Unshift a phase for EXP gains and/or one to switch in a replacement party member.
globalScene.phaseManager.unshiftNew("VictoryPhase", this.battlerIndex);
if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) {
const hasReservePartyMember = !!globalScene
.getEnemyParty()
.filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot)
.length;
if (hasReservePartyMember) {
globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false);
}
if (
[BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) &&
legalBackupPokemon.length > 0
) {
globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false);
}
}

View File

@ -64,7 +64,7 @@ export class MoveEffectPhase extends PokemonPhase {
/** Is this the first strike of a move? */
private firstHit: boolean;
/** Is this the last strike of a move? */
/** Is this the last strike of a move (either due to running out of hits or all targets being fainted/immune)? */
private lastHit: boolean;
/**
@ -305,7 +305,7 @@ export class MoveEffectPhase extends PokemonPhase {
const targets = this.conductHitChecks(user, fieldMove);
this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft;
this.lastHit = user.turnData.hitsLeft === 1 || !targets.some(t => t.isActive(true));
this.lastHit = user.turnData.hitsLeft === 1 || targets.every(t => !t.isActive(true));
// Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct)
if (
@ -770,15 +770,16 @@ export class MoveEffectPhase extends PokemonPhase {
if (!this.move.hitsSubstitute(user, target)) {
this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical);
}
if (this.lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
// Multi-hit check for Wimp Out/Emergency Exit
if (user.turnData.hitCount > 1) {
// TODO: Investigate why 0 is being passed for damage amount here
// and then determing if refactoring `applyMove` to return the damage dealt is appropriate.
applyAbAttrs("PostDamageAbAttr", { pokemon: target, damage: 0, source: user });
}
// Trigger Form changes on the final hit, alongside Wimp Out.
applyAbAttrs("PostDamageAbAttr", {
pokemon: target,
damage: user.turnData.lastMoveDamageDealt[target.getBattlerIndex()],
simulated: false,
source: user,
});
}
}
@ -811,6 +812,7 @@ export class MoveEffectPhase extends PokemonPhase {
isCritical,
});
// Apply and/or remove type boosting tags (Flash Fire, Charge, etc.)
const typeBoost = user.findTag(
t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move),
) as TypeBoostTag;
@ -818,18 +820,17 @@ export class MoveEffectPhase extends PokemonPhase {
user.removeTag(typeBoost.tagType);
}
const isOneHitKo = result === HitResult.ONE_HIT_KO;
if (!dmg) {
if (dmg === 0) {
return [result, false];
}
const isOneHitKo = result === HitResult.ONE_HIT_KO;
target.lapseTags(BattlerTagLapseType.HIT);
const substitute = target.getTag(SubstituteTag);
const isBlockedBySubstitute = substitute && this.move.hitsSubstitute(user, target);
const substituteTag = target.getTag(SubstituteTag);
const isBlockedBySubstitute = substituteTag && this.move.hitsSubstitute(user, target);
if (isBlockedBySubstitute) {
substitute.hp -= dmg;
substituteTag.hp -= dmg;
} else if (!target.isPlayer() && dmg >= target.hp) {
globalScene.applyModifiers(EnemyEndureChanceModifier, false, target);
}
@ -838,10 +839,9 @@ export class MoveEffectPhase extends PokemonPhase {
? 0
: target.damageAndUpdate(dmg, {
result: result as DamageResult,
ignoreFaintPhase: true,
ignoreFaintPhase: true, // ignore faint phase so we can handle it ourselves
ignoreSegments: isOneHitKo,
isCritical,
source: user,
});
if (isCritical) {
@ -855,14 +855,13 @@ export class MoveEffectPhase extends PokemonPhase {
if (user.isPlayer()) {
globalScene.validateAchvs(DamageAchv, new NumberHolder(damage));
if (damage > globalScene.gameData.gameStats.highestDamage) {
globalScene.gameData.gameStats.highestDamage = damage;
}
globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage);
}
user.turnData.totalDamageDealt += damage;
user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] += damage;
user.turnData.singleHitDamageDealt = damage;
target.battleData.hitCount++;
// TODO: this might be incorrect for counter moves
target.turnData.damageTaken += damage;
target.turnData.attacksReceived.unshift({

View File

@ -133,6 +133,8 @@ export class MovePhase extends BattlePhase {
}
this.pokemon.turnData.acted = true;
// TODO: Increase this if triple battles are added
this.pokemon.turnData.lastMoveDamageDealt = Array(4).fill(0);
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (isVirtual(this.useMode)) {

View File

@ -44,6 +44,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase {
}
if (damage.value) {
// Set preventEndure flag to avoid pokemon surviving thanks to focus band, sturdy, endure ...
// TODO: why don't we call `damageAndUpdate` here?
globalScene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true));
pokemon.updateInfo();
applyAbAttrs("PostDamageAbAttr", { pokemon, damage: damage.value });

View File

@ -275,7 +275,7 @@ export class SummonPhase extends PartyMemberPokemonPhase {
globalScene.phaseManager.unshiftNew("ShinySparklePhase", pokemon.getBattlerIndex());
}
pokemon.resetTurnData();
pokemon.resetTurnData(); // TODO: this can probably be removed...???
if (
!this.loaded ||

View File

@ -16,13 +16,12 @@ export class SwitchPhase extends BattlePhase {
private readonly doReturn: boolean;
/**
* Creates a new SwitchPhase
* @param switchType {@linkcode SwitchType} The type of switch logic this phase implements
* @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}.
* Creates a new {@linkcode SwitchPhase}, the phase where players select a Pokemon to send into battle.
* @param switchType - The {@linkcode SwitchType} dictating this switch's logic.
* @param fieldIndex - The 0-indexed field position of the Pokemon being switched out.
* @param isModal - Whether the switch should be forced (`true`) or optional (`false`).
* @param doReturn - Whether to render the "Come back!" dialogue for recalling player pokemon.
* @see {@linkcode SwitchSummonPhase} for the phase which does the actual switching.
*/
constructor(switchType: SwitchType, fieldIndex: number, isModal: boolean, doReturn: boolean) {
super();
@ -37,7 +36,7 @@ export class SwitchPhase extends BattlePhase {
super.start();
// Skip modal switch if impossible (no remaining party members that aren't in battle)
if (this.isModal && !globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) {
if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) {
return super.end();
}
@ -52,11 +51,11 @@ export class SwitchPhase extends BattlePhase {
return super.end();
}
// Check if there is any space still in field
// Check if there is any space still on field.
// We use > here as the prior pokemon still technically hasn't "left" the field _per se_ (unless they fainted).
if (
this.isModal &&
globalScene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >=
globalScene.currentBattle.getBattlerCount()
globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount()
) {
return super.end();
}

View File

@ -2,10 +2,8 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { SubstituteTag } from "#data/battler-tags";
import { allMoves } from "#data/data-lists";
import { SpeciesFormChangeActiveTrigger } from "#data/form-change-triggers";
import { getPokeballTintColor } from "#data/pokeball";
import { Command } from "#enums/command";
import { SwitchType } from "#enums/switch-type";
import { TrainerSlot } from "#enums/trainer-slot";
import type { Pokemon } from "#field/pokemon";
@ -13,6 +11,7 @@ import { SwitchEffectTransferModifier } from "#modifiers/modifier";
import { SummonPhase } from "#phases/summon-phase";
import i18next from "i18next";
// TODO: This and related phases desperately need to be refactored
export class SwitchSummonPhase extends SummonPhase {
public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" = "SwitchSummonPhase";
private readonly switchType: SwitchType;
@ -22,10 +21,11 @@ export class SwitchSummonPhase extends SummonPhase {
private lastPokemon: Pokemon;
/**
* Constructor for creating a new SwitchSummonPhase
* Constructor for creating a new {@linkcode SwitchSummonPhase}, the phase where player and enemy Pokemon are switched out
* and replaced by another Pokemon from the same party.
* @param switchType - The type of switch behavior
* @param fieldIndex - Position on the battle field
* @param slotIndex - The index of pokemon (in party of 6) to switch into
* @param fieldIndex - The position on field of the Pokemon being switched **out**
* @param slotIndex - The 0-indexed party position of the Pokemon switching **in**, or `-1` to use the default trainer switch logic.
* @param doReturn - Whether to render "comeback" dialogue
* @param player - Whether the switch came from the player or enemy; default `true`
*/
@ -33,48 +33,56 @@ export class SwitchSummonPhase extends SummonPhase {
super(fieldIndex, player);
this.switchType = switchType;
this.slotIndex = slotIndex;
// -1 = "use trainer switch logic"
this.slotIndex =
slotIndex > -1
? slotIndex
: globalScene.currentBattle.trainer!.getNextSummonIndex(this.getTrainerSlotFromFieldIndex());
this.doReturn = doReturn;
}
// TODO: This is calling `applyPreSummonAbAttrs` both far too early and on the wrong pokemon;
// `super.start` calls applyPreSummonAbAttrs(PreSummonAbAttr, this.getPokemon()),
// and `this.getPokemon` is the pokemon SWITCHING OUT, NOT IN
start(): void {
super.start();
}
preSummon(): void {
if (!this.player) {
if (this.slotIndex === -1) {
//@ts-expect-error
this.slotIndex = globalScene.currentBattle.trainer?.getNextSummonIndex(
!this.fieldIndex ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
); // TODO: what would be the default trainer-slot fallback?
}
if (this.slotIndex > -1) {
this.showEnemyTrainer(!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER);
globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty());
}
override preSummon(): void {
const switchOutPokemon = this.getPokemon();
if (!this.player && globalScene.currentBattle.trainer) {
this.showEnemyTrainer(this.getTrainerSlotFromFieldIndex());
globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty());
}
if (
!this.doReturn ||
// TODO: this part of the check need not exist `- `switchAndSummon` returns near immediately if we have no pokemon to switch into
(this.slotIndex !== -1 &&
!(this.player ? globalScene.getPlayerParty() : globalScene.getEnemyParty())[this.slotIndex])
) {
// If the target is still on-field, remove it and/or hide its info container.
// Effects are kept to be transferred to the new Pokemon later on.
if (switchOutPokemon.isOnField()) {
switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo().visible);
}
if (this.player) {
this.switchAndSummon();
return;
} else {
globalScene.time.delayedCall(750, () => this.switchAndSummon());
}
globalScene.time.delayedCall(750, () => this.switchAndSummon());
return;
}
const pokemon = this.getPokemon();
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon =>
enemyPokemon.removeTagsBySourceId(pokemon.id),
enemyPokemon.removeTagsBySourceId(switchOutPokemon.id),
);
if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) {
const substitute = pokemon.getTag(SubstituteTag);
// If not transferring a substitute, play animation to remove it from the field
if (!this.shouldKeepEffects()) {
const substitute = switchOutPokemon.getTag(SubstituteTag);
if (substitute) {
globalScene.tweens.add({
targets: substitute.sprite,
@ -89,26 +97,24 @@ export class SwitchSummonPhase extends SummonPhase {
globalScene.ui.showText(
this.player
? i18next.t("battle:playerComeBack", {
pokemonName: getPokemonNameWithAffix(pokemon),
pokemonName: getPokemonNameWithAffix(switchOutPokemon),
})
: i18next.t("battle:trainerComeBack", {
trainerName: globalScene.currentBattle.trainer?.getName(
!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
),
pokemonName: pokemon.getNameToRender(),
trainerName: globalScene.currentBattle.trainer?.getName(this.getTrainerSlotFromFieldIndex()),
pokemonName: switchOutPokemon.getNameToRender(),
}),
);
globalScene.playSound("se/pb_rel");
pokemon.hideInfo();
pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
switchOutPokemon.hideInfo();
switchOutPokemon.tint(getPokeballTintColor(switchOutPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
globalScene.tweens.add({
targets: pokemon,
targets: switchOutPokemon,
duration: 250,
ease: "Sine.easeIn",
scale: 0.5,
onComplete: () => {
globalScene.time.delayedCall(750, () => this.switchAndSummon());
pokemon.leaveField(this.switchType === SwitchType.SWITCH, false);
switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); // TODO: this reset effects call is dubious
},
});
}
@ -118,12 +124,8 @@ export class SwitchSummonPhase extends SummonPhase {
const switchedInPokemon: Pokemon | undefined = party[this.slotIndex];
this.lastPokemon = this.getPokemon();
// Defensive programming: Overcome the bug where the summon data has somehow not been reset
// prior to switching in a new Pokemon.
// Force the switch to occur and load the assets for the new pokemon, ignoring override.
switchedInPokemon.resetSummonData();
switchedInPokemon.loadAssets(true);
// TODO: Why do we trigger these attributes even if the switch in target doesn't exist?
// (This should almost certainly go somewhere inside `preSummon`)
applyAbAttrs("PreSummonAbAttr", { pokemon: switchedInPokemon });
applyAbAttrs("PreSwitchOutAbAttr", { pokemon: this.lastPokemon });
if (!switchedInPokemon) {
@ -131,6 +133,13 @@ export class SwitchSummonPhase extends SummonPhase {
return;
}
// Defensive programming: Overcome the bug where the summon data has somehow not been reset
// prior to switching in a new Pokemon.
// Force the switch to occur and load the assets for the new pokemon, ignoring override.
// TODO: Assess whether this is needed anymore and remove if needed
switchedInPokemon.resetSummonData();
switchedInPokemon.loadAssets(true);
if (this.switchType === SwitchType.BATON_PASS) {
// If switching via baton pass, update opposing tags coming from the prior pokemon
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) =>
@ -149,7 +158,7 @@ export class SwitchSummonPhase extends SummonPhase {
m =>
m instanceof SwitchEffectTransferModifier &&
(m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id,
) as SwitchEffectTransferModifier;
) as SwitchEffectTransferModifier | undefined;
if (batonPassModifier) {
globalScene.tryTransferHeldItemModifier(
@ -167,13 +176,14 @@ export class SwitchSummonPhase extends SummonPhase {
party[this.slotIndex] = this.lastPokemon;
party[this.fieldIndex] = switchedInPokemon;
// TODO: Make this a method
const showTextAndSummon = () => {
globalScene.ui.showText(this.getSendOutText(switchedInPokemon));
/**
* If this switch is passing a Substitute, make the switched Pokemon matches the returned Pokemon's state as it left.
* Otherwise, clear any persisting tags on the returned Pokemon.
*/
if (this.switchType === SwitchType.BATON_PASS || this.switchType === SwitchType.SHED_TAIL) {
if (this.shouldKeepEffects()) {
const substitute = this.lastPokemon.getTag(SubstituteTag);
if (substitute) {
switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0];
@ -181,7 +191,7 @@ export class SwitchSummonPhase extends SummonPhase {
switchedInPokemon.setAlpha(0.5);
}
} else {
switchedInPokemon.fieldSetup(true);
switchedInPokemon.fieldSetup();
}
this.summon();
};
@ -200,46 +210,33 @@ export class SwitchSummonPhase extends SummonPhase {
onEnd(): void {
super.onEnd();
const pokemon = this.getPokemon();
const activePokemon = this.getPokemon();
const moveId = globalScene.currentBattle.lastMove;
const lastUsedMove = moveId ? allMoves[moveId] : undefined;
const currentCommand = globalScene.currentBattle.turnCommands[this.fieldIndex]?.command;
const lastPokemonIsForceSwitchedAndNotFainted =
lastUsedMove?.hasAttr("ForceSwitchOutAttr") && !this.lastPokemon.isFainted();
const lastPokemonHasForceSwitchAbAttr =
this.lastPokemon.hasAbilityWithAttr("PostDamageForceSwitchAbAttr") && !this.lastPokemon.isFainted();
// Compensate for turn spent summoning/forced switch if switched out pokemon is not fainted.
// If not switching at start of battle, reset turn counts and temp data on the newly sent in Pokemon
// Needed as we increment turn counters in `TurnEndPhase`.
if (
currentCommand === Command.POKEMON ||
lastPokemonIsForceSwitchedAndNotFainted ||
lastPokemonHasForceSwitchAbAttr
) {
pokemon.tempSummonData.turnCount--;
pokemon.tempSummonData.waveTurnCount--;
if (this.switchType !== SwitchType.INITIAL_SWITCH) {
// No need to reset turn/summon data for initial switch
// (since both get initialized to defaults on object creation)
activePokemon.resetTurnData();
activePokemon.resetSummonData();
activePokemon.tempSummonData.turnCount--;
activePokemon.tempSummonData.waveTurnCount--;
activePokemon.turnData.switchedInThisTurn = true;
}
if (this.switchType === SwitchType.BATON_PASS && pokemon) {
pokemon.transferSummon(this.lastPokemon);
} else if (this.switchType === SwitchType.SHED_TAIL && pokemon) {
// Baton Pass over any eligible effects or substitutes before resetting the last pokemon's temporary data.
if (this.switchType === SwitchType.BATON_PASS) {
activePokemon.transferSummon(this.lastPokemon);
} else if (this.switchType === SwitchType.SHED_TAIL) {
const subTag = this.lastPokemon.getTag(SubstituteTag);
if (subTag) {
pokemon.summonData.tags.push(subTag);
activePokemon.summonData.tags.push(subTag);
}
}
// Reset turn data if not initial switch (since it gets initialized to an empty object on turn start)
if (this.switchType !== SwitchType.INITIAL_SWITCH) {
pokemon.resetTurnData();
pokemon.turnData.switchedInThisTurn = true;
}
this.lastPokemon.resetTurnData();
this.lastPokemon.resetSummonData();
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
globalScene.triggerPokemonFormChange(activePokemon, SpeciesFormChangeActiveTrigger, true);
// Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out
globalScene.arena.triggerWeatherBasedFormChanges();
}
@ -275,4 +272,16 @@ export class SwitchSummonPhase extends SummonPhase {
pokemonName: this.getPokemon().getNameToRender(),
});
}
private shouldKeepEffects(): boolean {
return [SwitchType.BATON_PASS, SwitchType.SHED_TAIL].includes(this.switchType);
}
private getTrainerSlotFromFieldIndex(): TrainerSlot {
return this.player || !globalScene.currentBattle.trainer
? TrainerSlot.NONE
: this.fieldIndex % 2 === 0
? TrainerSlot.TRAINER
: TrainerSlot.TRAINER_PARTNER;
}
}

45
src/utils/array.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* Split an array into a pair of arrays based on a conditional function.
* @param arr - The array to split into 2
* @param predicate - A function accepting up to 3 arguments. The split function calls the predicate function once per element of the array.
* @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value.
* @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`.
* @overload
*/
export function splitArray<T, S extends T>(
arr: T[],
predicate: (value: T, index: number, array: T[]) => value is S,
thisArg?: unknown,
): [matches: S[], nonMatches: S[]];
/**
* Split an array into a pair of arrays based on a conditional function.
* @param array - The array to split into 2
* @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array.
* @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value.
* @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`.
* @overload
*/
export function splitArray<T>(
arr: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
): [matches: T[], nonMatches: T[]];
export function splitArray<T>(
arr: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
): [matches: T[], nonMatches: T[]] {
const matches: T[] = [];
const nonMatches: T[] = [];
const p = predicate.bind(thisArg) as typeof predicate;
arr.forEach((value, index, array) => {
if (p(value, index, array)) {
matches.push(value);
} else {
nonMatches.push(value);
}
});
return [matches, nonMatches];
}

View File

@ -1,4 +1,8 @@
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
@ -37,13 +41,40 @@ describe("Abilities - Mold Breaker", () => {
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.X_SCISSOR);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(game.scene.arena.ignoreAbilities).toBe(true);
expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex());
await game.toEndOfTurn();
await game.phaseInterceptor.to("MoveEndPhase");
expect(game.scene.arena.ignoreAbilities).toBe(false);
expect(enemy.isFainted()).toBe(true);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy).toBe(true);
});
it("should keep Levitate opponents grounded when using force switch moves", async () => {
game.override.enemyAbility(AbilityId.LEVITATE).enemySpecies(SpeciesId.WEEZING).battleType(BattleType.TRAINER);
// Setup toxic spikes and spikes
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, -1, MoveId.TOXIC_SPIKES, 1, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, -1, MoveId.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const [weezing1, weezing2] = game.scene.getEnemyParty();
// Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeDefined();
expect(weezing1.hp).toBe(weezing1.getMaxHp());
game.move.use(MoveId.DRAGON_TAIL);
await game.toEndOfTurn();
// Levitate was ignored during the switch, causing Toxic Spikes to be removed and Spikes to deal damage
expect(weezing1.isOnField()).toBe(false);
expect(weezing2.isOnField()).toBe(true);
expect(weezing2.getHpRatio()).toBeCloseTo(0.75);
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
});

View File

@ -1,20 +1,20 @@
import { allMoves } from "#data/data-lists";
import type { ModifierOverride } from "#app/modifier/modifier-type";
import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase";
import { toDmgValue } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import { toDmgValue } from "#utils/common";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Wimp Out", () => {
describe("Abilities - Wimp Out/Emergency Exit", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
@ -35,9 +35,6 @@ describe("Abilities - Wimp Out", () => {
.ability(AbilityId.WIMP_OUT)
.enemySpecies(SpeciesId.NINJASK)
.enemyPassiveAbility(AbilityId.NO_GUARD)
.startingLevel(90)
.enemyLevel(70)
.moveset([MoveId.SPLASH, MoveId.FALSE_SWIPE, MoveId.ENDURE])
.enemyMoveset(MoveId.FALSE_SWIPE)
.criticalHits(false);
});
@ -50,7 +47,7 @@ describe("Abilities - Wimp Out", () => {
expect(pokemon1.species.speciesId).not.toBe(SpeciesId.WIMPOD);
expect(pokemon2.species.speciesId).toBe(SpeciesId.WIMPOD);
expect(pokemon2.isFainted()).toBe(false);
expect(pokemon2).toHaveFainted();
expect(pokemon2.getHpRatio()).toBeLessThan(0.5);
}
@ -62,295 +59,361 @@ describe("Abilities - Wimp Out", () => {
expect(pokemon2.species.speciesId).not.toBe(SpeciesId.WIMPOD);
expect(pokemon1.species.speciesId).toBe(SpeciesId.WIMPOD);
expect(pokemon1.isFainted()).toBe(false);
expect(pokemon1).toHaveFainted();
expect(pokemon1.getHpRatio()).toBeLessThan(0.5);
}
it("triggers regenerator passive single time when switching out with wimp out", async () => {
it.each<{ name: string; ability: AbilityId }>([
{ name: "Wimp Out", ability: AbilityId.WIMP_OUT },
{ name: "Emergency Exit", ability: AbilityId.EMERGENCY_EXIT },
])("should switch the user out when falling below half HP, canceling its subsequent moves", async ({ ability }) => {
game.override.ability(ability);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.52;
game.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
// Wimpod switched out after taking a hit, canceling its upcoming MoveEffectPhase before it could attack
confirmSwitch();
expect(game.field.getEnemyPokemon()).toHaveFullHp();
expect(game.phaseInterceptor.log.filter(phase => phase === "MoveEffectPhase")).toHaveLength(1);
});
it("should not trigger if user faints from damage and is revived", async () => {
game.override
.startingHeldItems([{ name: "REVIVER_SEED", count: 1 }])
.enemyMoveset(MoveId.BRAVE_BIRD)
.enemyLevel(1000);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.52;
game.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(wimpod).toHaveFainted();
expect(wimpod.isOnField()).toBe(true);
expect(wimpod.getHpRatio()).toBeCloseTo(0.5);
expect(wimpod.getHeldItems()).toHaveLength(0);
expect(wimpod).not.toHaveAbilityApplied(AbilityId.WIMP_OUT);
});
it("should trigger regenerator passive when switching out", async () => {
game.override.passiveAbility(AbilityId.REGENERATOR).startingLevel(5).enemyLevel(100);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
const wimpod = game.field.getPlayerPokemon();
game.move.select(MoveId.SPLASH);
game.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(wimpod.hp).toEqual(Math.floor(wimpod.getMaxHp() * 0.33 + 1));
expect(wimpod).toHaveHp(Math.floor(wimpod.getMaxHp() * 0.33 + 1));
confirmSwitch();
});
it("It makes wild pokemon flee if triggered", async () => {
it("should cause wild pokemon to flee when triggered", async () => {
game.override.enemyAbility(AbilityId.WIMP_OUT);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
const enemyPokemon = game.field.getEnemyPokemon();
enemyPokemon.hp *= 0.52;
game.move.select(MoveId.FALSE_SWIPE);
await game.phaseInterceptor.to("BerryPhase");
game.move.use(MoveId.FALSE_SWIPE);
await game.toEndOfTurn();
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus;
expect(!isVisible && hasFled).toBe(true);
expect(enemyPokemon.visible).toBe(false);
expect(enemyPokemon.switchOutStatus).toBe(true);
});
it("Does not trigger when HP already below half", async () => {
it("should not trigger if HP already below half", async () => {
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp = 5;
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.1;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
expect(wimpod.hp).toEqual(1);
expect(wimpod.getHpRatio()).toBeLessThan(0.1);
confirmNoSwitch();
});
it("Trapping moves do not prevent Wimp Out from activating.", async () => {
game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(1).passiveAbility(AbilityId.STURDY);
it("should bypass trapping moves", async () => {
game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(53).enemyLevel(45);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.move.select(MoveId.SPLASH);
game.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TRAPPED)).toBeUndefined();
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TRAPPED)).toBeUndefined();
expect(game.scene.getPlayerParty()[1].getTag(BattlerTagType.TRAPPED)).toBeUndefined();
confirmSwitch();
});
it("If this Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out.", async () => {
game.override.startingLevel(1).enemyMoveset([MoveId.U_TURN]).passiveAbility(AbilityId.STURDY);
it("should block U-turn or Volt Switch on activation", async () => {
game.override.battleType(BattleType.TRAINER);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.52;
game.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.move.forceEnemyMove(MoveId.U_TURN);
await game.toEndOfTurn();
const enemyPokemon = game.scene.getEnemyPokemon()!;
const hasFled = enemyPokemon.switchOutStatus;
expect(hasFled).toBe(false);
confirmSwitch();
const ninjask = game.field.getEnemyPokemon();
expect(ninjask.isOnField()).toBe(true);
});
it("If this Ability does not activate due to being hit by U-turn or Volt Switch, the user of that move will be switched out.", async () => {
game.override.startingLevel(190).startingWave(8).enemyMoveset([MoveId.U_TURN]);
it("should not block U-turn or Volt Switch if not activated", async () => {
game.override.battleType(BattleType.TRAINER);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]);
const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1);
const wimpod = game.field.getPlayerPokemon();
const ninjask = game.field.getEnemyPokemon();
// force enemy u turn to do 1 dmg
vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({
cancelled: false,
damage: 1,
result: HitResult.EFFECTIVE,
});
game.move.use(MoveId.SPLASH);
await game.phaseInterceptor.to("SwitchSummonPhase", false);
const switchSummonPhase = game.scene.phaseManager.getCurrentPhase() as SwitchSummonPhase;
expect(switchSummonPhase.getPokemon()).toBe(ninjask);
await game.toEndOfTurn();
expect(wimpod.isOnField()).toBe(true);
expect(ninjask.isOnField()).toBe(false);
});
it("Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.", async () => {
game.override.startingLevel(69).enemyMoveset([MoveId.DRAGON_TAIL]);
it("should not activate when hit by force switch moves", async () => {
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.52;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.CIRCLE_THROW);
await game.phaseInterceptor.to("SwitchSummonPhase", false);
expect(wimpod.waveData.abilitiesApplied).not.toContain(AbilityId.WIMP_OUT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(SpeciesId.WIMPOD);
// Force switches directly call `SwitchSummonPhase` to send in a random opponent,
// so wimp out triggering will stall out the test waiting for input
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon().species.speciesId).not.toBe(SpeciesId.WIMPOD);
});
it("triggers when recoil damage is taken", async () => {
game.override.moveset([MoveId.HEAD_SMASH]).enemyMoveset([MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.move.select(MoveId.HEAD_SMASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
confirmSwitch();
});
it("It does not activate when the Pokémon cuts its own HP", async () => {
game.override.moveset([MoveId.SUBSTITUTE]).enemyMoveset([MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52;
game.move.select(MoveId.SUBSTITUTE);
await game.phaseInterceptor.to("TurnEndPhase");
confirmNoSwitch();
});
it("Does not trigger when neutralized", async () => {
game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
confirmNoSwitch();
});
// TODO: Enable when this behavior is fixed (currently Shell Bell won't activate if Wimp Out activates because
// the pokemon is removed from the field before the Shell Bell modifier is applied, so it can't see the
// damage dealt and doesn't heal the pokemon)
it.todo(
"If it falls below half and recovers back above half from a Shell Bell, Wimp Out will activate even after the Shell Bell recovery",
async () => {
it.each<{
type: string;
playerMove?: MoveId;
playerPassive?: AbilityId;
enemyMove?: MoveId;
enemyAbility?: AbilityId;
}>([
{ type: "variable recoil moves", playerMove: MoveId.HEAD_SMASH },
{ type: "HP-based recoil moves", playerMove: MoveId.CHLOROBLAST },
{ type: "weather", enemyMove: MoveId.HAIL },
{ type: "status", enemyMove: MoveId.TOXIC },
{ type: "Ghost-type Curse", enemyMove: MoveId.CURSE },
{ type: "Salt Cure", enemyMove: MoveId.SALT_CURE },
{ type: "partial trapping moves", enemyMove: MoveId.WHIRLPOOL }, // no guard passive makes this 100% accurate
{ type: "Leech Seed", enemyMove: MoveId.LEECH_SEED },
{ type: "Powder", playerMove: MoveId.EMBER, enemyMove: MoveId.POWDER },
{ type: "Nightmare", playerPassive: AbilityId.COMATOSE, enemyMove: MoveId.NIGHTMARE },
{ type: "Bad Dreams", playerPassive: AbilityId.COMATOSE, enemyAbility: AbilityId.BAD_DREAMS },
])(
"should activate from damage caused by $type",
async ({
playerMove = MoveId.SPLASH,
playerPassive = AbilityId.NONE,
enemyMove = MoveId.SPLASH,
enemyAbility = AbilityId.STURDY,
}) => {
game.override
.moveset([MoveId.DOUBLE_EDGE])
.enemyMoveset([MoveId.SPLASH])
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]);
.enemyLevel(1)
.passiveAbility(playerPassive)
.enemySpecies(SpeciesId.GASTLY)
.enemyMoveset(enemyMove)
.enemyAbility(enemyAbility);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
const wimpod = game.field.getPlayerPokemon();
expect(wimpod).toBeDefined();
wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 2);
// mock enemy attack damage func to only do 1 dmg (for whirlpool)
vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({
cancelled: false,
result: HitResult.EFFECTIVE,
damage: 1,
});
wimpod.damageAndUpdate(toDmgValue(wimpod.getMaxHp() * 0.4));
game.move.select(MoveId.DOUBLE_EDGE);
game.move.use(playerMove);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toNextTurn();
expect(game.scene.getPlayerParty()[1]).toBe(wimpod);
expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2));
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.TYRUNT);
confirmSwitch();
},
);
it("Wimp Out will activate due to weather damage", async () => {
game.override.weather(WeatherType.HAIL).enemyMoveset([MoveId.SPLASH]);
it.each<{ name: string; ability: AbilityId }>([
{ name: "Innards Out", ability: AbilityId.INNARDS_OUT },
{ name: "Aftermath", ability: AbilityId.AFTERMATH },
{ name: "Rough Skin", ability: AbilityId.ROUGH_SKIN },
])("should trigger after taking damage from %s ability", async ({ ability }) => {
game.override.enemyAbility(ability).enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.51;
game.field.getEnemyPokemon().hp = wimpod.hp - 1; // Ensure innards out doesn't KO
game.move.select(MoveId.SPLASH);
game.move.use(MoveId.GUILLOTINE);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toNextWave();
confirmSwitch();
});
it("Does not trigger when enemy has sheer force", async () => {
game.override.enemyAbility(AbilityId.SHEER_FORCE).enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(95);
it("should not trigger from Sheer Force-boosted moves", async () => {
game.override.enemyAbility(AbilityId.SHEER_FORCE).startingLevel(1);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.field.getPlayerPokemon().hp *= 0.51;
game.move.select(MoveId.ENDURE);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.ENDURE);
await game.move.forceEnemyMove(MoveId.SLUDGE_BOMB);
await game.toEndOfTurn();
confirmNoSwitch();
});
it("Wimp Out will activate due to post turn status damage", async () => {
game.override.statusEffect(StatusEffect.POISON).enemyMoveset([MoveId.SPLASH]);
it("should trigger from Flame Burst splash damage in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.ZYGARDE, SpeciesId.TYRUNT]);
const wimpod = game.field.getPlayerPokemon();
expect(wimpod).toBeDefined();
wimpod.hp *= 0.52;
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.FLAME_BURST, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.SPLASH);
game.doSelectPartyPokemon(2);
await game.toEndOfTurn();
expect(wimpod.isOnField()).toBe(false);
expect(wimpod.getHpRatio()).toBeLessThan(0.5);
});
it("should not activate when the Pokémon cuts its own HP below half", async () => {
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
// Turn 1: Substitute knocks below half; no switch
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.52;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
game.move.use(MoveId.SUBSTITUTE);
await game.move.forceEnemyMove(MoveId.TIDY_UP);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
confirmNoSwitch();
// Turn 2: get back enough HP that substitute doesn't put us under
wimpod.hp = wimpod.getMaxHp() * 0.8;
game.move.use(MoveId.SUBSTITUTE);
await game.move.forceEnemyMove(MoveId.ROUND);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
confirmSwitch();
});
it("Wimp Out will activate due to bad dreams", async () => {
game.override.statusEffect(StatusEffect.SLEEP).enemyAbility(AbilityId.BAD_DREAMS);
it("should not trigger when neutralized", async () => {
game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.52;
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
game.move.select(MoveId.SPLASH);
confirmNoSwitch();
});
it("should disregard Shell Bell recovery while still activating it before switching", async () => {
game.override
.moveset(MoveId.DOUBLE_EDGE)
.enemyMoveset(MoveId.SPLASH)
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); // heals 50% of damage dealt, more than recoil takes away
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.51;
game.move.use(MoveId.DOUBLE_EDGE);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
await game.phaseInterceptor.to("MoveEffectPhase");
// Wimp out check activated from recoil before shell bell procced, but did not deny the pokemon its recovery
expect(wimpod.turnData.damageTaken).toBeGreaterThan(0);
expect(wimpod.getHpRatio()).toBeGreaterThan(0.5);
await game.toEndOfTurn();
confirmSwitch();
});
it("Wimp Out will activate due to leech seed", async () => {
game.override.enemyMoveset([MoveId.LEECH_SEED]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.52;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
confirmSwitch();
});
it("Wimp Out will activate due to curse damage", async () => {
game.override.enemySpecies(SpeciesId.DUSKNOIR).enemyMoveset([MoveId.CURSE]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.52;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
confirmSwitch();
});
it("Wimp Out will activate due to salt cure damage", async () => {
game.override.enemySpecies(SpeciesId.NACLI).enemyMoveset([MoveId.SALT_CURE]).enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.7;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
confirmSwitch();
});
it("Wimp Out will activate due to damaging trap damage", async () => {
game.override.enemySpecies(SpeciesId.MAGIKARP).enemyMoveset([MoveId.WHIRLPOOL]).enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.55;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
confirmSwitch();
});
it("Magic Guard passive should not allow indirect damage to trigger Wimp Out", async () => {
it("should activate from entry hazard damage", async () => {
// enemy centiscorch switches in... then dies
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY);
game.override
.passiveAbility(AbilityId.MAGIC_GUARD)
.enemyMoveset([MoveId.LEECH_SEED])
.weather(WeatherType.HAIL)
.statusEffect(StatusEffect.POISON);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT);
await game.classicMode.startBattle([SpeciesId.TYRUNT]);
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerParty()[0].getHpRatio()).toEqual(0.51);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.WIMPOD);
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
});
it("Wimp Out activating should not cancel a double battle", async () => {
game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyMoveset([MoveId.SPLASH]).enemyLevel(1);
it("should not switch if Magic Guard prevents damage", async () => {
game.override.passiveAbility(AbilityId.MAGIC_GUARD).enemyMoveset(MoveId.LEECH_SEED);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.51;
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(wimpod.isOnField()).toBe(true);
expect(wimpod.getHpRatio()).toBeCloseTo(0.51);
});
it("should not cancel a double battle on activation", async () => {
game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const enemyLeadPokemon = game.scene.getEnemyParty()[0];
const enemySecPokemon = game.scene.getEnemyParty()[1];
game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.SPLASH, 1);
game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
game.move.use(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
await game.toEndOfTurn();
const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.switchOutStatus;
@ -361,145 +424,63 @@ describe("Abilities - Wimp Out", () => {
expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp());
});
it("Wimp Out will activate due to aftermath", async () => {
game.override
.moveset([MoveId.THUNDER_PUNCH])
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.AFTERMATH)
.enemyMoveset([MoveId.SPLASH])
.enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.move.select(MoveId.THUNDER_PUNCH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
confirmSwitch();
});
it("Activates due to entry hazards", async () => {
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY);
game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT).startingWave(4);
await game.classicMode.startBattle([SpeciesId.TYRUNT]);
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
});
it("Wimp Out will activate due to Nightmare", async () => {
game.override.enemyMoveset([MoveId.NIGHTMARE]).statusEffect(StatusEffect.SLEEP);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.65;
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
confirmSwitch();
});
it("triggers status on the wimp out user before a new pokemon is switched in", async () => {
game.override.enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(80);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
vi.spyOn(allMoves[MoveId.SLUDGE_BOMB], "chance", "get").mockReturnValue(100);
game.move.select(MoveId.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerParty()[1].status?.effect).toEqual(StatusEffect.POISON);
confirmSwitch();
});
it("triggers after last hit of multi hit move", async () => {
game.override.enemyMoveset(MoveId.BULLET_SEED).enemyAbility(AbilityId.SKILL_LINK);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.move.select(MoveId.ENDURE);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
expect(enemyPokemon.turnData.hitCount).toBe(5);
confirmSwitch();
});
it("triggers after last hit of multi hit move (multi lens)", async () => {
game.override.enemyMoveset(MoveId.TACKLE).enemyHeldItems([{ name: "MULTI_LENS", count: 1 }]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.move.select(MoveId.ENDURE);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
expect(enemyPokemon.turnData.hitCount).toBe(2);
confirmSwitch();
});
it("triggers after last hit of Parental Bond", async () => {
game.override.enemyMoveset(MoveId.TACKLE).enemyAbility(AbilityId.PARENTAL_BOND);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.move.select(MoveId.ENDURE);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
expect(enemyPokemon.turnData.hitCount).toBe(2);
confirmSwitch();
});
// TODO: This interaction is not implemented yet
it.todo(
"Wimp Out will not activate if the Pokémon's HP falls below half due to hurting itself in confusion",
async () => {
game.override.moveset([MoveId.SWORDS_DANCE]).enemyMoveset([MoveId.SWAGGER]);
it.each<{ type: string; move?: MoveId; ability?: AbilityId; items?: ModifierOverride[] }>([
{ type: "normal", move: MoveId.DUAL_CHOP },
{ type: "Parental Bond", ability: AbilityId.PARENTAL_BOND },
{ type: "Multi Lens", items: [{ name: "MULTI_LENS", count: 1 }] },
])(
"should trigger after the last hit of $type multi-strike moves",
async ({ move = MoveId.TACKLE, ability = AbilityId.COMPOUND_EYES, items = [] }) => {
game.override.enemyMoveset(move).enemyAbility(ability).enemyHeldItems(items);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const playerPokemon = game.scene.getPlayerPokemon()!;
playerPokemon.hp *= 0.51;
playerPokemon.setStatStage(Stat.ATK, 6);
playerPokemon.addTag(BattlerTagType.CONFUSED);
// TODO: add helper function to force confusion self-hits
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.51;
while (playerPokemon.getHpRatio() > 0.49) {
game.move.select(MoveId.SWORDS_DANCE);
await game.phaseInterceptor.to("TurnEndPhase");
}
game.move.use(MoveId.ENDURE);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
confirmNoSwitch();
const enemyPokemon = game.field.getEnemyPokemon();
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
expect(enemyPokemon.turnData.hitCount).toBe(2);
confirmSwitch();
// Switch triggered after the MEPs for both hits finished
const phaseLogs = game.phaseInterceptor.log;
expect(phaseLogs.filter(l => l === "MoveEffectPhase")).toHaveLength(3); // 1 for endure + 2 for dual hit
expect(phaseLogs.lastIndexOf("SwitchSummonPhase")).toBeGreaterThan(phaseLogs.lastIndexOf("MoveEffectPhase"));
},
);
it("should not activate on wave X0 bosses", async () => {
game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5850).startingWave(10);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD]);
it("should not activate from confusion damage", async () => {
game.override.enemyMoveset(MoveId.CONFUSE_RAY).confusionActivation(true);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
const wimpod = game.field.getPlayerPokemon();
wimpod.hp *= 0.51;
// Use 2 turns of False Swipe due to opponent's health bar shield
game.move.select(MoveId.FALSE_SWIPE);
await game.toNextTurn();
game.move.select(MoveId.FALSE_SWIPE);
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus;
expect(isVisible && !hasFled).toBe(true);
confirmNoSwitch();
});
it("wimp out will not skip battles when triggered in a double battle", async () => {
it("should not activate on wave X0 bosses", async () => {
game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5000).startingWave(10).enemyHealthSegments(3);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.use(MoveId.FALSE_SWIPE);
await game.toNextTurn();
expect(enemyPokemon.visible).toBe(true);
expect(enemyPokemon.switchOutStatus).toBe(false);
});
it("should not skip battles when triggered in a double battle", async () => {
const wave = 2;
game.override
.enemyMoveset(MoveId.SPLASH)
@ -513,10 +494,10 @@ describe("Abilities - Wimp Out", () => {
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.PIKACHU]);
const [wimpod0, wimpod1] = game.scene.getEnemyField();
game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.MATCHA_GOTCHA, 1);
game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
game.move.use(MoveId.MATCHA_GOTCHA, 1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(wimpod0.hp).toBeGreaterThan(0);
expect(wimpod0.switchOutStatus).toBe(true);
@ -527,27 +508,24 @@ describe("Abilities - Wimp Out", () => {
expect(game.scene.currentBattle.waveIndex).toBe(wave + 1);
});
it("wimp out should not skip battles when triggering the same turn as another enemy faints", async () => {
const wave = 2;
it("should not skip battles when triggering the same turn as another enemy faints", async () => {
game.override
.enemySpecies(SpeciesId.WIMPOD)
.enemyAbility(AbilityId.WIMP_OUT)
.startingLevel(50)
.enemyLevel(1)
.enemyMoveset([MoveId.SPLASH, MoveId.ENDURE])
.battleStyle("double")
.moveset([MoveId.DRAGON_ENERGY, MoveId.SPLASH])
.startingWave(wave);
.startingWave(2);
await game.classicMode.startBattle([SpeciesId.REGIDRAGO, SpeciesId.MAGIKARP]);
// turn 1
game.move.select(MoveId.DRAGON_ENERGY, 0);
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.ENDURE);
// turn 1 - 1st wimpod faints while the 2nd one flees
game.move.use(MoveId.DRAGON_ENERGY, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.ENDURE);
await game.phaseInterceptor.to("SelectModifierPhase");
expect(game.scene.currentBattle.waveIndex).toBe(wave + 1);
await game.toNextWave();
expect(game.scene.currentBattle.waveIndex).toBe(3);
});
});

View File

@ -1,126 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Baton Pass", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset([MoveId.BATON_PASS, MoveId.NASTY_PLOT, MoveId.SPLASH])
.ability(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.criticalHits(false);
});
it("transfers all stat stages when player uses it", async () => {
// arrange
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
// round 1 - buff
game.move.select(MoveId.NASTY_PLOT);
await game.toNextTurn();
let playerPokemon = game.scene.getPlayerPokemon()!;
expect(playerPokemon.getStatStage(Stat.SPATK)).toEqual(2);
// round 2 - baton pass
game.move.select(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
// assert
playerPokemon = game.scene.getPlayerPokemon()!;
expect(playerPokemon.species.speciesId).toEqual(SpeciesId.SHUCKLE);
expect(playerPokemon.getStatStage(Stat.SPATK)).toEqual(2);
});
it("passes stat stage buffs when AI uses it", async () => {
// arrange
game.override.startingWave(5).enemyMoveset([MoveId.NASTY_PLOT, MoveId.BATON_PASS]);
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
// round 1 - ai buffs
game.move.select(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.NASTY_PLOT);
await game.toNextTurn();
// round 2 - baton pass
game.move.select(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.BATON_PASS);
await game.phaseInterceptor.to("PostSummonPhase", false);
// check buffs are still there
expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.SPATK)).toEqual(2);
// confirm that a switch actually happened. can't use species because I
// can't find a way to override trainer parties with more than 1 pokemon species
expect(game.phaseInterceptor.log.slice(-4)).toEqual([
"MoveEffectPhase",
"SwitchSummonPhase",
"SummonPhase",
"PostSummonPhase",
]);
});
it("doesn't transfer effects that aren't transferrable", async () => {
game.override.enemyMoveset([MoveId.SALT_CURE]);
await game.classicMode.startBattle([SpeciesId.PIKACHU, SpeciesId.FEEBAS]);
const [player1, player2] = game.scene.getPlayerParty();
game.move.select(MoveId.BATON_PASS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
expect(player1.findTag(t => t.tagType === BattlerTagType.SALT_CURED)).toBeTruthy();
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(player2.findTag(t => t.tagType === BattlerTagType.SALT_CURED)).toBeUndefined();
});
it("doesn't allow binding effects from the user to persist", async () => {
game.override.moveset([MoveId.FIRE_SPIN, MoveId.BATON_PASS]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.FIRE_SPIN);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined();
game.move.select(MoveId.BATON_PASS);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined();
});
});

View File

@ -77,9 +77,8 @@ describe("Moves - Chilly Reception", () => {
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
// TODO: Uncomment lines once wimp out PR fixes force switches to not reset summon data immediately
// await game.phaseInterceptor.to("SwitchSummonPhase", false);
// expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
await game.phaseInterceptor.to("SwitchSummonPhase", false);
expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
await game.toEndOfTurn();

View File

@ -1,307 +0,0 @@
import { allMoves } from "#data/data-lists";
import { Status } from "#data/status-effect";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Dragon Tail", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.DRAGON_TAIL, MoveId.SPLASH, MoveId.FLAMETHROWER])
.enemySpecies(SpeciesId.WAILORD)
.enemyMoveset(MoveId.SPLASH)
.startingLevel(5)
.enemyLevel(5);
vi.spyOn(allMoves[MoveId.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100);
});
it("should cause opponent to flee, and not crash", async () => {
await game.classicMode.startBattle([SpeciesId.DRATINI]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.DRAGON_TAIL);
await game.phaseInterceptor.to("BerryPhase");
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus;
expect(!isVisible && hasFled).toBe(true);
// simply want to test that the game makes it this far without crashing
await game.phaseInterceptor.to("BattleEndPhase");
});
it("should cause opponent to flee, display ability, and not crash", async () => {
game.override.enemyAbility(AbilityId.ROUGH_SKIN);
await game.classicMode.startBattle([SpeciesId.DRATINI]);
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.DRAGON_TAIL);
await game.phaseInterceptor.to("BerryPhase");
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus;
expect(!isVisible && hasFled).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
});
it("should proceed without crashing in a double battle", async () => {
game.override.battleStyle("double").enemyMoveset(MoveId.SPLASH).enemyAbility(AbilityId.ROUGH_SKIN);
await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.DRATINI, SpeciesId.WAILORD, SpeciesId.WAILORD]);
const leadPokemon = game.scene.getPlayerParty()[0]!;
const enemyLeadPokemon = game.scene.getEnemyParty()[0]!;
const enemySecPokemon = game.scene.getEnemyParty()[1]!;
game.move.select(MoveId.DRAGON_TAIL, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("TurnEndPhase");
const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.switchOutStatus;
const isVisibleSec = enemySecPokemon.visible;
const hasFledSec = enemySecPokemon.switchOutStatus;
expect(!isVisibleLead && hasFledLead && isVisibleSec && !hasFledSec).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
// second turn
game.move.select(MoveId.FLAMETHROWER, 0, BattlerIndex.ENEMY_2);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
});
it("should redirect targets upon opponent flee", async () => {
game.override.battleStyle("double").enemyMoveset(MoveId.SPLASH).enemyAbility(AbilityId.ROUGH_SKIN);
await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.DRATINI, SpeciesId.WAILORD, SpeciesId.WAILORD]);
const leadPokemon = game.scene.getPlayerParty()[0]!;
const secPokemon = game.scene.getPlayerParty()[1]!;
const enemyLeadPokemon = game.scene.getEnemyParty()[0]!;
const enemySecPokemon = game.scene.getEnemyParty()[1]!;
game.move.select(MoveId.DRAGON_TAIL, 0, BattlerIndex.ENEMY);
// target the same pokemon, second move should be redirected after first flees
game.move.select(MoveId.DRAGON_TAIL, 1, BattlerIndex.ENEMY);
await game.phaseInterceptor.to("BerryPhase");
const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.switchOutStatus;
const isVisibleSec = enemySecPokemon.visible;
const hasFledSec = enemySecPokemon.switchOutStatus;
expect(!isVisibleLead && hasFledLead && !isVisibleSec && hasFledSec).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
expect(secPokemon.hp).toBeLessThan(secPokemon.getMaxHp());
expect(enemyLeadPokemon.hp).toBeLessThan(enemyLeadPokemon.getMaxHp());
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
});
it("doesn't switch out if the target has suction cups", async () => {
game.override.enemyAbility(AbilityId.SUCTION_CUPS);
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
const enemy = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isFullHp()).toBe(false);
});
it("should force a switch upon fainting an opponent normally", async () => {
game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([SpeciesId.DRATINI]);
game.move.select(MoveId.DRAGON_TAIL);
await game.toNextTurn();
// Make sure the enemy switched to a healthy Pokemon
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy).toBeDefined();
expect(enemy.isFullHp()).toBe(true);
// Make sure the enemy has a fainted Pokemon in their party and not on the field
const faintedEnemy = game.scene.getEnemyParty().find(p => !p.isAllowedInBattle());
expect(faintedEnemy).toBeDefined();
expect(game.scene.getEnemyField().length).toBe(1);
});
it("should not cause a softlock when activating an opponent trainer's reviver seed", async () => {
game.override
.startingWave(5)
.enemyHeldItems([{ name: "REVIVER_SEED" }])
.startingLevel(1000); // To make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([SpeciesId.DRATINI]);
game.move.select(MoveId.DRAGON_TAIL);
await game.toNextTurn();
// Make sure the enemy field is not empty and has a revived Pokemon
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy).toBeDefined();
expect(enemy.hp).toBe(Math.floor(enemy.getMaxHp() / 2));
expect(game.scene.getEnemyField().length).toBe(1);
});
it("should not cause a softlock when activating a player's reviver seed", async () => {
game.override
.startingHeldItems([{ name: "REVIVER_SEED" }])
.enemyMoveset(MoveId.DRAGON_TAIL)
.enemyLevel(1000); // To make sure Dragon Tail KO's the player
await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.BULBASAUR]);
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
// Make sure the player's field is not empty and has a revived Pokemon
const dratini = game.scene.getPlayerPokemon()!;
expect(dratini).toBeDefined();
expect(dratini.hp).toBe(Math.floor(dratini.getMaxHp() / 2));
expect(game.scene.getPlayerField().length).toBe(1);
});
it("should force switches randomly", async () => {
game.override.enemyMoveset(MoveId.DRAGON_TAIL).startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]);
const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty();
// Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander)
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.DRAGON_TAIL);
await game.toNextTurn();
expect(bulbasaur.isOnField()).toBe(false);
expect(charmander.isOnField()).toBe(true);
expect(squirtle.isOnField()).toBe(false);
expect(bulbasaur.getInverseHp()).toBeGreaterThan(0);
// Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle)
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min + 1;
});
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
expect(bulbasaur.isOnField()).toBe(false);
expect(charmander.isOnField()).toBe(false);
expect(squirtle.isOnField()).toBe(true);
expect(charmander.getInverseHp()).toBeGreaterThan(0);
});
it("should not force a switch to a challenge-ineligible Pokemon", async () => {
game.override.enemyMoveset(MoveId.DRAGON_TAIL).startingLevel(100).enemyLevel(1);
// Mono-Water challenge, Eevee is ineligible
game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0);
await game.challengeMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
// Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(true);
expect(primarina.isOnField()).toBe(false);
expect(lapras.getInverseHp()).toBeGreaterThan(0);
});
it("should not force a switch to a fainted Pokemon", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.DRAGON_TAIL]).startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
// Turn 1: Eevee faints
eevee.hp = 0;
eevee.status = new Status(StatusEffect.FAINT);
expect(eevee.isFainted()).toBe(true);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.DRAGON_TAIL);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(true);
expect(primarina.isOnField()).toBe(false);
expect(lapras.getInverseHp()).toBeGreaterThan(0);
});
it("should not force a switch if there are no available Pokemon to switch into", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.DRAGON_TAIL]).startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE]);
const [lapras, eevee] = game.scene.getPlayerParty();
// Turn 1: Eevee faints
eevee.hp = 0;
eevee.status = new Status(StatusEffect.FAINT);
expect(eevee.isFainted()).toBe(true);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.DRAGON_TAIL);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(true);
expect(eevee.isOnField()).toBe(false);
expect(lapras.getInverseHp()).toBeGreaterThan(0);
});
});

View File

@ -44,20 +44,18 @@ describe("Moves - Focus Punch", () => {
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
const enemyStartingHp = enemyPokemon.hp;
game.move.select(MoveId.FOCUS_PUNCH);
await game.phaseInterceptor.to(MessagePhase);
expect(enemyPokemon.hp).toBe(enemyStartingHp);
expect(enemyPokemon.getInverseHp()).toBe(0);
expect(leadPokemon.getMoveHistory().length).toBe(0);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp);
expect(enemyPokemon.getInverseHp()).toBe(0);
expect(leadPokemon.getMoveHistory().length).toBe(1);
expect(leadPokemon.turnData.totalDamageDealt).toBe(enemyStartingHp - enemyPokemon.hp);
expect(enemyPokemon.getInverseHp()).toBeGreaterThan(0);
});
it("should fail if the user is hit", async () => {
@ -72,16 +70,16 @@ describe("Moves - Focus Punch", () => {
game.move.select(MoveId.FOCUS_PUNCH);
await game.phaseInterceptor.to(MessagePhase);
await game.phaseInterceptor.to("MessagePhase");
expect(enemyPokemon.hp).toBe(enemyStartingHp);
expect(leadPokemon.getMoveHistory().length).toBe(0);
await game.phaseInterceptor.to(BerryPhase, false);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyStartingHp);
expect(leadPokemon.getMoveHistory().length).toBe(1);
expect(leadPokemon.turnData.totalDamageDealt).toBe(0);
expect(enemyPokemon.getInverseHp()).toBe(0);
});
it("should be cancelled if the user falls asleep mid-turn", async () => {

View File

@ -0,0 +1,502 @@
import { SubstituteTag } from "#app/data/battler-tags";
import { getPokemonNameWithAffix } from "#app/messages";
import { splitArray } from "#app/utils/array";
import { toDmgValue } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { TrainerSlot } from "#enums/trainer-slot";
import { TrainerType } from "#enums/trainer-type";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Switching Moves", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.ability(AbilityId.STURDY)
.passiveAbility(AbilityId.NO_GUARD)
.enemySpecies(SpeciesId.WAILORD)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH)
.criticalHits(false);
});
describe("Force Switch Moves", () => {
it.each<{ name: string; move: MoveId }>([
{ name: "Whirlwind", move: MoveId.WHIRLWIND },
{ name: "Roar", move: MoveId.ROAR },
{ name: "Dragon Tail", move: MoveId.DRAGON_TAIL },
{ name: "Circle Throw", move: MoveId.CIRCLE_THROW },
])("$name should switch the target out and display custom text", async ({ move }) => {
game.override.battleType(BattleType.TRAINER);
await game.classicMode.startBattle([SpeciesId.BLISSEY, SpeciesId.BULBASAUR]);
const enemy = game.field.getEnemyPokemon();
game.move.use(move);
await game.toNextTurn();
const newEnemy = game.field.getEnemyPokemon();
expect(newEnemy).not.toBe(enemy);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).toContain(
i18next.t("battle:pokemonDraggedOut", {
pokemonName: getPokemonNameWithAffix(newEnemy),
}),
);
});
it("should force switches to a random off-field pokemon", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]);
const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty();
// Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander)
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.DRAGON_TAIL);
await game.toNextTurn();
expect(bulbasaur.isOnField()).toBe(false);
expect(charmander.isOnField()).toBe(true);
expect(squirtle.isOnField()).toBe(false);
expect(bulbasaur).not.toHaveFullHp();
// Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle)
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min + 1;
});
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(bulbasaur.isOnField()).toBe(false);
expect(charmander.isOnField()).toBe(false);
expect(squirtle.isOnField()).toBe(true);
expect(charmander).not.toHaveFullHp();
});
it("should force trainers to switch randomly without selecting from a partner's party", async () => {
game.override
.battleStyle("double")
.battleType(BattleType.TRAINER)
.randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true })
.enemySpecies(0);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRANITAR]);
expect(game.scene.currentBattle.trainer).not.toBeNull();
const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex");
// Grab each trainer's pokemon based on species name
const [tateParty, lizaParty] = splitArray(
game.scene.getEnemyParty(),
pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER,
).map(a => a.map(p => p.species.name));
expect(tateParty).not.toEqual(lizaParty);
// Force enemy trainers to switch to the first mon available.
// Due to how enemy trainer parties are laid out, this prevents false positives
// as Tate's pokemon are placed immediately before Liza's corresponding members.
vi.spyOn(Phaser.Math.RND, "integerInRange").mockImplementation(min => min);
game.move.use(MoveId.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
const [tatePartyNew, lizaPartyNew] = splitArray(
game.scene.getEnemyParty(),
pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER,
).map(a => a.map(p => p.species.name));
// Forced switch move should have switched Liza's Pokemon with another one of her own at random
expect(tatePartyNew).toEqual(tateParty);
expect(lizaPartyNew).not.toEqual(lizaParty);
expect(choiceSwitchSpy).not.toHaveBeenCalled();
});
it("should force wild Pokemon to flee and redirect moves accordingly", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.DRATINI]);
const [enemyLeadPokemon, enemySecPokemon] = game.scene.getEnemyParty();
game.move.use(MoveId.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
// target the same pokemon, second move should be redirected after first flees
// Focus punch used due to having even lower priority than Dtail
game.move.use(MoveId.FOCUS_PUNCH, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]);
await game.toEndOfTurn();
expect(enemyLeadPokemon.visible).toBe(false);
expect(enemyLeadPokemon.switchOutStatus).toBe(true);
expect(enemySecPokemon).not.toHaveFullHp();
});
it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => {
game.override.enemyAbility(AbilityId.SUCTION_CUPS);
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.DRAGON_TAIL);
await game.toEndOfTurn();
expect(enemy.isOnField()).toBe(true);
expect(enemy).not.toHaveFullHp();
// Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target
game.field.mockAbility(game.field.getPlayerPokemon(), AbilityId.MOLD_BREAKER);
enemy.hp = enemy.getMaxHp();
game.move.use(MoveId.DRAGON_TAIL);
await game.toEndOfTurn();
expect(enemy.isOnField()).toBe(false);
expect(enemy).not.toHaveFullHp();
});
it("should not switch out a Commanded Dondozo", async () => {
game.override.battleStyle("double").enemySpecies(SpeciesId.DONDOZO);
await game.classicMode.startBattle([SpeciesId.REGIELEKI]);
// pretend dondozo 2 commanded dondozo 1 (silly I know, but it works)
const [dondozo1, dondozo2] = game.scene.getEnemyField();
dondozo1.addTag(BattlerTagType.COMMANDED, 1, MoveId.NONE, dondozo2.id);
game.move.use(MoveId.DRAGON_TAIL);
await game.toEndOfTurn();
expect(dondozo1.isOnField()).toBe(true);
expect(dondozo1).not.toHaveFullHp();
});
it("should perform a normal switch upon fainting an opponent", async () => {
game.override.battleType(BattleType.TRAINER).startingLevel(1000); // To make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([SpeciesId.DRATINI]);
expect(game.scene.getEnemyParty()).toHaveLength(2);
const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex");
game.move.use(MoveId.DRAGON_TAIL);
await game.toNextTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy).toBeDefined();
expect(enemy).toHaveFullHp();
expect(choiceSwitchSpy).toHaveBeenCalledTimes(1);
});
it("should neither switch nor softlock when activating an opponent's reviver seed", async () => {
game.override
.battleType(BattleType.TRAINER)
.enemySpecies(SpeciesId.BLISSEY)
.enemyHeldItems([{ name: "REVIVER_SEED" }]);
await game.classicMode.startBattle([SpeciesId.DRATINI]);
const [blissey1, blissey2] = game.scene.getEnemyParty()!;
blissey1.hp = 1;
game.move.use(MoveId.DRAGON_TAIL);
await game.toNextTurn();
// Bliseey #1 should have consumed the reviver seed and stayed on field
expect(blissey1.isOnField()).toBe(true);
expect(blissey1.getHpRatio()).toBeCloseTo(0.5);
expect(blissey1.getHeldItems()).toHaveLength(0);
expect(blissey2.isOnField()).toBe(false);
});
it("should neither switch nor softlock when activating a player's reviver seed", async () => {
game.override.startingHeldItems([{ name: "REVIVER_SEED" }]).startingLevel(1000); // make hp rounding consistent
await game.classicMode.startBattle([SpeciesId.BLISSEY, SpeciesId.BULBASAUR]);
const [blissey, bulbasaur] = game.scene.getPlayerParty();
blissey.hp = 1;
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.DRAGON_TAIL);
await game.toNextTurn();
// dratini should have consumed the reviver seed and stayed on field
expect(blissey.isOnField()).toBe(true);
expect(blissey.getHpRatio()).toBeCloseTo(0.5);
expect(blissey.getHeldItems()).toHaveLength(0);
expect(bulbasaur.isOnField()).toBe(false);
});
it("should not force a switch to a fainted or challenge-ineligible Pokemon", async () => {
game.override.startingLevel(100).enemyLevel(1);
// Mono-Water challenge, Eevee is ineligible
game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0);
await game.challengeMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
toxapex.hp = 0;
// Mock an RNG call to switch to the first eligible pokemon.
// Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.DRAGON_TAIL);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(false);
expect(primarina.isOnField()).toBe(true);
expect(lapras).not.toHaveFullHp();
});
});
describe("Self-Switch Attack Moves", () => {
it("should trigger post defend abilities before a new pokemon is switched in", async () => {
game.override.enemyAbility(AbilityId.ROUGH_SKIN);
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
const raichu = game.field.getPlayerPokemon();
game.move.use(MoveId.U_TURN);
game.doSelectPartyPokemon(1);
// advance to the phase for picking party members to send out
await game.phaseInterceptor.to("SwitchPhase", false);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
const player = game.field.getPlayerPokemon();
expect(player).toBe(raichu);
expect(player).not.toHaveFullHp();
expect(game.field.getEnemyPokemon().waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated
});
});
describe("Baton Pass", () => {
it("should pass the user's stat stages and BattlerTags to an ally", async () => {
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
game.move.use(MoveId.NASTY_PLOT);
await game.toNextTurn();
const [raichu, shuckle] = game.scene.getPlayerParty();
expect(raichu).toHaveStatStage(Stat.SPATK, 2);
game.move.use(MoveId.SUBSTITUTE);
await game.toNextTurn();
expect(raichu).toHaveBattlerTag(BattlerTagType.SUBSTITUTE);
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).toBe(shuckle);
expect(shuckle).toHaveStatStage(Stat.SPATK, 2);
expect(shuckle).toHaveBattlerTag(BattlerTagType.SUBSTITUTE);
});
it("should not transfer non-transferrable effects", async () => {
await game.classicMode.startBattle([SpeciesId.PIKACHU, SpeciesId.FEEBAS]);
const [player1, player2] = game.scene.getPlayerParty();
game.move.use(MoveId.BATON_PASS);
await game.move.forceEnemyMove(MoveId.SALT_CURE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
expect(player1).toHaveBattlerTag(BattlerTagType.SALT_CURED);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(player1.isOnField()).toBe(false);
expect(player2.isOnField()).toBe(true);
expect(player2).not.toHaveBattlerTag(BattlerTagType.SALT_CURED);
});
it("should remove the user's binding effects on end", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
game.move.use(MoveId.FIRE_SPIN);
await game.move.forceHit();
await game.toNextTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy).toHaveBattlerTag(BattlerTagType.FIRE_SPIN);
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(enemy).not.toHaveBattlerTag(BattlerTagType.FIRE_SPIN);
});
});
describe("Shed Tail", () => {
it("should consume 50% of the user's max HP (rounded up) to transfer a 25% HP Substitute doll", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const magikarp = game.field.getPlayerPokemon();
game.move.use(MoveId.SHED_TAIL);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).not.toBe(magikarp);
expect(feebas.hp).toBe(feebas.getMaxHp());
const substituteTag = feebas.getTag(SubstituteTag)!;
expect(substituteTag).toBeDefined();
expect(magikarp).toHaveTakenDamage(Math.ceil(magikarp.getMaxHp() / 2));
expect(substituteTag.hp).toBe(Math.floor(magikarp.getMaxHp() / 4));
});
it("should not transfer other effects", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const magikarp = game.field.getPlayerPokemon();
magikarp.setStatStage(Stat.ATK, 6);
game.move.use(MoveId.SHED_TAIL);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
const newMon = game.field.getPlayerPokemon();
expect(newMon).not.toBe(magikarp);
expect(newMon.getStatStage(Stat.ATK)).toBe(0);
expect(magikarp.getStatStage(Stat.ATK)).toBe(0);
});
it("should fail if the user's HP is insufficient", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const magikarp = game.field.getPlayerPokemon();
const initHp = toDmgValue(magikarp.getMaxHp() / 2 - 1);
magikarp.hp = initHp;
game.move.use(MoveId.SHED_TAIL);
await game.toEndOfTurn();
expect(magikarp.isOnField()).toBe(true);
expect(magikarp).toHaveUsedMove({ move: MoveId.SHED_TAIL, result: MoveResult.FAIL });
expect(magikarp).toHaveHp(initHp);
});
});
describe("Failure Checks", () => {
it.each<{ name: string; move: MoveId }>([
{ name: "U-Turn", move: MoveId.U_TURN },
{ name: "Flip Turn", move: MoveId.FLIP_TURN },
{ name: "Volt Switch", move: MoveId.VOLT_SWITCH },
{ name: "Baton Pass", move: MoveId.BATON_PASS },
{ name: "Shed Tail", move: MoveId.SHED_TAIL },
{ name: "Parting Shot", move: MoveId.PARTING_SHOT },
])("$name should not allow wild pokemon to flee", async ({ move }) => {
game.override.enemyMoveset(move);
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
const karp = game.field.getEnemyPokemon();
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase");
const enemy = game.field.getEnemyPokemon();
expect(enemy).toBe(karp);
expect(enemy.switchOutStatus).toBe(false);
});
it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([
{ name: "Teleport", enemyMove: MoveId.TELEPORT },
{ name: "Whirlwind", move: MoveId.WHIRLWIND },
{ name: "Roar", move: MoveId.ROAR },
{ name: "Dragon Tail", move: MoveId.DRAGON_TAIL },
{ name: "Circle Throw", move: MoveId.CIRCLE_THROW },
])("$name should allow wild pokemon to flee", async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => {
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
const enemy = game.field.getEnemyPokemon();
game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
expect(game.field.getEnemyPokemon()).not.toBe(enemy);
});
it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([
{ name: "U-Turn", move: MoveId.U_TURN },
{ name: "Flip Turn", move: MoveId.FLIP_TURN },
{ name: "Volt Switch", move: MoveId.VOLT_SWITCH },
// TODO: Enable once Parting shot is fixed
// { name: "Parting Shot", move: MoveId.PARTING_SHOT },
{ name: "Dragon Tail", enemyMove: MoveId.DRAGON_TAIL },
{ name: "Circle Throw", enemyMove: MoveId.CIRCLE_THROW },
])(
"$name should not fail if no valid switch out target is found",
async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => {
await game.classicMode.startBattle([SpeciesId.RAICHU]);
game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
const user = enemyMove === MoveId.SPLASH ? game.field.getPlayerPokemon() : game.field.getEnemyPokemon();
expect(user.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
},
);
it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([
{ name: "Teleport", move: MoveId.TELEPORT },
{ name: "Baton Pass", move: MoveId.BATON_PASS },
{ name: "Shed Tail", move: MoveId.SHED_TAIL },
{ name: "Roar", enemyMove: MoveId.ROAR },
{ name: "Whirlwind", enemyMove: MoveId.WHIRLWIND },
])(
"$name should fail if no valid switch out target is found",
async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => {
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
const user = enemyMove === MoveId.SPLASH ? game.field.getPlayerPokemon() : game.field.getEnemyPokemon();
expect(user.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
},
);
});
});

View File

@ -175,7 +175,7 @@ describe("Moves - Powder", () => {
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
// enemy should have taken damage from player's Fiery Dance + 2 Powder procs
expect(enemyPokemon.hp).toBe(
enemyStartingHp - playerPokemon.turnData.totalDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4),
enemyStartingHp - playerPokemon.turnData.singleHitDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4),
);
});

View File

@ -1,68 +0,0 @@
import { SubstituteTag } from "#data/battler-tags";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Shed Tail", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.SHED_TAIL])
.battleStyle("single")
.enemySpecies(SpeciesId.SNORLAX)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("transfers a Substitute doll to the switched in Pokemon", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const magikarp = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SHED_TAIL);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase", false);
const feebas = game.scene.getPlayerPokemon()!;
const substituteTag = feebas.getTag(SubstituteTag);
expect(feebas).not.toBe(magikarp);
expect(feebas.hp).toBe(feebas.getMaxHp());
// Note: Altered the test to be consistent with the correct HP cost :yipeevee_static:
expect(magikarp.hp).toBe(Math.floor(magikarp.getMaxHp() / 2));
expect(substituteTag).toBeDefined();
expect(substituteTag?.hp).toBe(Math.floor(magikarp.getMaxHp() / 4));
});
it("should fail if no ally is available to switch in", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const magikarp = game.scene.getPlayerPokemon()!;
expect(game.scene.getPlayerParty().length).toBe(1);
game.move.select(MoveId.SHED_TAIL);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(magikarp.isOnField()).toBeTruthy();
expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
});

View File

@ -1,106 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - U-turn", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemySpecies(SpeciesId.GENGAR)
.startingLevel(90)
.startingWave(97)
.moveset([MoveId.U_TURN])
.enemyMoveset(MoveId.SPLASH)
.criticalHits(false);
});
it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => {
// arrange
const playerHp = 1;
game.override.ability(AbilityId.REGENERATOR);
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
game.scene.getPlayerPokemon()!.hp = playerHp;
// act
game.move.select(MoveId.U_TURN);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
// assert
expect(game.scene.getPlayerParty()[1].hp).toEqual(
Math.floor(game.scene.getPlayerParty()[1].getMaxHp() * 0.33 + playerHp),
);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.SHUCKLE);
});
it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => {
// arrange
game.override.enemyAbility(AbilityId.ROUGH_SKIN);
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
// act
game.move.select(MoveId.U_TURN);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("SwitchPhase", false);
// assert
const playerPkm = game.scene.getPlayerPokemon()!;
expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp());
expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated
expect(playerPkm.species.speciesId).toEqual(SpeciesId.RAICHU);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
});
it("triggers contact abilities on the u-turn user (eg poison point) before a new pokemon is switched in", async () => {
// arrange
game.override.enemyAbility(AbilityId.POISON_POINT);
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
vi.spyOn(game.scene.getEnemyPokemon()!, "randBattleSeedInt").mockReturnValue(0);
// act
game.move.select(MoveId.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(SpeciesId.RAICHU);
expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
});
it("still forces a switch if u-turn KO's the opponent", async () => {
game.override.startingLevel(1000); // Ensure that U-Turn KO's the opponent
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]);
const enemy = game.scene.getEnemyPokemon()!;
// KO the opponent with U-Turn
game.move.select(MoveId.U_TURN);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isFainted()).toBe(true);
// Check that U-Turn forced a switch
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.SHUCKLE);
});
});

View File

@ -1,252 +0,0 @@
import { globalScene } from "#app/global-scene";
import { Status } from "#data/status-effect";
import { AbilityId } from "#enums/ability-id";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { TrainerType } from "#enums/trainer-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Whirlwind", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.SPLASH])
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH, MoveId.WHIRLWIND])
.enemySpecies(SpeciesId.PIDGEY);
});
it.each([
{ move: MoveId.FLY, name: "Fly" },
{ move: MoveId.BOUNCE, name: "Bounce" },
{ move: MoveId.SKY_DROP, name: "Sky Drop" },
])("should not hit a flying target: $name (=$move)", async ({ move }) => {
game.override.moveset([move]);
// Must have a pokemon in the back so that the move misses instead of fails.
await game.classicMode.startBattle([SpeciesId.STARAPTOR, SpeciesId.MAGIKARP]);
const staraptor = game.scene.getPlayerPokemon()!;
game.move.select(move);
await game.move.selectEnemyMove(MoveId.WHIRLWIND);
await game.phaseInterceptor.to("BerryPhase", false);
expect(staraptor.findTag(t => t.tagType === BattlerTagType.FLYING)).toBeDefined();
expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
});
it("should force switches randomly", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]);
const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty();
// Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander)
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.WHIRLWIND);
await game.toNextTurn();
expect(bulbasaur.isOnField()).toBe(false);
expect(charmander.isOnField()).toBe(true);
expect(squirtle.isOnField()).toBe(false);
// Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle)
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min + 1;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.WHIRLWIND);
await game.toNextTurn();
expect(bulbasaur.isOnField()).toBe(false);
expect(charmander.isOnField()).toBe(false);
expect(squirtle.isOnField()).toBe(true);
});
it("should not force a switch to a challenge-ineligible Pokemon", async () => {
// Mono-Water challenge, Eevee is ineligible
game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0);
await game.challengeMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
// Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.WHIRLWIND);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(true);
expect(primarina.isOnField()).toBe(false);
});
it("should not force a switch to a fainted Pokemon", async () => {
await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
// Turn 1: Eevee faints
eevee.hp = 0;
eevee.status = new Status(StatusEffect.FAINT);
expect(eevee.isFainted()).toBe(true);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.WHIRLWIND);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(true);
expect(primarina.isOnField()).toBe(false);
});
it("should not force a switch if there are no available Pokemon to switch into", async () => {
await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE]);
const [lapras, eevee] = game.scene.getPlayerParty();
// Turn 1: Eevee faints
eevee.hp = 0;
eevee.status = new Status(StatusEffect.FAINT);
expect(eevee.isFainted()).toBe(true);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted
vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min;
});
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.WHIRLWIND);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(true);
expect(eevee.isOnField()).toBe(false);
});
it("should fail when player uses Whirlwind against an opponent with only one available Pokémon", async () => {
// Set up the battle scenario with the player knowing Whirlwind
game.override.startingWave(5).enemySpecies(SpeciesId.PIDGEY).moveset([MoveId.WHIRLWIND]);
await game.classicMode.startBattle();
const enemyParty = game.scene.getEnemyParty();
// Ensure the opponent has only one available Pokémon
if (enemyParty.length > 1) {
enemyParty.slice(1).forEach(p => {
p.hp = 0;
p.status = new Status(StatusEffect.FAINT);
});
}
const eligibleEnemy = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle());
expect(eligibleEnemy.length).toBe(1);
// Spy on the queueMessage function
const queueSpy = vi.spyOn(globalScene.phaseManager, "queueMessage");
// Player uses Whirlwind; opponent uses Splash
game.move.select(MoveId.WHIRLWIND);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Verify that the failure message is displayed for Whirlwind
expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But it failed"));
// Verify the opponent's Splash message
expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But nothing happened!"));
});
it("should not pull in the other trainer's pokemon in a partner trainer battle", async () => {
game.override
.startingWave(2)
.battleType(BattleType.TRAINER)
.randomTrainer({
trainerType: TrainerType.BREEDER,
alwaysDouble: true,
})
.enemyMoveset([MoveId.SPLASH, MoveId.LUNAR_DANCE])
.moveset([MoveId.WHIRLWIND, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.TOTODILE]);
// expect the enemy to have at least 4 pokemon, necessary for this check to even work
expect(game.scene.getEnemyParty().length, "enemy must have exactly 4 pokemon").toBeGreaterThanOrEqual(4);
const user = game.scene.getPlayerPokemon()!;
console.log(user.getMoveset(false));
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.MEMENTO);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Get the enemy pokemon id so we can check if is the same after switch.
const enemy_id = game.scene.getEnemyPokemon()!.id;
// Hit the enemy that fainted with whirlwind.
game.move.select(MoveId.WHIRLWIND, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Expect the enemy pokemon to not have switched out.
expect(game.scene.getEnemyPokemon()!.id).toBe(enemy_id);
});
it("should force a wild pokemon to flee", async () => {
game.override
.battleType(BattleType.WILD)
.moveset([MoveId.WHIRLWIND, MoveId.SPLASH])
.enemyMoveset(MoveId.SPLASH)
.ability(AbilityId.BALL_FETCH);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const user = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.WHIRLWIND);
await game.phaseInterceptor.to("BerryPhase");
expect(user.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
});

View File

@ -172,6 +172,8 @@ export class GameManager {
* @param mode - The mode to wait for.
* @param callback - The callback function to execute on next prompt.
* @param expireFn - Optional function to determine if the prompt has expired.
* @remarks
* If multiple callbacks are queued for the same phase, they will be executed in the order they were added.
*/
onNextPrompt(
phaseTarget: string,