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 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. * 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. * 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) { if (this.currentBattle.double === false) {
return; return;
} }
// TODO: Remove while loop
if (allyPokemon?.isActive(true)) { if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase; let targetingMovePhase: MovePhase;
do { do {

View File

@ -13,6 +13,7 @@ import { getBerryEffectFunc } from "#data/berry";
import { allAbilities, allMoves } from "#data/data-lists"; import { allAbilities, allMoves } from "#data/data-lists";
import { SpeciesFormChangeAbilityTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import { SpeciesFormChangeAbilityTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
import { Gender } from "#data/gender"; import { Gender } from "#data/gender";
import { ForceSwitchOutHelper } from "#data/helpers/force-switch";
import { getPokeballName } from "#data/pokeball"; import { getPokeballName } from "#data/pokeball";
import { pokemonFormChanges } from "#data/pokemon-forms"; import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species"; import type { PokemonSpecies } from "#data/pokemon-species";
@ -22,7 +23,6 @@ import type { Weather } from "#data/weather";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-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 type { BattleStat, EffectiveStat } from "#enums/stat";
import { BATTLE_STATS, EFFECTIVE_STATS, getStatKey, Stat } from "#enums/stat"; import { BATTLE_STATS, EFFECTIVE_STATS, getStatKey, Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect"; 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 { WeatherType } from "#enums/weather-type";
import { BerryUsedEvent } from "#events/battle-scene"; import { BerryUsedEvent } from "#events/battle-scene";
import type { EnemyPokemon, Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "#modifiers/modifier"; import { BerryModifier, PokemonHeldItemModifier } from "#modifiers/modifier";
import { BerryModifierType } from "#modifiers/modifier-type"; import { BerryModifierType } from "#modifiers/modifier-type";
import { applyMoveAttrs } from "#moves/apply-attrs"; import { applyMoveAttrs } from "#moves/apply-attrs";
import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves"; import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move"; import type { Move } from "#moves/move";
import type { PokemonMove } from "#moves/pokemon-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 { StatStageChangePhase } from "#phases/stat-stage-change-phase";
import type { import type {
AbAttrCondition, AbAttrCondition,
@ -3254,9 +3255,9 @@ export class CommanderAbAttr extends AbAttr {
const ally = pokemon.getAlly(); const ally = pokemon.getAlly();
return ( return (
globalScene.currentBattle?.double && globalScene.currentBattle?.double &&
!isNullOrUndefined(ally) && ally?.species.speciesId === SpeciesId.DONDOZO &&
ally.species.speciesId === SpeciesId.DONDOZO && !ally.isFainted() &&
!(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)) !ally.getTag(BattlerTagType.COMMANDED)
); );
} }
@ -4159,7 +4160,7 @@ export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr {
/** /**
* Condition function to applied to abilities related to Sheer Force. * 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: * 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. * @returns An {@linkcode AbAttrCondition} to disable the ability under the proper conditions.
*/ */
function getSheerForceHitDisableAbCondition(): AbAttrCondition { 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 { export interface PostDamageAbAttrParams extends AbAttrBaseParams {
/** The pokemon that caused the damage; omitted if the damage was not from a pokemon */ /** 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 */ /** The amount of damage that was dealt */
readonly damage: number; readonly damage: number;
} }
/** /**
* Triggers after the Pokemon takes any damage * 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 * 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. * and its opponents, and determines whether a forced switch-out should occur.
* *
* Used by Wimp Out and Emergency Exit * Used for {@linkcode AbilityId.WIMP_OUT} and {@linkcode AbilityId.EMERGENCY_EXIT}.
*
* @see {@linkcode applyPostDamage}
* @sealed
*/ */
export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr {
private helper: ForceSwitchOutHelper = new ForceSwitchOutHelper(SwitchType.SWITCH);
private hpRatio: number; private hpRatio: number;
private helper: ForceSwitchOutHelper;
constructor(hpRatio = 0.5) { constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) {
super(); super();
this.hpRatio = hpRatio; 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 { public override canApply({ pokemon, source, damage }: PostDamageAbAttrParams): boolean {
const moveHistory = pokemon.getMoveHistory(); // Skip move checks for damage not occurring due to a move (eg: hazards)
// Will not activate when the Pokémon's HP is lowered by cutting its own HP const currentPhase = globalScene.phaseManager.getCurrentPhase();
const fordbiddenAttackingMoves = [MoveId.BELLY_DRUM, MoveId.SUBSTITUTE, MoveId.CURSE, MoveId.PAIN_SPLIT]; if (currentPhase?.is("MoveEffectPhase") && !this.passesMoveChecks(source)) {
if (moveHistory.length > 0) { return false;
const lastMoveUsed = moveHistory[moveHistory.length - 1];
if (fordbiddenAttackingMoves.includes(lastMoveUsed.move)) {
return false;
}
} }
// Dragon Tail and Circle Throw switch out Pokémon before the Ability activates. if (!this.wasKnockedBelowHalf(pokemon, damage)) {
const fordbiddenDefendingMoves = [MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW]; return false;
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 (pokemon.hp + damage >= pokemon.getMaxHp() * this.hpRatio) { return this.helper.canSwitchOut(pokemon);
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 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. * 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 { public override apply({ pokemon, simulated }: PostDamageAbAttrParams): void {
// TODO: Consider respecting the `simulated` flag here if (simulated) {
this.helper.switchOutLogic(pokemon); return;
}
this.helper.doSwitch(pokemon);
} }
} }
@ -7385,10 +7230,10 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(AbilityId.WIMP_OUT, 7) new Ability(AbilityId.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr) .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) new Ability(AbilityId.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr) .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) new Ability(AbilityId.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(AbilityId.MERCILESS, 7) 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 { 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; return false;
} }
@ -830,7 +836,9 @@ export class ConfusedTag extends SerializableBattlerTag {
phaseManager.unshiftNew("CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION); phaseManager.unshiftNew("CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION);
// 1/3 chance of hitting self with a 40 base power move // 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 atk = pokemon.getEffectiveStat(Stat.ATK);
const def = pokemon.getEffectiveStat(Stat.DEF); const def = pokemon.getEffectiveStat(Stat.DEF);
const damage = toDmgValue( 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 { applyChallenges } from "#data/challenge";
import { allAbilities, allMoves } from "#data/data-lists"; import { allAbilities, allMoves } from "#data/data-lists";
import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
import { ForceSwitchOutHelper, ForceSwitchOutHelperArgs } from "#data/helpers/force-switch";
import { DelayedAttackTag } from "#data/positional-tags/positional-tag"; import { DelayedAttackTag } from "#data/positional-tags/positional-tag";
import { import {
getNonVolatileStatusEffects, getNonVolatileStatusEffects,
@ -1303,10 +1304,12 @@ export class MoveEffectAttr extends MoveAttr {
* @param user {@linkcode Pokemon} using the move * @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move * @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute * @param move {@linkcode Move} with this attribute
* @param args Set of unique arguments needed by this attribute * @param args - Any unique arguments needed by this attribute
* @returns true if basic application of the ability attribute should be possible * @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[]) { 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) return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp)
&& (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) ||
move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })); move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target }));
@ -1628,6 +1631,7 @@ export class MatchHpAttr extends FixedDamageAttr {
type MoveFilter = (move: Move) => boolean; type MoveFilter = (move: Move) => boolean;
// TODO: fix this to check the last direct damage instance taken
export class CounterDamageAttr extends FixedDamageAttr { export class CounterDamageAttr extends FixedDamageAttr {
private moveFilter: MoveFilter; private moveFilter: MoveFilter;
private multiplier: number; private multiplier: number;
@ -1709,6 +1713,7 @@ export class CelebrateAttr extends MoveEffectAttr {
} }
export class RecoilAttr 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 useHp: boolean;
private damageRatio: number; private damageRatio: number;
private unblockable: boolean; private unblockable: boolean;
@ -1742,8 +1747,8 @@ export class RecoilAttr extends MoveEffectAttr {
return false; return false;
} }
const damageValue = (!this.useHp ? user.turnData.totalDamageDealt : user.getMaxHp()) * this.damageRatio; const damageValue = (!this.useHp ? user.turnData.singleHitDamageDealt : user.getMaxHp()) * this.damageRatio;
const minValue = user.turnData.totalDamageDealt ? 1 : 0; const minValue = user.turnData.singleHitDamageDealt ? 1 : 0;
const recoilDamage = toDmgValue(damageValue, minValue); const recoilDamage = toDmgValue(damageValue, minValue);
if (!recoilDamage) { if (!recoilDamage) {
return false; 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 * Removes a fraction of the user's maximum HP to create a substitute.
* @param user - The {@linkcode Pokemon} that used the move. * @param user - The {@linkcode Pokemon} using the move.
* @param target - n/a * @param target - n/a
* @param move - The {@linkcode Move} with this attribute. * @param move - The {@linkcode Move} with this attribute.
* @param args - n/a * @param args - n/a
@ -1908,7 +1913,7 @@ export class AddSubstituteAttr extends MoveEffectAttr {
return false; 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.damageAndUpdate(damageTaken, { result: HitResult.INDIRECT, ignoreSegments: true, ignoreFaintPhase: true });
user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id); user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id);
return true; 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 { export class SacrificialFullRestoreAttr extends SacrificialAttr {
protected restorePP: boolean; protected restorePP: boolean;
protected moveMessage: string; 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 // 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 party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0); const maxPartyMemberHp = Math.max(...party.map(p => p.getMaxHp()));
const pm = globalScene.phaseManager; const pm = globalScene.phaseManager;
@ -2121,7 +2133,10 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
} }
getCondition(): MoveConditionFunc { 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 { export class ForceSwitchOutAttr extends MoveEffectAttr {
constructor( private readonly helper: ForceSwitchOutHelper;
private selfSwitch: boolean = false,
private switchType: SwitchType = SwitchType.SWITCH constructor(args: ForceSwitchOutHelperArgs) {
) {
super(false, { lastHitOnly: true }); super(false, { lastHitOnly: true });
this.helper = new ForceSwitchOutHelper(args);
} }
isBatonPass() { apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
return this.switchType === SwitchType.BATON_PASS; 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 * Check whether the target can be switched out.
if (!this.getSwitchOutCondition()(user, target, move)) { */
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]) {
if (!super.canApply(user, target, _move, _args)) {
return false; return false;
} }
/** The {@linkcode Pokemon} to be switched out with this effect */ const switchOutTarget = this.helper.selfSwitch ? user : target;
const switchOutTarget = this.selfSwitch ? user : target;
// If the switch-out target is a Dondozo with a Tatsugiri in its mouth // Check for Wimp Out edge case - self-switching moves cannot proc if the attack also triggers Wimp Out/EE
// (e.g. when it uses Flip Turn), make it spit out the Tatsugiri before switching out. // TODO: This can be improved with a move in flight global object
switchOutTarget.lapseTag(BattlerTagType.COMMANDED); const moveDmgDealt = user.turnData.lastMoveDamageDealt[target.getBattlerIndex()]
if (
if (switchOutTarget.isPlayer()) { this.helper.selfSwitch
/** && moveDmgDealt
* Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch && target.getAbilityAttrs("PostDamageForceSwitchAbAttr").some(
* If it did, the user of U-turn or Volt Switch will not be switched out. p => p.canApply({pokemon: target, damage: moveDmgDealt, simulated: false, source: user}))
*/
if (target.getAbility().hasAttr("PostDamageForceSwitchAbAttr")
&& [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id)
) { ) {
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; return false;
} }
if (switchOutTarget.hp > 0) { return this.helper.canSwitchOut(switchOutTarget)
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;
} }
getCondition(): MoveConditionFunc { 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); const cancelled = new BooleanHolder(false);
applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled}); applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled});
if (cancelled.value) { 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 { 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; return -20;
} }
let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
if (this.selfSwitch && this.isBatonPass()) { let ret = this.helper.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
const statStageTotal = user.getStatStages().reduce((s: number, total: number) => total += s, 0); 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)); ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10));
} }
return ret; return ret;
} }
/** public isBatonPass(): boolean {
* Helper function to check if the Pokémon's health is below half after taking damage. return this.helper.switchType === SwitchType.BATON_PASS;
* 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;
} }
} }
@ -6598,10 +6444,12 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr {
} }
getCondition(): MoveConditionFunc { 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 // chilly reception will succeed if the weather is changeable to snow OR the user can be switched out,
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move); // 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 { export class RemoveTypeAttr extends MoveEffectAttr {
private removedType: PokemonType; 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 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 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; 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(), .windMove(),
new AttackMove(MoveId.WING_ATTACK, PokemonType.FLYING, MoveCategory.PHYSICAL, 60, 100, 35, -1, 0, 1), 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) 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() .ignoresSubstitute()
.hidesTarget() .hidesTarget()
.windMove() .windMove()
@ -8623,7 +8466,7 @@ export function initMoves() {
.target(MoveTarget.ALL_NEAR_ENEMIES) .target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(), .reflectable(),
new StatusMove(MoveId.ROAR, PokemonType.NORMAL, -1, 20, -1, -6, 1) 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() .soundBased()
.hidesTarget() .hidesTarget()
.reflectable(), .reflectable(),
@ -8783,7 +8626,7 @@ export function initMoves() {
new AttackMove(MoveId.RAGE, PokemonType.NORMAL, MoveCategory.PHYSICAL, 20, 100, 20, -1, 0, 1) new AttackMove(MoveId.RAGE, PokemonType.NORMAL, MoveCategory.PHYSICAL, 20, 100, 20, -1, 0, 1)
.partial(), // No effect implemented .partial(), // No effect implemented
new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1) new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr, true) .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SWITCH, allowFlee: true})
.hidesUser(), .hidesUser(),
new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1) new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
.attr(LevelDamageAttr), .attr(LevelDamageAttr),
@ -9183,8 +9026,7 @@ export function initMoves() {
new AttackMove(MoveId.DRAGON_BREATH, PokemonType.DRAGON, MoveCategory.SPECIAL, 60, 100, 20, 30, 0, 2) new AttackMove(MoveId.DRAGON_BREATH, PokemonType.DRAGON, MoveCategory.SPECIAL, 60, 100, 20, 30, 0, 2)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS), .attr(StatusEffectAttr, StatusEffect.PARALYSIS),
new SelfStatusMove(MoveId.BATON_PASS, PokemonType.NORMAL, -1, 40, -1, 0, 2) new SelfStatusMove(MoveId.BATON_PASS, PokemonType.NORMAL, -1, 40, -1, 0, 2)
.attr(ForceSwitchOutAttr, true, SwitchType.BATON_PASS) .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.BATON_PASS})
.condition(failIfLastInPartyCondition)
.hidesUser(), .hidesUser(),
new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2) new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
@ -9633,8 +9475,7 @@ export function initMoves() {
.ballBombMove(), .ballBombMove(),
new SelfStatusMove(MoveId.HEALING_WISH, PokemonType.PSYCHIC, -1, 10, -1, 0, 4) new SelfStatusMove(MoveId.HEALING_WISH, PokemonType.PSYCHIC, -1, 10, -1, 0, 4)
.attr(SacrificialFullRestoreAttr, false, "moveTriggers:sacrificialFullRestore") .attr(SacrificialFullRestoreAttr, false, "moveTriggers:sacrificialFullRestore")
.triageMove() .triageMove(),
.condition(failIfLastInPartyCondition),
new AttackMove(MoveId.BRINE, PokemonType.WATER, MoveCategory.SPECIAL, 65, 100, 10, -1, 0, 4) 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), .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) new AttackMove(MoveId.NATURAL_GIFT, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 4)
@ -9665,7 +9506,7 @@ export function initMoves() {
.makesContact(false) .makesContact(false)
.target(MoveTarget.ATTACKER), .target(MoveTarget.ATTACKER),
new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) 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) new AttackMove(MoveId.CLOSE_COMBAT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4)
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true), .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true),
new AttackMove(MoveId.PAYBACK, PokemonType.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4) 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) new SelfStatusMove(MoveId.LUNAR_DANCE, PokemonType.PSYCHIC, -1, 10, -1, 0, 4)
.attr(SacrificialFullRestoreAttr, true, "moveTriggers:lunarDanceRestore") .attr(SacrificialFullRestoreAttr, true, "moveTriggers:lunarDanceRestore")
.danceMove() .danceMove()
.triageMove() .triageMove(),
.condition(failIfLastInPartyCondition),
new AttackMove(MoveId.CRUSH_GRIP, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4) new AttackMove(MoveId.CRUSH_GRIP, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4)
.attr(OpponentHighHpPowerAttr, 120), .attr(OpponentHighHpPowerAttr, 120),
new AttackMove(MoveId.MAGMA_STORM, PokemonType.FIRE, MoveCategory.SPECIAL, 100, 75, 5, -1, 0, 4) 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.ATK ], 1, true)
.attr(StatStageChangeAttr, [ Stat.SPD ], 2, true), .attr(StatStageChangeAttr, [ Stat.SPD ], 2, true),
new AttackMove(MoveId.CIRCLE_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5) 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(), .hidesTarget(),
new AttackMove(MoveId.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) new AttackMove(MoveId.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5)
.target(MoveTarget.ALL_NEAR_ENEMIES) .target(MoveTarget.ALL_NEAR_ENEMIES)
@ -10154,7 +9994,7 @@ export function initMoves() {
.attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, MoveId.FIRE_PLEDGE) .attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, MoveId.FIRE_PLEDGE)
.attr(BypassRedirectAttr, true), .attr(BypassRedirectAttr, true),
new AttackMove(MoveId.VOLT_SWITCH, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 20, -1, 0, 5) 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) new AttackMove(MoveId.STRUGGLE_BUG, PokemonType.BUG, MoveCategory.SPECIAL, 50, 100, 20, 100, 0, 5)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -1) .attr(StatStageChangeAttr, [ Stat.SPATK ], -1)
.target(MoveTarget.ALL_NEAR_ENEMIES), .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) new AttackMove(MoveId.FROST_BREATH, PokemonType.ICE, MoveCategory.SPECIAL, 60, 90, 10, -1, 0, 5)
.attr(CritOnlyAttr), .attr(CritOnlyAttr),
new AttackMove(MoveId.DRAGON_TAIL, PokemonType.DRAGON, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5) 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(), .hidesTarget(),
new SelfStatusMove(MoveId.WORK_UP, PokemonType.NORMAL, -1, 30, -1, 0, 5) new SelfStatusMove(MoveId.WORK_UP, PokemonType.NORMAL, -1, 30, -1, 0, 5)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, true), .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, true),
@ -10322,9 +10162,10 @@ export function initMoves() {
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(MoveId.PARTING_SHOT, PokemonType.DARK, 100, 20, -1, 0, 6) 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(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, { trigger: MoveEffectTrigger.PRE_APPLY })
.attr(ForceSwitchOutAttr, true) .attr(ForceSwitchOutAttr, {selfSwitch: true})
.soundBased() .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) new StatusMove(MoveId.TOPSY_TURVY, PokemonType.DARK, -1, 20, -1, 0, 6)
.attr(InvertStatsAttr) .attr(InvertStatsAttr)
.reflectable(), .reflectable(),
@ -11055,7 +10896,7 @@ export function initMoves() {
.target(MoveTarget.NEAR_ALLY) .target(MoveTarget.NEAR_ALLY)
.condition(failIfSingleBattle), .condition(failIfSingleBattle),
new AttackMove(MoveId.FLIP_TURN, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 8) 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) new AttackMove(MoveId.TRIPLE_AXEL, PokemonType.ICE, MoveCategory.PHYSICAL, 20, 90, 10, -1, 0, 8)
.attr(MultiHitAttr, MultiHitType._3) .attr(MultiHitAttr, MultiHitType._3)
.attr(MultiHitPowerIncrementAttr, 3) .attr(MultiHitPowerIncrementAttr, 3)
@ -11389,15 +11230,14 @@ export function initMoves() {
.makesContact(), .makesContact(),
new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9) new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(AddSubstituteAttr, 0.5, true) .attr(AddSubstituteAttr, 0.5, true)
.attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL) .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SHED_TAIL}),
.condition(failIfLastInPartyCondition),
new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9) new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9)
.attr(PreMoveMessageAttr, (user, _target, _move) => .attr(PreMoveMessageAttr, (user, _target, _move) =>
// Don't display text if current move phase is follow up (ie move called indirectly) // Don't display text if current move phase is follow up (ie move called indirectly)
isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode) isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode)
? "" ? ""
: i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) })) : 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) new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true)
.attr(RemoveArenaTrapAttr, 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. * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements.
*/ */
private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean { private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean {
if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) { let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
const activeMon = globalScene.getPlayerParty().filter(p => p.isActive(true)); if (!this.primaryPokemonRequirements?.length) {
if (activeMon.length > 0) { // If we lack specified criterion, grab the first on-field pokemon, or else the first pokemon allowed in battle
this.primaryPokemon = activeMon[0]; const activeMons = qualified.filter(p => p.isAllowedInBattle());
} else { this.primaryPokemon = activeMons.find(p => p.isOnField()) ?? activeMons[0];
this.primaryPokemon = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle())[0];
}
return true; return true;
} }
let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
for (const req of this.primaryPokemonRequirements) { for (const req of this.primaryPokemonRequirements) {
if (req.meetsRequirement()) { if (req.meetsRequirement()) {
qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn)); qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn));

