Fixed things with switching moves; refactored code and removed extraneous resetSummonData calls

This commit is contained in:
Bertie690 2025-05-17 14:33:00 -04:00
parent 09bdfa395b
commit 02a3a56ef6
19 changed files with 596 additions and 417 deletions

View File

@ -872,48 +872,51 @@ export default class BattleScene extends SceneBase {
}
/**
* Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
* 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 of all {@linkcode PlayerPokemon} in reserve able to be switched into.
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
* @overload
*/
public getBackupPartyMembers(player: true): PlayerPokemon[];
public getBackupPartyMemberIndices(player: true): number[];
/**
* Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
* 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 EnemyPokemon.trainerSlot | trainer slot} of the Pokemon being switched out;
* @param trainerSlot - The {@linkcode TrainerSlot | trainer slot} to check against for enemy trainers;
* used to verify ownership in multi battles.
* @returns An array of all {@linkcode EnemyPokemon} in reserve able to be switched into.
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
* @overload
*/
public getBackupPartyMembers(player: false, trainerSlot: number): EnemyPokemon[];
public getBackupPartyMemberIndices(player: false, trainerSlot: TrainerSlot): number[];
/**
* Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field}
* 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 enemy Pokemon's {@linkcode EnemyPokemon.trainerSlot | trainer slot} for opposing trainers;
* @param trainerSlot - The {@linkcode TrainerSlot | trainer slot} to check against for enemy trainers;
* used to verify ownership in multi battles and unused for player pokemon.
* @returns An array of all {@linkcode PlayerPokemon}/{@linkcode EnemyPokemon} in reserve able to be switched into.
* @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into.
* @overload
*/
public getBackupPartyMembers(player: boolean, trainerSlot: number | undefined): PlayerPokemon | EnemyPokemon[];
public getBackupPartyMemberIndices(player: boolean, trainerSlot: TrainerSlot | undefined): number[];
public getBackupPartyMembers<B extends boolean = never, R = B extends true ? PlayerPokemon : EnemyPokemon>(
player: B,
trainerSlot?: number,
): R[] {
return (player ? this.getPlayerParty() : this.getEnemyParty()).filter(
(p: PlayerPokemon | EnemyPokemon) =>
p.isAllowedInBattle() && !p.isOnField() && (p instanceof PlayerPokemon || p.trainerSlot !== trainerSlot),
) as R[];
public getBackupPartyMemberIndices(player: boolean, 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.
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;
}
/**

View File

@ -80,6 +80,7 @@ import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag";
import { SelectBiomePhase } from "#app/phases/select-biome-phase";
import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves";
import { ForceSwitch } from "../mixins/force-switch";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
export class BlockRecoilDamageAttr extends AbAttr {
constructor() {
@ -1301,7 +1302,6 @@ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr {
if (!pokemon.isTerastallized &&
move.id !== Moves.STRUGGLE &&
/**
* Skip moves that call other moves because these moves generate a following move that will trigger this ability attribute
* @see {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves}
*/
!move.findAttr((attr) =>
@ -2818,8 +2818,12 @@ export class CommanderAbAttr extends AbAttr {
// TODO: Should this work with X + Dondozo fusions?
const ally = pokemon.getAlly();
return globalScene.currentBattle?.double && !isNullOrUndefined(ally) && ally.species.speciesId === Species.DONDOZO
&& !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED));
return (
globalScene.currentBattle?.double
&& ally?.species.speciesId === Species.DONDOZO
&& !ally.isFainted()
&& !ally.getTag(BattlerTagType.COMMANDED)
);
}
override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: null, args: any[]): void {
@ -4105,7 +4109,6 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr {
return false;
}
// Clamp procChance to [0, 1]. Skip if didn't proc (less than pass)
const pass = Phaser.Math.RND.realInRange(0, 1);
return Phaser.Math.Clamp(this.procChance(pokemon), 0, 1) >= pass;
}
@ -5578,7 +5581,7 @@ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) {
constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) {
super();
this.selfSwitch = false; // TODO: change if any abilities get damage
this.selfSwitch = false; // TODO: change if any force switch abilities with red card exist
this.switchType = switchType;
this.hpRatio = hpRatio;
}
@ -5600,34 +5603,64 @@ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) {
source: Pokemon | undefined,
_args: any[],
): boolean {
const userLastMove = pokemon.getLastXMoves()[0];
// Will not activate when the Pokémon's HP is lowered by cutting its own HP
const forbiddenAttackingMoves = new Set<Moves>([ Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.CURSE, Moves.PAIN_SPLIT ]);
if (!isNullOrUndefined(userLastMove) && forbiddenAttackingMoves.has(userLastMove.move)) {
// Skip move checks for damage not occurring due to a move (eg: hazards)
const currentPhase = globalScene.getCurrentPhase();
if (currentPhase instanceof MoveEffectPhase && !this.passesMoveChecks(pokemon, source)) {
return false;
}
// Skip last move checks if no enemy move
if (!this.wasKnockedBelowHalf(pokemon, damage)) {
return false;
}
return this.canSwitchOut(pokemon)
}
/**
* Perform move checks to determine if this pokemon should switch out.
* @param pokemon - The {@linkcode Pokemon} with this ability
* @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(pokemon: Pokemon, source: Pokemon | undefined): boolean {
// Wimp Out and Emergency Exit...
const currentPhase = globalScene.getCurrentPhase() as MoveEffectPhase;
const currentMove = currentPhase.move;
// will not activate from self-induced HP cutting...
// TODO: Verify that Fillet Away and Clangorous Soul proc wimp out
const hpCutMoves = new Set<Moves>([ Moves.CURSE, Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.PAIN_SPLIT, Moves.CLANGOROUS_SOUL, Moves.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 ability to begin with.
const notHpCut = !hpCutMoves.has(currentMove.id)
// will not activate for forced switch moves (which trigger before wimp out activates)...
const notForceSwitched = ![Moves.DRAGON_TAIL, Moves.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 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 moot)
const lastMove = source?.getLastXMoves()[0]
if (
lastMove &&
// Will not activate for forced switch moves (triggers before wimp out activates)
(allMoves[lastMove.move].hasAttr(ForceSwitchOutAttr)
// Will not activate if the Pokémon's HP falls below half while it is in the air during Sky Drop
// TODO: Make this check the user's tags rather than the last move used by the target - we could be lifted by another pokemon
|| (lastMove.move === Moves.SKY_DROP && lastMove.result === MoveResult.OTHER))
) {
return false;
}
const notSkyDropped = !(lastMove?.move === Moves.SKY_DROP && lastMove.result === MoveResult.OTHER)
// Check for HP percents - don't switch if the move didn't knock us below our switch threshold
// (either because we were below it to begin with or are still above it after the hit).
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 `true` if this Pokemon was knocked below half after `damage` was applied
*/
private wasKnockedBelowHalf(pokemon: Pokemon, damage: number) {
// NB: This occurs in `MoveEffectPhase` _after_ attack damage has been dealt,
// so `pokemon.hp` contains the post-taking damage hp value.
const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio;
if (pokemon.hp + damage < hpNeededToSwitch || pokemon.hp >= hpNeededToSwitch) {
return false;
}
return this.canSwitchOut(pokemon, undefined)
return pokemon.hp < hpNeededToSwitch && pokemon.hp + damage >= hpNeededToSwitch
}
/**
@ -6913,12 +6946,10 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(Abilities.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition())
.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(Abilities.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition())
.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(Abilities.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(Abilities.MERCILESS, 7)

View File

@ -751,10 +751,16 @@ export class ConfusedTag extends BattlerTag {
);
}
/**
* 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;
}
@ -766,7 +772,9 @@ export class ConfusedTag extends BattlerTag {
globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION));
// 1/3 chance of hitting self with a 40 base power move
if (pokemon.randSeedInt(3) === 0 || Overrides.CONFUSION_ACTIVATION_OVERRIDE === true) {
const shouldInterruptMove = Overrides.CONFUSION_ACTIVATION_OVERRIDE ?? pokemon.randSeedInt(3) === 0;
if (shouldInterruptMove) {
// TODO: Are these calculations correct? We really shouldn't hardcode the damage formula here...
const atk = pokemon.getEffectiveStat(Stat.ATK);
const def = pokemon.getEffectiveStat(Stat.DEF);
const damage = toDmgValue(

View File

@ -17,51 +17,49 @@ import { applyAbAttrs, ForceSwitchOutImmunityAbAttr } from "../abilities/ability
import type { MoveAttr } from "../moves/move";
import { getPokemonNameWithAffix } from "#app/messages";
import type { AbAttr } from "../abilities/ab-attrs/ab-attr";
import type { TrainerSlot } from "#enums/trainer-slot";
// NB: This shouldn't be terribly hard to extend from if switching items are added (à la Eject Button)
// NB: This shouldn't be terribly hard to extend from items if switching items are added (à la Eject Button/Red Card);
type SubMoveOrAbAttr = (new (...args: any[]) => MoveAttr) | (new (...args: any[]) => AbAttr);
/** Mixin to handle shared logic for switch-in moves and abilities. */
/** Mixin to handle shared logic for switching moves and abilities. */
export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
return class ForceSwitchClass extends Base {
protected selfSwitch = false;
protected switchType: NormalSwitchType = SwitchType.SWITCH;
/**
* Determines if a Pokémon can be forcibly switched out based on its status, the opponent's status and battle conditions.
* @see {@linkcode performOpponentChecks} for opponent-related check code.
* @param switchOutTarget - The {@linkcode Pokemon} attempting to switch out.
* @param opponent - The {@linkcode Pokemon} opposing the currently switched out Pokemon.
* Unused if {@linkcode selfSwitch} is `true`, as it is only used to check Suction Cups.
* Determines 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 Move or Ability.
*/
protected canSwitchOut(switchOutTarget: Pokemon, opponent: Pokemon | undefined): boolean {
protected canSwitchOut(switchOutTarget: Pokemon): boolean {
const isPlayer = switchOutTarget instanceof PlayerPokemon;
if (!this.selfSwitch && opponent && !this.performOpponentChecks(switchOutTarget, opponent)) {
// 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 baton pass, nor by any means on X0 waves (don't want easy boss wins)
if (!isPlayer && globalScene.currentBattle.battleType === BattleType.WILD) {
// enemies should not be allowed to flee with baton pass, nor by any means on X0 waves (don't want easy boss wins)
return this.switchType !== SwitchType.BATON_PASS && globalScene.currentBattle.waveIndex % 10 !== 0;
}
// Finally, ensure that we have valid switch out targets.
const reservePartyMembers = globalScene.getBackupPartyMembers(
// Finally, ensure that a trainer switching out has at least 1 valid reserve member to send in.
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(
isPlayer,
(switchOutTarget as EnemyPokemon).trainerSlot as TrainerSlot | undefined,
); // evaluates to `undefined` if not present
if (reservePartyMembers.length === 0) {
return false;
}
return true;
!isPlayer ? (switchOutTarget as EnemyPokemon).trainerSlot : undefined,
);
return reservePartyMembers.length > 0;
}
protected performOpponentChecks(switchOutTarget: Pokemon, opponent: Pokemon): boolean {
/**
* 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)) {
@ -70,8 +68,8 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
// Check for opposing switch block abilities (Suction Cups and co)
const blockedByAbility = new BooleanHolder(false);
applyAbAttrs(ForceSwitchOutImmunityAbAttr, opponent, blockedByAbility);
if (!blockedByAbility.value) {
applyAbAttrs(ForceSwitchOutImmunityAbAttr, switchOutTarget, blockedByAbility);
if (blockedByAbility.value) {
return false;
}
@ -97,7 +95,10 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
}
if (!(switchOutTarget instanceof EnemyPokemon)) {
console.warn("Switched out target not instance of Player or enemy Pokemon!");
console.warn(
"Switched out target (index %i) neither player nor enemy Pokemon!",
switchOutTarget.getFieldIndex(),
);
return;
}
@ -119,12 +120,12 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
return;
}
// Pick a random player pokemon to switch out.
const reservePartyMembers = globalScene.getBackupPartyMembers(true);
const switchOutIndex = switchOutTarget.randSeedInt(reservePartyMembers.length);
// Pick a random eligible player pokemon to replace the switched out one.
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(true);
const switchInIndex = reservePartyMembers[switchOutTarget.randSeedInt(reservePartyMembers.length)];
globalScene.appendToPhase(
new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchOutIndex, false, true),
new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchInIndex, false, true),
MoveEndPhase,
);
}
@ -132,15 +133,16 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void {
// fallback for no trainer
if (!globalScene.currentBattle.trainer) {
console.warn("Enemy trainer switch logic approached without a trainer!");
console.warn("Enemy trainer switch logic triggered without a trainer!");
return;
}
// Forced switches will to pick a random eligible pokemon, while
// choice-based switching uses the trainer's default switch behavior
const reservePartyMembers = globalScene.getBackupPartyMembers(false, switchOutTarget.trainerSlot);
// 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
? switchOutTarget.randSeedInt(reservePartyMembers.length)
? reservePartyIndices[switchOutTarget.randSeedInt(reservePartyIndices.length)]
: (globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot) ?? 0);
globalScene.appendToPhase(
new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), summonIndex, false, false),
@ -180,5 +182,9 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
public isBatonPass(): boolean {
return this.switchType === SwitchType.BATON_PASS;
}
public isForcedSwitch(): boolean {
return this.switchType === SwitchType.FORCE_SWITCH;
}
};
}

View File

@ -2002,6 +2002,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;
@ -2019,8 +2026,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()));
globalScene.pushPhase(
new PokemonHealPhase(
@ -6210,7 +6217,9 @@ export class RevivalBlessingAttr extends MoveEffectAttr {
}
}
/**
* Attribute to forcibly switch out the user or target of a Move.
*/
export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) {
constructor(
selfSwitch: boolean = false,
@ -6221,54 +6230,67 @@ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) {
this.switchType = switchType;
}
apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
if (!this.getSwitchOutCondition()(user, target, move)) {
return false;
}
this.doSwitch(this.selfSwitch ? user : target)
return true;
}
getCondition(): MoveConditionFunc {
return this.getSwitchOutCondition();
// Damaging switch moves should not "fail" _per se_ upon a failed switch -
// they still succeed and deal damage (but just without actually switching out).
return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move));
}
/**
* Check whether the target can be switched out.
* @returns A {@linkcode MoveConditionFunc} that returns `true` if the switch out attempt should succeed.
*/
getSwitchOutCondition(): MoveConditionFunc {
return (user, target, move) => {
const [switchOutTarget, opponent] = this.selfSwitch ? [user, target] : [target, user];
const switchOutTarget = this.selfSwitch ? user : target;
// Don't allow wild mons to flee with U-turn et al.
if (switchOutTarget instanceof EnemyPokemon && globalScene.currentBattle.battleType === BattleType.WILD && !(this.selfSwitch && move.category !== MoveCategory.STATUS)) {
if (
switchOutTarget instanceof EnemyPokemon
&& globalScene.currentBattle.battleType === BattleType.WILD
&& this.selfSwitch
&& move.category !== MoveCategory.STATUS
) {
return false;
}
return this.canSwitchOut(switchOutTarget, opponent)
// Check for Wimp Out edge case - self-switching moves cannot proc if the attack also triggers Wimp Out/EE
const moveDmgDealt = user.turnData.lastMoveDamageDealt[target.getBattlerIndex()]
if (
this.selfSwitch
&& moveDmgDealt
&& target.getAbilityAttrs(PostDamageForceSwitchAbAttr).some(
p => p.canApplyPostDamage(target, moveDmgDealt, false, user, []))
) {
return false;
}
return this.canSwitchOut(switchOutTarget)
};
}
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
if (!globalScene.getEnemyParty().find(p => p.isActive() && !p.isOnField())) {
const reservePartyMembers = globalScene.getBackupPartyMemberIndices(user.isPlayer() === this.selfSwitch, !user.isPlayer() ? (user as EnemyPokemon).trainerSlot : undefined)
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);
// 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;
}
}
export class ChillyReceptionAttr extends ForceSwitchOutAttr {

View File

@ -344,12 +344,13 @@ export default class MysteryEncounter implements IMysteryEncounter {
*/
private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean {
let qualified: PlayerPokemon[] = globalScene.getPlayerParty();
if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) {
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;
}
for (const req of this.primaryPokemonRequirements) {
if (req.meetsRequirement()) {
qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn));

View File

@ -4757,7 +4757,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @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, which instead occurs at the end of `MoveEffectPhase`.
* This will not trigger "on damage" effects for direct damage moves, instead occuring at the end of `MoveEffectPhase`.
*/
damageAndUpdate(damage: number,
{
@ -4782,7 +4782,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
);
globalScene.unshiftPhase(damagePhase);
// TODO: Review if wimp out battle skip actually needs this anymore
// Prevent enemies not on field from taking damage.
// TODO: Review if wimp out actually needs this anymore
if (this.switchOutStatus) {
damage = 0;
}
@ -4803,8 +4804,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
// Damage amount may have changed, but needed to be queued before calling damage function
damagePhase.updateAmount(damage);
// Trigger PostDamageAbAttr (ie wimp out) for indirect damage only.
if (isIndirectDamage) {
// Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances.
// We leave `source` as undefined for indirect hits to specify that the damage instance is indirect.
if (isIndirectDamage && result !== HitResult.CONFUSION) {
applyPostDamageAbAttrs(
PostDamageAbAttr,
this,
@ -5726,8 +5728,8 @@ export default 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 a `SummonPhase` or `SwitchSummonPhase` is already being added,
* both of which call this method (directly or indirectly) on both pokemon changing positions.
* 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}`)
@ -5801,6 +5803,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
resetTurnData(): void {
console.log(`resetTurnData called on Pokemon ${this.name}`)
this.turnData = new PokemonTurnData();
}
@ -6311,7 +6314,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false`
* @remarks
* This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added,
* which can lead to erroneous resetting of {@linkcode turnData} or {@linkcode summonData}.
* which can lead to premature resetting of {@klinkcode turnData} and {@linkcode summonData}.
*/
leaveField(clearEffects = true, hideInfo = true, destroy = false) {
console.log(`leaveField called on Pokemon ${this.name}`)
@ -7960,12 +7963,20 @@ export class PokemonTurnData {
*/
public hitsLeft = -1;
/**
* The amount of damage dealt by this Pokemon's last attack.
* Reset upon successfully using a move and used to enable internal tracking of damage amounts.
* The final amount of damage dealt by this Pokemon's last attack against each of its targets,
* mapped 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.).
*/
public lastMoveDamageDealt: number[] = [];
/**
* The amount of damage dealt by this Pokemon's last hit.
* Used to calculate recoil damage amounts.
* TODO: Merge with `lastMoveDamageDealt` if any spread recoil moves are added
*/
public lastMoveDamageDealt = 0;
public singleHitDamageDealt = 0;
// TODO: Rework this into "damage taken last" counter for metal burst and co.
// TODO: Make this into a "damage taken last" counter for metal burst and co.
// This is currently ONLY USED FOR ASSURANCE
public damageTaken = 0;
public attacksReceived: AttackMoveResult[] = [];
public order: number;

View File

@ -1783,21 +1783,22 @@ export class HitHealModifier extends PokemonHeldItemModifier {
* @returns `true` if the {@linkcode Pokemon} was healed
*/
override apply(pokemon: Pokemon): boolean {
if (pokemon.turnData.lastMoveDamageDealt && !pokemon.isFullHp()) {
// TODO: this shouldn't be undefined AFAIK
globalScene.unshiftPhase(
new PokemonHealPhase(
pokemon.getBattlerIndex(),
toDmgValue(pokemon.turnData.lastMoveDamageDealt / 8) * this.stackCount,
i18next.t("modifier:hitHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name,
}),
true,
),
);
if (pokemon.isFullHp()) {
return false;
}
const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0);
globalScene.unshiftPhase(
new PokemonHealPhase(
pokemon.getBattlerIndex(),
toDmgValue(totalDmgDealt / 8) * this.stackCount,
i18next.t("modifier:hitHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name,
}),
true,
),
);
return true;
}

View File

@ -61,8 +61,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,
@ -71,6 +69,7 @@ export class FaintPhase extends PokemonPhase {
) as PokemonInstantReviveModifier;
if (instantReviveModifier) {
faintPokemon.resetSummonData();
faintPokemon.loseHeldItem(instantReviveModifier);
globalScene.updateModifiers(this.player);
return this.end();

View File

@ -3,6 +3,7 @@ import { globalScene } from "#app/global-scene";
import {
AddSecondStrikeAbAttr,
AlwaysHitAbAttr,
applyAbAttrs,
applyPostAttackAbAttrs,
applyPostDamageAbAttrs,
applyPostDefendAbAttrs,
@ -78,6 +79,7 @@ import type Move from "#app/data/moves/move";
import { isFieldTargeted } from "#app/data/moves/move-utils";
import { FaintPhase } from "./faint-phase";
import { DamageAchv } from "#app/system/achv";
import { userInfo } from "node:os";
type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier];
@ -95,7 +97,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;
/** Phases queued during moves */
@ -181,7 +183,6 @@ export class MoveEffectPhase extends PokemonPhase {
* Queue the phaes that should occur when the target reflects the move back to the user
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} that is reflecting the move
*
*/
private queueReflectedMove(user: Pokemon, target: Pokemon): void {
const newTargets = this.move.isMultiTarget()
@ -342,7 +343,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 (
@ -766,6 +767,7 @@ export class MoveEffectPhase extends PokemonPhase {
* - Triggering form changes and emergency exit / wimp out if this is the last hit
*
* @param target - the {@linkcode Pokemon} hit by this phase's move.
* @param targetIndex - The index of the target (used to update damage dealt amounts)
* @param effectiveness - The effectiveness of the move (as previously evaluated in {@linkcode hitCheck})
* @param firstTarget - Whether this is the first target successfully struck by the move
*/
@ -785,7 +787,13 @@ export class MoveEffectPhase extends PokemonPhase {
}
if (this.lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
applyPostDamageAbAttrs(PostDamageAbAttr, target, target.turnData.lastMoveDamageDealt);
applyPostDamageAbAttrs(
PostDamageAbAttr,
target,
user.turnData.lastMoveDamageDealt[target.getBattlerIndex()],
false,
user,
);
}
}
@ -863,7 +871,7 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage);
}
user.turnData.lastMoveDamageDealt += damage;
user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] += damage;
user.turnData.singleHitDamageDealt = damage;
target.battleData.hitCount++;
// TODO: this might be incorrect for counter moves

View File

@ -43,7 +43,7 @@ import { CommonAnimPhase } from "#app/phases/common-anim-phase";
import { MoveChargePhase } from "#app/phases/move-charge-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { MoveEndPhase } from "#app/phases/move-end-phase";
import { NumberHolder } from "#app/utils/common";
import { getEnumValues, NumberHolder } from "#app/utils/common";
import { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type";
@ -160,7 +160,7 @@ export class MovePhase extends BattlePhase {
}
this.pokemon.turnData.acted = true;
this.pokemon.turnData.lastMoveDamageDealt = 0;
this.pokemon.turnData.lastMoveDamageDealt = Array(Math.max(...getEnumValues(BattlerIndex))).fill(0);
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (this.followUp) {

View File

@ -274,6 +274,7 @@ export class SummonPhase extends PartyMemberPokemonPhase {
globalScene.unshiftPhase(new ShinySparklePhase(pokemon.getBattlerIndex()));
}
// TODO: This might be a duplicate - check to see if can be removed without breaking things
pokemon.resetTurnData();
if (

View File

@ -37,7 +37,7 @@ export class SwitchPhase extends BattlePhase {
super.start();
// Failsafe: skip modal switches if impossible (no eligible party members in reserve).
if (this.isModal && globalScene.getBackupPartyMembers(true).length === 0) {
if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) {
return super.end();
}
@ -52,11 +52,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.
// TODO: Do we need this?
if (
this.isModal &&
globalScene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >=
globalScene.currentBattle.getBattlerCount()
globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount()
) {
return super.end();
}

View File

@ -2,18 +2,15 @@ import { globalScene } from "#app/global-scene";
import {
applyPreSummonAbAttrs,
applyPreSwitchOutAbAttrs,
PostDamageForceSwitchAbAttr,
PreSummonAbAttr,
PreSwitchOutAbAttr,
} from "#app/data/abilities/ability";
import { allMoves, ForceSwitchOutAttr } from "#app/data/moves/move";
import { getPokeballTintColor } from "#app/data/pokeball";
import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms";
import { TrainerSlot } from "#enums/trainer-slot";
import type Pokemon from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages";
import { SwitchEffectTransferModifier } from "#app/modifier/modifier";
import { Command } from "#app/ui/command-ui-handler";
import i18next from "i18next";
import { PostSummonPhase } from "./post-summon-phase";
import { SummonPhase } from "./summon-phase";
@ -74,13 +71,13 @@ export class SwitchSummonPhase extends SummonPhase {
return;
}
const pokemon = this.getPokemon();
const lastPokemon = this.getPokemon();
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon =>
enemyPokemon.removeTagsBySourceId(pokemon.id),
enemyPokemon.removeTagsBySourceId(lastPokemon.id),
);
if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) {
const substitute = pokemon.getTag(SubstituteTag);
const substitute = lastPokemon.getTag(SubstituteTag);
if (substitute) {
globalScene.tweens.add({
targets: substitute.sprite,
@ -95,26 +92,26 @@ export class SwitchSummonPhase extends SummonPhase {
globalScene.ui.showText(
this.player
? i18next.t("battle:playerComeBack", {
pokemonName: getPokemonNameWithAffix(pokemon),
pokemonName: getPokemonNameWithAffix(lastPokemon),
})
: i18next.t("battle:trainerComeBack", {
trainerName: globalScene.currentBattle.trainer?.getName(
!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
),
pokemonName: pokemon.getNameToRender(),
pokemonName: lastPokemon.getNameToRender(),
}),
);
globalScene.playSound("se/pb_rel");
pokemon.hideInfo();
pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
lastPokemon.hideInfo();
lastPokemon.tint(getPokeballTintColor(lastPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
globalScene.tweens.add({
targets: pokemon,
targets: lastPokemon,
duration: 250,
ease: "Sine.easeIn",
scale: 0.5,
onComplete: () => {
globalScene.time.delayedCall(750, () => this.switchAndSummon());
pokemon.leaveField(this.switchType === SwitchType.SWITCH, false);
lastPokemon.leaveField(this.switchType === SwitchType.SWITCH, false);
},
});
}
@ -192,8 +189,6 @@ export class SwitchSummonPhase extends SummonPhase {
switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1];
switchedInPokemon.setAlpha(0.5);
}
} else {
switchedInPokemon.resetSummonData();
}
this.summon();
};
@ -214,26 +209,6 @@ export class SwitchSummonPhase extends SummonPhase {
const pokemon = 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.
// Needed as we increment turn counters in `TurnEndPhase`.
if (
currentCommand === Command.POKEMON ||
lastPokemonIsForceSwitchedAndNotFainted ||
lastPokemonHasForceSwitchAbAttr
) {
pokemon.tempSummonData.turnCount--;
pokemon.tempSummonData.waveTurnCount--;
}
if (this.switchType === SwitchType.BATON_PASS && pokemon) {
pokemon.transferSummon(this.lastPokemon);
} else if (this.switchType === SwitchType.SHED_TAIL && pokemon) {
@ -243,14 +218,17 @@ export class SwitchSummonPhase extends SummonPhase {
}
}
// Reset turn data if not initial switch (since it gets initialized to an empty object on turn start)
// If not switching at start of battle, reset turn counts and temp data.
// Needed as we increment turn counters in `TurnEndPhase`.
if (this.switchType !== SwitchType.INITIAL_SWITCH) {
pokemon.tempSummonData.turnCount--;
pokemon.tempSummonData.waveTurnCount--;
// No need to reset turn/summon data for initial switch since both get initialized to an empty object on object creation
pokemon.resetTurnData();
pokemon.resetSummonData();
pokemon.turnData.switchedInThisTurn = true;
}
this.lastPokemon.resetSummonData();
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
// Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out
globalScene.arena.triggerWeatherBasedFormChanges();

View File

@ -566,3 +566,55 @@ export function animationFileName(move: Moves): string {
export function camelCaseToKebabCase(str: string): string {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase());
}
/**
* 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 predicate function once per element of the array.
* @param thisArg - An object to which the this keyword can refer in the predicate function. If thisArg is 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`.
*/
export function splitArray<T, S extends T>(
array: 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 thisArg is 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`.
*/
export function splitArray<T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
): [matches: T[], nonMatches: T[]];
/**
* 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 thisArg is 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`.
*/
export function splitArray<T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: any,
): [matches: T[], nonMatches: T[]] {
const matches: T[] = [];
const nonMatches: T[] = [];
const p = predicate.bind(thisArg) as typeof predicate;
array.forEach((val, index, ar) => {
if (p(val, index, ar)) {
matches.push(val);
} else {
nonMatches.push(val);
}
});
return [matches, nonMatches];
}

View File

@ -8,11 +8,12 @@ import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BattleType } from "#enums/battle-type";
import { HitResult } from "#app/field/pokemon";
import type { ModifierOverride } from "#app/modifier/modifier-type";
describe("Abilities - Wimp Out", () => {
let phaserGame: Phaser.Game;
@ -37,7 +38,7 @@ describe("Abilities - Wimp Out", () => {
.enemyPassiveAbility(Abilities.NO_GUARD)
.startingLevel(90)
.enemyLevel(70)
.moveset([Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE, Moves.THUNDER_PUNCH])
.moveset([Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE, Moves.GUILLOTINE])
.enemyMoveset(Moves.FALSE_SWIPE)
.disableCrits();
});
@ -72,16 +73,17 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52;
game.move.select(Moves.THUNDER_PUNCH);
game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
// Wimpod switched out after taking a hit, canceling its upcoming MovePhase before it could attack
// Wimpod switched out after taking a hit, canceling its upcoming MoveEffectPhase before it could attack
confirmSwitch();
expect(game.scene.getEnemyPokemon()!.getInverseHp()).toBe(0);
expect(game.phaseInterceptor.log.reduce((count, phase) => count + (phase === "MoveEffectPhase" ? 1 : 0), 0)).toBe(
1,
);
expect(wimpod.turnData.acted).toBe(false);
});
it("should not trigger if user faints from damage", async () => {
@ -91,12 +93,12 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52;
game.move.select(Moves.THUNDER_PUNCH);
game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(wimpod.isFainted()).toBe(true);
confirmNoSwitch();
expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT);
});
it("should trigger regenerator passive when switching out", async () => {
@ -128,15 +130,15 @@ describe("Abilities - Wimp Out", () => {
expect(!isVisible && hasFled).toBe(true);
});
it("should not trigger when HP already below half", async () => {
it("should not trigger if HP already below half", async () => {
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp = 5;
wimpod.hp *= 0.1;
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(wimpod.hp).toEqual(1);
expect(wimpod.getHpRatio()).toBeLessThan(0.1);
confirmNoSwitch();
});
@ -155,8 +157,35 @@ describe("Abilities - Wimp Out", () => {
confirmSwitch();
});
// TODO: Enable when dynamic speed order happens
it.todo("should trigger separately for each Pokemon hit in speed order", async () => {
game.override.battleStyle("double").enemyLevel(600).enemyMoveset(Moves.DRAGON_ENERGY);
await game.classicMode.startBattle([Species.WIMPOD, Species.GOLISOPOD, Species.TYRANITAR, Species.KINGAMBIT]);
// Golisopod switches out, Wimpod switches back in immediately afterwards
game.move.select(Moves.ENDURE, BattlerIndex.PLAYER);
game.move.select(Moves.ENDURE, BattlerIndex.PLAYER_2);
game.doSelectPartyPokemon(3);
game.doSelectPartyPokemon(3);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerParty().map(p => p.species.speciesId)).toBe([
Species.TYRANITAR,
Species.WIMPOD,
Species.KINGAMBIT,
Species.GOLISOPOD,
]);
// Ttar and Kingambit should be at full HP; wimpod and golisopod should not
// Ttar and Wimpod should be on field; kingambit and golisopod should not
game.scene.getPlayerParty().forEach((p, i) => {
expect(p.isOnField()).toBe(i < 2);
expect(p.isFullHp()).toBe(i % 2 === 1);
});
});
it("should block U-turn or Volt Switch on activation", async () => {
game.override.startingLevel(95).enemyMoveset([Moves.U_TURN]);
game.override.enemyMoveset(Moves.U_TURN);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.move.select(Moves.SPLASH);
@ -170,12 +199,20 @@ describe("Abilities - Wimp Out", () => {
});
it("should not block U-turn or Volt Switch if not activated", async () => {
game.override.startingLevel(190).startingWave(8).enemyMoveset([Moves.U_TURN]);
game.override.enemyMoveset(Moves.U_TURN).battleType(BattleType.TRAINER);
await game.classicMode.startBattle([Species.GOLISOPOD, Species.TYRUNT]);
const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id;
const ninjask1 = game.scene.getEnemyPokemon()!;
vi.spyOn(game.scene.getPlayerPokemon()!, "getAttackDamage").mockReturnValue({
cancelled: false,
damage: 1,
result: HitResult.EFFECTIVE,
});
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(ninjask1.isOnField()).toBe(true);
});
it("should not activate from Dragon Tail and Circle Throw", async () => {
@ -185,7 +222,6 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("SwitchSummonPhase", false);
expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT);
@ -193,42 +229,78 @@ describe("Abilities - Wimp Out", () => {
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(Species.WIMPOD);
// Force switches directly call `SwitchSummonPhase` to send in a random opponent,
// as opposed to `SwitchPhase` which allows for player choice
expect(game.phaseInterceptor.log).not.toContain("SwitchPhase");
});
it.each<{ type: string; enemyMove?: Moves; enemyAbility?: Abilities }>([
{ type: "weather", enemyMove: Moves.HAIL },
{ type: "status", enemyMove: Moves.TOXIC },
{ type: "Curse", enemyMove: Moves.CURSE },
{ type: "Salt Cure", enemyMove: Moves.SALT_CURE },
{ type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL },
{ type: "Leech Seed", enemyMove: Moves.LEECH_SEED },
{ type: "Nightmare", enemyMove: Moves.NIGHTMARE },
{ type: "Aftermath", enemyAbility: Abilities.AFTERMATH },
{ type: "Bad Dreams", enemyAbility: Abilities.BAD_DREAMS },
])(
"should activate from damage caused by $name",
async ({ enemyMove = Moves.SPLASH, enemyAbility = Abilities.BALL_FETCH }) => {
it.each<{ type: string; playerMove?: Moves; playerPassive?: Abilities; enemyMove?: Moves; enemyAbility?: Abilities }>(
[
{ type: "variable recoil moves", playerMove: Moves.HEAD_SMASH },
{ type: "HP-based recoil moves", playerMove: Moves.CHLOROBLAST },
{ type: "weather", enemyMove: Moves.HAIL },
{ type: "status", enemyMove: Moves.TOXIC },
{ type: "Ghost-type Curse", enemyMove: Moves.CURSE },
{ type: "Salt Cure", enemyMove: Moves.SALT_CURE },
{ type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this guaranteed
{ type: "Leech Seed", enemyMove: Moves.LEECH_SEED },
{ type: "Powder", playerMove: Moves.EMBER, enemyMove: Moves.POWDER },
{ type: "Nightmare", playerPassive: Abilities.COMATOSE, enemyMove: Moves.NIGHTMARE },
{ type: "Bad Dreams", playerPassive: Abilities.COMATOSE, enemyAbility: Abilities.BAD_DREAMS },
],
)(
"should activate from damage caused by $type",
async ({
playerMove = Moves.SPLASH,
playerPassive = Abilities.NONE,
enemyMove = Moves.SPLASH,
enemyAbility = Abilities.BALL_FETCH,
}) => {
game.override
.passiveAbility(Abilities.COMATOSE)
.moveset(playerMove)
.passiveAbility(playerPassive)
.enemySpecies(Species.GASTLY)
.enemyMoveset(enemyMove)
.enemyAbility(enemyAbility)
.enemyLevel(1);
.enemyAbility(enemyAbility);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
expect(wimpod).toBeDefined();
wimpod.hp *= 0.55;
wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 5);
// mock enemy attack damage func to only do 1 dmg (for whirlpool)
vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({
cancelled: false,
result: HitResult.EFFECTIVE,
damage: 1,
});
game.move.select(Moves.THUNDER_PUNCH);
game.move.select(playerMove);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toNextTurn();
confirmSwitch();
},
);
it.each<[name: string, ability: Abilities]>([
["Innards Out", Abilities.INNARDS_OUT],
["Aftermath", Abilities.AFTERMATH],
["Rough Skin", Abilities.ROUGH_SKIN],
])("should trigger after taking damage from %s ability", async (_, ability) => {
game.override.enemyAbility(ability).enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.51;
game.scene.getEnemyPokemon()!.hp = wimpod.hp - 1; // Ensure innards out doesn't KO
game.move.select(Moves.GUILLOTINE);
game.doSelectPartyPokemon(1);
await game.toNextWave();
confirmSwitch();
});
it("should not trigger from Sheer Force-boosted moves", async () => {
game.override.enemyAbility(Abilities.SHEER_FORCE).enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(95);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
@ -241,18 +313,7 @@ describe("Abilities - Wimp Out", () => {
confirmNoSwitch();
});
it("should trigger from recoil damage", async () => {
game.override.moveset(Moves.HEAD_SMASH).enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.move.select(Moves.HEAD_SMASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
confirmSwitch();
});
it("should trigger from Flame Burst ally damage in doubles", async () => {
it("should trigger from Flame Burst splash damage in doubles", async () => {
game.override.battleStyle("double").enemyMoveset([Moves.FLAME_BURST, Moves.SPLASH]);
await game.classicMode.startBattle([Species.WIMPOD, Species.ZYGARDE, Species.TYRUNT]);
@ -267,20 +328,36 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(2);
await game.phaseInterceptor.to("TurnEndPhase");
confirmSwitch();
expect(wimpod.isOnField()).toBe(false);
expect(wimpod.getHpRatio()).toBeLessThan(0.5);
});
it("should not activate when the Pokémon cuts its own HP", async () => {
game.override.moveset(Moves.SUBSTITUTE).enemyMoveset(Moves.SPLASH);
it("should not activate when the Pokémon cuts its own HP below half", async () => {
game.override.moveset(Moves.SUBSTITUTE).enemyMoveset([Moves.TIDY_UP, Moves.ROUND]);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
// Turn 1: Substitute knocks below half; no switch
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52;
game.move.select(Moves.SUBSTITUTE);
await game.phaseInterceptor.to("TurnEndPhase");
await game.forceEnemyMove(Moves.TIDY_UP);
game.doSelectPartyPokemon(1);
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.select(Moves.SUBSTITUTE);
game.doSelectPartyPokemon(1);
await game.forceEnemyMove(Moves.ROUND);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("TurnEndPhase");
confirmSwitch();
});
it("should not trigger when neutralized", async () => {
@ -295,9 +372,9 @@ describe("Abilities - Wimp Out", () => {
it("should disregard Shell Bell recovery while still activating it before switching", async () => {
game.override
.moveset([Moves.DOUBLE_EDGE])
.moveset(Moves.DOUBLE_EDGE)
.enemyMoveset([Moves.SPLASH])
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]);
.startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); // heals 50% of damage dealt, more than recoil takes away
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
@ -307,29 +384,34 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
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(Species.TYRUNT);
// Wimp out activated before shell bell healing
expect(wimpod.getHpRatio()).toBeGreaterThan(0.5);
confirmSwitch();
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
});
it("should activate from entry hazard damage", async () => {
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, Moves.SPIKES, 0, ArenaTagSide.ENEMY);
game.override.enemySpecies(Species.CENTISKORCH).enemyAbility(Abilities.WIMP_OUT);
await game.classicMode.startBattle([Species.TYRUNT]);
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
});
it("should not switch if Magic Guard prevents damage", async () => {
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, Moves.SPIKES, 0, ArenaTagSide.ENEMY);
game.override
.passiveAbility(Abilities.MAGIC_GUARD)
.enemyMoveset([Moves.LEECH_SEED])
.weather(WeatherType.HAIL)
.statusEffect(StatusEffect.POISON);
game.override.passiveAbility(Abilities.MAGIC_GUARD).enemyMoveset(Moves.LEECH_SEED);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.51;
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toNextTurn();
expect(game.scene.getPlayerParty()[0].getHpRatio()).toEqual(0.51);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD);
confirmNoSwitch();
expect(wimpod.getHpRatio()).toBeCloseTo(0.51);
});
it("should not cancel a double battle on activation", async () => {
@ -352,17 +434,7 @@ describe("Abilities - Wimp Out", () => {
expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp());
});
it("should activate from entry hazard damage", async () => {
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, 1, Moves.SPIKES, 0, ArenaTagSide.ENEMY);
game.override.enemySpecies(Species.CENTISKORCH).enemyAbility(Abilities.WIMP_OUT).startingWave(4);
await game.classicMode.startBattle([Species.TYRUNT]);
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
});
it("triggers status on the wimp out user before a new pokemon is switched in", async () => {
it("triggers move effects on the wimp out user before switching", async () => {
game.override.enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(80);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
vi.spyOn(allMoves[Moves.SLUDGE_BOMB], "chance", "get").mockReturnValue(100);
@ -371,92 +443,64 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerParty()[1].status?.effect).toEqual(StatusEffect.POISON);
confirmSwitch();
expect(game.scene.getPlayerParty()[1].status?.effect).toBe(StatusEffect.POISON);
});
it("triggers after last hit of multi hit moves", async () => {
game.override.enemyMoveset(Moves.BULLET_SEED).enemyAbility(Abilities.SKILL_LINK);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
it.each<{ type: string; move?: Moves; ability?: Abilities; items?: ModifierOverride[] }>([
{ type: "normal", move: Moves.DUAL_CHOP },
{ type: "Parental Bond", ability: Abilities.PARENTAL_BOND },
{ type: "Multi Lens", items: [{ name: "MULTI_LENS", count: 1 }] },
])(
"should trigger after the last hit of $type multi-strike moves",
async ({ move = Moves.TACKLE, ability = Abilities.COMPOUND_EYES, items = [] }) => {
game.override.enemyMoveset(move).enemyAbility(ability).enemyHeldItems(items);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.51;
game.move.select(Moves.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 moves from multi lens", async () => {
game.override.enemyMoveset(Moves.TACKLE).enemyHeldItems([{ name: "MULTI_LENS", count: 1 }]);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.move.select(Moves.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(Moves.TACKLE).enemyAbility(Abilities.PARENTAL_BOND);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.scene.getPlayerPokemon()!.hp *= 0.51;
game.move.select(Moves.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("should not activate if the Pokémon's HP falls below half due to hurting itself in confusion", async () => {
game.override.moveset([Moves.SWORDS_DANCE]).enemyMoveset([Moves.SWAGGER]);
await game.classicMode.startBattle([Species.WIMPOD, Species.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
while (playerPokemon.getHpRatio() > 0.49) {
game.move.select(Moves.SWORDS_DANCE);
game.move.select(Moves.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();
// 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 from confusion damage", async () => {
game.override.enemyMoveset(Moves.CONFUSE_RAY).confusionActivation(true);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.51;
game.move.select(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
confirmNoSwitch();
});
it("should not activate on wave X0 bosses", async () => {
game.override.enemyAbility(Abilities.WIMP_OUT).startingLevel(5850).startingWave(10);
game.override.enemyAbility(Abilities.WIMP_OUT).startingLevel(5850).startingWave(10).enemyHealthSegments(3);
await game.classicMode.startBattle([Species.GOLISOPOD]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
// Use 2 turns of False Swipe due to opponent's health bar shield
game.move.select(Moves.FALSE_SWIPE);
await game.toNextTurn();
game.move.select(Moves.FALSE_SWIPE);
await game.toNextTurn();
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus;
expect(isVisible && !hasFled).toBe(true);
expect(enemyPokemon.visible).toBe(true);
expect(enemyPokemon.switchOutStatus).toBe(false);
});
it("should not skip battles when triggered in a double battle", async () => {

View File

@ -106,9 +106,10 @@ describe("Items - Reviver Seed", () => {
// Self-damage tests
it.each([
{ moveType: "Recoil", move: Moves.DOUBLE_EDGE },
{ moveType: "Relative Recoil", move: Moves.DOUBLE_EDGE },
{ moveType: "HP% Recoil", move: Moves.CHLOROBLAST },
{ moveType: "Self-KO", move: Moves.EXPLOSION },
{ moveType: "Self-Deduction", move: Moves.CURSE },
{ moveType: "Ghost-type Curse", move: Moves.CURSE },
{ moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN },
])("should not activate the holder's reviver seed from $moveType", async ({ move }) => {
game.override

View File

@ -1,8 +1,6 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/moves/move";
import { Status } from "#app/data/status-effect";
import { Challenges } from "#enums/challenges";
import { StatusEffect } from "#enums/status-effect";
import { PokemonType } from "#enums/pokemon-type";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
@ -10,6 +8,11 @@ import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BattleType } from "#enums/battle-type";
import { TrainerSlot } from "#enums/trainer-slot";
import { TrainerType } from "#enums/trainer-type";
import { splitArray } from "#app/utils/common";
import { BattlerTagType } from "#enums/battler-tag-type";
describe("Moves - Dragon Tail", () => {
let phaserGame: Phaser.Game;
@ -38,23 +41,6 @@ describe("Moves - Dragon Tail", () => {
vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100);
});
it("should cause opponent to flee, and not crash", async () => {
await game.classicMode.startBattle([Species.DRATINI]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.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(Abilities.ROUGH_SKIN);
await game.classicMode.startBattle([Species.DRATINI]);
@ -68,7 +54,8 @@ describe("Moves - Dragon Tail", () => {
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus;
expect(!isVisible && hasFled).toBe(true);
expect(isVisible).toBe(false);
expect(hasFled).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
});
@ -101,6 +88,47 @@ describe("Moves - Dragon Tail", () => {
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
});
it("should force trainers to switch randomly without selecting from a partner's party", async () => {
game.override
.battleStyle("double")
.enemyMoveset(Moves.SPLASH)
.enemyAbility(Abilities.STURDY)
.battleType(BattleType.TRAINER)
.randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true })
.enemySpecies(0);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRANITAR]);
// 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.fn(Phaser.Math.RND.integerInRange).mockImplementation(min => min);
// Spy on the function responsible for making informed switches
const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex");
game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("BerryPhase");
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 redirect targets upon opponent flee", async () => {
game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN);
await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]);
@ -128,7 +156,7 @@ describe("Moves - Dragon Tail", () => {
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
});
it("doesn't switch out if the target has suction cups", async () => {
it("should not switch out a target with suction cups", async () => {
game.override.enemyAbility(Abilities.SUCTION_CUPS);
await game.classicMode.startBattle([Species.REGIELEKI]);
@ -137,9 +165,25 @@ describe("Moves - Dragon Tail", () => {
game.move.select(Moves.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isOnField()).toBe(true);
expect(enemy.isFullHp()).toBe(false);
});
it("should not switch out a Commanded Dondozo", async () => {
game.override.battleStyle("double").enemySpecies(Species.DONDOZO);
await game.classicMode.startBattle([Species.REGIELEKI]);
// pretend dondozo 2 commanded dondozo 1 (silly I know, but it works)
const [dondozo1, dondozo2] = game.scene.getEnemyField();
dondozo1.addTag(BattlerTagType.COMMANDED, 1, Moves.NONE, dondozo2.id);
game.move.select(Moves.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase");
expect(dondozo1.isOnField()).toBe(true);
expect(dondozo1.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([Species.DRATINI]);
@ -227,81 +271,55 @@ describe("Moves - Dragon Tail", () => {
expect(charmander.getInverseHp()).toBeGreaterThan(0);
});
it("should not force a switch to a challenge-ineligible Pokemon", async () => {
it("should not force a switch to a fainted or challenge-ineligible Pokemon", async () => {
game.override.enemyMoveset(Moves.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([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
expect(toxapex).toBeDefined();
// Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible
// 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.select(Moves.SPLASH);
await game.killPokemon(toxapex);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(true);
expect(primarina.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(false);
expect(primarina.isOnField()).toBe(true);
expect(lapras.getInverseHp()).toBeGreaterThan(0);
});
it("should not force a switch to a fainted Pokemon", async () => {
game.override.enemyMoveset([Moves.SPLASH, Moves.DRAGON_TAIL]).startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]);
it("should deal damage without switching if there are no available backup Pokemon to switch into", async () => {
game.override.enemyMoveset(Moves.DRAGON_TAIL).battleStyle("double").startingLevel(100).enemyLevel(1);
// Mono-Water challenge
game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0);
await game.challengeMode.startBattle([Species.LAPRAS, Species.KYOGRE, Species.EEVEE, Species.CLOYSTER]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
const [lapras, kyogre, eevee, cloyster] = game.scene.getPlayerParty();
expect(cloyster).toBeDefined();
// Turn 1: Eevee faints
eevee.hp = 0;
eevee.status = new Status(StatusEffect.FAINT);
expect(eevee.isFainted()).toBe(true);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.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(Moves.SPLASH);
await game.forceEnemyMove(Moves.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([Moves.SPLASH, Moves.DRAGON_TAIL]).startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([Species.LAPRAS, Species.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(Moves.SPLASH);
await game.forceEnemyMove(Moves.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(Moves.SPLASH);
await game.forceEnemyMove(Moves.DRAGON_TAIL);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER);
await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER_2);
await game.killPokemon(cloyster);
await game.toNextTurn();
// Eevee is ineligble due to challenge and cloyster is fainted, leaving no backup pokemon able to switch in
expect(lapras.isOnField()).toBe(true);
expect(kyogre.isOnField()).toBe(true);
expect(eevee.isOnField()).toBe(false);
expect(cloyster.isOnField()).toBe(false);
expect(lapras.getInverseHp()).toBeGreaterThan(0);
expect(kyogre.getInverseHp()).toBeGreaterThan(0);
expect(game.scene.getBackupPartyMemberIndices(true)).toHaveLength(0);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
});
});

View File

@ -7,9 +7,9 @@ import { Species } from "#enums/species";
import { BerryPhase } from "#app/phases/berry-phase";
import { MoveResult, PokemonMove } from "#app/field/pokemon";
import { PokemonType } from "#enums/pokemon-type";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { StatusEffect } from "#enums/status-effect";
import { BattlerIndex } from "#app/battle";
import { toDmgValue } from "#app/utils/common";
describe("Moves - Powder", () => {
let phaserGame: Phaser.Game;
@ -168,18 +168,13 @@ describe("Moves - Powder", () => {
game.move.select(Moves.FIERY_DANCE, 0, BattlerIndex.ENEMY);
game.move.select(Moves.POWDER, 1, BattlerIndex.ENEMY);
await game.phaseInterceptor.to(MoveEffectPhase);
const enemyStartingHp = enemyPokemon.hp;
await game.phaseInterceptor.to(BerryPhase, false);
await game.phaseInterceptor.to("BerryPhase", false);
// player should not take damage
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
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.lastMoveDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4),
);
expect(enemyPokemon.hp).toBeLessThan(2 * toDmgValue(enemyPokemon.getMaxHp() / 4));
});
it("should cancel Fiery Dance, then prevent it from triggering Dancer", async () => {