mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-06 07:29:30 +02:00
Merge eed5f0c011
into 5bfcb1d379
This commit is contained in:
commit
dc99017968
@ -810,6 +810,58 @@ export class BattleScene extends SceneBase {
|
||||
return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
|
||||
* but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
|
||||
*
|
||||
* Used for switch out logic checks.
|
||||
* @param pokemon - A {@linkcode Pokemon} on the desired side of the field, used to infer the side and trainer slot (as applicable)
|
||||
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
|
||||
* @overload
|
||||
*/
|
||||
public getBackupPartyMemberIndices(pokemon: Pokemon): number[];
|
||||
/**
|
||||
* Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
|
||||
* but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
|
||||
*
|
||||
* Used for switch out logic checks.
|
||||
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true`
|
||||
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
|
||||
* @overload
|
||||
*/
|
||||
public getBackupPartyMemberIndices(player: true): number[];
|
||||
/**
|
||||
* Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
|
||||
* but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
|
||||
*
|
||||
* Used for switch out logic checks.
|
||||
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true`
|
||||
* @param trainerSlot - The {@linkcode TrainerSlot | trainer slot} to check against for enemy trainers;
|
||||
* used to verify ownership in multi battles
|
||||
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
|
||||
* @overload
|
||||
*/
|
||||
public getBackupPartyMemberIndices(player: false, trainerSlot: TrainerSlot): number[];
|
||||
|
||||
public getBackupPartyMemberIndices(player: boolean | Pokemon, trainerSlot?: number): number[] {
|
||||
// Note: We return the indices instead of the actual Pokemon because `SwitchSummonPhase` and co. take an index instead of a pokemon.
|
||||
// If this is ever changed, this can be replaced with a simpler version involving `filter` and conditional type annotations.
|
||||
if (typeof player === "object") {
|
||||
// Marginally faster than using a ternary
|
||||
trainerSlot = (player as unknown as EnemyPokemon)["trainerSlot"];
|
||||
player = player.isPlayer();
|
||||
}
|
||||
|
||||
const indices: number[] = [];
|
||||
const party = player ? this.getPlayerParty() : this.getEnemyParty();
|
||||
party.forEach((p: PlayerPokemon | EnemyPokemon, i: number) => {
|
||||
if (p.isAllowedInBattle() && !p.isOnField() && (player || (p as EnemyPokemon).trainerSlot === trainerSlot)) {
|
||||
indices.push(i);
|
||||
}
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of Pokemon on both sides of the battle - player first, then enemy.
|
||||
* Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type.
|
||||
@ -836,6 +888,7 @@ export class BattleScene extends SceneBase {
|
||||
if (this.currentBattle.double === false) {
|
||||
return;
|
||||
}
|
||||
// TODO: Remove while loop
|
||||
if (allyPokemon?.isActive(true)) {
|
||||
let targetingMovePhase: MovePhase;
|
||||
do {
|
||||
|
@ -13,6 +13,7 @@ import { getBerryEffectFunc } from "#data/berry";
|
||||
import { allAbilities, allMoves } from "#data/data-lists";
|
||||
import { SpeciesFormChangeAbilityTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
|
||||
import { Gender } from "#data/gender";
|
||||
import { ForceSwitchOutHelper } from "#data/helpers/force-switch";
|
||||
import { getPokeballName } from "#data/pokeball";
|
||||
import { pokemonFormChanges } from "#data/pokemon-forms";
|
||||
import type { PokemonSpecies } from "#data/pokemon-species";
|
||||
@ -22,7 +23,6 @@ import type { Weather } from "#data/weather";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { ArenaTagSide } from "#enums/arena-tag-side";
|
||||
import { ArenaTagType } from "#enums/arena-tag-type";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
@ -42,16 +42,17 @@ import { SpeciesId } from "#enums/species-id";
|
||||
import type { BattleStat, EffectiveStat } from "#enums/stat";
|
||||
import { BATTLE_STATS, EFFECTIVE_STATS, getStatKey, Stat } from "#enums/stat";
|
||||
import { StatusEffect } from "#enums/status-effect";
|
||||
import { SwitchType } from "#enums/switch-type";
|
||||
import { type NormalSwitchType, SwitchType } from "#enums/switch-type";
|
||||
import { WeatherType } from "#enums/weather-type";
|
||||
import { BerryUsedEvent } from "#events/battle-scene";
|
||||
import type { EnemyPokemon, Pokemon } from "#field/pokemon";
|
||||
import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "#modifiers/modifier";
|
||||
import type { Pokemon } from "#field/pokemon";
|
||||
import { BerryModifier, PokemonHeldItemModifier } from "#modifiers/modifier";
|
||||
import { BerryModifierType } from "#modifiers/modifier-type";
|
||||
import { applyMoveAttrs } from "#moves/apply-attrs";
|
||||
import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves";
|
||||
import type { Move } from "#moves/move";
|
||||
import type { PokemonMove } from "#moves/pokemon-move";
|
||||
import type { MoveEffectPhase } from "#phases/move-effect-phase";
|
||||
import type { StatStageChangePhase } from "#phases/stat-stage-change-phase";
|
||||
import type {
|
||||
AbAttrCondition,
|
||||
@ -3254,9 +3255,9 @@ export class CommanderAbAttr extends AbAttr {
|
||||
const ally = pokemon.getAlly();
|
||||
return (
|
||||
globalScene.currentBattle?.double &&
|
||||
!isNullOrUndefined(ally) &&
|
||||
ally.species.speciesId === SpeciesId.DONDOZO &&
|
||||
!(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED))
|
||||
ally?.species.speciesId === SpeciesId.DONDOZO &&
|
||||
!ally.isFainted() &&
|
||||
!ally.getTag(BattlerTagType.COMMANDED)
|
||||
);
|
||||
}
|
||||
|
||||
@ -4159,7 +4160,7 @@ export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr {
|
||||
/**
|
||||
* Condition function to applied to abilities related to Sheer Force.
|
||||
* Checks if last move used against target was affected by a Sheer Force user and:
|
||||
* Disables: Color Change, Pickpocket, Berserk, Anger Shell
|
||||
* Disables: Color Change, Pickpocket, Berserk, Anger Shell, Wimp Out and Emergency Exit.
|
||||
* @returns An {@linkcode AbAttrCondition} to disable the ability under the proper conditions.
|
||||
*/
|
||||
function getSheerForceHitDisableAbCondition(): AbAttrCondition {
|
||||
@ -6229,187 +6230,13 @@ export class TerrainEventTypeChangeAbAttr extends PostSummonAbAttr {
|
||||
}
|
||||
}
|
||||
|
||||
class ForceSwitchOutHelper {
|
||||
constructor(private switchType: SwitchType) {}
|
||||
|
||||
/**
|
||||
* Handles the logic for switching out a Pokémon based on battle conditions, HP, and the switch type.
|
||||
*
|
||||
* @param pokemon The {@linkcode Pokemon} attempting to switch out.
|
||||
* @returns `true` if the switch is successful
|
||||
*/
|
||||
// TODO: Make this cancel pending move phases on the switched out target
|
||||
public switchOutLogic(pokemon: Pokemon): boolean {
|
||||
const switchOutTarget = pokemon;
|
||||
/**
|
||||
* If the switch-out target is a player-controlled Pokémon, the function checks:
|
||||
* - Whether there are available party members to switch in.
|
||||
* - If the Pokémon is still alive (hp > 0), and if so, it leaves the field and a new SwitchPhase is initiated.
|
||||
*/
|
||||
if (switchOutTarget.isPlayer()) {
|
||||
if (globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isOnField()).length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (switchOutTarget.hp > 0) {
|
||||
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
|
||||
globalScene.phaseManager.prependNewToPhase(
|
||||
"MoveEndPhase",
|
||||
"SwitchPhase",
|
||||
this.switchType,
|
||||
switchOutTarget.getFieldIndex(),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* For non-wild battles, it checks if the opposing party has any available Pokémon to switch in.
|
||||
* If yes, the Pokémon leaves the field and a new SwitchSummonPhase is initiated.
|
||||
*/
|
||||
} else if (globalScene.currentBattle.battleType !== BattleType.WILD) {
|
||||
if (globalScene.getEnemyParty().filter(p => p.isAllowedInBattle() && !p.isOnField()).length < 1) {
|
||||
return false;
|
||||
}
|
||||
if (switchOutTarget.hp > 0) {
|
||||
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
|
||||
const summonIndex = globalScene.currentBattle.trainer
|
||||
? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot)
|
||||
: 0;
|
||||
globalScene.phaseManager.prependNewToPhase(
|
||||
"MoveEndPhase",
|
||||
"SwitchSummonPhase",
|
||||
this.switchType,
|
||||
switchOutTarget.getFieldIndex(),
|
||||
summonIndex,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* For wild Pokémon battles, the Pokémon will flee if the conditions are met (waveIndex and double battles).
|
||||
* It will not flee if it is a Mystery Encounter with fleeing disabled (checked in `getSwitchOutCondition()`) or if it is a wave 10x wild boss
|
||||
*/
|
||||
} else {
|
||||
const allyPokemon = switchOutTarget.getAlly();
|
||||
|
||||
if (!globalScene.currentBattle.waveIndex || globalScene.currentBattle.waveIndex % 10 === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (switchOutTarget.hp > 0) {
|
||||
switchOutTarget.leaveField(false);
|
||||
globalScene.phaseManager.queueMessage(
|
||||
i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }),
|
||||
null,
|
||||
true,
|
||||
500,
|
||||
);
|
||||
if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) {
|
||||
globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allyPokemon?.isActive(true)) {
|
||||
globalScene.clearEnemyHeldItemModifiers();
|
||||
|
||||
if (switchOutTarget.hp) {
|
||||
globalScene.phaseManager.pushNew("BattleEndPhase", false);
|
||||
|
||||
if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) {
|
||||
globalScene.phaseManager.pushNew("SelectBiomePhase");
|
||||
}
|
||||
|
||||
globalScene.phaseManager.pushNew("NewBattlePhase");
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a Pokémon can switch out based on its status, the opponent's status, and battle conditions.
|
||||
*
|
||||
* @param pokemon The Pokémon attempting to switch out.
|
||||
* @param opponent The opponent Pokémon.
|
||||
* @returns `true` if the switch-out condition is met
|
||||
*/
|
||||
public getSwitchOutCondition(pokemon: Pokemon, opponent: Pokemon): boolean {
|
||||
const switchOutTarget = pokemon;
|
||||
const player = switchOutTarget.isPlayer();
|
||||
|
||||
if (player) {
|
||||
const blockedByAbility = new BooleanHolder(false);
|
||||
applyAbAttrs("ForceSwitchOutImmunityAbAttr", { pokemon: opponent, cancelled: blockedByAbility });
|
||||
return !blockedByAbility.value;
|
||||
}
|
||||
|
||||
if (!player && globalScene.currentBattle.battleType === BattleType.WILD) {
|
||||
if (!globalScene.currentBattle.waveIndex && globalScene.currentBattle.waveIndex % 10 === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!player &&
|
||||
globalScene.currentBattle.isBattleMysteryEncounter() &&
|
||||
!globalScene.currentBattle.mysteryEncounter?.fleeAllowed
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
|
||||
return (
|
||||
(!player && globalScene.currentBattle.battleType === BattleType.WILD) ||
|
||||
party.filter(
|
||||
p =>
|
||||
p.isAllowedInBattle() &&
|
||||
!p.isOnField() &&
|
||||
(player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot),
|
||||
).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a message if the switch-out attempt fails due to ability effects.
|
||||
*
|
||||
* @param target The target Pokémon.
|
||||
* @returns The failure message, or `null` if no failure.
|
||||
*/
|
||||
public getFailedText(target: Pokemon): string | null {
|
||||
const blockedByAbility = new BooleanHolder(false);
|
||||
applyAbAttrs("ForceSwitchOutImmunityAbAttr", { pokemon: target, cancelled: blockedByAbility });
|
||||
return blockedByAbility.value
|
||||
? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) })
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the amount of recovery from the Shell Bell item.
|
||||
*
|
||||
* If the Pokémon is holding a Shell Bell, this function computes the amount of health
|
||||
* recovered based on the damage dealt in the current turn. The recovery is multiplied by the
|
||||
* Shell Bell's modifier (if any).
|
||||
*
|
||||
* @param pokemon - The Pokémon whose Shell Bell recovery is being calculated.
|
||||
* @returns The amount of health recovered by Shell Bell.
|
||||
*/
|
||||
function calculateShellBellRecovery(pokemon: Pokemon): number {
|
||||
const shellBellModifier = pokemon.getHeldItems().find(m => m instanceof HitHealModifier);
|
||||
if (shellBellModifier) {
|
||||
return toDmgValue(pokemon.turnData.totalDamageDealt / 8) * shellBellModifier.stackCount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface PostDamageAbAttrParams extends AbAttrBaseParams {
|
||||
/** The pokemon that caused the damage; omitted if the damage was not from a pokemon */
|
||||
source?: Pokemon;
|
||||
readonly source?: Pokemon;
|
||||
/** The amount of damage that was dealt */
|
||||
readonly damage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers after the Pokemon takes any damage
|
||||
*/
|
||||
@ -6426,83 +6253,101 @@ export class PostDamageAbAttr extends AbAttr {
|
||||
* This attribute checks various conditions related to the damage received, the moves used by the Pokémon
|
||||
* and its opponents, and determines whether a forced switch-out should occur.
|
||||
*
|
||||
* Used by Wimp Out and Emergency Exit
|
||||
*
|
||||
* @see {@linkcode applyPostDamage}
|
||||
* @sealed
|
||||
* Used for {@linkcode AbilityId.WIMP_OUT} and {@linkcode AbilityId.EMERGENCY_EXIT}.
|
||||
*/
|
||||
export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr {
|
||||
private helper: ForceSwitchOutHelper = new ForceSwitchOutHelper(SwitchType.SWITCH);
|
||||
private hpRatio: number;
|
||||
private helper: ForceSwitchOutHelper;
|
||||
|
||||
constructor(hpRatio = 0.5) {
|
||||
constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) {
|
||||
super();
|
||||
this.hpRatio = hpRatio;
|
||||
this.helper = new ForceSwitchOutHelper({ selfSwitch: true, switchType, allowFlee: true });
|
||||
// TODO: change if any force switch abilities with red card-like effects are added
|
||||
}
|
||||
|
||||
// TODO: Refactor to use more early returns
|
||||
/**
|
||||
* Check to see if the user should be switched out after taking damage.
|
||||
* @param pokemon - The {@linkcode Pokemon} with this ability; will be switched out if conditions are met
|
||||
* @param damage - The amount of damage dealt by the triggering damage instance
|
||||
* @param source - The {@linkcode Pokemon} having damaged the user with an attack, or `undefined`
|
||||
* if the damage source was indirect
|
||||
* @returns Whether `pokemon` should be switched out upon move conclusion.
|
||||
*/
|
||||
public override canApply({ pokemon, source, damage }: PostDamageAbAttrParams): boolean {
|
||||
const moveHistory = pokemon.getMoveHistory();
|
||||
// Will not activate when the Pokémon's HP is lowered by cutting its own HP
|
||||
const fordbiddenAttackingMoves = [MoveId.BELLY_DRUM, MoveId.SUBSTITUTE, MoveId.CURSE, MoveId.PAIN_SPLIT];
|
||||
if (moveHistory.length > 0) {
|
||||
const lastMoveUsed = moveHistory[moveHistory.length - 1];
|
||||
if (fordbiddenAttackingMoves.includes(lastMoveUsed.move)) {
|
||||
return false;
|
||||
}
|
||||
// Skip move checks for damage not occurring due to a move (eg: hazards)
|
||||
const currentPhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (currentPhase?.is("MoveEffectPhase") && !this.passesMoveChecks(source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.
|
||||
const fordbiddenDefendingMoves = [MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW];
|
||||
if (source) {
|
||||
const enemyMoveHistory = source.getMoveHistory();
|
||||
if (enemyMoveHistory.length > 0) {
|
||||
const enemyLastMoveUsed = enemyMoveHistory[enemyMoveHistory.length - 1];
|
||||
// Will not activate if the Pokémon's HP falls below half while it is in the air during Sky Drop.
|
||||
if (
|
||||
fordbiddenDefendingMoves.includes(enemyLastMoveUsed.move) ||
|
||||
(enemyLastMoveUsed.move === MoveId.SKY_DROP && enemyLastMoveUsed.result === MoveResult.OTHER)
|
||||
) {
|
||||
return false;
|
||||
// Will not activate if the Pokémon's HP falls below half by a move affected by Sheer Force.
|
||||
// TODO: Make this use the sheer force disable condition
|
||||
}
|
||||
if (allMoves[enemyLastMoveUsed.move].chance >= 0 && source.hasAbility(AbilityId.SHEER_FORCE)) {
|
||||
return false;
|
||||
}
|
||||
// Activate only after the last hit of multistrike moves
|
||||
if (source.turnData.hitsLeft > 1) {
|
||||
return false;
|
||||
}
|
||||
if (source.turnData.hitCount > 1) {
|
||||
damage = pokemon.turnData.damageTaken;
|
||||
}
|
||||
}
|
||||
if (!this.wasKnockedBelowHalf(pokemon, damage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pokemon.hp + damage >= pokemon.getMaxHp() * this.hpRatio) {
|
||||
const shellBellHeal = calculateShellBellRecovery(pokemon);
|
||||
if (pokemon.hp - shellBellHeal < pokemon.getMaxHp() * this.hpRatio) {
|
||||
for (const opponent of pokemon.getOpponents()) {
|
||||
if (!this.helper.getSwitchOutCondition(pokemon, opponent)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return this.helper.canSwitchOut(pokemon);
|
||||
}
|
||||
|
||||
return false;
|
||||
/**
|
||||
* Perform move checks to determine if this pokemon should switch out.
|
||||
* @param source - The {@linkcode Pokemon} whose attack caused the user to switch out,
|
||||
* or `undefined` if the damage source was indirect.
|
||||
* @returns `true` if this Pokemon should be allowed to switch out.
|
||||
*/
|
||||
private passesMoveChecks(source: Pokemon | undefined): boolean {
|
||||
// Wimp Out and Emergency Exit...
|
||||
const currentPhase = globalScene.phaseManager.getCurrentPhase() as MoveEffectPhase;
|
||||
const currentMove = currentPhase.move;
|
||||
|
||||
// will not activate from self-induced HP cutting,
|
||||
// TODO: Verify that Fillet Away and Clangorous Soul do not proc wimp out
|
||||
const hpCutMoves = new Set<MoveId>([
|
||||
MoveId.CURSE,
|
||||
MoveId.BELLY_DRUM,
|
||||
MoveId.SUBSTITUTE,
|
||||
MoveId.PAIN_SPLIT,
|
||||
MoveId.CLANGOROUS_SOUL,
|
||||
MoveId.FILLET_AWAY,
|
||||
]);
|
||||
// NB: Given this attribute is only applied after _taking damage_ or recieving a damaging attack,
|
||||
// a failed Substitute or non-Ghost type Curse will not trigger this code.
|
||||
const notHpCut = !hpCutMoves.has(currentMove.id);
|
||||
|
||||
// will not activate for forced switch moves (which trigger before wimp out activates),
|
||||
const notForceSwitched = ![MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW].includes(currentMove.id);
|
||||
|
||||
// and will not activate if the Pokemon is currently in the air from Sky Drop.
|
||||
// TODO: Make this check the user's tags and move to main `canApply` block once Sky Drop is fully implemented -
|
||||
// we could be sky dropped by another Pokemon or take indirect damage while skybound (both of which render this check useless)
|
||||
const lastMove = source?.getLastXMoves()[0];
|
||||
const notSkyDropped = !(lastMove?.move === MoveId.SKY_DROP && lastMove.result === MoveResult.OTHER);
|
||||
|
||||
return notHpCut && notForceSwitched && notSkyDropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform HP checks to determine if this pokemon should switch out.
|
||||
* The switch fails if the pokemon was below {@linkcode hpRatio} before being hit
|
||||
* or is still above it after the hit.
|
||||
* @param pokemon - The {@linkcode Pokemon} with this ability
|
||||
* @param damage - The amount of damage taken
|
||||
* @returns Whether the Pokemon was knocked below half after `damage` was applied
|
||||
*/
|
||||
private wasKnockedBelowHalf(pokemon: Pokemon, damage: number) {
|
||||
// NB: This occurs _after_ the damage instance has been dealt,
|
||||
// so `pokemon.hp` contains the post-taking damage hp value.
|
||||
const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio;
|
||||
return pokemon.hp < hpNeededToSwitch && pokemon.hp + damage >= hpNeededToSwitch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the switch-out logic after the Pokémon takes damage.
|
||||
* Checks various conditions based on the moves used by the Pokémon, the opponents' moves, and
|
||||
* the Pokémon's health after damage to determine whether the switch-out should occur.
|
||||
*/
|
||||
public override apply({ pokemon }: PostDamageAbAttrParams): void {
|
||||
// TODO: Consider respecting the `simulated` flag here
|
||||
this.helper.switchOutLogic(pokemon);
|
||||
public override apply({ pokemon, simulated }: PostDamageAbAttrParams): void {
|
||||
if (simulated) {
|
||||
return;
|
||||
}
|
||||
this.helper.doSwitch(pokemon);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7385,10 +7230,10 @@ export function initAbilities() {
|
||||
.attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
|
||||
new Ability(AbilityId.WIMP_OUT, 7)
|
||||
.attr(PostDamageForceSwitchAbAttr)
|
||||
.edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode
|
||||
.condition(getSheerForceHitDisableAbCondition()),
|
||||
new Ability(AbilityId.EMERGENCY_EXIT, 7)
|
||||
.attr(PostDamageForceSwitchAbAttr)
|
||||
.edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode
|
||||
.condition(getSheerForceHitDisableAbCondition()),
|
||||
new Ability(AbilityId.WATER_COMPACTION, 7)
|
||||
.attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
|
||||
new Ability(AbilityId.MERCILESS, 7)
|
||||
|
@ -813,10 +813,16 @@ export class ConfusedTag extends SerializableBattlerTag {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick down this Pokemon's confusion duration, randomly interrupting its move if not cured.
|
||||
* @param pokemon - The {@linkcode Pokemon} with this tag
|
||||
* @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} triggering this tag's effects.
|
||||
* @returns Whether the tag should be kept.
|
||||
*/
|
||||
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
|
||||
const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType);
|
||||
const shouldRemain = super.lapse(pokemon, lapseType);
|
||||
|
||||
if (!shouldLapse) {
|
||||
if (!shouldRemain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -830,7 +836,9 @@ export class ConfusedTag extends SerializableBattlerTag {
|
||||
phaseManager.unshiftNew("CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION);
|
||||
|
||||
// 1/3 chance of hitting self with a 40 base power move
|
||||
if (pokemon.randBattleSeedInt(3) === 0 || Overrides.CONFUSION_ACTIVATION_OVERRIDE === true) {
|
||||
const shouldInterruptMove = Overrides.CONFUSION_ACTIVATION_OVERRIDE ?? pokemon.randBattleSeedInt(3) === 0;
|
||||
if (shouldInterruptMove) {
|
||||
// TODO: Are these calculations correct? We probably shouldn't hardcode the damage formula here...
|
||||
const atk = pokemon.getEffectiveStat(Stat.ATK);
|
||||
const def = pokemon.getEffectiveStat(Stat.DEF);
|
||||
const damage = toDmgValue(
|
||||
|
221
src/data/helpers/force-switch.ts
Normal file
221
src/data/helpers/force-switch.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -25,6 +25,7 @@ import { getBerryEffectFunc } from "#data/berry";
|
||||
import { applyChallenges } from "#data/challenge";
|
||||
import { allAbilities, allMoves } from "#data/data-lists";
|
||||
import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
|
||||
import { ForceSwitchOutHelper, ForceSwitchOutHelperArgs } from "#data/helpers/force-switch";
|
||||
import { DelayedAttackTag } from "#data/positional-tags/positional-tag";
|
||||
import {
|
||||
getNonVolatileStatusEffects,
|
||||
@ -1303,10 +1304,12 @@ export class MoveEffectAttr extends MoveAttr {
|
||||
* @param user {@linkcode Pokemon} using the move
|
||||
* @param target {@linkcode Pokemon} target of the move
|
||||
* @param move {@linkcode Move} with this attribute
|
||||
* @param args Set of unique arguments needed by this attribute
|
||||
* @returns true if basic application of the ability attribute should be possible
|
||||
* @param args - Any unique arguments needed by this attribute
|
||||
* @returns `true` if basic application of the ability attribute should be possible.
|
||||
* By default, checks that the target is not fainted and (for non self-targeting moves) not protected by an effect.
|
||||
*/
|
||||
canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) {
|
||||
// TODO: why do we check frenzy tag here?
|
||||
return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp)
|
||||
&& (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) ||
|
||||
move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target }));
|
||||
@ -1628,6 +1631,7 @@ export class MatchHpAttr extends FixedDamageAttr {
|
||||
|
||||
type MoveFilter = (move: Move) => boolean;
|
||||
|
||||
// TODO: fix this to check the last direct damage instance taken
|
||||
export class CounterDamageAttr extends FixedDamageAttr {
|
||||
private moveFilter: MoveFilter;
|
||||
private multiplier: number;
|
||||
@ -1709,6 +1713,7 @@ export class CelebrateAttr extends MoveEffectAttr {
|
||||
}
|
||||
|
||||
export class RecoilAttr extends MoveEffectAttr {
|
||||
/** Whether the recoil damage should use the user's max HP (`true`) or damage dealt `false`. */
|
||||
private useHp: boolean;
|
||||
private damageRatio: number;
|
||||
private unblockable: boolean;
|
||||
@ -1742,8 +1747,8 @@ export class RecoilAttr extends MoveEffectAttr {
|
||||
return false;
|
||||
}
|
||||
|
||||
const damageValue = (!this.useHp ? user.turnData.totalDamageDealt : user.getMaxHp()) * this.damageRatio;
|
||||
const minValue = user.turnData.totalDamageDealt ? 1 : 0;
|
||||
const damageValue = (!this.useHp ? user.turnData.singleHitDamageDealt : user.getMaxHp()) * this.damageRatio;
|
||||
const minValue = user.turnData.singleHitDamageDealt ? 1 : 0;
|
||||
const recoilDamage = toDmgValue(damageValue, minValue);
|
||||
if (!recoilDamage) {
|
||||
return false;
|
||||
@ -1896,8 +1901,8 @@ export class AddSubstituteAttr extends MoveEffectAttr {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user
|
||||
* @param user - The {@linkcode Pokemon} that used the move.
|
||||
* Removes a fraction of the user's maximum HP to create a substitute.
|
||||
* @param user - The {@linkcode Pokemon} using the move.
|
||||
* @param target - n/a
|
||||
* @param move - The {@linkcode Move} with this attribute.
|
||||
* @param args - n/a
|
||||
@ -1908,7 +1913,7 @@ export class AddSubstituteAttr extends MoveEffectAttr {
|
||||
return false;
|
||||
}
|
||||
|
||||
const damageTaken = this.roundUp ? Math.ceil(user.getMaxHp() * this.hpCost) : Math.floor(user.getMaxHp() * this.hpCost);
|
||||
const damageTaken = (this.roundUp ? Math.ceil : Math.floor)(user.getMaxHp() * this.hpCost);
|
||||
user.damageAndUpdate(damageTaken, { result: HitResult.INDIRECT, ignoreSegments: true, ignoreFaintPhase: true });
|
||||
user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id);
|
||||
return true;
|
||||
@ -2078,6 +2083,13 @@ export class FlameBurstAttr extends MoveEffectAttr {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute to KO the user while fully restoring HP/status of the next switched in Pokemon.
|
||||
*
|
||||
* Used for {@linkcode Moves.HEALING_WISH} and {@linkcode Moves.LUNAR_DANCE}.
|
||||
* TODO: Implement "heal storing" if switched in pokemon is at full HP (likely with an end-of-turn ArenaTag).
|
||||
* Will likely be blocked by the need for a "slot dependent ArenaTag" similar to Future Sight
|
||||
*/
|
||||
export class SacrificialFullRestoreAttr extends SacrificialAttr {
|
||||
protected restorePP: boolean;
|
||||
protected moveMessage: string;
|
||||
@ -2095,8 +2107,8 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
|
||||
}
|
||||
|
||||
// We don't know which party member will be chosen, so pick the highest max HP in the party
|
||||
const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
|
||||
const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0);
|
||||
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
|
||||
const maxPartyMemberHp = Math.max(...party.map(p => p.getMaxHp()));
|
||||
|
||||
const pm = globalScene.phaseManager;
|
||||
|
||||
@ -2121,7 +2133,10 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
|
||||
}
|
||||
|
||||
getCondition(): MoveConditionFunc {
|
||||
return (user, _target, _move) => globalScene.getPlayerParty().filter(p => p.isActive()).length > globalScene.currentBattle.getBattlerCount();
|
||||
return (user) => {
|
||||
const otherPartyIndices = globalScene.getBackupPartyMemberIndices(user)
|
||||
return otherPartyIndices.length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6339,168 +6354,61 @@ export class RevivalBlessingAttr extends MoveEffectAttr {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attribute to forcibly switch out the user or target of a Move.
|
||||
*/
|
||||
// TODO: Add locales for forced recall moves
|
||||
export class ForceSwitchOutAttr extends MoveEffectAttr {
|
||||
constructor(
|
||||
private selfSwitch: boolean = false,
|
||||
private switchType: SwitchType = SwitchType.SWITCH
|
||||
) {
|
||||
private readonly helper: ForceSwitchOutHelper;
|
||||
|
||||
constructor(args: ForceSwitchOutHelperArgs) {
|
||||
super(false, { lastHitOnly: true });
|
||||
this.helper = new ForceSwitchOutHelper(args);
|
||||
}
|
||||
|
||||
isBatonPass() {
|
||||
return this.switchType === SwitchType.BATON_PASS;
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
|
||||
if (!super.apply(user, target, move, _args)) {
|
||||
return false;
|
||||
};
|
||||
|
||||
this.helper.doSwitch(this.helper.selfSwitch ? user : target)
|
||||
return true;
|
||||
}
|
||||
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
// Check if the move category is not STATUS or if the switch out condition is not met
|
||||
if (!this.getSwitchOutCondition()(user, target, move)) {
|
||||
/**
|
||||
* Check whether the target can be switched out.
|
||||
*/
|
||||
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]) {
|
||||
if (!super.canApply(user, target, _move, _args)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The {@linkcode Pokemon} to be switched out with this effect */
|
||||
const switchOutTarget = this.selfSwitch ? user : target;
|
||||
const switchOutTarget = this.helper.selfSwitch ? user : target;
|
||||
|
||||
// If the switch-out target is a Dondozo with a Tatsugiri in its mouth
|
||||
// (e.g. when it uses Flip Turn), make it spit out the Tatsugiri before switching out.
|
||||
switchOutTarget.lapseTag(BattlerTagType.COMMANDED);
|
||||
|
||||
if (switchOutTarget.isPlayer()) {
|
||||
/**
|
||||
* Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch
|
||||
* If it did, the user of U-turn or Volt Switch will not be switched out.
|
||||
*/
|
||||
if (target.getAbility().hasAttr("PostDamageForceSwitchAbAttr")
|
||||
&& [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id)
|
||||
// Check for Wimp Out edge case - self-switching moves cannot proc if the attack also triggers Wimp Out/EE
|
||||
// TODO: This can be improved with a move in flight global object
|
||||
const moveDmgDealt = user.turnData.lastMoveDamageDealt[target.getBattlerIndex()]
|
||||
if (
|
||||
this.helper.selfSwitch
|
||||
&& moveDmgDealt
|
||||
&& target.getAbilityAttrs("PostDamageForceSwitchAbAttr").some(
|
||||
p => p.canApply({pokemon: target, damage: moveDmgDealt, simulated: false, source: user}))
|
||||
) {
|
||||
if (this.hpDroppedBelowHalf(target)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Find indices of off-field Pokemon that are eligible to be switched into
|
||||
const eligibleNewIndices: number[] = [];
|
||||
globalScene.getPlayerParty().forEach((pokemon, index) => {
|
||||
if (pokemon.isAllowedInBattle() && !pokemon.isOnField()) {
|
||||
eligibleNewIndices.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
if (eligibleNewIndices.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (switchOutTarget.hp > 0) {
|
||||
if (this.switchType === SwitchType.FORCE_SWITCH) {
|
||||
switchOutTarget.leaveField(true);
|
||||
const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)];
|
||||
globalScene.phaseManager.prependNewToPhase(
|
||||
"MoveEndPhase",
|
||||
"SwitchSummonPhase",
|
||||
this.switchType,
|
||||
switchOutTarget.getFieldIndex(),
|
||||
slotIndex,
|
||||
false,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
|
||||
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
|
||||
"SwitchPhase",
|
||||
this.switchType,
|
||||
switchOutTarget.getFieldIndex(),
|
||||
true,
|
||||
true
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else if (globalScene.currentBattle.battleType !== BattleType.WILD) { // Switch out logic for enemy trainers
|
||||
// Find indices of off-field Pokemon that are eligible to be switched into
|
||||
const isPartnerTrainer = globalScene.currentBattle.trainer?.isPartner();
|
||||
const eligibleNewIndices: number[] = [];
|
||||
globalScene.getEnemyParty().forEach((pokemon, index) => {
|
||||
if (pokemon.isAllowedInBattle() && !pokemon.isOnField() && (!isPartnerTrainer || pokemon.trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)) {
|
||||
eligibleNewIndices.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
if (eligibleNewIndices.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (switchOutTarget.hp > 0) {
|
||||
if (this.switchType === SwitchType.FORCE_SWITCH) {
|
||||
switchOutTarget.leaveField(true);
|
||||
const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)];
|
||||
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
|
||||
"SwitchSummonPhase",
|
||||
this.switchType,
|
||||
switchOutTarget.getFieldIndex(),
|
||||
slotIndex,
|
||||
false,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH);
|
||||
globalScene.phaseManager.prependNewToPhase("MoveEndPhase",
|
||||
"SwitchSummonPhase",
|
||||
this.switchType,
|
||||
switchOutTarget.getFieldIndex(),
|
||||
(globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0),
|
||||
false,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
} else { // Switch out logic for wild pokemon
|
||||
/**
|
||||
* Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch
|
||||
* If it did, the user of U-turn or Volt Switch will not be switched out.
|
||||
*/
|
||||
if (target.getAbility().hasAttr("PostDamageForceSwitchAbAttr")
|
||||
&& [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id)
|
||||
) {
|
||||
if (this.hpDroppedBelowHalf(target)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const allyPokemon = switchOutTarget.getAlly();
|
||||
|
||||
if (switchOutTarget.hp > 0) {
|
||||
switchOutTarget.leaveField(false);
|
||||
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500);
|
||||
|
||||
// in double battles redirect potential moves off fled pokemon
|
||||
if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) {
|
||||
globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon);
|
||||
}
|
||||
}
|
||||
|
||||
// clear out enemy held item modifiers of the switch out target
|
||||
globalScene.clearEnemyHeldItemModifiers(switchOutTarget);
|
||||
|
||||
if (!allyPokemon?.isActive(true) && switchOutTarget.hp) {
|
||||
globalScene.phaseManager.pushNew("BattleEndPhase", false);
|
||||
|
||||
if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) {
|
||||
globalScene.phaseManager.pushNew("SelectBiomePhase");
|
||||
}
|
||||
|
||||
globalScene.phaseManager.pushNew("NewBattlePhase");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return this.helper.canSwitchOut(switchOutTarget)
|
||||
}
|
||||
|
||||
getCondition(): MoveConditionFunc {
|
||||
return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move));
|
||||
// Damaging switch moves and ones w/o a secondary effect do not "fail"
|
||||
// upon an unsuccessful switch - they still succeed and perform secondary effects
|
||||
// (just without actually switching out).
|
||||
// TODO: Remove attr check once move attribute application is cleaned up
|
||||
return (user, target, move) => (move.category !== MoveCategory.STATUS || move.attrs.length > 1 || this.canApply(user, target, move, []));
|
||||
}
|
||||
|
||||
getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined {
|
||||
getFailedText(_user: Pokemon, target: Pokemon): string | undefined {
|
||||
const cancelled = new BooleanHolder(false);
|
||||
applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled});
|
||||
if (cancelled.value) {
|
||||
@ -6508,86 +6416,24 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getSwitchOutCondition(): MoveConditionFunc {
|
||||
return (user, target, move) => {
|
||||
const switchOutTarget = (this.selfSwitch ? user : target);
|
||||
const player = switchOutTarget.isPlayer();
|
||||
const forceSwitchAttr = move.getAttrs("ForceSwitchOutAttr").find(attr => attr.switchType === SwitchType.FORCE_SWITCH);
|
||||
|
||||
if (!this.selfSwitch) {
|
||||
if (move.hitsSubstitute(user, target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the move is Roar or Whirlwind and if there is a trainer with only Pokémon left.
|
||||
if (forceSwitchAttr && globalScene.currentBattle.trainer) {
|
||||
const enemyParty = globalScene.getEnemyParty();
|
||||
// Filter out any Pokémon that are not allowed in battle (e.g. fainted ones)
|
||||
const remainingPokemon = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle());
|
||||
if (remainingPokemon.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Dondozo with an allied Tatsugiri in its mouth cannot be forced out
|
||||
const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED);
|
||||
if (commandedTag?.getSourcePokemon()?.isActive(true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!player && globalScene.currentBattle.isBattleMysteryEncounter() && !globalScene.currentBattle.mysteryEncounter?.fleeAllowed) {
|
||||
// Don't allow wild opponents to be force switched during MEs with flee disabled
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockedByAbility = new BooleanHolder(false);
|
||||
applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled: blockedByAbility});
|
||||
if (blockedByAbility.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!player && globalScene.currentBattle.battleType === BattleType.WILD) {
|
||||
// wild pokemon cannot switch out with baton pass.
|
||||
return !this.isBatonPass()
|
||||
&& globalScene.currentBattle.waveIndex % 10 !== 0
|
||||
// Don't allow wild mons to flee with U-turn et al.
|
||||
&& !(this.selfSwitch && MoveCategory.STATUS !== move.category);
|
||||
}
|
||||
|
||||
const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
|
||||
return party.filter(p => p.isAllowedInBattle() && !p.isOnField()
|
||||
&& (player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)).length > 0;
|
||||
};
|
||||
}
|
||||
|
||||
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
|
||||
if (!globalScene.getEnemyParty().find(p => p.isActive() && !p.isOnField())) {
|
||||
const switchOutTarget = this.helper.selfSwitch ? user : target;
|
||||
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(switchOutTarget)
|
||||
if (reservePartyMembers.length === 0) {
|
||||
return -20;
|
||||
}
|
||||
let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
|
||||
if (this.selfSwitch && this.isBatonPass()) {
|
||||
const statStageTotal = user.getStatStages().reduce((s: number, total: number) => total += s, 0);
|
||||
|
||||
let ret = this.helper.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
|
||||
if (this.helper.selfSwitch && this.isBatonPass()) {
|
||||
const statStageTotal = user.getStatStages().reduce((total, s) => total + s, 0);
|
||||
// TODO: Why do we use a sine tween?
|
||||
ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10));
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if the Pokémon's health is below half after taking damage.
|
||||
* Used for an edge case interaction with Wimp Out/Emergency Exit.
|
||||
* If the Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out.
|
||||
*/
|
||||
hpDroppedBelowHalf(target: Pokemon): boolean {
|
||||
const pokemonHealth = target.hp;
|
||||
const maxPokemonHealth = target.getMaxHp();
|
||||
const damageTaken = target.turnData.damageTaken;
|
||||
const initialHealth = pokemonHealth + damageTaken;
|
||||
|
||||
// Check if the Pokémon's health has dropped below half after the damage
|
||||
return initialHealth >= maxPokemonHealth / 2 && pokemonHealth < maxPokemonHealth / 2;
|
||||
public isBatonPass(): boolean {
|
||||
return this.helper.switchType === SwitchType.BATON_PASS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6598,10 +6444,12 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr {
|
||||
}
|
||||
|
||||
getCondition(): MoveConditionFunc {
|
||||
// chilly reception move will go through if the weather is change-able to snow, or the user can switch out, else move will fail
|
||||
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move);
|
||||
// chilly reception will succeed if the weather is changeable to snow OR the user can be switched out,
|
||||
// only failing if neither is the case.
|
||||
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getCondition()(user, target, move);
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveTypeAttr extends MoveEffectAttr {
|
||||
|
||||
private removedType: PokemonType;
|
||||
@ -8069,11 +7917,6 @@ const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target
|
||||
|
||||
const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined;
|
||||
|
||||
const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => {
|
||||
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
|
||||
return party.some(pokemon => pokemon.isActive() && !pokemon.isOnField());
|
||||
};
|
||||
|
||||
const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => !target.isOfType(PokemonType.GHOST);
|
||||
|
||||
const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0;
|
||||
@ -8540,7 +8383,7 @@ export function initMoves() {
|
||||
.windMove(),
|
||||
new AttackMove(MoveId.WING_ATTACK, PokemonType.FLYING, MoveCategory.PHYSICAL, 60, 100, 35, -1, 0, 1),
|
||||
new StatusMove(MoveId.WHIRLWIND, PokemonType.NORMAL, -1, 20, -1, -6, 1)
|
||||
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
|
||||
.attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
|
||||
.ignoresSubstitute()
|
||||
.hidesTarget()
|
||||
.windMove()
|
||||
@ -8623,7 +8466,7 @@ export function initMoves() {
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES)
|
||||
.reflectable(),
|
||||
new StatusMove(MoveId.ROAR, PokemonType.NORMAL, -1, 20, -1, -6, 1)
|
||||
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
|
||||
.soundBased()
|
||||
.hidesTarget()
|
||||
.reflectable(),
|
||||
@ -8783,7 +8626,7 @@ export function initMoves() {
|
||||
new AttackMove(MoveId.RAGE, PokemonType.NORMAL, MoveCategory.PHYSICAL, 20, 100, 20, -1, 0, 1)
|
||||
.partial(), // No effect implemented
|
||||
new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1)
|
||||
.attr(ForceSwitchOutAttr, true)
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SWITCH, allowFlee: true})
|
||||
.hidesUser(),
|
||||
new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
|
||||
.attr(LevelDamageAttr),
|
||||
@ -9183,8 +9026,7 @@ export function initMoves() {
|
||||
new AttackMove(MoveId.DRAGON_BREATH, PokemonType.DRAGON, MoveCategory.SPECIAL, 60, 100, 20, 30, 0, 2)
|
||||
.attr(StatusEffectAttr, StatusEffect.PARALYSIS),
|
||||
new SelfStatusMove(MoveId.BATON_PASS, PokemonType.NORMAL, -1, 40, -1, 0, 2)
|
||||
.attr(ForceSwitchOutAttr, true, SwitchType.BATON_PASS)
|
||||
.condition(failIfLastInPartyCondition)
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.BATON_PASS})
|
||||
.hidesUser(),
|
||||
new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2)
|
||||
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
|
||||
@ -9633,8 +9475,7 @@ export function initMoves() {
|
||||
.ballBombMove(),
|
||||
new SelfStatusMove(MoveId.HEALING_WISH, PokemonType.PSYCHIC, -1, 10, -1, 0, 4)
|
||||
.attr(SacrificialFullRestoreAttr, false, "moveTriggers:sacrificialFullRestore")
|
||||
.triageMove()
|
||||
.condition(failIfLastInPartyCondition),
|
||||
.triageMove(),
|
||||
new AttackMove(MoveId.BRINE, PokemonType.WATER, MoveCategory.SPECIAL, 65, 100, 10, -1, 0, 4)
|
||||
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getHpRatio() < 0.5 ? 2 : 1),
|
||||
new AttackMove(MoveId.NATURAL_GIFT, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 4)
|
||||
@ -9665,7 +9506,7 @@ export function initMoves() {
|
||||
.makesContact(false)
|
||||
.target(MoveTarget.ATTACKER),
|
||||
new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4)
|
||||
.attr(ForceSwitchOutAttr, true),
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true}),
|
||||
new AttackMove(MoveId.CLOSE_COMBAT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4)
|
||||
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true),
|
||||
new AttackMove(MoveId.PAYBACK, PokemonType.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4)
|
||||
@ -9932,8 +9773,7 @@ export function initMoves() {
|
||||
new SelfStatusMove(MoveId.LUNAR_DANCE, PokemonType.PSYCHIC, -1, 10, -1, 0, 4)
|
||||
.attr(SacrificialFullRestoreAttr, true, "moveTriggers:lunarDanceRestore")
|
||||
.danceMove()
|
||||
.triageMove()
|
||||
.condition(failIfLastInPartyCondition),
|
||||
.triageMove(),
|
||||
new AttackMove(MoveId.CRUSH_GRIP, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4)
|
||||
.attr(OpponentHighHpPowerAttr, 120),
|
||||
new AttackMove(MoveId.MAGMA_STORM, PokemonType.FIRE, MoveCategory.SPECIAL, 100, 75, 5, -1, 0, 4)
|
||||
@ -10094,7 +9934,7 @@ export function initMoves() {
|
||||
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true)
|
||||
.attr(StatStageChangeAttr, [ Stat.SPD ], 2, true),
|
||||
new AttackMove(MoveId.CIRCLE_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5)
|
||||
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
|
||||
.attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
|
||||
.hidesTarget(),
|
||||
new AttackMove(MoveId.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5)
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES)
|
||||
@ -10154,7 +9994,7 @@ export function initMoves() {
|
||||
.attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, MoveId.FIRE_PLEDGE)
|
||||
.attr(BypassRedirectAttr, true),
|
||||
new AttackMove(MoveId.VOLT_SWITCH, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 20, -1, 0, 5)
|
||||
.attr(ForceSwitchOutAttr, true),
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true}),
|
||||
new AttackMove(MoveId.STRUGGLE_BUG, PokemonType.BUG, MoveCategory.SPECIAL, 50, 100, 20, 100, 0, 5)
|
||||
.attr(StatStageChangeAttr, [ Stat.SPATK ], -1)
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
||||
@ -10166,7 +10006,7 @@ export function initMoves() {
|
||||
new AttackMove(MoveId.FROST_BREATH, PokemonType.ICE, MoveCategory.SPECIAL, 60, 90, 10, -1, 0, 5)
|
||||
.attr(CritOnlyAttr),
|
||||
new AttackMove(MoveId.DRAGON_TAIL, PokemonType.DRAGON, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5)
|
||||
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
|
||||
.attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true})
|
||||
.hidesTarget(),
|
||||
new SelfStatusMove(MoveId.WORK_UP, PokemonType.NORMAL, -1, 30, -1, 0, 5)
|
||||
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, true),
|
||||
@ -10322,9 +10162,10 @@ export function initMoves() {
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
||||
new StatusMove(MoveId.PARTING_SHOT, PokemonType.DARK, 100, 20, -1, 0, 6)
|
||||
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, { trigger: MoveEffectTrigger.PRE_APPLY })
|
||||
.attr(ForceSwitchOutAttr, true)
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true})
|
||||
.soundBased()
|
||||
.reflectable(),
|
||||
.reflectable()
|
||||
.edgeCase(), // should not fail if no target is switched out
|
||||
new StatusMove(MoveId.TOPSY_TURVY, PokemonType.DARK, -1, 20, -1, 0, 6)
|
||||
.attr(InvertStatsAttr)
|
||||
.reflectable(),
|
||||
@ -11055,7 +10896,7 @@ export function initMoves() {
|
||||
.target(MoveTarget.NEAR_ALLY)
|
||||
.condition(failIfSingleBattle),
|
||||
new AttackMove(MoveId.FLIP_TURN, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 8)
|
||||
.attr(ForceSwitchOutAttr, true),
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true}),
|
||||
new AttackMove(MoveId.TRIPLE_AXEL, PokemonType.ICE, MoveCategory.PHYSICAL, 20, 90, 10, -1, 0, 8)
|
||||
.attr(MultiHitAttr, MultiHitType._3)
|
||||
.attr(MultiHitPowerIncrementAttr, 3)
|
||||
@ -11389,15 +11230,14 @@ export function initMoves() {
|
||||
.makesContact(),
|
||||
new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9)
|
||||
.attr(AddSubstituteAttr, 0.5, true)
|
||||
.attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL)
|
||||
.condition(failIfLastInPartyCondition),
|
||||
.attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SHED_TAIL}),
|
||||
new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9)
|
||||
.attr(PreMoveMessageAttr, (user, _target, _move) =>
|
||||
// Don't display text if current move phase is follow up (ie move called indirectly)
|
||||
isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode)
|
||||
? ""
|
||||
: i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) }))
|
||||
.attr(ChillyReceptionAttr, true),
|
||||
.attr(ChillyReceptionAttr, {selfSwitch: true}),
|
||||
new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9)
|
||||
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true)
|
||||
.attr(RemoveArenaTrapAttr, true)
|
||||
|
@ -341,16 +341,14 @@ export class MysteryEncounter implements IMysteryEncounter {
|
||||
* can cause scenarios where there are not enough Pokemon that are sufficient for all requirements.
|
||||
*/
|
||||
private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean {
|
||||
if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) {
|
||||
const activeMon = globalScene.getPlayerParty().filter(p => p.isActive(true));
|
||||
if (activeMon.length > 0) {
|
||||
this.primaryPokemon = activeMon[0];
|
||||
} else {
|
||||
this.primaryPokemon = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle())[0];
|
||||
}
|
||||
let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
|
||||
if (!this.primaryPokemonRequirements?.length) {
|
||||
// If we lack specified criterion, grab the first on-field pokemon, or else the first pokemon allowed in battle
|
||||
const activeMons = qualified.filter(p => p.isAllowedInBattle());
|
||||
this.primaryPokemon = activeMons.find(p => p.isOnField()) ?? activeMons[0];
|
||||
return true;
|
||||
}
|
||||
let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
|
||||
|
||||
for (const req of this.primaryPokemonRequirements) {
|
||||
if (req.meetsRequirement()) {
|
||||
qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn));
|
||||
|
@ -315,7 +315,14 @@ export class PokemonTurnData {
|
||||
* - `0` = Move is finished
|
||||
*/
|
||||
public hitsLeft = -1;
|
||||
public totalDamageDealt = 0;
|
||||
/**
|
||||
* The final amount of damage dealt by this Pokemon's last attack against each of its targets,
|
||||
* indexed by their respective `BattlerIndex`es. \
|
||||
* Reset to an empty array upon attempting to use a move,
|
||||
* and is used to calculate various damage-related effects (Shell Bell, U-Turn + Wimp Out interactions, etc.).
|
||||
*/
|
||||
// TODO: move this or something like it to some sort of "move in flight" object
|
||||
public lastMoveDamageDealt: number[] = [0, 0, 0, 0];
|
||||
public singleHitDamageDealt = 0;
|
||||
public damageTaken = 0;
|
||||
public attacksReceived: AttackMoveResult[] = [];
|
||||
|
@ -14,3 +14,6 @@ export enum SwitchType {
|
||||
/** Force switchout to a random party member */
|
||||
FORCE_SWITCH,
|
||||
}
|
||||
|
||||
/** Union type of all "normal" switch types that can be used by force switch moves. */
|
||||
export type NormalSwitchType = Exclude<SwitchType, SwitchType.INITIAL_SWITCH>
|
@ -243,6 +243,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
public luck: number;
|
||||
public pauseEvolutions: boolean;
|
||||
public pokerus: boolean;
|
||||
/** Whether this Pokemon is currently attempting to switch in. */
|
||||
public switchOutStatus = false;
|
||||
public evoCounter: number;
|
||||
public teraType: PokemonType;
|
||||
@ -1228,7 +1229,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
* @see {@linkcode SubstituteTag}
|
||||
* @see {@linkcode getFieldPositionOffset}
|
||||
*/
|
||||
getSubstituteOffset(): [number, number] {
|
||||
getSubstituteOffset(): [x: number, y: number] {
|
||||
return this.isPlayer() ? [-30, 10] : [30, -10];
|
||||
}
|
||||
|
||||
@ -1640,6 +1641,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
return this.getMaxHp() - this.hp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return this Pokemon's current HP as a fraction of its maximum HP.
|
||||
* @param precise - Whether to return the exact HP ratio (`true`) or rounded to the nearest 1% (`false`); default `false`
|
||||
* @returns This pokemon's current HP ratio (current / max).
|
||||
*/
|
||||
getHpRatio(precise = false): number {
|
||||
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100;
|
||||
}
|
||||
@ -4093,13 +4099,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
* Given the damage, adds a new DamagePhase and update HP values, etc.
|
||||
*
|
||||
* Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly
|
||||
* @param damage integer - passed to damage()
|
||||
* @param result an enum if it's super effective, not very, etc.
|
||||
* @param isCritical boolean if move is a critical hit
|
||||
* @param ignoreSegments boolean, passed to damage() and not used currently
|
||||
* @param preventEndure boolean, ignore endure properties of pokemon, passed to damage()
|
||||
* @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage()
|
||||
* @returns integer of damage done
|
||||
* @param damage - Amount of damage to deal
|
||||
* @param result - The {@linkcode HitResult} of the damage instance; default `HitResult.EFFECTIVE`
|
||||
* @param isCritical - Whether the move being used (if any) was a critical hit; default `false`
|
||||
* @param ignoreSegments - Whether to ignore boss segments; default `false` and currently unused
|
||||
* @param preventEndure - Whether to ignore {@linkcode Moves.ENDURE} and similar effects when applying damage; default `false`
|
||||
* @param ignoreFaintPhase - Whether to ignore adding a faint phase if the damage causes the target to faint; default `false`
|
||||
* @returns The amount of damage actually dealt.
|
||||
* @remarks
|
||||
* This will not trigger "on damage" effects for direct damage moves, instead occuring at the end of `MoveEffectPhase`.
|
||||
*/
|
||||
damageAndUpdate(
|
||||
damage: number,
|
||||
@ -4108,13 +4116,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
isCritical = false,
|
||||
ignoreSegments = false,
|
||||
ignoreFaintPhase = false,
|
||||
source = undefined,
|
||||
}: {
|
||||
result?: DamageResult;
|
||||
isCritical?: boolean;
|
||||
ignoreSegments?: boolean;
|
||||
ignoreFaintPhase?: boolean;
|
||||
source?: Pokemon;
|
||||
} = {},
|
||||
): number {
|
||||
const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result);
|
||||
@ -4122,27 +4128,30 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
"DamageAnimPhase",
|
||||
this.getBattlerIndex(),
|
||||
damage,
|
||||
result as DamageResult,
|
||||
result,
|
||||
isCritical,
|
||||
);
|
||||
globalScene.phaseManager.unshiftPhase(damagePhase);
|
||||
if (this.switchOutStatus && source) {
|
||||
|
||||
// Prevent enemies not on field from taking damage.
|
||||
// TODO: Review if wimp out actually needs this anymore
|
||||
if (this.switchOutStatus) {
|
||||
damage = 0;
|
||||
}
|
||||
|
||||
damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase);
|
||||
// Ensure the battle-info bar's HP is updated, though only if the battle info is visible
|
||||
// TODO: When battle-info UI is refactored, make this only update the HP bar
|
||||
if (this.battleInfo.visible) {
|
||||
this.updateInfo();
|
||||
}
|
||||
|
||||
// Damage amount may have changed, but needed to be queued before calling damage function
|
||||
damagePhase.updateAmount(damage);
|
||||
/**
|
||||
* Run PostDamageAbAttr from any source of damage that is not from a multi-hit
|
||||
* Multi-hits are handled in move-effect-phase.ts for PostDamageAbAttr
|
||||
*/
|
||||
if (!source || source.turnData.hitCount <= 1) {
|
||||
applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage, source });
|
||||
|
||||
// Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances.
|
||||
if (isIndirectDamage && result !== HitResult.CONFUSION) {
|
||||
applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage });
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
@ -4356,6 +4365,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
}
|
||||
|
||||
for (const tag of source.summonData.tags) {
|
||||
// Skip non-Baton Passable tags (or telekinesis for mega gengar; cf. https://bulbapedia.bulbagarden.net/wiki/Telekinesis_(move))
|
||||
if (
|
||||
!tag.isBatonPassable ||
|
||||
(tag.tagType === BattlerTagType.TELEKINESIS &&
|
||||
@ -5064,8 +5074,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
* Reset this Pokemon's {@linkcode PokemonSummonData | SummonData} and {@linkcode PokemonTempSummonData | TempSummonData}
|
||||
* in preparation for switching pokemon, as well as removing any relevant on-switch tags.
|
||||
* @remarks
|
||||
* This **SHOULD NOT** be called when {@linkcode leaveField} is already being called,
|
||||
* which already calls this function.
|
||||
*/
|
||||
resetSummonData(): void {
|
||||
console.log(`resetSummonData called on Pokemon ${this.name}`);
|
||||
const illusion: IllusionData | null = this.summonData.illusion;
|
||||
if (this.summonData.speciesForm) {
|
||||
this.summonData.speciesForm = null;
|
||||
@ -5107,6 +5121,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
}
|
||||
|
||||
resetTurnData(): void {
|
||||
console.log(`resetTurnData called on Pokemon ${this.name}`);
|
||||
this.turnData = new PokemonTurnData();
|
||||
}
|
||||
|
||||
@ -5549,15 +5564,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
}
|
||||
|
||||
/**
|
||||
* Causes a Pokemon to leave the field (such as in preparation for a switch out/escape).
|
||||
* @param clearEffects Indicates if effects should be cleared (true) or passed
|
||||
* to the next pokemon, such as during a baton pass (false)
|
||||
* @param hideInfo Indicates if this should also play the animation to hide the Pokemon's
|
||||
* info container.
|
||||
* Cause this {@linkcode Pokemon} to leave the field (such as in preparation for a switch out/escape).
|
||||
* @param clearEffects - Whether to clear (`true`) or transfer (`false`) transient effects upon switching; default `true`
|
||||
* @param hideInfo - Whether to play the animation to hide the Pokemon's info container; default `true`.
|
||||
* @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false`
|
||||
* @remarks
|
||||
* This **SHOULD NOT** be called with `clearEffects=true` when a `SummonPhase` or `SwitchSummonPhase` is already being added,
|
||||
* both of which do so already and can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}.
|
||||
*/
|
||||
// TODO: Review where this is being called and where it is necessary to call it
|
||||
leaveField(clearEffects = true, hideInfo = true, destroy = false) {
|
||||
console.log(`leaveField called on Pokemon ${this.name}`);
|
||||
this.resetSprite();
|
||||
this.resetTurnData();
|
||||
globalScene
|
||||
.getField(true)
|
||||
.filter(p => p !== this)
|
||||
@ -5566,6 +5584,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
if (clearEffects) {
|
||||
this.destroySubstitute();
|
||||
this.resetSummonData();
|
||||
this.resetTurnData();
|
||||
}
|
||||
if (hideInfo) {
|
||||
this.hideInfo();
|
||||
|
@ -1765,20 +1765,26 @@ export class HitHealModifier extends PokemonHeldItemModifier {
|
||||
* @returns `true` if the {@linkcode Pokemon} was healed
|
||||
*/
|
||||
override apply(pokemon: Pokemon): boolean {
|
||||
if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) {
|
||||
// TODO: this shouldn't be undefined AFAIK
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount,
|
||||
i18next.t("modifier:hitHealApply", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
typeName: this.type.name,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
if (pokemon.isFullHp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collate the amount of damage this attack did against all its targets.
|
||||
const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0);
|
||||
if (totalDmgDealt === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(totalDmgDealt / 8) * this.stackCount,
|
||||
i18next.t("modifier:hitHealApply", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
typeName: this.type.name,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -38,16 +38,12 @@ export class CheckSwitchPhase extends BattlePhase {
|
||||
}
|
||||
|
||||
// ...if there are no other allowed Pokemon in the player's party to switch with
|
||||
if (
|
||||
!globalScene
|
||||
.getPlayerParty()
|
||||
.slice(1)
|
||||
.filter(p => p.isActive()).length
|
||||
) {
|
||||
if (globalScene.getBackupPartyMemberIndices(true).length === 0) {
|
||||
return super.end();
|
||||
}
|
||||
|
||||
// ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching
|
||||
// TODO: Ignore trapping check if baton item is held (since those bypass trapping)
|
||||
if (
|
||||
pokemon.getTag(BattlerTagType.FRENZY) ||
|
||||
pokemon.isTrapped() ||
|
||||
|
@ -49,8 +49,6 @@ export class FaintPhase extends PokemonPhase {
|
||||
faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source);
|
||||
}
|
||||
|
||||
faintPokemon.resetSummonData();
|
||||
|
||||
if (!this.preventInstantRevive) {
|
||||
const instantReviveModifier = globalScene.applyModifier(
|
||||
PokemonInstantReviveModifier,
|
||||
@ -59,6 +57,7 @@ export class FaintPhase extends PokemonPhase {
|
||||
) as PokemonInstantReviveModifier;
|
||||
|
||||
if (instantReviveModifier) {
|
||||
faintPokemon.resetSummonData();
|
||||
faintPokemon.loseHeldItem(instantReviveModifier);
|
||||
globalScene.updateModifiers(this.player);
|
||||
return this.end();
|
||||
@ -146,41 +145,32 @@ export class FaintPhase extends PokemonPhase {
|
||||
}
|
||||
}
|
||||
|
||||
const legalBackupPokemon = globalScene.getBackupPartyMemberIndices(pokemon);
|
||||
|
||||
if (this.player) {
|
||||
/** The total number of Pokemon in the player's party that can legally fight */
|
||||
/** An array of Pokemon in the player's party that can legally fight. */
|
||||
const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle();
|
||||
/** The total number of legal player Pokemon that aren't currently on the field */
|
||||
const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true));
|
||||
if (!legalPlayerPokemon.length) {
|
||||
/** If the player doesn't have any legal Pokemon, end the game */
|
||||
if (legalPlayerPokemon.length === 0) {
|
||||
// If the player doesn't have any legal Pokemon left in their party, end the game.
|
||||
globalScene.phaseManager.unshiftNew("GameOverPhase");
|
||||
} else if (
|
||||
globalScene.currentBattle.double &&
|
||||
legalPlayerPokemon.length === 1 &&
|
||||
legalPlayerPartyPokemon.length === 0
|
||||
) {
|
||||
/**
|
||||
* If the player has exactly one Pokemon in total at this point in a double battle, and that Pokemon
|
||||
* is already on the field, unshift a phase that moves that Pokemon to center position.
|
||||
*/
|
||||
} else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) {
|
||||
/*
|
||||
Otherwise, if the player has no reserve members left to switch in,
|
||||
unshift a phase to move the other on-field pokemon to center position.
|
||||
*/
|
||||
globalScene.phaseManager.unshiftNew("ToggleDoublePositionPhase", true);
|
||||
} else if (legalPlayerPartyPokemon.length > 0) {
|
||||
/**
|
||||
* If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field,
|
||||
* push a phase that prompts the player to summon a Pokemon from their party.
|
||||
*/
|
||||
} else {
|
||||
// If previous conditions weren't met, push a phase to prompt the player to select a new pokemon from their party.
|
||||
globalScene.phaseManager.pushNew("SwitchPhase", SwitchType.SWITCH, this.fieldIndex, true, false);
|
||||
}
|
||||
} else {
|
||||
// Unshift a phase for EXP gains and/or one to switch in a replacement party member.
|
||||
globalScene.phaseManager.unshiftNew("VictoryPhase", this.battlerIndex);
|
||||
if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) {
|
||||
const hasReservePartyMember = !!globalScene
|
||||
.getEnemyParty()
|
||||
.filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot)
|
||||
.length;
|
||||
if (hasReservePartyMember) {
|
||||
globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false);
|
||||
}
|
||||
if (
|
||||
[BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) &&
|
||||
legalBackupPokemon.length > 0
|
||||
) {
|
||||
globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
|
||||
/** Is this the first strike of a move? */
|
||||
private firstHit: boolean;
|
||||
/** Is this the last strike of a move? */
|
||||
/** Is this the last strike of a move (either due to running out of hits or all targets being fainted/immune)? */
|
||||
private lastHit: boolean;
|
||||
|
||||
/**
|
||||
@ -305,7 +305,7 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
const targets = this.conductHitChecks(user, fieldMove);
|
||||
|
||||
this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft;
|
||||
this.lastHit = user.turnData.hitsLeft === 1 || !targets.some(t => t.isActive(true));
|
||||
this.lastHit = user.turnData.hitsLeft === 1 || targets.every(t => !t.isActive(true));
|
||||
|
||||
// Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct)
|
||||
if (
|
||||
@ -770,15 +770,16 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
if (!this.move.hitsSubstitute(user, target)) {
|
||||
this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical);
|
||||
}
|
||||
|
||||
if (this.lastHit) {
|
||||
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
|
||||
|
||||
// Multi-hit check for Wimp Out/Emergency Exit
|
||||
if (user.turnData.hitCount > 1) {
|
||||
// TODO: Investigate why 0 is being passed for damage amount here
|
||||
// and then determing if refactoring `applyMove` to return the damage dealt is appropriate.
|
||||
applyAbAttrs("PostDamageAbAttr", { pokemon: target, damage: 0, source: user });
|
||||
}
|
||||
// Trigger Form changes on the final hit, alongside Wimp Out.
|
||||
applyAbAttrs("PostDamageAbAttr", {
|
||||
pokemon: target,
|
||||
damage: user.turnData.lastMoveDamageDealt[target.getBattlerIndex()],
|
||||
simulated: false,
|
||||
source: user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -811,6 +812,7 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
isCritical,
|
||||
});
|
||||
|
||||
// Apply and/or remove type boosting tags (Flash Fire, Charge, etc.)
|
||||
const typeBoost = user.findTag(
|
||||
t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move),
|
||||
) as TypeBoostTag;
|
||||
@ -818,18 +820,17 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
user.removeTag(typeBoost.tagType);
|
||||
}
|
||||
|
||||
const isOneHitKo = result === HitResult.ONE_HIT_KO;
|
||||
|
||||
if (!dmg) {
|
||||
if (dmg === 0) {
|
||||
return [result, false];
|
||||
}
|
||||
|
||||
const isOneHitKo = result === HitResult.ONE_HIT_KO;
|
||||
target.lapseTags(BattlerTagLapseType.HIT);
|
||||
|
||||
const substitute = target.getTag(SubstituteTag);
|
||||
const isBlockedBySubstitute = substitute && this.move.hitsSubstitute(user, target);
|
||||
const substituteTag = target.getTag(SubstituteTag);
|
||||
const isBlockedBySubstitute = substituteTag && this.move.hitsSubstitute(user, target);
|
||||
if (isBlockedBySubstitute) {
|
||||
substitute.hp -= dmg;
|
||||
substituteTag.hp -= dmg;
|
||||
} else if (!target.isPlayer() && dmg >= target.hp) {
|
||||
globalScene.applyModifiers(EnemyEndureChanceModifier, false, target);
|
||||
}
|
||||
@ -838,10 +839,9 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
? 0
|
||||
: target.damageAndUpdate(dmg, {
|
||||
result: result as DamageResult,
|
||||
ignoreFaintPhase: true,
|
||||
ignoreFaintPhase: true, // ignore faint phase so we can handle it ourselves
|
||||
ignoreSegments: isOneHitKo,
|
||||
isCritical,
|
||||
source: user,
|
||||
});
|
||||
|
||||
if (isCritical) {
|
||||
@ -855,14 +855,13 @@ export class MoveEffectPhase extends PokemonPhase {
|
||||
if (user.isPlayer()) {
|
||||
globalScene.validateAchvs(DamageAchv, new NumberHolder(damage));
|
||||
|
||||
if (damage > globalScene.gameData.gameStats.highestDamage) {
|
||||
globalScene.gameData.gameStats.highestDamage = damage;
|
||||
}
|
||||
globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage);
|
||||
}
|
||||
|
||||
user.turnData.totalDamageDealt += damage;
|
||||
user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] += damage;
|
||||
user.turnData.singleHitDamageDealt = damage;
|
||||
target.battleData.hitCount++;
|
||||
// TODO: this might be incorrect for counter moves
|
||||
target.turnData.damageTaken += damage;
|
||||
|
||||
target.turnData.attacksReceived.unshift({
|
||||
|
@ -133,6 +133,8 @@ export class MovePhase extends BattlePhase {
|
||||
}
|
||||
|
||||
this.pokemon.turnData.acted = true;
|
||||
// TODO: Increase this if triple battles are added
|
||||
this.pokemon.turnData.lastMoveDamageDealt = Array(4).fill(0);
|
||||
|
||||
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
|
||||
if (isVirtual(this.useMode)) {
|
||||
|
@ -44,6 +44,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase {
|
||||
}
|
||||
if (damage.value) {
|
||||
// Set preventEndure flag to avoid pokemon surviving thanks to focus band, sturdy, endure ...
|
||||
// TODO: why don't we call `damageAndUpdate` here?
|
||||
globalScene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true));
|
||||
pokemon.updateInfo();
|
||||
applyAbAttrs("PostDamageAbAttr", { pokemon, damage: damage.value });
|
||||
|
@ -275,7 +275,7 @@ export class SummonPhase extends PartyMemberPokemonPhase {
|
||||
globalScene.phaseManager.unshiftNew("ShinySparklePhase", pokemon.getBattlerIndex());
|
||||
}
|
||||
|
||||
pokemon.resetTurnData();
|
||||
pokemon.resetTurnData(); // TODO: this can probably be removed...???
|
||||
|
||||
if (
|
||||
!this.loaded ||
|
||||
|
@ -16,13 +16,12 @@ export class SwitchPhase extends BattlePhase {
|
||||
private readonly doReturn: boolean;
|
||||
|
||||
/**
|
||||
* Creates a new SwitchPhase
|
||||
* @param switchType {@linkcode SwitchType} The type of switch logic this phase implements
|
||||
* @param fieldIndex Field index to switch out
|
||||
* @param isModal Indicates if the switch should be forced (true) or is
|
||||
* optional (false).
|
||||
* @param doReturn Indicates if the party member on the field should be
|
||||
* recalled to ball or has already left the field. Passed to {@linkcode SwitchSummonPhase}.
|
||||
* Creates a new {@linkcode SwitchPhase}, the phase where players select a Pokemon to send into battle.
|
||||
* @param switchType - The {@linkcode SwitchType} dictating this switch's logic.
|
||||
* @param fieldIndex - The 0-indexed field position of the Pokemon being switched out.
|
||||
* @param isModal - Whether the switch should be forced (`true`) or optional (`false`).
|
||||
* @param doReturn - Whether to render the "Come back!" dialogue for recalling player pokemon.
|
||||
* @see {@linkcode SwitchSummonPhase} for the phase which does the actual switching.
|
||||
*/
|
||||
constructor(switchType: SwitchType, fieldIndex: number, isModal: boolean, doReturn: boolean) {
|
||||
super();
|
||||
@ -37,7 +36,7 @@ export class SwitchPhase extends BattlePhase {
|
||||
super.start();
|
||||
|
||||
// Skip modal switch if impossible (no remaining party members that aren't in battle)
|
||||
if (this.isModal && !globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) {
|
||||
if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) {
|
||||
return super.end();
|
||||
}
|
||||
|
||||
@ -52,11 +51,11 @@ export class SwitchPhase extends BattlePhase {
|
||||
return super.end();
|
||||
}
|
||||
|
||||
// Check if there is any space still in field
|
||||
// Check if there is any space still on field.
|
||||
// We use > here as the prior pokemon still technically hasn't "left" the field _per se_ (unless they fainted).
|
||||
if (
|
||||
this.isModal &&
|
||||
globalScene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >=
|
||||
globalScene.currentBattle.getBattlerCount()
|
||||
globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount()
|
||||
) {
|
||||
return super.end();
|
||||
}
|
||||
|
@ -2,10 +2,8 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
|
||||
import { globalScene } from "#app/global-scene";
|
||||
import { getPokemonNameWithAffix } from "#app/messages";
|
||||
import { SubstituteTag } from "#data/battler-tags";
|
||||
import { allMoves } from "#data/data-lists";
|
||||
import { SpeciesFormChangeActiveTrigger } from "#data/form-change-triggers";
|
||||
import { getPokeballTintColor } from "#data/pokeball";
|
||||
import { Command } from "#enums/command";
|
||||
import { SwitchType } from "#enums/switch-type";
|
||||
import { TrainerSlot } from "#enums/trainer-slot";
|
||||
import type { Pokemon } from "#field/pokemon";
|
||||
@ -13,6 +11,7 @@ import { SwitchEffectTransferModifier } from "#modifiers/modifier";
|
||||
import { SummonPhase } from "#phases/summon-phase";
|
||||
import i18next from "i18next";
|
||||
|
||||
// TODO: This and related phases desperately need to be refactored
|
||||
export class SwitchSummonPhase extends SummonPhase {
|
||||
public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" = "SwitchSummonPhase";
|
||||
private readonly switchType: SwitchType;
|
||||
@ -22,10 +21,11 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
private lastPokemon: Pokemon;
|
||||
|
||||
/**
|
||||
* Constructor for creating a new SwitchSummonPhase
|
||||
* Constructor for creating a new {@linkcode SwitchSummonPhase}, the phase where player and enemy Pokemon are switched out
|
||||
* and replaced by another Pokemon from the same party.
|
||||
* @param switchType - The type of switch behavior
|
||||
* @param fieldIndex - Position on the battle field
|
||||
* @param slotIndex - The index of pokemon (in party of 6) to switch into
|
||||
* @param fieldIndex - The position on field of the Pokemon being switched **out**
|
||||
* @param slotIndex - The 0-indexed party position of the Pokemon switching **in**, or `-1` to use the default trainer switch logic.
|
||||
* @param doReturn - Whether to render "comeback" dialogue
|
||||
* @param player - Whether the switch came from the player or enemy; default `true`
|
||||
*/
|
||||
@ -33,48 +33,56 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
super(fieldIndex, player);
|
||||
|
||||
this.switchType = switchType;
|
||||
this.slotIndex = slotIndex;
|
||||
// -1 = "use trainer switch logic"
|
||||
this.slotIndex =
|
||||
slotIndex > -1
|
||||
? slotIndex
|
||||
: globalScene.currentBattle.trainer!.getNextSummonIndex(this.getTrainerSlotFromFieldIndex());
|
||||
this.doReturn = doReturn;
|
||||
}
|
||||
|
||||
// TODO: This is calling `applyPreSummonAbAttrs` both far too early and on the wrong pokemon;
|
||||
// `super.start` calls applyPreSummonAbAttrs(PreSummonAbAttr, this.getPokemon()),
|
||||
// and `this.getPokemon` is the pokemon SWITCHING OUT, NOT IN
|
||||
start(): void {
|
||||
super.start();
|
||||
}
|
||||
|
||||
preSummon(): void {
|
||||
if (!this.player) {
|
||||
if (this.slotIndex === -1) {
|
||||
//@ts-expect-error
|
||||
this.slotIndex = globalScene.currentBattle.trainer?.getNextSummonIndex(
|
||||
!this.fieldIndex ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
|
||||
); // TODO: what would be the default trainer-slot fallback?
|
||||
}
|
||||
if (this.slotIndex > -1) {
|
||||
this.showEnemyTrainer(!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER);
|
||||
globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty());
|
||||
}
|
||||
override preSummon(): void {
|
||||
const switchOutPokemon = this.getPokemon();
|
||||
|
||||
if (!this.player && globalScene.currentBattle.trainer) {
|
||||
this.showEnemyTrainer(this.getTrainerSlotFromFieldIndex());
|
||||
globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty());
|
||||
}
|
||||
|
||||
if (
|
||||
!this.doReturn ||
|
||||
// TODO: this part of the check need not exist `- `switchAndSummon` returns near immediately if we have no pokemon to switch into
|
||||
(this.slotIndex !== -1 &&
|
||||
!(this.player ? globalScene.getPlayerParty() : globalScene.getEnemyParty())[this.slotIndex])
|
||||
) {
|
||||
// If the target is still on-field, remove it and/or hide its info container.
|
||||
// Effects are kept to be transferred to the new Pokemon later on.
|
||||
if (switchOutPokemon.isOnField()) {
|
||||
switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo().visible);
|
||||
}
|
||||
|
||||
if (this.player) {
|
||||
this.switchAndSummon();
|
||||
return;
|
||||
} else {
|
||||
globalScene.time.delayedCall(750, () => this.switchAndSummon());
|
||||
}
|
||||
globalScene.time.delayedCall(750, () => this.switchAndSummon());
|
||||
return;
|
||||
}
|
||||
|
||||
const pokemon = this.getPokemon();
|
||||
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon =>
|
||||
enemyPokemon.removeTagsBySourceId(pokemon.id),
|
||||
enemyPokemon.removeTagsBySourceId(switchOutPokemon.id),
|
||||
);
|
||||
|
||||
if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) {
|
||||
const substitute = pokemon.getTag(SubstituteTag);
|
||||
// If not transferring a substitute, play animation to remove it from the field
|
||||
if (!this.shouldKeepEffects()) {
|
||||
const substitute = switchOutPokemon.getTag(SubstituteTag);
|
||||
if (substitute) {
|
||||
globalScene.tweens.add({
|
||||
targets: substitute.sprite,
|
||||
@ -89,26 +97,24 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
globalScene.ui.showText(
|
||||
this.player
|
||||
? i18next.t("battle:playerComeBack", {
|
||||
pokemonName: getPokemonNameWithAffix(pokemon),
|
||||
pokemonName: getPokemonNameWithAffix(switchOutPokemon),
|
||||
})
|
||||
: i18next.t("battle:trainerComeBack", {
|
||||
trainerName: globalScene.currentBattle.trainer?.getName(
|
||||
!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
|
||||
),
|
||||
pokemonName: pokemon.getNameToRender(),
|
||||
trainerName: globalScene.currentBattle.trainer?.getName(this.getTrainerSlotFromFieldIndex()),
|
||||
pokemonName: switchOutPokemon.getNameToRender(),
|
||||
}),
|
||||
);
|
||||
globalScene.playSound("se/pb_rel");
|
||||
pokemon.hideInfo();
|
||||
pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
|
||||
switchOutPokemon.hideInfo();
|
||||
switchOutPokemon.tint(getPokeballTintColor(switchOutPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
|
||||
globalScene.tweens.add({
|
||||
targets: pokemon,
|
||||
targets: switchOutPokemon,
|
||||
duration: 250,
|
||||
ease: "Sine.easeIn",
|
||||
scale: 0.5,
|
||||
onComplete: () => {
|
||||
globalScene.time.delayedCall(750, () => this.switchAndSummon());
|
||||
pokemon.leaveField(this.switchType === SwitchType.SWITCH, false);
|
||||
switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); // TODO: this reset effects call is dubious
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -118,12 +124,8 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
const switchedInPokemon: Pokemon | undefined = party[this.slotIndex];
|
||||
this.lastPokemon = this.getPokemon();
|
||||
|
||||
// Defensive programming: Overcome the bug where the summon data has somehow not been reset
|
||||
// prior to switching in a new Pokemon.
|
||||
// Force the switch to occur and load the assets for the new pokemon, ignoring override.
|
||||
switchedInPokemon.resetSummonData();
|
||||
switchedInPokemon.loadAssets(true);
|
||||
|
||||
// TODO: Why do we trigger these attributes even if the switch in target doesn't exist?
|
||||
// (This should almost certainly go somewhere inside `preSummon`)
|
||||
applyAbAttrs("PreSummonAbAttr", { pokemon: switchedInPokemon });
|
||||
applyAbAttrs("PreSwitchOutAbAttr", { pokemon: this.lastPokemon });
|
||||
if (!switchedInPokemon) {
|
||||
@ -131,6 +133,13 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive programming: Overcome the bug where the summon data has somehow not been reset
|
||||
// prior to switching in a new Pokemon.
|
||||
// Force the switch to occur and load the assets for the new pokemon, ignoring override.
|
||||
// TODO: Assess whether this is needed anymore and remove if needed
|
||||
switchedInPokemon.resetSummonData();
|
||||
switchedInPokemon.loadAssets(true);
|
||||
|
||||
if (this.switchType === SwitchType.BATON_PASS) {
|
||||
// If switching via baton pass, update opposing tags coming from the prior pokemon
|
||||
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) =>
|
||||
@ -149,7 +158,7 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
m =>
|
||||
m instanceof SwitchEffectTransferModifier &&
|
||||
(m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id,
|
||||
) as SwitchEffectTransferModifier;
|
||||
) as SwitchEffectTransferModifier | undefined;
|
||||
|
||||
if (batonPassModifier) {
|
||||
globalScene.tryTransferHeldItemModifier(
|
||||
@ -167,13 +176,14 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
|
||||
party[this.slotIndex] = this.lastPokemon;
|
||||
party[this.fieldIndex] = switchedInPokemon;
|
||||
// TODO: Make this a method
|
||||
const showTextAndSummon = () => {
|
||||
globalScene.ui.showText(this.getSendOutText(switchedInPokemon));
|
||||
/**
|
||||
* If this switch is passing a Substitute, make the switched Pokemon matches the returned Pokemon's state as it left.
|
||||
* Otherwise, clear any persisting tags on the returned Pokemon.
|
||||
*/
|
||||
if (this.switchType === SwitchType.BATON_PASS || this.switchType === SwitchType.SHED_TAIL) {
|
||||
if (this.shouldKeepEffects()) {
|
||||
const substitute = this.lastPokemon.getTag(SubstituteTag);
|
||||
if (substitute) {
|
||||
switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0];
|
||||
@ -181,7 +191,7 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
switchedInPokemon.setAlpha(0.5);
|
||||
}
|
||||
} else {
|
||||
switchedInPokemon.fieldSetup(true);
|
||||
switchedInPokemon.fieldSetup();
|
||||
}
|
||||
this.summon();
|
||||
};
|
||||
@ -200,46 +210,33 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
onEnd(): void {
|
||||
super.onEnd();
|
||||
|
||||
const pokemon = this.getPokemon();
|
||||
const activePokemon = this.getPokemon();
|
||||
|
||||
const moveId = globalScene.currentBattle.lastMove;
|
||||
const lastUsedMove = moveId ? allMoves[moveId] : undefined;
|
||||
|
||||
const currentCommand = globalScene.currentBattle.turnCommands[this.fieldIndex]?.command;
|
||||
const lastPokemonIsForceSwitchedAndNotFainted =
|
||||
lastUsedMove?.hasAttr("ForceSwitchOutAttr") && !this.lastPokemon.isFainted();
|
||||
const lastPokemonHasForceSwitchAbAttr =
|
||||
this.lastPokemon.hasAbilityWithAttr("PostDamageForceSwitchAbAttr") && !this.lastPokemon.isFainted();
|
||||
|
||||
// Compensate for turn spent summoning/forced switch if switched out pokemon is not fainted.
|
||||
// If not switching at start of battle, reset turn counts and temp data on the newly sent in Pokemon
|
||||
// Needed as we increment turn counters in `TurnEndPhase`.
|
||||
if (
|
||||
currentCommand === Command.POKEMON ||
|
||||
lastPokemonIsForceSwitchedAndNotFainted ||
|
||||
lastPokemonHasForceSwitchAbAttr
|
||||
) {
|
||||
pokemon.tempSummonData.turnCount--;
|
||||
pokemon.tempSummonData.waveTurnCount--;
|
||||
if (this.switchType !== SwitchType.INITIAL_SWITCH) {
|
||||
// No need to reset turn/summon data for initial switch
|
||||
// (since both get initialized to defaults on object creation)
|
||||
activePokemon.resetTurnData();
|
||||
activePokemon.resetSummonData();
|
||||
activePokemon.tempSummonData.turnCount--;
|
||||
activePokemon.tempSummonData.waveTurnCount--;
|
||||
activePokemon.turnData.switchedInThisTurn = true;
|
||||
}
|
||||
|
||||
if (this.switchType === SwitchType.BATON_PASS && pokemon) {
|
||||
pokemon.transferSummon(this.lastPokemon);
|
||||
} else if (this.switchType === SwitchType.SHED_TAIL && pokemon) {
|
||||
// Baton Pass over any eligible effects or substitutes before resetting the last pokemon's temporary data.
|
||||
if (this.switchType === SwitchType.BATON_PASS) {
|
||||
activePokemon.transferSummon(this.lastPokemon);
|
||||
} else if (this.switchType === SwitchType.SHED_TAIL) {
|
||||
const subTag = this.lastPokemon.getTag(SubstituteTag);
|
||||
if (subTag) {
|
||||
pokemon.summonData.tags.push(subTag);
|
||||
activePokemon.summonData.tags.push(subTag);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset turn data if not initial switch (since it gets initialized to an empty object on turn start)
|
||||
if (this.switchType !== SwitchType.INITIAL_SWITCH) {
|
||||
pokemon.resetTurnData();
|
||||
pokemon.turnData.switchedInThisTurn = true;
|
||||
}
|
||||
|
||||
this.lastPokemon.resetTurnData();
|
||||
this.lastPokemon.resetSummonData();
|
||||
|
||||
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
|
||||
globalScene.triggerPokemonFormChange(activePokemon, SpeciesFormChangeActiveTrigger, true);
|
||||
// Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out
|
||||
globalScene.arena.triggerWeatherBasedFormChanges();
|
||||
}
|
||||
@ -275,4 +272,16 @@ export class SwitchSummonPhase extends SummonPhase {
|
||||
pokemonName: this.getPokemon().getNameToRender(),
|
||||
});
|
||||
}
|
||||
|
||||
private shouldKeepEffects(): boolean {
|
||||
return [SwitchType.BATON_PASS, SwitchType.SHED_TAIL].includes(this.switchType);
|
||||
}
|
||||
|
||||
private getTrainerSlotFromFieldIndex(): TrainerSlot {
|
||||
return this.player || !globalScene.currentBattle.trainer
|
||||
? TrainerSlot.NONE
|
||||
: this.fieldIndex % 2 === 0
|
||||
? TrainerSlot.TRAINER
|
||||
: TrainerSlot.TRAINER_PARTNER;
|
||||
}
|
||||
}
|
||||
|
45
src/utils/array.ts
Normal file
45
src/utils/array.ts
Normal 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];
|
||||
}
|
@ -1,4 +1,8 @@
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { ArenaTagSide } from "#enums/arena-tag-side";
|
||||
import { ArenaTagType } from "#enums/arena-tag-type";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
@ -37,13 +41,40 @@ describe("Abilities - Mold Breaker", () => {
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
|
||||
game.move.use(MoveId.X_SCISSOR);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to("MoveEffectPhase");
|
||||
|
||||
expect(game.scene.arena.ignoreAbilities).toBe(true);
|
||||
expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex());
|
||||
|
||||
await game.toEndOfTurn();
|
||||
await game.phaseInterceptor.to("MoveEndPhase");
|
||||
expect(game.scene.arena.ignoreAbilities).toBe(false);
|
||||
expect(enemy.isFainted()).toBe(true);
|
||||
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
expect(enemy).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep Levitate opponents grounded when using force switch moves", async () => {
|
||||
game.override.enemyAbility(AbilityId.LEVITATE).enemySpecies(SpeciesId.WEEZING).battleType(BattleType.TRAINER);
|
||||
|
||||
// Setup toxic spikes and spikes
|
||||
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, -1, MoveId.TOXIC_SPIKES, 1, ArenaTagSide.ENEMY);
|
||||
game.scene.arena.addTag(ArenaTagType.SPIKES, -1, MoveId.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY);
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const [weezing1, weezing2] = game.scene.getEnemyParty();
|
||||
|
||||
// Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage
|
||||
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeDefined();
|
||||
expect(weezing1.hp).toBe(weezing1.getMaxHp());
|
||||
|
||||
game.move.use(MoveId.DRAGON_TAIL);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// Levitate was ignored during the switch, causing Toxic Spikes to be removed and Spikes to deal damage
|
||||
expect(weezing1.isOnField()).toBe(false);
|
||||
expect(weezing2.isOnField()).toBe(true);
|
||||
expect(weezing2.getHpRatio()).toBeCloseTo(0.75);
|
||||
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
@ -1,20 +1,20 @@
|
||||
import { allMoves } from "#data/data-lists";
|
||||
import type { ModifierOverride } from "#app/modifier/modifier-type";
|
||||
import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase";
|
||||
import { toDmgValue } from "#app/utils/common";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { ArenaTagSide } from "#enums/arena-tag-side";
|
||||
import { ArenaTagType } from "#enums/arena-tag-type";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { HitResult } from "#enums/hit-result";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { Stat } from "#enums/stat";
|
||||
import { StatusEffect } from "#enums/status-effect";
|
||||
import { WeatherType } from "#enums/weather-type";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import { toDmgValue } from "#utils/common";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("Abilities - Wimp Out", () => {
|
||||
describe("Abilities - Wimp Out/Emergency Exit", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
|
||||
@ -35,9 +35,6 @@ describe("Abilities - Wimp Out", () => {
|
||||
.ability(AbilityId.WIMP_OUT)
|
||||
.enemySpecies(SpeciesId.NINJASK)
|
||||
.enemyPassiveAbility(AbilityId.NO_GUARD)
|
||||
.startingLevel(90)
|
||||
.enemyLevel(70)
|
||||
.moveset([MoveId.SPLASH, MoveId.FALSE_SWIPE, MoveId.ENDURE])
|
||||
.enemyMoveset(MoveId.FALSE_SWIPE)
|
||||
.criticalHits(false);
|
||||
});
|
||||
@ -50,7 +47,7 @@ describe("Abilities - Wimp Out", () => {
|
||||
expect(pokemon1.species.speciesId).not.toBe(SpeciesId.WIMPOD);
|
||||
|
||||
expect(pokemon2.species.speciesId).toBe(SpeciesId.WIMPOD);
|
||||
expect(pokemon2.isFainted()).toBe(false);
|
||||
expect(pokemon2).toHaveFainted();
|
||||
expect(pokemon2.getHpRatio()).toBeLessThan(0.5);
|
||||
}
|
||||
|
||||
@ -62,295 +59,361 @@ describe("Abilities - Wimp Out", () => {
|
||||
expect(pokemon2.species.speciesId).not.toBe(SpeciesId.WIMPOD);
|
||||
|
||||
expect(pokemon1.species.speciesId).toBe(SpeciesId.WIMPOD);
|
||||
expect(pokemon1.isFainted()).toBe(false);
|
||||
expect(pokemon1).toHaveFainted();
|
||||
expect(pokemon1.getHpRatio()).toBeLessThan(0.5);
|
||||
}
|
||||
|
||||
it("triggers regenerator passive single time when switching out with wimp out", async () => {
|
||||
it.each<{ name: string; ability: AbilityId }>([
|
||||
{ name: "Wimp Out", ability: AbilityId.WIMP_OUT },
|
||||
{ name: "Emergency Exit", ability: AbilityId.EMERGENCY_EXIT },
|
||||
])("should switch the user out when falling below half HP, canceling its subsequent moves", async ({ ability }) => {
|
||||
game.override.ability(ability);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// Wimpod switched out after taking a hit, canceling its upcoming MoveEffectPhase before it could attack
|
||||
confirmSwitch();
|
||||
expect(game.field.getEnemyPokemon()).toHaveFullHp();
|
||||
expect(game.phaseInterceptor.log.filter(phase => phase === "MoveEffectPhase")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not trigger if user faints from damage and is revived", async () => {
|
||||
game.override
|
||||
.startingHeldItems([{ name: "REVIVER_SEED", count: 1 }])
|
||||
.enemyMoveset(MoveId.BRAVE_BIRD)
|
||||
.enemyLevel(1000);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(wimpod).toHaveFainted();
|
||||
expect(wimpod.isOnField()).toBe(true);
|
||||
expect(wimpod.getHpRatio()).toBeCloseTo(0.5);
|
||||
expect(wimpod.getHeldItems()).toHaveLength(0);
|
||||
expect(wimpod).not.toHaveAbilityApplied(AbilityId.WIMP_OUT);
|
||||
});
|
||||
|
||||
it("should trigger regenerator passive when switching out", async () => {
|
||||
game.override.passiveAbility(AbilityId.REGENERATOR).startingLevel(5).enemyLevel(100);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.scene.getPlayerPokemon()!;
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.move.use(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(wimpod.hp).toEqual(Math.floor(wimpod.getMaxHp() * 0.33 + 1));
|
||||
expect(wimpod).toHaveHp(Math.floor(wimpod.getMaxHp() * 0.33 + 1));
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("It makes wild pokemon flee if triggered", async () => {
|
||||
it("should cause wild pokemon to flee when triggered", async () => {
|
||||
game.override.enemyAbility(AbilityId.WIMP_OUT);
|
||||
await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
const enemyPokemon = game.field.getEnemyPokemon();
|
||||
enemyPokemon.hp *= 0.52;
|
||||
|
||||
game.move.select(MoveId.FALSE_SWIPE);
|
||||
await game.phaseInterceptor.to("BerryPhase");
|
||||
game.move.use(MoveId.FALSE_SWIPE);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const isVisible = enemyPokemon.visible;
|
||||
const hasFled = enemyPokemon.switchOutStatus;
|
||||
expect(!isVisible && hasFled).toBe(true);
|
||||
expect(enemyPokemon.visible).toBe(false);
|
||||
expect(enemyPokemon.switchOutStatus).toBe(true);
|
||||
});
|
||||
|
||||
it("Does not trigger when HP already below half", async () => {
|
||||
it("should not trigger if HP already below half", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
const wimpod = game.scene.getPlayerPokemon()!;
|
||||
wimpod.hp = 5;
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.1;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(wimpod.hp).toEqual(1);
|
||||
expect(wimpod.getHpRatio()).toBeLessThan(0.1);
|
||||
confirmNoSwitch();
|
||||
});
|
||||
|
||||
it("Trapping moves do not prevent Wimp Out from activating.", async () => {
|
||||
game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(1).passiveAbility(AbilityId.STURDY);
|
||||
it("should bypass trapping moves", async () => {
|
||||
game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(53).enemyLevel(45);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.move.use(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
|
||||
expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TRAPPED)).toBeUndefined();
|
||||
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TRAPPED)).toBeUndefined();
|
||||
expect(game.scene.getPlayerParty()[1].getTag(BattlerTagType.TRAPPED)).toBeUndefined();
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("If this Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out.", async () => {
|
||||
game.override.startingLevel(1).enemyMoveset([MoveId.U_TURN]).passiveAbility(AbilityId.STURDY);
|
||||
it("should block U-turn or Volt Switch on activation", async () => {
|
||||
game.override.battleType(BattleType.TRAINER);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.move.forceEnemyMove(MoveId.U_TURN);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
const hasFled = enemyPokemon.switchOutStatus;
|
||||
expect(hasFled).toBe(false);
|
||||
confirmSwitch();
|
||||
const ninjask = game.field.getEnemyPokemon();
|
||||
expect(ninjask.isOnField()).toBe(true);
|
||||
});
|
||||
|
||||
it("If this Ability does not activate due to being hit by U-turn or Volt Switch, the user of that move will be switched out.", async () => {
|
||||
game.override.startingLevel(190).startingWave(8).enemyMoveset([MoveId.U_TURN]);
|
||||
it("should not block U-turn or Volt Switch if not activated", async () => {
|
||||
game.override.battleType(BattleType.TRAINER);
|
||||
await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]);
|
||||
const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id;
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to("BerryPhase", false);
|
||||
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1);
|
||||
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
const ninjask = game.field.getEnemyPokemon();
|
||||
|
||||
// force enemy u turn to do 1 dmg
|
||||
vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({
|
||||
cancelled: false,
|
||||
damage: 1,
|
||||
result: HitResult.EFFECTIVE,
|
||||
});
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to("SwitchSummonPhase", false);
|
||||
const switchSummonPhase = game.scene.phaseManager.getCurrentPhase() as SwitchSummonPhase;
|
||||
expect(switchSummonPhase.getPokemon()).toBe(ninjask);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(wimpod.isOnField()).toBe(true);
|
||||
expect(ninjask.isOnField()).toBe(false);
|
||||
});
|
||||
|
||||
it("Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.", async () => {
|
||||
game.override.startingLevel(69).enemyMoveset([MoveId.DRAGON_TAIL]);
|
||||
it("should not activate when hit by force switch moves", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.scene.getPlayerPokemon()!;
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.move.forceEnemyMove(MoveId.CIRCLE_THROW);
|
||||
await game.phaseInterceptor.to("SwitchSummonPhase", false);
|
||||
|
||||
expect(wimpod.waveData.abilitiesApplied).not.toContain(AbilityId.WIMP_OUT);
|
||||
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(SpeciesId.WIMPOD);
|
||||
// Force switches directly call `SwitchSummonPhase` to send in a random opponent,
|
||||
// so wimp out triggering will stall out the test waiting for input
|
||||
await game.toEndOfTurn();
|
||||
expect(game.field.getPlayerPokemon().species.speciesId).not.toBe(SpeciesId.WIMPOD);
|
||||
});
|
||||
|
||||
it("triggers when recoil damage is taken", async () => {
|
||||
game.override.moveset([MoveId.HEAD_SMASH]).enemyMoveset([MoveId.SPLASH]);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.move.select(MoveId.HEAD_SMASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("It does not activate when the Pokémon cuts its own HP", async () => {
|
||||
game.override.moveset([MoveId.SUBSTITUTE]).enemyMoveset([MoveId.SPLASH]);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.scene.getPlayerPokemon()!;
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.select(MoveId.SUBSTITUTE);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
confirmNoSwitch();
|
||||
});
|
||||
|
||||
it("Does not trigger when neutralized", async () => {
|
||||
game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
confirmNoSwitch();
|
||||
});
|
||||
|
||||
// TODO: Enable when this behavior is fixed (currently Shell Bell won't activate if Wimp Out activates because
|
||||
// the pokemon is removed from the field before the Shell Bell modifier is applied, so it can't see the
|
||||
// damage dealt and doesn't heal the pokemon)
|
||||
it.todo(
|
||||
"If it falls below half and recovers back above half from a Shell Bell, Wimp Out will activate even after the Shell Bell recovery",
|
||||
async () => {
|
||||
it.each<{
|
||||
type: string;
|
||||
playerMove?: MoveId;
|
||||
playerPassive?: AbilityId;
|
||||
enemyMove?: MoveId;
|
||||
enemyAbility?: AbilityId;
|
||||
}>([
|
||||
{ type: "variable recoil moves", playerMove: MoveId.HEAD_SMASH },
|
||||
{ type: "HP-based recoil moves", playerMove: MoveId.CHLOROBLAST },
|
||||
{ type: "weather", enemyMove: MoveId.HAIL },
|
||||
{ type: "status", enemyMove: MoveId.TOXIC },
|
||||
{ type: "Ghost-type Curse", enemyMove: MoveId.CURSE },
|
||||
{ type: "Salt Cure", enemyMove: MoveId.SALT_CURE },
|
||||
{ type: "partial trapping moves", enemyMove: MoveId.WHIRLPOOL }, // no guard passive makes this 100% accurate
|
||||
{ type: "Leech Seed", enemyMove: MoveId.LEECH_SEED },
|
||||
{ type: "Powder", playerMove: MoveId.EMBER, enemyMove: MoveId.POWDER },
|
||||
{ type: "Nightmare", playerPassive: AbilityId.COMATOSE, enemyMove: MoveId.NIGHTMARE },
|
||||
{ type: "Bad Dreams", playerPassive: AbilityId.COMATOSE, enemyAbility: AbilityId.BAD_DREAMS },
|
||||
])(
|
||||
"should activate from damage caused by $type",
|
||||
async ({
|
||||
playerMove = MoveId.SPLASH,
|
||||
playerPassive = AbilityId.NONE,
|
||||
enemyMove = MoveId.SPLASH,
|
||||
enemyAbility = AbilityId.STURDY,
|
||||
}) => {
|
||||
game.override
|
||||
.moveset([MoveId.DOUBLE_EDGE])
|
||||
.enemyMoveset([MoveId.SPLASH])
|
||||
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]);
|
||||
.enemyLevel(1)
|
||||
.passiveAbility(playerPassive)
|
||||
.enemySpecies(SpeciesId.GASTLY)
|
||||
.enemyMoveset(enemyMove)
|
||||
.enemyAbility(enemyAbility);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.scene.getPlayerPokemon()!;
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
expect(wimpod).toBeDefined();
|
||||
wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 2);
|
||||
// mock enemy attack damage func to only do 1 dmg (for whirlpool)
|
||||
vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({
|
||||
cancelled: false,
|
||||
result: HitResult.EFFECTIVE,
|
||||
damage: 1,
|
||||
});
|
||||
|
||||
wimpod.damageAndUpdate(toDmgValue(wimpod.getMaxHp() * 0.4));
|
||||
|
||||
game.move.select(MoveId.DOUBLE_EDGE);
|
||||
game.move.use(playerMove);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(game.scene.getPlayerParty()[1]).toBe(wimpod);
|
||||
expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2));
|
||||
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
|
||||
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.TYRUNT);
|
||||
confirmSwitch();
|
||||
},
|
||||
);
|
||||
|
||||
it("Wimp Out will activate due to weather damage", async () => {
|
||||
game.override.weather(WeatherType.HAIL).enemyMoveset([MoveId.SPLASH]);
|
||||
it.each<{ name: string; ability: AbilityId }>([
|
||||
{ name: "Innards Out", ability: AbilityId.INNARDS_OUT },
|
||||
{ name: "Aftermath", ability: AbilityId.AFTERMATH },
|
||||
{ name: "Rough Skin", ability: AbilityId.ROUGH_SKIN },
|
||||
])("should trigger after taking damage from %s ability", async ({ ability }) => {
|
||||
game.override.enemyAbility(ability).enemyMoveset(MoveId.SPLASH);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.51;
|
||||
game.field.getEnemyPokemon().hp = wimpod.hp - 1; // Ensure innards out doesn't KO
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.move.use(MoveId.GUILLOTINE);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
await game.toNextWave();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Does not trigger when enemy has sheer force", async () => {
|
||||
game.override.enemyAbility(AbilityId.SHEER_FORCE).enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(95);
|
||||
it("should not trigger from Sheer Force-boosted moves", async () => {
|
||||
game.override.enemyAbility(AbilityId.SHEER_FORCE).startingLevel(1);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
game.field.getPlayerPokemon().hp *= 0.51;
|
||||
|
||||
game.move.select(MoveId.ENDURE);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
game.move.use(MoveId.ENDURE);
|
||||
await game.move.forceEnemyMove(MoveId.SLUDGE_BOMB);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
confirmNoSwitch();
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to post turn status damage", async () => {
|
||||
game.override.statusEffect(StatusEffect.POISON).enemyMoveset([MoveId.SPLASH]);
|
||||
it("should trigger from Flame Burst splash damage in doubles", async () => {
|
||||
game.override.battleStyle("double");
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.ZYGARDE, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
expect(wimpod).toBeDefined();
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
|
||||
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
|
||||
await game.move.forceEnemyMove(MoveId.FLAME_BURST, BattlerIndex.PLAYER_2);
|
||||
await game.move.forceEnemyMove(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(2);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(wimpod.isOnField()).toBe(false);
|
||||
expect(wimpod.getHpRatio()).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("should not activate when the Pokémon cuts its own HP below half", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
// Turn 1: Substitute knocks below half; no switch
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.52;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
game.move.use(MoveId.SUBSTITUTE);
|
||||
await game.move.forceEnemyMove(MoveId.TIDY_UP);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toNextTurn();
|
||||
|
||||
confirmNoSwitch();
|
||||
// Turn 2: get back enough HP that substitute doesn't put us under
|
||||
wimpod.hp = wimpod.getMaxHp() * 0.8;
|
||||
|
||||
game.move.use(MoveId.SUBSTITUTE);
|
||||
await game.move.forceEnemyMove(MoveId.ROUND);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to bad dreams", async () => {
|
||||
game.override.statusEffect(StatusEffect.SLEEP).enemyAbility(AbilityId.BAD_DREAMS);
|
||||
it("should not trigger when neutralized", async () => {
|
||||
game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.52;
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
confirmNoSwitch();
|
||||
});
|
||||
|
||||
it("should disregard Shell Bell recovery while still activating it before switching", async () => {
|
||||
game.override
|
||||
.moveset(MoveId.DOUBLE_EDGE)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); // heals 50% of damage dealt, more than recoil takes away
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.51;
|
||||
|
||||
game.move.use(MoveId.DOUBLE_EDGE);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
await game.phaseInterceptor.to("MoveEffectPhase");
|
||||
|
||||
// Wimp out check activated from recoil before shell bell procced, but did not deny the pokemon its recovery
|
||||
expect(wimpod.turnData.damageTaken).toBeGreaterThan(0);
|
||||
expect(wimpod.getHpRatio()).toBeGreaterThan(0.5);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to leech seed", async () => {
|
||||
game.override.enemyMoveset([MoveId.LEECH_SEED]);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.52;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to curse damage", async () => {
|
||||
game.override.enemySpecies(SpeciesId.DUSKNOIR).enemyMoveset([MoveId.CURSE]);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.52;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to salt cure damage", async () => {
|
||||
game.override.enemySpecies(SpeciesId.NACLI).enemyMoveset([MoveId.SALT_CURE]).enemyLevel(1);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.7;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to damaging trap damage", async () => {
|
||||
game.override.enemySpecies(SpeciesId.MAGIKARP).enemyMoveset([MoveId.WHIRLPOOL]).enemyLevel(1);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.55;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Magic Guard passive should not allow indirect damage to trigger Wimp Out", async () => {
|
||||
it("should activate from entry hazard damage", async () => {
|
||||
// enemy centiscorch switches in... then dies
|
||||
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
|
||||
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY);
|
||||
game.override
|
||||
.passiveAbility(AbilityId.MAGIC_GUARD)
|
||||
.enemyMoveset([MoveId.LEECH_SEED])
|
||||
.weather(WeatherType.HAIL)
|
||||
.statusEffect(StatusEffect.POISON);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT);
|
||||
await game.classicMode.startBattle([SpeciesId.TYRUNT]);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
expect(game.scene.getPlayerParty()[0].getHpRatio()).toEqual(0.51);
|
||||
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
|
||||
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.WIMPOD);
|
||||
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
|
||||
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
|
||||
});
|
||||
|
||||
it("Wimp Out activating should not cancel a double battle", async () => {
|
||||
game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyMoveset([MoveId.SPLASH]).enemyLevel(1);
|
||||
it("should not switch if Magic Guard prevents damage", async () => {
|
||||
game.override.passiveAbility(AbilityId.MAGIC_GUARD).enemyMoveset(MoveId.LEECH_SEED);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.51;
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(wimpod.isOnField()).toBe(true);
|
||||
expect(wimpod.getHpRatio()).toBeCloseTo(0.51);
|
||||
});
|
||||
|
||||
it("should not cancel a double battle on activation", async () => {
|
||||
game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyLevel(1);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
const enemyLeadPokemon = game.scene.getEnemyParty()[0];
|
||||
const enemySecPokemon = game.scene.getEnemyParty()[1];
|
||||
|
||||
game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
|
||||
game.move.select(MoveId.SPLASH, 1);
|
||||
game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
|
||||
game.move.use(MoveId.SPLASH, 1);
|
||||
|
||||
await game.phaseInterceptor.to("BerryPhase");
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const isVisibleLead = enemyLeadPokemon.visible;
|
||||
const hasFledLead = enemyLeadPokemon.switchOutStatus;
|
||||
@ -361,145 +424,63 @@ describe("Abilities - Wimp Out", () => {
|
||||
expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp());
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to aftermath", async () => {
|
||||
game.override
|
||||
.moveset([MoveId.THUNDER_PUNCH])
|
||||
.enemySpecies(SpeciesId.MAGIKARP)
|
||||
.enemyAbility(AbilityId.AFTERMATH)
|
||||
.enemyMoveset([MoveId.SPLASH])
|
||||
.enemyLevel(1);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
|
||||
game.move.select(MoveId.THUNDER_PUNCH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("Activates due to entry hazards", async () => {
|
||||
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
|
||||
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY);
|
||||
game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT).startingWave(4);
|
||||
await game.classicMode.startBattle([SpeciesId.TYRUNT]);
|
||||
|
||||
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
|
||||
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
|
||||
});
|
||||
|
||||
it("Wimp Out will activate due to Nightmare", async () => {
|
||||
game.override.enemyMoveset([MoveId.NIGHTMARE]).statusEffect(StatusEffect.SLEEP);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.65;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("triggers status on the wimp out user before a new pokemon is switched in", async () => {
|
||||
game.override.enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(80);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
vi.spyOn(allMoves[MoveId.SLUDGE_BOMB], "chance", "get").mockReturnValue(100);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
expect(game.scene.getPlayerParty()[1].status?.effect).toEqual(StatusEffect.POISON);
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("triggers after last hit of multi hit move", async () => {
|
||||
game.override.enemyMoveset(MoveId.BULLET_SEED).enemyAbility(AbilityId.SKILL_LINK);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
|
||||
game.move.select(MoveId.ENDURE);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
|
||||
expect(enemyPokemon.turnData.hitCount).toBe(5);
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
it("triggers after last hit of multi hit move (multi lens)", async () => {
|
||||
game.override.enemyMoveset(MoveId.TACKLE).enemyHeldItems([{ name: "MULTI_LENS", count: 1 }]);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
|
||||
game.move.select(MoveId.ENDURE);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
|
||||
expect(enemyPokemon.turnData.hitCount).toBe(2);
|
||||
confirmSwitch();
|
||||
});
|
||||
it("triggers after last hit of Parental Bond", async () => {
|
||||
game.override.enemyMoveset(MoveId.TACKLE).enemyAbility(AbilityId.PARENTAL_BOND);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
game.scene.getPlayerPokemon()!.hp *= 0.51;
|
||||
|
||||
game.move.select(MoveId.ENDURE);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
|
||||
expect(enemyPokemon.turnData.hitCount).toBe(2);
|
||||
confirmSwitch();
|
||||
});
|
||||
|
||||
// TODO: This interaction is not implemented yet
|
||||
it.todo(
|
||||
"Wimp Out will not activate if the Pokémon's HP falls below half due to hurting itself in confusion",
|
||||
async () => {
|
||||
game.override.moveset([MoveId.SWORDS_DANCE]).enemyMoveset([MoveId.SWAGGER]);
|
||||
it.each<{ type: string; move?: MoveId; ability?: AbilityId; items?: ModifierOverride[] }>([
|
||||
{ type: "normal", move: MoveId.DUAL_CHOP },
|
||||
{ type: "Parental Bond", ability: AbilityId.PARENTAL_BOND },
|
||||
{ type: "Multi Lens", items: [{ name: "MULTI_LENS", count: 1 }] },
|
||||
])(
|
||||
"should trigger after the last hit of $type multi-strike moves",
|
||||
async ({ move = MoveId.TACKLE, ability = AbilityId.COMPOUND_EYES, items = [] }) => {
|
||||
game.override.enemyMoveset(move).enemyAbility(ability).enemyHeldItems(items);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
const playerPokemon = game.scene.getPlayerPokemon()!;
|
||||
playerPokemon.hp *= 0.51;
|
||||
playerPokemon.setStatStage(Stat.ATK, 6);
|
||||
playerPokemon.addTag(BattlerTagType.CONFUSED);
|
||||
|
||||
// TODO: add helper function to force confusion self-hits
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.51;
|
||||
|
||||
while (playerPokemon.getHpRatio() > 0.49) {
|
||||
game.move.select(MoveId.SWORDS_DANCE);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
}
|
||||
game.move.use(MoveId.ENDURE);
|
||||
game.doSelectPartyPokemon(1);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
confirmNoSwitch();
|
||||
const enemyPokemon = game.field.getEnemyPokemon();
|
||||
expect(enemyPokemon.turnData.hitsLeft).toBe(0);
|
||||
expect(enemyPokemon.turnData.hitCount).toBe(2);
|
||||
confirmSwitch();
|
||||
|
||||
// Switch triggered after the MEPs for both hits finished
|
||||
const phaseLogs = game.phaseInterceptor.log;
|
||||
expect(phaseLogs.filter(l => l === "MoveEffectPhase")).toHaveLength(3); // 1 for endure + 2 for dual hit
|
||||
expect(phaseLogs.lastIndexOf("SwitchSummonPhase")).toBeGreaterThan(phaseLogs.lastIndexOf("MoveEffectPhase"));
|
||||
},
|
||||
);
|
||||
|
||||
it("should not activate on wave X0 bosses", async () => {
|
||||
game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5850).startingWave(10);
|
||||
await game.classicMode.startBattle([SpeciesId.GOLISOPOD]);
|
||||
it("should not activate from confusion damage", async () => {
|
||||
game.override.enemyMoveset(MoveId.CONFUSE_RAY).confusionActivation(true);
|
||||
await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]);
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
const wimpod = game.field.getPlayerPokemon();
|
||||
wimpod.hp *= 0.51;
|
||||
|
||||
// Use 2 turns of False Swipe due to opponent's health bar shield
|
||||
game.move.select(MoveId.FALSE_SWIPE);
|
||||
await game.toNextTurn();
|
||||
game.move.select(MoveId.FALSE_SWIPE);
|
||||
await game.toNextTurn();
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const isVisible = enemyPokemon.visible;
|
||||
const hasFled = enemyPokemon.switchOutStatus;
|
||||
expect(isVisible && !hasFled).toBe(true);
|
||||
confirmNoSwitch();
|
||||
});
|
||||
|
||||
it("wimp out will not skip battles when triggered in a double battle", async () => {
|
||||
it("should not activate on wave X0 bosses", async () => {
|
||||
game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5000).startingWave(10).enemyHealthSegments(3);
|
||||
await game.classicMode.startBattle([SpeciesId.GOLISOPOD]);
|
||||
|
||||
const enemyPokemon = game.field.getEnemyPokemon();
|
||||
|
||||
game.move.use(MoveId.FALSE_SWIPE);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(enemyPokemon.visible).toBe(true);
|
||||
expect(enemyPokemon.switchOutStatus).toBe(false);
|
||||
});
|
||||
|
||||
it("should not skip battles when triggered in a double battle", async () => {
|
||||
const wave = 2;
|
||||
game.override
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
@ -513,10 +494,10 @@ describe("Abilities - Wimp Out", () => {
|
||||
await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.PIKACHU]);
|
||||
const [wimpod0, wimpod1] = game.scene.getEnemyField();
|
||||
|
||||
game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
|
||||
game.move.select(MoveId.MATCHA_GOTCHA, 1);
|
||||
game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY);
|
||||
game.move.use(MoveId.MATCHA_GOTCHA, 1);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(wimpod0.hp).toBeGreaterThan(0);
|
||||
expect(wimpod0.switchOutStatus).toBe(true);
|
||||
@ -527,27 +508,24 @@ describe("Abilities - Wimp Out", () => {
|
||||
expect(game.scene.currentBattle.waveIndex).toBe(wave + 1);
|
||||
});
|
||||
|
||||
it("wimp out should not skip battles when triggering the same turn as another enemy faints", async () => {
|
||||
const wave = 2;
|
||||
it("should not skip battles when triggering the same turn as another enemy faints", async () => {
|
||||
game.override
|
||||
.enemySpecies(SpeciesId.WIMPOD)
|
||||
.enemyAbility(AbilityId.WIMP_OUT)
|
||||
.startingLevel(50)
|
||||
.enemyLevel(1)
|
||||
.enemyMoveset([MoveId.SPLASH, MoveId.ENDURE])
|
||||
.battleStyle("double")
|
||||
.moveset([MoveId.DRAGON_ENERGY, MoveId.SPLASH])
|
||||
.startingWave(wave);
|
||||
.startingWave(2);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.REGIDRAGO, SpeciesId.MAGIKARP]);
|
||||
|
||||
// turn 1
|
||||
game.move.select(MoveId.DRAGON_ENERGY, 0);
|
||||
game.move.select(MoveId.SPLASH, 1);
|
||||
await game.move.selectEnemyMove(MoveId.SPLASH);
|
||||
await game.move.selectEnemyMove(MoveId.ENDURE);
|
||||
// turn 1 - 1st wimpod faints while the 2nd one flees
|
||||
game.move.use(MoveId.DRAGON_ENERGY, BattlerIndex.PLAYER);
|
||||
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
|
||||
await game.move.forceEnemyMove(MoveId.SPLASH);
|
||||
await game.move.forceEnemyMove(MoveId.ENDURE);
|
||||
|
||||
await game.phaseInterceptor.to("SelectModifierPhase");
|
||||
expect(game.scene.currentBattle.waveIndex).toBe(wave + 1);
|
||||
await game.toNextWave();
|
||||
expect(game.scene.currentBattle.waveIndex).toBe(3);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -77,9 +77,8 @@ describe("Moves - Chilly Reception", () => {
|
||||
|
||||
game.move.select(MoveId.CHILLY_RECEPTION);
|
||||
game.doSelectPartyPokemon(1);
|
||||
// TODO: Uncomment lines once wimp out PR fixes force switches to not reset summon data immediately
|
||||
// await game.phaseInterceptor.to("SwitchSummonPhase", false);
|
||||
// expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
|
||||
await game.phaseInterceptor.to("SwitchSummonPhase", false);
|
||||
expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -44,20 +44,18 @@ describe("Moves - Focus Punch", () => {
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
|
||||
const enemyStartingHp = enemyPokemon.hp;
|
||||
|
||||
game.move.select(MoveId.FOCUS_PUNCH);
|
||||
|
||||
await game.phaseInterceptor.to(MessagePhase);
|
||||
|
||||
expect(enemyPokemon.hp).toBe(enemyStartingHp);
|
||||
expect(enemyPokemon.getInverseHp()).toBe(0);
|
||||
expect(leadPokemon.getMoveHistory().length).toBe(0);
|
||||
|
||||
await game.phaseInterceptor.to(BerryPhase, false);
|
||||
|
||||
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp);
|
||||
expect(enemyPokemon.getInverseHp()).toBe(0);
|
||||
expect(leadPokemon.getMoveHistory().length).toBe(1);
|
||||
expect(leadPokemon.turnData.totalDamageDealt).toBe(enemyStartingHp - enemyPokemon.hp);
|
||||
expect(enemyPokemon.getInverseHp()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should fail if the user is hit", async () => {
|
||||
@ -72,16 +70,16 @@ describe("Moves - Focus Punch", () => {
|
||||
|
||||
game.move.select(MoveId.FOCUS_PUNCH);
|
||||
|
||||
await game.phaseInterceptor.to(MessagePhase);
|
||||
await game.phaseInterceptor.to("MessagePhase");
|
||||
|
||||
expect(enemyPokemon.hp).toBe(enemyStartingHp);
|
||||
expect(leadPokemon.getMoveHistory().length).toBe(0);
|
||||
|
||||
await game.phaseInterceptor.to(BerryPhase, false);
|
||||
await game.phaseInterceptor.to("BerryPhase", false);
|
||||
|
||||
expect(enemyPokemon.hp).toBe(enemyStartingHp);
|
||||
expect(leadPokemon.getMoveHistory().length).toBe(1);
|
||||
expect(leadPokemon.turnData.totalDamageDealt).toBe(0);
|
||||
expect(enemyPokemon.getInverseHp()).toBe(0);
|
||||
});
|
||||
|
||||
it("should be cancelled if the user falls asleep mid-turn", async () => {
|
||||
|
502
test/moves/force-switch.test.ts
Normal file
502
test/moves/force-switch.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -175,7 +175,7 @@ describe("Moves - Powder", () => {
|
||||
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
|
||||
// enemy should have taken damage from player's Fiery Dance + 2 Powder procs
|
||||
expect(enemyPokemon.hp).toBe(
|
||||
enemyStartingHp - playerPokemon.turnData.totalDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4),
|
||||
enemyStartingHp - playerPokemon.turnData.singleHitDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -172,6 +172,8 @@ export class GameManager {
|
||||
* @param mode - The mode to wait for.
|
||||
* @param callback - The callback function to execute on next prompt.
|
||||
* @param expireFn - Optional function to determine if the prompt has expired.
|
||||
* @remarks
|
||||
* If multiple callbacks are queued for the same phase, they will be executed in the order they were added.
|
||||
*/
|
||||
onNextPrompt(
|
||||
phaseTarget: string,
|
||||
|
Loading…
Reference in New Issue
Block a user