View File

@ -315,7 +315,14 @@ export class PokemonTurnData {
* - `0` = Move is finished * - `0` = Move is finished
*/ */
public hitsLeft = -1; 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 singleHitDamageDealt = 0;
public damageTaken = 0; public damageTaken = 0;
public attacksReceived: AttackMoveResult[] = []; public attacksReceived: AttackMoveResult[] = [];

View File

@ -14,3 +14,6 @@ export enum SwitchType {
/** Force switchout to a random party member */ /** Force switchout to a random party member */
FORCE_SWITCH, 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 luck: number;
public pauseEvolutions: boolean; public pauseEvolutions: boolean;
public pokerus: boolean; public pokerus: boolean;
/** Whether this Pokemon is currently attempting to switch in. */
public switchOutStatus = false; public switchOutStatus = false;
public evoCounter: number; public evoCounter: number;
public teraType: PokemonType; public teraType: PokemonType;
@ -1228,7 +1229,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @see {@linkcode SubstituteTag} * @see {@linkcode SubstituteTag}
* @see {@linkcode getFieldPositionOffset} * @see {@linkcode getFieldPositionOffset}
*/ */
getSubstituteOffset(): [number, number] { getSubstituteOffset(): [x: number, y: number] {
return this.isPlayer() ? [-30, 10] : [30, -10]; 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.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 { getHpRatio(precise = false): number {
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; 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. * Given the damage, adds a new DamagePhase and update HP values, etc.
* *
* Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly * Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly
* @param damage integer - passed to damage() * @param damage - Amount of damage to deal
* @param result an enum if it's super effective, not very, etc. * @param result - The {@linkcode HitResult} of the damage instance; default `HitResult.EFFECTIVE`
* @param isCritical boolean if move is a critical hit * @param isCritical - Whether the move being used (if any) was a critical hit; default `false`
* @param ignoreSegments boolean, passed to damage() and not used currently * @param ignoreSegments - Whether to ignore boss segments; default `false` and currently unused
* @param preventEndure boolean, ignore endure properties of pokemon, passed to damage() * @param preventEndure - Whether to ignore {@linkcode Moves.ENDURE} and similar effects when applying damage; default `false`
* @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage() * @param ignoreFaintPhase - Whether to ignore adding a faint phase if the damage causes the target to faint; default `false`
* @returns integer of damage done * @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( damageAndUpdate(
damage: number, damage: number,
@ -4108,13 +4116,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
isCritical = false, isCritical = false,
ignoreSegments = false, ignoreSegments = false,
ignoreFaintPhase = false, ignoreFaintPhase = false,
source = undefined,
}: { }: {
result?: DamageResult; result?: DamageResult;
isCritical?: boolean; isCritical?: boolean;
ignoreSegments?: boolean; ignoreSegments?: boolean;
ignoreFaintPhase?: boolean; ignoreFaintPhase?: boolean;
source?: Pokemon;
} = {}, } = {},
): number { ): number {
const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result); const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result);
@ -4122,27 +4128,30 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
"DamageAnimPhase", "DamageAnimPhase",
this.getBattlerIndex(), this.getBattlerIndex(),
damage, damage,
result as DamageResult, result,
isCritical, isCritical,
); );
globalScene.phaseManager.unshiftPhase(damagePhase); 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 = 0;
} }
damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase); damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase);
// Ensure the battle-info bar's HP is updated, though only if the battle info is visible // 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 // TODO: When battle-info UI is refactored, make this only update the HP bar
if (this.battleInfo.visible) { if (this.battleInfo.visible) {
this.updateInfo(); this.updateInfo();
} }
// Damage amount may have changed, but needed to be queued before calling damage function // Damage amount may have changed, but needed to be queued before calling damage function
damagePhase.updateAmount(damage); damagePhase.updateAmount(damage);
/**
* Run PostDamageAbAttr from any source of damage that is not from a multi-hit // Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances.
* Multi-hits are handled in move-effect-phase.ts for PostDamageAbAttr if (isIndirectDamage && result !== HitResult.CONFUSION) {
*/ applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage });
if (!source || source.turnData.hitCount <= 1) {
applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage, source });
} }
return damage; return damage;
} }
@ -4356,6 +4365,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
for (const tag of source.summonData.tags) { 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 ( if (
!tag.isBatonPassable || !tag.isBatonPassable ||
(tag.tagType === BattlerTagType.TELEKINESIS && (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} * 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. * 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 { resetSummonData(): void {
console.log(`resetSummonData called on Pokemon ${this.name}`);
const illusion: IllusionData | null = this.summonData.illusion; const illusion: IllusionData | null = this.summonData.illusion;
if (this.summonData.speciesForm) { if (this.summonData.speciesForm) {
this.summonData.speciesForm = null; this.summonData.speciesForm = null;
@ -5107,6 +5121,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
resetTurnData(): void { resetTurnData(): void {
console.log(`resetTurnData called on Pokemon ${this.name}`);
this.turnData = new PokemonTurnData(); 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). * Cause this {@linkcode 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 * @param clearEffects - Whether to clear (`true`) or transfer (`false`) transient effects upon switching; default `true`
* to the next pokemon, such as during a baton pass (false) * @param hideInfo - Whether to play the animation to hide the Pokemon's info container; default `true`.
* @param hideInfo Indicates if this should also play the animation to hide the Pokemon's * @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false`
* info container. * @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) { leaveField(clearEffects = true, hideInfo = true, destroy = false) {
console.log(`leaveField called on Pokemon ${this.name}`);
this.resetSprite(); this.resetSprite();
this.resetTurnData();
globalScene globalScene
.getField(true) .getField(true)
.filter(p => p !== this) .filter(p => p !== this)
@ -5566,6 +5584,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (clearEffects) { if (clearEffects) {
this.destroySubstitute(); this.destroySubstitute();
this.resetSummonData(); this.resetSummonData();
this.resetTurnData();
} }
if (hideInfo) { if (hideInfo) {
this.hideInfo(); this.hideInfo();

View File

@ -1765,20 +1765,26 @@ export class HitHealModifier extends PokemonHeldItemModifier {
* @returns `true` if the {@linkcode Pokemon} was healed * @returns `true` if the {@linkcode Pokemon} was healed
*/ */
override apply(pokemon: Pokemon): boolean { override apply(pokemon: Pokemon): boolean {
if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) { if (pokemon.isFullHp()) {
// TODO: this shouldn't be undefined AFAIK return false;
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,
);
} }
// 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; 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 there are no other allowed Pokemon in the player's party to switch with
if ( if (globalScene.getBackupPartyMemberIndices(true).length === 0) {
!globalScene
.getPlayerParty()
.slice(1)
.filter(p => p.isActive()).length
) {
return super.end(); return super.end();
} }
// ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching // ...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 ( if (
pokemon.getTag(BattlerTagType.FRENZY) || pokemon.getTag(BattlerTagType.FRENZY) ||
pokemon.isTrapped() || pokemon.isTrapped() ||

View File

@ -49,8 +49,6 @@ export class FaintPhase extends PokemonPhase {
faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source); faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source);
} }
faintPokemon.resetSummonData();
if (!this.preventInstantRevive) { if (!this.preventInstantRevive) {
const instantReviveModifier = globalScene.applyModifier( const instantReviveModifier = globalScene.applyModifier(
PokemonInstantReviveModifier, PokemonInstantReviveModifier,
@ -59,6 +57,7 @@ export class FaintPhase extends PokemonPhase {
) as PokemonInstantReviveModifier; ) as PokemonInstantReviveModifier;
if (instantReviveModifier) { if (instantReviveModifier) {
faintPokemon.resetSummonData();
faintPokemon.loseHeldItem(instantReviveModifier); faintPokemon.loseHeldItem(instantReviveModifier);
globalScene.updateModifiers(this.player); globalScene.updateModifiers(this.player);
return this.end(); return this.end();
@ -146,41 +145,32 @@ export class FaintPhase extends PokemonPhase {
} }
} }
const legalBackupPokemon = globalScene.getBackupPartyMemberIndices(pokemon);
if (this.player) { 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(); const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle();
/** The total number of legal player Pokemon that aren't currently on the field */ if (legalPlayerPokemon.length === 0) {
const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true)); // If the player doesn't have any legal Pokemon left in their party, end the game.
if (!legalPlayerPokemon.length) {
/** If the player doesn't have any legal Pokemon, end the game */
globalScene.phaseManager.unshiftNew("GameOverPhase"); globalScene.phaseManager.unshiftNew("GameOverPhase");
} else if ( } else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) {
globalScene.currentBattle.double && /*
legalPlayerPokemon.length === 1 && Otherwise, if the player has no reserve members left to switch in,
legalPlayerPartyPokemon.length === 0 unshift a phase to move the other on-field pokemon to center position.
) { */
/**
* 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.
*/
globalScene.phaseManager.unshiftNew("ToggleDoublePositionPhase", true); globalScene.phaseManager.unshiftNew("ToggleDoublePositionPhase", true);
} else if (legalPlayerPartyPokemon.length > 0) { } else {
/** // If previous conditions weren't met, push a phase to prompt the player to select a new pokemon from their party.
* 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.
*/
globalScene.phaseManager.pushNew("SwitchPhase", SwitchType.SWITCH, this.fieldIndex, true, false); globalScene.phaseManager.pushNew("SwitchPhase", SwitchType.SWITCH, this.fieldIndex, true, false);
} }
} else { } else {
// Unshift a phase for EXP gains and/or one to switch in a replacement party member.
globalScene.phaseManager.unshiftNew("VictoryPhase", this.battlerIndex); globalScene.phaseManager.unshiftNew("VictoryPhase", this.battlerIndex);
if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { if (
const hasReservePartyMember = !!globalScene [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) &&
.getEnemyParty() legalBackupPokemon.length > 0
.filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot) ) {
.length; globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false);
if (hasReservePartyMember) {
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? */ /** Is this the first strike of a move? */
private firstHit: boolean; 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; private lastHit: boolean;
/** /**
@ -305,7 +305,7 @@ export class MoveEffectPhase extends PokemonPhase {
const targets = this.conductHitChecks(user, fieldMove); const targets = this.conductHitChecks(user, fieldMove);
this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft; 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) // Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct)
if ( if (
@ -770,15 +770,16 @@ export class MoveEffectPhase extends PokemonPhase {
if (!this.move.hitsSubstitute(user, target)) { if (!this.move.hitsSubstitute(user, target)) {
this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical); this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical);
} }
if (this.lastHit) { if (this.lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
// Trigger Form changes on the final hit, alongside Wimp Out.
// Multi-hit check for Wimp Out/Emergency Exit applyAbAttrs("PostDamageAbAttr", {
if (user.turnData.hitCount > 1) { pokemon: target,
// TODO: Investigate why 0 is being passed for damage amount here damage: user.turnData.lastMoveDamageDealt[target.getBattlerIndex()],
// and then determing if refactoring `applyMove` to return the damage dealt is appropriate. simulated: false,
applyAbAttrs("PostDamageAbAttr", { pokemon: target, damage: 0, source: user }); source: user,
} });
} }
} }
@ -811,6 +812,7 @@ export class MoveEffectPhase extends PokemonPhase {
isCritical, isCritical,
}); });
// Apply and/or remove type boosting tags (Flash Fire, Charge, etc.)
const typeBoost = user.findTag( const typeBoost = user.findTag(
t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move), t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move),
) as TypeBoostTag; ) as TypeBoostTag;
@ -818,18 +820,17 @@ export class MoveEffectPhase extends PokemonPhase {
user.removeTag(typeBoost.tagType); user.removeTag(typeBoost.tagType);
} }
const isOneHitKo = result === HitResult.ONE_HIT_KO; if (dmg === 0) {
if (!dmg) {
return [result, false]; return [result, false];
} }
const isOneHitKo = result === HitResult.ONE_HIT_KO;
target.lapseTags(BattlerTagLapseType.HIT); target.lapseTags(BattlerTagLapseType.HIT);
const substitute = target.getTag(SubstituteTag); const substituteTag = target.getTag(SubstituteTag);
const isBlockedBySubstitute = substitute && this.move.hitsSubstitute(user, target); const isBlockedBySubstitute = substituteTag && this.move.hitsSubstitute(user, target);
if (isBlockedBySubstitute) { if (isBlockedBySubstitute) {
substitute.hp -= dmg; substituteTag.hp -= dmg;
} else if (!target.isPlayer() && dmg >= target.hp) { } else if (!target.isPlayer() && dmg >= target.hp) {
globalScene.applyModifiers(EnemyEndureChanceModifier, false, target); globalScene.applyModifiers(EnemyEndureChanceModifier, false, target);
} }
@ -838,10 +839,9 @@ export class MoveEffectPhase extends PokemonPhase {
? 0 ? 0
: target.damageAndUpdate(dmg, { : target.damageAndUpdate(dmg, {
result: result as DamageResult, result: result as DamageResult,
ignoreFaintPhase: true, ignoreFaintPhase: true, // ignore faint phase so we can handle it ourselves
ignoreSegments: isOneHitKo, ignoreSegments: isOneHitKo,
isCritical, isCritical,
source: user,
}); });
if (isCritical) { if (isCritical) {
@ -855,14 +855,13 @@ export class MoveEffectPhase extends PokemonPhase {
if (user.isPlayer()) { if (user.isPlayer()) {
globalScene.validateAchvs(DamageAchv, new NumberHolder(damage)); globalScene.validateAchvs(DamageAchv, new NumberHolder(damage));
if (damage > globalScene.gameData.gameStats.highestDamage) { globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage);
globalScene.gameData.gameStats.highestDamage = damage;
}
} }
user.turnData.totalDamageDealt += damage; user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] += damage;
user.turnData.singleHitDamageDealt = damage; user.turnData.singleHitDamageDealt = damage;
target.battleData.hitCount++; target.battleData.hitCount++;
// TODO: this might be incorrect for counter moves
target.turnData.damageTaken += damage; target.turnData.damageTaken += damage;
target.turnData.attacksReceived.unshift({ target.turnData.attacksReceived.unshift({

View File

@ -133,6 +133,8 @@ export class MovePhase extends BattlePhase {
} }
this.pokemon.turnData.acted = true; 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) // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (isVirtual(this.useMode)) { if (isVirtual(this.useMode)) {

View File

@ -44,6 +44,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase {
} }
if (damage.value) { if (damage.value) {
// Set preventEndure flag to avoid pokemon surviving thanks to focus band, sturdy, endure ... // 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)); globalScene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true));
pokemon.updateInfo(); pokemon.updateInfo();
applyAbAttrs("PostDamageAbAttr", { pokemon, damage: damage.value }); applyAbAttrs("PostDamageAbAttr", { pokemon, damage: damage.value });

View File

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

View File

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

View File

@ -2,10 +2,8 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import { SubstituteTag } from "#data/battler-tags"; import { SubstituteTag } from "#data/battler-tags";
import { allMoves } from "#data/data-lists";
import { SpeciesFormChangeActiveTrigger } from "#data/form-change-triggers"; import { SpeciesFormChangeActiveTrigger } from "#data/form-change-triggers";
import { getPokeballTintColor } from "#data/pokeball"; import { getPokeballTintColor } from "#data/pokeball";
import { Command } from "#enums/command";
import { SwitchType } from "#enums/switch-type"; import { SwitchType } from "#enums/switch-type";
import { TrainerSlot } from "#enums/trainer-slot"; import { TrainerSlot } from "#enums/trainer-slot";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
@ -13,6 +11,7 @@ import { SwitchEffectTransferModifier } from "#modifiers/modifier";
import { SummonPhase } from "#phases/summon-phase"; import { SummonPhase } from "#phases/summon-phase";
import i18next from "i18next"; import i18next from "i18next";
// TODO: This and related phases desperately need to be refactored
export class SwitchSummonPhase extends SummonPhase { export class SwitchSummonPhase extends SummonPhase {
public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" = "SwitchSummonPhase"; public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" = "SwitchSummonPhase";
private readonly switchType: SwitchType; private readonly switchType: SwitchType;
@ -22,10 +21,11 @@ export class SwitchSummonPhase extends SummonPhase {
private lastPokemon: Pokemon; 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 switchType - The type of switch behavior
* @param fieldIndex - Position on the battle field * @param fieldIndex - The position on field of the Pokemon being switched **out**
* @param slotIndex - The index of pokemon (in party of 6) to switch into * @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 doReturn - Whether to render "comeback" dialogue
* @param player - Whether the switch came from the player or enemy; default `true` * @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); super(fieldIndex, player);
this.switchType = switchType; 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; 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 { start(): void {
super.start(); super.start();
} }
preSummon(): void { override preSummon(): void {
if (!this.player) { const switchOutPokemon = this.getPokemon();
if (this.slotIndex === -1) {
//@ts-expect-error if (!this.player && globalScene.currentBattle.trainer) {
this.slotIndex = globalScene.currentBattle.trainer?.getNextSummonIndex( this.showEnemyTrainer(this.getTrainerSlotFromFieldIndex());
!this.fieldIndex ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty());
); // 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());
}
} }
if ( if (
!this.doReturn || !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.slotIndex !== -1 &&
!(this.player ? globalScene.getPlayerParty() : globalScene.getEnemyParty())[this.slotIndex]) !(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) { if (this.player) {
this.switchAndSummon(); this.switchAndSummon();
return; } else {
globalScene.time.delayedCall(750, () => this.switchAndSummon());
} }
globalScene.time.delayedCall(750, () => this.switchAndSummon());
return; return;
} }
const pokemon = this.getPokemon();
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon => (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) { // If not transferring a substitute, play animation to remove it from the field
const substitute = pokemon.getTag(SubstituteTag); if (!this.shouldKeepEffects()) {
const substitute = switchOutPokemon.getTag(SubstituteTag);
if (substitute) { if (substitute) {
globalScene.tweens.add({ globalScene.tweens.add({
targets: substitute.sprite, targets: substitute.sprite,
@ -89,26 +97,24 @@ export class SwitchSummonPhase extends SummonPhase {
globalScene.ui.showText( globalScene.ui.showText(
this.player this.player
? i18next.t("battle:playerComeBack", { ? i18next.t("battle:playerComeBack", {
pokemonName: getPokemonNameWithAffix(pokemon), pokemonName: getPokemonNameWithAffix(switchOutPokemon),
}) })
: i18next.t("battle:trainerComeBack", { : i18next.t("battle:trainerComeBack", {
trainerName: globalScene.currentBattle.trainer?.getName( trainerName: globalScene.currentBattle.trainer?.getName(this.getTrainerSlotFromFieldIndex()),
!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, pokemonName: switchOutPokemon.getNameToRender(),
),
pokemonName: pokemon.getNameToRender(),
}), }),
); );
globalScene.playSound("se/pb_rel"); globalScene.playSound("se/pb_rel");
pokemon.hideInfo(); switchOutPokemon.hideInfo();
pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); switchOutPokemon.tint(getPokeballTintColor(switchOutPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
globalScene.tweens.add({ globalScene.tweens.add({
targets: pokemon, targets: switchOutPokemon,
duration: 250, duration: 250,
ease: "Sine.easeIn", ease: "Sine.easeIn",
scale: 0.5, scale: 0.5,
onComplete: () => { onComplete: () => {
globalScene.time.delayedCall(750, () => this.switchAndSummon()); 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]; const switchedInPokemon: Pokemon | undefined = party[this.slotIndex];
this.lastPokemon = this.getPokemon(); this.lastPokemon = this.getPokemon();
// Defensive programming: Overcome the bug where the summon data has somehow not been reset // TODO: Why do we trigger these attributes even if the switch in target doesn't exist?
// prior to switching in a new Pokemon. // (This should almost certainly go somewhere inside `preSummon`)
// Force the switch to occur and load the assets for the new pokemon, ignoring override.
switchedInPokemon.resetSummonData();
switchedInPokemon.loadAssets(true);
applyAbAttrs("PreSummonAbAttr", { pokemon: switchedInPokemon }); applyAbAttrs("PreSummonAbAttr", { pokemon: switchedInPokemon });
applyAbAttrs("PreSwitchOutAbAttr", { pokemon: this.lastPokemon }); applyAbAttrs("PreSwitchOutAbAttr", { pokemon: this.lastPokemon });
if (!switchedInPokemon) { if (!switchedInPokemon) {
@ -131,6 +133,13 @@ export class SwitchSummonPhase extends SummonPhase {
return; 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 (this.switchType === SwitchType.BATON_PASS) {
// If switching via baton pass, update opposing tags coming from the prior pokemon // If switching via baton pass, update opposing tags coming from the prior pokemon
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) => (this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) =>
@ -149,7 +158,7 @@ export class SwitchSummonPhase extends SummonPhase {
m => m =>
m instanceof SwitchEffectTransferModifier && m instanceof SwitchEffectTransferModifier &&
(m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id, (m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id,
) as SwitchEffectTransferModifier; ) as SwitchEffectTransferModifier | undefined;
if (batonPassModifier) { if (batonPassModifier) {
globalScene.tryTransferHeldItemModifier( globalScene.tryTransferHeldItemModifier(
@ -167,13 +176,14 @@ export class SwitchSummonPhase extends SummonPhase {
party[this.slotIndex] = this.lastPokemon; party[this.slotIndex] = this.lastPokemon;
party[this.fieldIndex] = switchedInPokemon; party[this.fieldIndex] = switchedInPokemon;
// TODO: Make this a method
const showTextAndSummon = () => { const showTextAndSummon = () => {
globalScene.ui.showText(this.getSendOutText(switchedInPokemon)); 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. * 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. * 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); const substitute = this.lastPokemon.getTag(SubstituteTag);
if (substitute) { if (substitute) {
switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0]; switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0];
@ -181,7 +191,7 @@ export class SwitchSummonPhase extends SummonPhase {
switchedInPokemon.setAlpha(0.5); switchedInPokemon.setAlpha(0.5);
} }
} else { } else {
switchedInPokemon.fieldSetup(true); switchedInPokemon.fieldSetup();
} }
this.summon(); this.summon();
}; };
@ -200,46 +210,33 @@ export class SwitchSummonPhase extends SummonPhase {
onEnd(): void { onEnd(): void {
super.onEnd(); super.onEnd();
const pokemon = this.getPokemon(); const activePokemon = this.getPokemon();
const moveId = globalScene.currentBattle.lastMove; // If not switching at start of battle, reset turn counts and temp data on the newly sent in Pokemon
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.
// Needed as we increment turn counters in `TurnEndPhase`. // Needed as we increment turn counters in `TurnEndPhase`.
if ( if (this.switchType !== SwitchType.INITIAL_SWITCH) {
currentCommand === Command.POKEMON || // No need to reset turn/summon data for initial switch
lastPokemonIsForceSwitchedAndNotFainted || // (since both get initialized to defaults on object creation)
lastPokemonHasForceSwitchAbAttr activePokemon.resetTurnData();
) { activePokemon.resetSummonData();
pokemon.tempSummonData.turnCount--; activePokemon.tempSummonData.turnCount--;
pokemon.tempSummonData.waveTurnCount--; activePokemon.tempSummonData.waveTurnCount--;
activePokemon.turnData.switchedInThisTurn = true;
} }
if (this.switchType === SwitchType.BATON_PASS && pokemon) { // Baton Pass over any eligible effects or substitutes before resetting the last pokemon's temporary data.
pokemon.transferSummon(this.lastPokemon); if (this.switchType === SwitchType.BATON_PASS) {
} else if (this.switchType === SwitchType.SHED_TAIL && pokemon) { activePokemon.transferSummon(this.lastPokemon);
} else if (this.switchType === SwitchType.SHED_TAIL) {
const subTag = this.lastPokemon.getTag(SubstituteTag); const subTag = this.lastPokemon.getTag(SubstituteTag);
if (subTag) { if (subTag) {
pokemon.summonData.tags.push(subTag); activePokemon.summonData.tags.push(subTag);
} }
} }
this.lastPokemon.resetTurnData();
// 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.resetSummonData(); 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 // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out
globalScene.arena.triggerWeatherBasedFormChanges(); globalScene.arena.triggerWeatherBasedFormChanges();
} }
@ -275,4 +272,16 @@ export class SwitchSummonPhase extends SummonPhase {
pokemonName: this.getPokemon().getNameToRender(), 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 { 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 { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
@ -37,13 +41,40 @@ describe("Abilities - Mold Breaker", () => {
const enemy = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.X_SCISSOR); game.move.use(MoveId.X_SCISSOR);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase"); await game.phaseInterceptor.to("MoveEffectPhase");
expect(game.scene.arena.ignoreAbilities).toBe(true); expect(game.scene.arena.ignoreAbilities).toBe(true);
expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex()); expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex());
await game.toEndOfTurn(); await game.phaseInterceptor.to("MoveEndPhase");
expect(game.scene.arena.ignoreAbilities).toBe(false); 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 { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-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 { GameManager } from "#test/test-utils/game-manager";
import { toDmgValue } from "#utils/common";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 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 phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
@ -35,9 +35,6 @@ describe("Abilities - Wimp Out", () => {
.ability(AbilityId.WIMP_OUT) .ability(AbilityId.WIMP_OUT)
.enemySpecies(SpeciesId.NINJASK) .enemySpecies(SpeciesId.NINJASK)
.enemyPassiveAbility(AbilityId.NO_GUARD) .enemyPassiveAbility(AbilityId.NO_GUARD)
.startingLevel(90)
.enemyLevel(70)
.moveset([MoveId.SPLASH, MoveId.FALSE_SWIPE, MoveId.ENDURE])
.enemyMoveset(MoveId.FALSE_SWIPE) .enemyMoveset(MoveId.FALSE_SWIPE)
.criticalHits(false); .criticalHits(false);
}); });
@ -50,7 +47,7 @@ describe("Abilities - Wimp Out", () => {
expect(pokemon1.species.speciesId).not.toBe(SpeciesId.WIMPOD); expect(pokemon1.species.speciesId).not.toBe(SpeciesId.WIMPOD);
expect(pokemon2.species.speciesId).toBe(SpeciesId.WIMPOD); expect(pokemon2.species.speciesId).toBe(SpeciesId.WIMPOD);
expect(pokemon2.isFainted()).toBe(false); expect(pokemon2).toHaveFainted();
expect(pokemon2.getHpRatio()).toBeLessThan(0.5); expect(pokemon2.getHpRatio()).toBeLessThan(0.5);
} }
@ -62,295 +59,361 @@ describe("Abilities - Wimp Out", () => {
expect(pokemon2.species.speciesId).not.toBe(SpeciesId.WIMPOD); expect(pokemon2.species.speciesId).not.toBe(SpeciesId.WIMPOD);
expect(pokemon1.species.speciesId).toBe(SpeciesId.WIMPOD); expect(pokemon1.species.speciesId).toBe(SpeciesId.WIMPOD);
expect(pokemon1.isFainted()).toBe(false); expect(pokemon1).toHaveFainted();
expect(pokemon1.getHpRatio()).toBeLessThan(0.5); 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); game.override.passiveAbility(AbilityId.REGENERATOR).startingLevel(5).enemyLevel(100);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); 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); 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(); 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); game.override.enemyAbility(AbilityId.WIMP_OUT);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]); await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]);
const enemyPokemon = game.scene.getEnemyPokemon()!; const enemyPokemon = game.field.getEnemyPokemon();
enemyPokemon.hp *= 0.52; enemyPokemon.hp *= 0.52;
game.move.select(MoveId.FALSE_SWIPE); game.move.use(MoveId.FALSE_SWIPE);
await game.phaseInterceptor.to("BerryPhase"); await game.toEndOfTurn();
const isVisible = enemyPokemon.visible; expect(enemyPokemon.visible).toBe(false);
const hasFled = enemyPokemon.switchOutStatus; expect(enemyPokemon.switchOutStatus).toBe(true);
expect(!isVisible && hasFled).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]); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.field.getPlayerPokemon();
wimpod.hp = 5; wimpod.hp *= 0.1;
game.move.select(MoveId.SPLASH); game.move.use(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase"); await game.toEndOfTurn();
expect(wimpod.hp).toEqual(1); expect(wimpod.getHpRatio()).toBeLessThan(0.1);
confirmNoSwitch(); confirmNoSwitch();
}); });
it("Trapping moves do not prevent Wimp Out from activating.", async () => { it("should bypass trapping moves", async () => {
game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(1).passiveAbility(AbilityId.STURDY); game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(53).enemyLevel(45);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.move.select(MoveId.SPLASH); game.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); await game.toEndOfTurn();
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TRAPPED)).toBeUndefined();
expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TRAPPED)).toBeUndefined();
expect(game.scene.getPlayerParty()[1].getTag(BattlerTagType.TRAPPED)).toBeUndefined(); expect(game.scene.getPlayerParty()[1].getTag(BattlerTagType.TRAPPED)).toBeUndefined();
confirmSwitch(); 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 () => { it("should block U-turn or Volt Switch on activation", async () => {
game.override.startingLevel(1).enemyMoveset([MoveId.U_TURN]).passiveAbility(AbilityId.STURDY); game.override.battleType(BattleType.TRAINER);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
game.move.select(MoveId.SPLASH); const wimpod = game.field.getPlayerPokemon();
game.doSelectPartyPokemon(1); wimpod.hp *= 0.52;
await game.phaseInterceptor.to("TurnEndPhase");
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(); 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 () => { it("should not block U-turn or Volt Switch if not activated", async () => {
game.override.startingLevel(190).startingWave(8).enemyMoveset([MoveId.U_TURN]); game.override.battleType(BattleType.TRAINER);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]); await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]);
const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id;
game.move.select(MoveId.SPLASH); const wimpod = game.field.getPlayerPokemon();
await game.phaseInterceptor.to("BerryPhase", false); const ninjask = game.field.getEnemyPokemon();
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1);
// 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 () => { it("should not activate when hit by force switch moves", async () => {
game.override.startingLevel(69).enemyMoveset([MoveId.DRAGON_TAIL]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); 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.move.use(MoveId.SPLASH);
game.doSelectPartyPokemon(1); await game.move.forceEnemyMove(MoveId.CIRCLE_THROW);
await game.phaseInterceptor.to("SwitchSummonPhase", false); await game.phaseInterceptor.to("SwitchSummonPhase", false);
expect(wimpod.waveData.abilitiesApplied).not.toContain(AbilityId.WIMP_OUT); expect(wimpod.waveData.abilitiesApplied).not.toContain(AbilityId.WIMP_OUT);
await game.phaseInterceptor.to("TurnEndPhase"); // Force switches directly call `SwitchSummonPhase` to send in a random opponent,
// so wimp out triggering will stall out the test waiting for input
expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(SpeciesId.WIMPOD); await game.toEndOfTurn();
expect(game.field.getPlayerPokemon().species.speciesId).not.toBe(SpeciesId.WIMPOD);
}); });
it("triggers when recoil damage is taken", async () => { it.each<{
game.override.moveset([MoveId.HEAD_SMASH]).enemyMoveset([MoveId.SPLASH]); type: string;
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); playerMove?: MoveId;
playerPassive?: AbilityId;
game.move.select(MoveId.HEAD_SMASH); enemyMove?: MoveId;
game.doSelectPartyPokemon(1); enemyAbility?: AbilityId;
await game.phaseInterceptor.to("TurnEndPhase"); }>([
{ type: "variable recoil moves", playerMove: MoveId.HEAD_SMASH },
confirmSwitch(); { type: "HP-based recoil moves", playerMove: MoveId.CHLOROBLAST },
}); { type: "weather", enemyMove: MoveId.HAIL },
{ type: "status", enemyMove: MoveId.TOXIC },
it("It does not activate when the Pokémon cuts its own HP", async () => { { type: "Ghost-type Curse", enemyMove: MoveId.CURSE },
game.override.moveset([MoveId.SUBSTITUTE]).enemyMoveset([MoveId.SPLASH]); { type: "Salt Cure", enemyMove: MoveId.SALT_CURE },
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); { type: "partial trapping moves", enemyMove: MoveId.WHIRLPOOL }, // no guard passive makes this 100% accurate
{ type: "Leech Seed", enemyMove: MoveId.LEECH_SEED },
const wimpod = game.scene.getPlayerPokemon()!; { type: "Powder", playerMove: MoveId.EMBER, enemyMove: MoveId.POWDER },
wimpod.hp *= 0.52; { type: "Nightmare", playerPassive: AbilityId.COMATOSE, enemyMove: MoveId.NIGHTMARE },
{ type: "Bad Dreams", playerPassive: AbilityId.COMATOSE, enemyAbility: AbilityId.BAD_DREAMS },
game.move.select(MoveId.SUBSTITUTE); ])(
await game.phaseInterceptor.to("TurnEndPhase"); "should activate from damage caused by $type",
async ({
confirmNoSwitch(); playerMove = MoveId.SPLASH,
}); playerPassive = AbilityId.NONE,
enemyMove = MoveId.SPLASH,
it("Does not trigger when neutralized", async () => { enemyAbility = AbilityId.STURDY,
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 () => {
game.override game.override
.moveset([MoveId.DOUBLE_EDGE]) .enemyLevel(1)
.enemyMoveset([MoveId.SPLASH]) .passiveAbility(playerPassive)
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); .enemySpecies(SpeciesId.GASTLY)
.enemyMoveset(enemyMove)
.enemyAbility(enemyAbility);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); 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.use(playerMove);
game.move.select(MoveId.DOUBLE_EDGE);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); await game.toNextTurn();
expect(game.scene.getPlayerParty()[1]).toBe(wimpod); confirmSwitch();
expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2));
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.TYRUNT);
}, },
); );
it("Wimp Out will activate due to weather damage", async () => { it.each<{ name: string; ability: AbilityId }>([
game.override.weather(WeatherType.HAIL).enemyMoveset([MoveId.SPLASH]); { 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]); 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); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); await game.toNextWave();
confirmSwitch(); confirmSwitch();
}); });
it("Does not trigger when enemy has sheer force", async () => { it("should not trigger from Sheer Force-boosted moves", async () => {
game.override.enemyAbility(AbilityId.SHEER_FORCE).enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(95); game.override.enemyAbility(AbilityId.SHEER_FORCE).startingLevel(1);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); 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); game.move.use(MoveId.ENDURE);
await game.phaseInterceptor.to("TurnEndPhase"); await game.move.forceEnemyMove(MoveId.SLUDGE_BOMB);
await game.toEndOfTurn();
confirmNoSwitch(); confirmNoSwitch();
}); });
it("Wimp Out will activate due to post turn status damage", async () => { it("should trigger from Flame Burst splash damage in doubles", async () => {
game.override.statusEffect(StatusEffect.POISON).enemyMoveset([MoveId.SPLASH]); 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]); 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.move.use(MoveId.SUBSTITUTE);
game.doSelectPartyPokemon(1); await game.move.forceEnemyMove(MoveId.TIDY_UP);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn(); 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(); confirmSwitch();
}); });
it("Wimp Out will activate due to bad dreams", async () => { it("should not trigger when neutralized", async () => {
game.override.statusEffect(StatusEffect.SLEEP).enemyAbility(AbilityId.BAD_DREAMS); game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); 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); 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(); confirmSwitch();
}); });
it("Wimp Out will activate due to leech seed", async () => { it("should activate from entry hazard damage", async () => {
game.override.enemyMoveset([MoveId.LEECH_SEED]); // enemy centiscorch switches in... then dies
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 () => {
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY); 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.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY);
game.override game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT);
.passiveAbility(AbilityId.MAGIC_GUARD) await game.classicMode.startBattle([SpeciesId.TYRUNT]);
.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.move.select(MoveId.SPLASH); expect(game.phaseInterceptor.log).not.toContain("MovePhase");
await game.phaseInterceptor.to("TurnEndPhase"); expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
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);
}); });
it("Wimp Out activating should not cancel a double battle", async () => { it("should not switch if Magic Guard prevents damage", async () => {
game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyMoveset([MoveId.SPLASH]).enemyLevel(1); 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]); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
const enemyLeadPokemon = game.scene.getEnemyParty()[0]; const enemyLeadPokemon = game.scene.getEnemyParty()[0];
const enemySecPokemon = game.scene.getEnemyParty()[1]; const enemySecPokemon = game.scene.getEnemyParty()[1];
game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY); game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.SPLASH, 1); game.move.use(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase"); await game.toEndOfTurn();
const isVisibleLead = enemyLeadPokemon.visible; const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.switchOutStatus; const hasFledLead = enemyLeadPokemon.switchOutStatus;
@ -361,145 +424,63 @@ describe("Abilities - Wimp Out", () => {
expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp()); expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp());
}); });
it("Wimp Out will activate due to aftermath", async () => { it.each<{ type: string; move?: MoveId; ability?: AbilityId; items?: ModifierOverride[] }>([
game.override { type: "normal", move: MoveId.DUAL_CHOP },
.moveset([MoveId.THUNDER_PUNCH]) { type: "Parental Bond", ability: AbilityId.PARENTAL_BOND },
.enemySpecies(SpeciesId.MAGIKARP) { type: "Multi Lens", items: [{ name: "MULTI_LENS", count: 1 }] },
.enemyAbility(AbilityId.AFTERMATH) ])(
.enemyMoveset([MoveId.SPLASH]) "should trigger after the last hit of $type multi-strike moves",
.enemyLevel(1); async ({ move = MoveId.TACKLE, ability = AbilityId.COMPOUND_EYES, items = [] }) => {
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); game.override.enemyMoveset(move).enemyAbility(ability).enemyHeldItems(items);
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]);
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); 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.use(MoveId.ENDURE);
game.move.select(MoveId.SWORDS_DANCE); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); 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 () => { it("should not activate from confusion damage", async () => {
game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5850).startingWave(10); game.override.enemyMoveset(MoveId.CONFUSE_RAY).confusionActivation(true);
await game.classicMode.startBattle([SpeciesId.GOLISOPOD]); 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.use(MoveId.SPLASH);
game.move.select(MoveId.FALSE_SWIPE); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); await game.toEndOfTurn();
game.move.select(MoveId.FALSE_SWIPE);
await game.toNextTurn();
const isVisible = enemyPokemon.visible; confirmNoSwitch();
const hasFled = enemyPokemon.switchOutStatus;
expect(isVisible && !hasFled).toBe(true);
}); });
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; const wave = 2;
game.override game.override
.enemyMoveset(MoveId.SPLASH) .enemyMoveset(MoveId.SPLASH)
@ -513,10 +494,10 @@ describe("Abilities - Wimp Out", () => {
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.PIKACHU]); await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.PIKACHU]);
const [wimpod0, wimpod1] = game.scene.getEnemyField(); const [wimpod0, wimpod1] = game.scene.getEnemyField();
game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY); game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
game.move.select(MoveId.MATCHA_GOTCHA, 1); game.move.use(MoveId.MATCHA_GOTCHA, 1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); 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.hp).toBeGreaterThan(0);
expect(wimpod0.switchOutStatus).toBe(true); expect(wimpod0.switchOutStatus).toBe(true);
@ -527,27 +508,24 @@ describe("Abilities - Wimp Out", () => {
expect(game.scene.currentBattle.waveIndex).toBe(wave + 1); 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 () => { it("should not skip battles when triggering the same turn as another enemy faints", async () => {
const wave = 2;
game.override game.override
.enemySpecies(SpeciesId.WIMPOD) .enemySpecies(SpeciesId.WIMPOD)
.enemyAbility(AbilityId.WIMP_OUT) .enemyAbility(AbilityId.WIMP_OUT)
.startingLevel(50) .startingLevel(50)
.enemyLevel(1) .enemyLevel(1)
.enemyMoveset([MoveId.SPLASH, MoveId.ENDURE])
.battleStyle("double") .battleStyle("double")
.moveset([MoveId.DRAGON_ENERGY, MoveId.SPLASH]) .startingWave(2);
.startingWave(wave);
await game.classicMode.startBattle([SpeciesId.REGIDRAGO, SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.REGIDRAGO, SpeciesId.MAGIKARP]);
// turn 1 // turn 1 - 1st wimpod faints while the 2nd one flees
game.move.select(MoveId.DRAGON_ENERGY, 0); game.move.use(MoveId.DRAGON_ENERGY, BattlerIndex.PLAYER);
game.move.select(MoveId.SPLASH, 1); game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.selectEnemyMove(MoveId.SPLASH); await game.move.forceEnemyMove(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.ENDURE); await game.move.forceEnemyMove(MoveId.ENDURE);
await game.phaseInterceptor.to("SelectModifierPhase"); await game.toNextWave();
expect(game.scene.currentBattle.waveIndex).toBe(wave + 1); 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.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1); 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);
// await game.phaseInterceptor.to("SwitchSummonPhase", false); expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
// expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
await game.toEndOfTurn(); 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 leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!;
const enemyStartingHp = enemyPokemon.hp;
game.move.select(MoveId.FOCUS_PUNCH); game.move.select(MoveId.FOCUS_PUNCH);
await game.phaseInterceptor.to(MessagePhase); await game.phaseInterceptor.to(MessagePhase);
expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(enemyPokemon.getInverseHp()).toBe(0);
expect(leadPokemon.getMoveHistory().length).toBe(0); expect(leadPokemon.getMoveHistory().length).toBe(0);
await game.phaseInterceptor.to(BerryPhase, false); await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); expect(enemyPokemon.getInverseHp()).toBe(0);
expect(leadPokemon.getMoveHistory().length).toBe(1); 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 () => { it("should fail if the user is hit", async () => {
@ -72,16 +70,16 @@ describe("Moves - Focus Punch", () => {
game.move.select(MoveId.FOCUS_PUNCH); game.move.select(MoveId.FOCUS_PUNCH);
await game.phaseInterceptor.to(MessagePhase); await game.phaseInterceptor.to("MessagePhase");
expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(enemyPokemon.hp).toBe(enemyStartingHp);
expect(leadPokemon.getMoveHistory().length).toBe(0); expect(leadPokemon.getMoveHistory().length).toBe(0);
await game.phaseInterceptor.to(BerryPhase, false); await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(enemyPokemon.hp).toBe(enemyStartingHp);
expect(leadPokemon.getMoveHistory().length).toBe(1); 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 () => { 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()); expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
// enemy should have taken damage from player's Fiery Dance + 2 Powder procs // enemy should have taken damage from player's Fiery Dance + 2 Powder procs
expect(enemyPokemon.hp).toBe( 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 mode - The mode to wait for.
* @param callback - The callback function to execute on next prompt. * @param callback - The callback function to execute on next prompt.
* @param expireFn - Optional function to determine if the prompt has expired. * @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( onNextPrompt(
phaseTarget: string, phaseTarget: string,