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}. * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
* Used for switch out logic checks. * Used for switch out logic checks.
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` * @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 * @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}. * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
* Used for switch out logic checks. * Used for switch out logic checks.
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` * @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. * 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 * @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}. * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}.
* Used for switch out logic checks. * Used for switch out logic checks.
* @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` * @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. * 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 * @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>( public getBackupPartyMemberIndices(player: boolean, trainerSlot?: number): number[] {
player: B, // Note: We return the indices instead of the actual Pokemon because `SwitchSummonPhase` and co. take an index instead of a pokemon.
trainerSlot?: number, // If this is ever changed, this can be replaced with a simpler version involving `filter` and conditional type annotations.
): R[] { const indices: number[] = [];
return (player ? this.getPlayerParty() : this.getEnemyParty()).filter( const party = player ? this.getPlayerParty() : this.getEnemyParty();
(p: PlayerPokemon | EnemyPokemon) => party.forEach((p: PlayerPokemon | EnemyPokemon, i: number) => {
p.isAllowedInBattle() && !p.isOnField() && (p instanceof PlayerPokemon || p.trainerSlot !== trainerSlot), if (p.isAllowedInBattle() && !p.isOnField() && (player || (p as EnemyPokemon).trainerSlot === trainerSlot)) {
) as R[]; 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 { SelectBiomePhase } from "#app/phases/select-biome-phase";
import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves"; import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves";
import { ForceSwitch } from "../mixins/force-switch"; import { ForceSwitch } from "../mixins/force-switch";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
export class BlockRecoilDamageAttr extends AbAttr { export class BlockRecoilDamageAttr extends AbAttr {
constructor() { constructor() {
@ -1301,7 +1302,6 @@ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr {
if (!pokemon.isTerastallized && if (!pokemon.isTerastallized &&
move.id !== Moves.STRUGGLE && 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} * @see {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves}
*/ */
!move.findAttr((attr) => !move.findAttr((attr) =>
@ -2818,8 +2818,12 @@ export class CommanderAbAttr extends AbAttr {
// TODO: Should this work with X + Dondozo fusions? // TODO: Should this work with X + Dondozo fusions?
const ally = pokemon.getAlly(); const ally = pokemon.getAlly();
return globalScene.currentBattle?.double && !isNullOrUndefined(ally) && ally.species.speciesId === Species.DONDOZO return (
&& !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)); 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 { override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: null, args: any[]): void {
@ -4105,7 +4109,6 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr {
return false; return false;
} }
// Clamp procChance to [0, 1]. Skip if didn't proc (less than pass)
const pass = Phaser.Math.RND.realInRange(0, 1); const pass = Phaser.Math.RND.realInRange(0, 1);
return Phaser.Math.Clamp(this.procChance(pokemon), 0, 1) >= pass; 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) { constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) {
super(); 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.switchType = switchType;
this.hpRatio = hpRatio; this.hpRatio = hpRatio;
} }
@ -5600,34 +5603,64 @@ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) {
source: Pokemon | undefined, source: Pokemon | undefined,
_args: any[], _args: any[],
): boolean { ): boolean {
const userLastMove = pokemon.getLastXMoves()[0];
// Will not activate when the Pokémon's HP is lowered by cutting its own HP // Skip move checks for damage not occurring due to a move (eg: hazards)
const forbiddenAttackingMoves = new Set<Moves>([ Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.CURSE, Moves.PAIN_SPLIT ]); const currentPhase = globalScene.getCurrentPhase();
if (!isNullOrUndefined(userLastMove) && forbiddenAttackingMoves.has(userLastMove.move)) { if (currentPhase instanceof MoveEffectPhase && !this.passesMoveChecks(pokemon, source)) {
return false; 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] const lastMove = source?.getLastXMoves()[0]
if ( const notSkyDropped = !(lastMove?.move === Moves.SKY_DROP && lastMove.result === MoveResult.OTHER)
lastMove &&
// Will not activate for forced switch moves (triggers before wimp out activates) return notHpCut && notForceSwitched && notSkyDropped;
(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;
} }
// 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). * 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; const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio;
if (pokemon.hp + damage < hpNeededToSwitch || pokemon.hp >= hpNeededToSwitch) { return pokemon.hp < hpNeededToSwitch && pokemon.hp + damage >= hpNeededToSwitch
return false;
}
return this.canSwitchOut(pokemon, undefined)
} }
/** /**
@ -6913,12 +6946,10 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(Abilities.WIMP_OUT, 7) new Ability(Abilities.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr) .attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition()) .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
new Ability(Abilities.EMERGENCY_EXIT, 7) new Ability(Abilities.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr) .attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition()) .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
new Ability(Abilities.WATER_COMPACTION, 7) new Ability(Abilities.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(Abilities.MERCILESS, 7) 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 { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType); const shouldRemain = super.lapse(pokemon, lapseType);
if (!shouldLapse) { if (!shouldRemain) {
return false; return false;
} }
@ -766,7 +772,9 @@ export class ConfusedTag extends BattlerTag {
globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION)); globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION));
// 1/3 chance of hitting self with a 40 base power move // 1/3 chance of hitting self with a 40 base power move
if (pokemon.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 atk = pokemon.getEffectiveStat(Stat.ATK);
const def = pokemon.getEffectiveStat(Stat.DEF); const def = pokemon.getEffectiveStat(Stat.DEF);
const damage = toDmgValue( const damage = toDmgValue(

View File

@ -17,51 +17,49 @@ import { applyAbAttrs, ForceSwitchOutImmunityAbAttr } from "../abilities/ability
import type { MoveAttr } from "../moves/move"; import type { MoveAttr } from "../moves/move";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import type { AbAttr } from "../abilities/ab-attrs/ab-attr"; 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); 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) { export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
return class ForceSwitchClass extends Base { return class ForceSwitchClass extends Base {
protected selfSwitch = false; protected selfSwitch = false;
protected switchType: NormalSwitchType = SwitchType.SWITCH; 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. * Determines if a Pokémon can be forcibly switched out based on its status and battle conditions.
* @see {@linkcode performOpponentChecks} for opponent-related check code. * @param switchOutTarget - The {@linkcode Pokemon} being switched out.
* @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.
* @returns Whether {@linkcode switchOutTarget} can be switched out by the current Move or Ability. * @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; 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; 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) { 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; return this.switchType !== SwitchType.BATON_PASS && globalScene.currentBattle.waveIndex % 10 !== 0;
} }
// Finally, ensure that we have valid switch out targets. // Finally, ensure that a trainer switching out has at least 1 valid reserve member to send in.
const reservePartyMembers = globalScene.getBackupPartyMembers( const reservePartyMembers = globalScene.getBackupPartyMemberIndices(
isPlayer, isPlayer,
(switchOutTarget as EnemyPokemon).trainerSlot as TrainerSlot | undefined, !isPlayer ? (switchOutTarget as EnemyPokemon).trainerSlot : undefined,
); // evaluates to `undefined` if not present );
if (reservePartyMembers.length === 0) { return reservePartyMembers.length > 0;
return false;
} }
return true; /**
} * Perform various checks to confirm the switched out target can be forcibly removed from the field
* by another Pokemon.
protected performOpponentChecks(switchOutTarget: Pokemon, opponent: Pokemon): boolean { * @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 // Dondozo with an allied Tatsugiri in its mouth cannot be forced out by enemies
const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED); const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED);
if (commandedTag?.getSourcePokemon()?.isActive(true)) { 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) // Check for opposing switch block abilities (Suction Cups and co)
const blockedByAbility = new BooleanHolder(false); const blockedByAbility = new BooleanHolder(false);
applyAbAttrs(ForceSwitchOutImmunityAbAttr, opponent, blockedByAbility); applyAbAttrs(ForceSwitchOutImmunityAbAttr, switchOutTarget, blockedByAbility);
if (!blockedByAbility.value) { if (blockedByAbility.value) {
return false; return false;
} }
@ -97,7 +95,10 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
} }
if (!(switchOutTarget instanceof EnemyPokemon)) { 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; return;
} }
@ -119,12 +120,12 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
return; return;
} }
// Pick a random player pokemon to switch out. // Pick a random eligible player pokemon to replace the switched out one.
const reservePartyMembers = globalScene.getBackupPartyMembers(true); const reservePartyMembers = globalScene.getBackupPartyMemberIndices(true);
const switchOutIndex = switchOutTarget.randSeedInt(reservePartyMembers.length); const switchInIndex = reservePartyMembers[switchOutTarget.randSeedInt(reservePartyMembers.length)];
globalScene.appendToPhase( globalScene.appendToPhase(
new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchOutIndex, false, true), new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchInIndex, false, true),
MoveEndPhase, MoveEndPhase,
); );
} }
@ -132,15 +133,16 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void { private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void {
// fallback for no trainer // fallback for no trainer
if (!globalScene.currentBattle.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; return;
} }
// Forced switches will to pick a random eligible pokemon, while
// choice-based switching uses the trainer's default switch behavior // Forced switches will pick a random eligible pokemon from this trainer's side, while
const reservePartyMembers = globalScene.getBackupPartyMembers(false, switchOutTarget.trainerSlot); // choice-based switching uses the trainer's default switch behavior.
const reservePartyIndices = globalScene.getBackupPartyMemberIndices(false, switchOutTarget.trainerSlot);
const summonIndex = const summonIndex =
this.switchType === SwitchType.FORCE_SWITCH this.switchType === SwitchType.FORCE_SWITCH
? switchOutTarget.randSeedInt(reservePartyMembers.length) ? reservePartyIndices[switchOutTarget.randSeedInt(reservePartyIndices.length)]
: (globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot) ?? 0); : (globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot) ?? 0);
globalScene.appendToPhase( globalScene.appendToPhase(
new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), summonIndex, false, false), new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), summonIndex, false, false),
@ -180,5 +182,9 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
public isBatonPass(): boolean { public isBatonPass(): boolean {
return this.switchType === SwitchType.BATON_PASS; 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 { export class SacrificialFullRestoreAttr extends SacrificialAttr {
protected restorePP: boolean; protected restorePP: boolean;
protected moveMessage: string; 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 // We don't know which party member will be chosen, so pick the highest max HP in the party
const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0); const maxPartyMemberHp = Math.max(...party.map(p => p.getMaxHp()));
globalScene.pushPhase( globalScene.pushPhase(
new PokemonHealPhase( 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) { export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) {
constructor( constructor(
selfSwitch: boolean = false, selfSwitch: boolean = false,
@ -6221,54 +6230,67 @@ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) {
this.switchType = switchType; 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) this.doSwitch(this.selfSwitch ? user : target)
return true; return true;
} }
getCondition(): MoveConditionFunc { 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 { getSwitchOutCondition(): MoveConditionFunc {
return (user, target, move) => { 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. // 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 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 { 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; return -20;
} }
let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move); let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move);
if (this.selfSwitch && this.isBatonPass()) { if (this.selfSwitch && this.isBatonPass()) {
const statStageTotal = user.getStatStages().reduce((s: number, total: number) => total += s, 0); 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)); ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10));
} }
return ret; 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 { export class ChillyReceptionAttr extends ForceSwitchOutAttr {

View File

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

View File

@ -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` * @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. * @returns The amount of damage actually dealt.
* @remarks * @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, damageAndUpdate(damage: number,
{ {
@ -4782,7 +4782,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
); );
globalScene.unshiftPhase(damagePhase); 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) { if (this.switchOutStatus) {
damage = 0; 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 // Damage amount may have changed, but needed to be queued before calling damage function
damagePhase.updateAmount(damage); damagePhase.updateAmount(damage);
// Trigger PostDamageAbAttr (ie wimp out) for indirect damage only. // Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances.
if (isIndirectDamage) { // We leave `source` as undefined for indirect hits to specify that the damage instance is indirect.
if (isIndirectDamage && result !== HitResult.CONFUSION) {
applyPostDamageAbAttrs( applyPostDamageAbAttrs(
PostDamageAbAttr, PostDamageAbAttr,
this, 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} * Reset this Pokemon's {@linkcode PokemonSummonData | SummonData} and {@linkcode PokemonTempSummonData | TempSummonData}
* in preparation for switching pokemon, as well as removing any relevant on-switch tags. * in preparation for switching pokemon, as well as removing any relevant on-switch tags.
* @remarks * @remarks
* This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, * This **SHOULD NOT** be called when {@linkcode leaveField} is already being called,
* both of which call this method (directly or indirectly) on both pokemon changing positions. * which already calls this function.
*/ */
resetSummonData(): void { resetSummonData(): void {
console.log(`resetSummonData called on Pokemon ${this.name}`) console.log(`resetSummonData called on Pokemon ${this.name}`)
@ -5801,6 +5803,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
} }
resetTurnData(): void { resetTurnData(): void {
console.log(`resetTurnData called on Pokemon ${this.name}`)
this.turnData = new PokemonTurnData(); this.turnData = new PokemonTurnData();
} }
@ -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` * @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false`
* @remarks * @remarks
* This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, * 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) { leaveField(clearEffects = true, hideInfo = true, destroy = false) {
console.log(`leaveField called on Pokemon ${this.name}`) console.log(`leaveField called on Pokemon ${this.name}`)
@ -7960,12 +7963,20 @@ export class PokemonTurnData {
*/ */
public hitsLeft = -1; public hitsLeft = -1;
/** /**
* The amount of damage dealt by this Pokemon's last attack. * The final amount of damage dealt by this Pokemon's last attack against each of its targets,
* Reset upon successfully using a move and used to enable internal tracking of damage amounts. * 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; 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 damageTaken = 0;
public attacksReceived: AttackMoveResult[] = []; public attacksReceived: AttackMoveResult[] = [];
public order: number; public order: number;

View File

@ -1783,12 +1783,15 @@ export class HitHealModifier extends PokemonHeldItemModifier {
* @returns `true` if the {@linkcode Pokemon} was healed * @returns `true` if the {@linkcode Pokemon} was healed
*/ */
override apply(pokemon: Pokemon): boolean { override apply(pokemon: Pokemon): boolean {
if (pokemon.turnData.lastMoveDamageDealt && !pokemon.isFullHp()) { if (pokemon.isFullHp()) {
// TODO: this shouldn't be undefined AFAIK return false;
}
const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0);
globalScene.unshiftPhase( globalScene.unshiftPhase(
new PokemonHealPhase( new PokemonHealPhase(
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.turnData.lastMoveDamageDealt / 8) * this.stackCount, toDmgValue(totalDmgDealt / 8) * this.stackCount,
i18next.t("modifier:hitHealApply", { i18next.t("modifier:hitHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name, typeName: this.type.name,
@ -1796,8 +1799,6 @@ export class HitHealModifier extends PokemonHeldItemModifier {
true, true,
), ),
); );
}
return 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.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source);
} }
faintPokemon.resetSummonData();
if (!this.preventInstantRevive) { if (!this.preventInstantRevive) {
const instantReviveModifier = globalScene.applyModifier( const instantReviveModifier = globalScene.applyModifier(
PokemonInstantReviveModifier, PokemonInstantReviveModifier,
@ -71,6 +69,7 @@ export class FaintPhase extends PokemonPhase {
) as PokemonInstantReviveModifier; ) as PokemonInstantReviveModifier;
if (instantReviveModifier) { if (instantReviveModifier) {
faintPokemon.resetSummonData();
faintPokemon.loseHeldItem(instantReviveModifier); faintPokemon.loseHeldItem(instantReviveModifier);
globalScene.updateModifiers(this.player); globalScene.updateModifiers(this.player);
return this.end(); return this.end();

View File

@ -3,6 +3,7 @@ import { globalScene } from "#app/global-scene";
import { import {
AddSecondStrikeAbAttr, AddSecondStrikeAbAttr,
AlwaysHitAbAttr, AlwaysHitAbAttr,
applyAbAttrs,
applyPostAttackAbAttrs, applyPostAttackAbAttrs,
applyPostDamageAbAttrs, applyPostDamageAbAttrs,
applyPostDefendAbAttrs, applyPostDefendAbAttrs,
@ -78,6 +79,7 @@ import type Move from "#app/data/moves/move";
import { isFieldTargeted } from "#app/data/moves/move-utils"; import { isFieldTargeted } from "#app/data/moves/move-utils";
import { FaintPhase } from "./faint-phase"; import { FaintPhase } from "./faint-phase";
import { DamageAchv } from "#app/system/achv"; import { DamageAchv } from "#app/system/achv";
import { userInfo } from "node:os";
type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier];
@ -95,7 +97,7 @@ export class MoveEffectPhase extends PokemonPhase {
/** Is this the first strike of a move? */ /** Is this the first strike of a move? */
private firstHit: boolean; private firstHit: boolean;
/** Is this the last strike of a move? */ /** Is this the last strike of a move (either due to running out of hits or all targets being fainted/immune)? */
private lastHit: boolean; private lastHit: boolean;
/** Phases queued during moves */ /** 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 * 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 user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} that is reflecting the move * @param target - The {@linkcode Pokemon} that is reflecting the move
*
*/ */
private queueReflectedMove(user: Pokemon, target: Pokemon): void { private queueReflectedMove(user: Pokemon, target: Pokemon): void {
const newTargets = this.move.isMultiTarget() const newTargets = this.move.isMultiTarget()
@ -342,7 +343,7 @@ export class MoveEffectPhase extends PokemonPhase {
const targets = this.conductHitChecks(user, fieldMove); const targets = this.conductHitChecks(user, fieldMove);
this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft; this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft;
this.lastHit = user.turnData.hitsLeft === 1 || !targets.some(t => t.isActive(true)); this.lastHit = user.turnData.hitsLeft === 1 || targets.every(t => !t.isActive(true));
// Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct) // Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct)
if ( if (
@ -766,6 +767,7 @@ export class MoveEffectPhase extends PokemonPhase {
* - Triggering form changes and emergency exit / wimp out if this is the last hit * - 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 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 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 * @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) { if (this.lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); 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); 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; user.turnData.singleHitDamageDealt = damage;
target.battleData.hitCount++; target.battleData.hitCount++;
// TODO: this might be incorrect for counter moves // 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 { MoveChargePhase } from "#app/phases/move-charge-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { MoveEndPhase } from "#app/phases/move-end-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 { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-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.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) // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (this.followUp) { if (this.followUp) {

View File

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

View File

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

View File

@ -2,18 +2,15 @@ import { globalScene } from "#app/global-scene";
import { import {
applyPreSummonAbAttrs, applyPreSummonAbAttrs,
applyPreSwitchOutAbAttrs, applyPreSwitchOutAbAttrs,
PostDamageForceSwitchAbAttr,
PreSummonAbAttr, PreSummonAbAttr,
PreSwitchOutAbAttr, PreSwitchOutAbAttr,
} from "#app/data/abilities/ability"; } from "#app/data/abilities/ability";
import { allMoves, ForceSwitchOutAttr } from "#app/data/moves/move";
import { getPokeballTintColor } from "#app/data/pokeball"; import { getPokeballTintColor } from "#app/data/pokeball";
import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms";
import { TrainerSlot } from "#enums/trainer-slot"; import { TrainerSlot } from "#enums/trainer-slot";
import type Pokemon from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import { SwitchEffectTransferModifier } from "#app/modifier/modifier"; import { SwitchEffectTransferModifier } from "#app/modifier/modifier";
import { Command } from "#app/ui/command-ui-handler";
import i18next from "i18next"; import i18next from "i18next";
import { PostSummonPhase } from "./post-summon-phase"; import { PostSummonPhase } from "./post-summon-phase";
import { SummonPhase } from "./summon-phase"; import { SummonPhase } from "./summon-phase";
@ -74,13 +71,13 @@ export class SwitchSummonPhase extends SummonPhase {
return; return;
} }
const pokemon = this.getPokemon(); const lastPokemon = this.getPokemon();
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon => (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) { if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) {
const substitute = pokemon.getTag(SubstituteTag); const substitute = lastPokemon.getTag(SubstituteTag);
if (substitute) { if (substitute) {
globalScene.tweens.add({ globalScene.tweens.add({
targets: substitute.sprite, targets: substitute.sprite,
@ -95,26 +92,26 @@ export class SwitchSummonPhase extends SummonPhase {
globalScene.ui.showText( globalScene.ui.showText(
this.player this.player
? i18next.t("battle:playerComeBack", { ? i18next.t("battle:playerComeBack", {
pokemonName: getPokemonNameWithAffix(pokemon), pokemonName: getPokemonNameWithAffix(lastPokemon),
}) })
: i18next.t("battle:trainerComeBack", { : i18next.t("battle:trainerComeBack", {
trainerName: globalScene.currentBattle.trainer?.getName( trainerName: globalScene.currentBattle.trainer?.getName(
!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, !(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
), ),
pokemonName: pokemon.getNameToRender(), pokemonName: lastPokemon.getNameToRender(),
}), }),
); );
globalScene.playSound("se/pb_rel"); globalScene.playSound("se/pb_rel");
pokemon.hideInfo(); lastPokemon.hideInfo();
pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); lastPokemon.tint(getPokeballTintColor(lastPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
globalScene.tweens.add({ globalScene.tweens.add({
targets: pokemon, targets: lastPokemon,
duration: 250, duration: 250,
ease: "Sine.easeIn", ease: "Sine.easeIn",
scale: 0.5, scale: 0.5,
onComplete: () => { onComplete: () => {
globalScene.time.delayedCall(750, () => this.switchAndSummon()); globalScene.time.delayedCall(750, () => this.switchAndSummon());
pokemon.leaveField(this.switchType === SwitchType.SWITCH, false); lastPokemon.leaveField(this.switchType === SwitchType.SWITCH, false);
}, },
}); });
} }
@ -192,8 +189,6 @@ export class SwitchSummonPhase extends SummonPhase {
switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1]; switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1];
switchedInPokemon.setAlpha(0.5); switchedInPokemon.setAlpha(0.5);
} }
} else {
switchedInPokemon.resetSummonData();
} }
this.summon(); this.summon();
}; };
@ -214,26 +209,6 @@ export class SwitchSummonPhase extends SummonPhase {
const pokemon = this.getPokemon(); 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) { if (this.switchType === SwitchType.BATON_PASS && pokemon) {
pokemon.transferSummon(this.lastPokemon); pokemon.transferSummon(this.lastPokemon);
} else if (this.switchType === SwitchType.SHED_TAIL && pokemon) { } 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) { 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.resetTurnData();
pokemon.resetSummonData();
pokemon.turnData.switchedInThisTurn = true; pokemon.turnData.switchedInThisTurn = true;
} }
this.lastPokemon.resetSummonData();
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
// Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out
globalScene.arena.triggerWeatherBasedFormChanges(); globalScene.arena.triggerWeatherBasedFormChanges();

View File

@ -566,3 +566,55 @@ export function animationFileName(move: Moves): string {
export function camelCaseToKebabCase(str: string): string { export function camelCaseToKebabCase(str: string): string {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase()); 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 { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BattleType } from "#enums/battle-type";
import { HitResult } from "#app/field/pokemon";
import type { ModifierOverride } from "#app/modifier/modifier-type";
describe("Abilities - Wimp Out", () => { describe("Abilities - Wimp Out", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -37,7 +38,7 @@ describe("Abilities - Wimp Out", () => {
.enemyPassiveAbility(Abilities.NO_GUARD) .enemyPassiveAbility(Abilities.NO_GUARD)
.startingLevel(90) .startingLevel(90)
.enemyLevel(70) .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) .enemyMoveset(Moves.FALSE_SWIPE)
.disableCrits(); .disableCrits();
}); });
@ -72,16 +73,17 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52; wimpod.hp *= 0.52;
game.move.select(Moves.THUNDER_PUNCH); game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); 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(); confirmSwitch();
expect(game.scene.getEnemyPokemon()!.getInverseHp()).toBe(0); expect(game.scene.getEnemyPokemon()!.getInverseHp()).toBe(0);
expect(game.phaseInterceptor.log.reduce((count, phase) => count + (phase === "MoveEffectPhase" ? 1 : 0), 0)).toBe( expect(game.phaseInterceptor.log.reduce((count, phase) => count + (phase === "MoveEffectPhase" ? 1 : 0), 0)).toBe(
1, 1,
); );
expect(wimpod.turnData.acted).toBe(false);
}); });
it("should not trigger if user faints from damage", async () => { it("should not trigger if user faints from damage", async () => {
@ -91,12 +93,12 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52; wimpod.hp *= 0.52;
game.move.select(Moves.THUNDER_PUNCH); game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(wimpod.isFainted()).toBe(true); expect(wimpod.isFainted()).toBe(true);
confirmNoSwitch(); expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT);
}); });
it("should trigger regenerator passive when switching out", async () => { it("should trigger regenerator passive when switching out", async () => {
@ -128,15 +130,15 @@ describe("Abilities - Wimp Out", () => {
expect(!isVisible && hasFled).toBe(true); 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]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp = 5; wimpod.hp *= 0.1;
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(wimpod.hp).toEqual(1); expect(wimpod.getHpRatio()).toBeLessThan(0.1);
confirmNoSwitch(); confirmNoSwitch();
}); });
@ -155,8 +157,35 @@ describe("Abilities - Wimp Out", () => {
confirmSwitch(); 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 () => { 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]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
game.move.select(Moves.SPLASH); 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 () => { 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]); 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); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1);
expect(ninjask1.isOnField()).toBe(true);
}); });
it("should not activate from Dragon Tail and Circle Throw", async () => { it("should not activate from Dragon Tail and Circle Throw", async () => {
@ -185,7 +222,6 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("SwitchSummonPhase", false); await game.phaseInterceptor.to("SwitchSummonPhase", false);
expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT); expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT);
@ -193,42 +229,78 @@ describe("Abilities - Wimp Out", () => {
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(Species.WIMPOD); 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 }>([ 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: "weather", enemyMove: Moves.HAIL },
{ type: "status", enemyMove: Moves.TOXIC }, { type: "status", enemyMove: Moves.TOXIC },
{ type: "Curse", enemyMove: Moves.CURSE }, { type: "Ghost-type Curse", enemyMove: Moves.CURSE },
{ type: "Salt Cure", enemyMove: Moves.SALT_CURE }, { type: "Salt Cure", enemyMove: Moves.SALT_CURE },
{ type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, { type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this guaranteed
{ type: "Leech Seed", enemyMove: Moves.LEECH_SEED }, { type: "Leech Seed", enemyMove: Moves.LEECH_SEED },
{ type: "Nightmare", enemyMove: Moves.NIGHTMARE }, { type: "Powder", playerMove: Moves.EMBER, enemyMove: Moves.POWDER },
{ type: "Aftermath", enemyAbility: Abilities.AFTERMATH }, { type: "Nightmare", playerPassive: Abilities.COMATOSE, enemyMove: Moves.NIGHTMARE },
{ type: "Bad Dreams", enemyAbility: Abilities.BAD_DREAMS }, { type: "Bad Dreams", playerPassive: Abilities.COMATOSE, enemyAbility: Abilities.BAD_DREAMS },
])( ],
"should activate from damage caused by $name", )(
async ({ enemyMove = Moves.SPLASH, enemyAbility = Abilities.BALL_FETCH }) => { "should activate from damage caused by $type",
async ({
playerMove = Moves.SPLASH,
playerPassive = Abilities.NONE,
enemyMove = Moves.SPLASH,
enemyAbility = Abilities.BALL_FETCH,
}) => {
game.override game.override
.passiveAbility(Abilities.COMATOSE) .moveset(playerMove)
.passiveAbility(playerPassive)
.enemySpecies(Species.GASTLY) .enemySpecies(Species.GASTLY)
.enemyMoveset(enemyMove) .enemyMoveset(enemyMove)
.enemyAbility(enemyAbility) .enemyAbility(enemyAbility);
.enemyLevel(1);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
expect(wimpod).toBeDefined(); 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); game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn();
await game.phaseInterceptor.to("TurnEndPhase");
confirmSwitch(); 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 () => { it("should not trigger from Sheer Force-boosted moves", async () => {
game.override.enemyAbility(Abilities.SHEER_FORCE).enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(95); game.override.enemyAbility(Abilities.SHEER_FORCE).enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(95);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
@ -241,18 +313,7 @@ describe("Abilities - Wimp Out", () => {
confirmNoSwitch(); confirmNoSwitch();
}); });
it("should trigger from recoil damage", async () => { it("should trigger from Flame Burst splash damage in doubles", 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 () => {
game.override.battleStyle("double").enemyMoveset([Moves.FLAME_BURST, Moves.SPLASH]); game.override.battleStyle("double").enemyMoveset([Moves.FLAME_BURST, Moves.SPLASH]);
await game.classicMode.startBattle([Species.WIMPOD, Species.ZYGARDE, Species.TYRUNT]); await game.classicMode.startBattle([Species.WIMPOD, Species.ZYGARDE, Species.TYRUNT]);
@ -267,20 +328,36 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(2); game.doSelectPartyPokemon(2);
await game.phaseInterceptor.to("TurnEndPhase"); 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 () => { it("should not activate when the Pokémon cuts its own HP below half", async () => {
game.override.moveset(Moves.SUBSTITUTE).enemyMoveset(Moves.SPLASH); game.override.moveset(Moves.SUBSTITUTE).enemyMoveset([Moves.TIDY_UP, Moves.ROUND]);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
// Turn 1: Substitute knocks below half; no switch
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
wimpod.hp *= 0.52; wimpod.hp *= 0.52;
game.move.select(Moves.SUBSTITUTE); 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(); 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 () => { 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 () => { it("should disregard Shell Bell recovery while still activating it before switching", async () => {
game.override game.override
.moveset([Moves.DOUBLE_EDGE]) .moveset(Moves.DOUBLE_EDGE)
.enemyMoveset([Moves.SPLASH]) .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]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!; const wimpod = game.scene.getPlayerPokemon()!;
@ -307,29 +384,34 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerParty()[1]).toBe(wimpod); // Wimp out activated before shell bell healing
expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2)); expect(wimpod.getHpRatio()).toBeGreaterThan(0.5);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); confirmSwitch();
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); 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 () => { 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.override.passiveAbility(Abilities.MAGIC_GUARD).enemyMoveset(Moves.LEECH_SEED);
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);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); 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); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase"); await game.toNextTurn();
expect(game.scene.getPlayerParty()[0].getHpRatio()).toEqual(0.51); confirmNoSwitch();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); expect(wimpod.getHpRatio()).toBeCloseTo(0.51);
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD);
}); });
it("should not cancel a double battle on activation", async () => { it("should not cancel a double battle on activation", async () => {
@ -352,17 +434,7 @@ describe("Abilities - Wimp Out", () => {
expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp()); expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp());
}); });
it("should activate from entry hazard damage", async () => { it("triggers move effects on the wimp out user before switching", 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 () => {
game.override.enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(80); game.override.enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(80);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
vi.spyOn(allMoves[Moves.SLUDGE_BOMB], "chance", "get").mockReturnValue(100); vi.spyOn(allMoves[Moves.SLUDGE_BOMB], "chance", "get").mockReturnValue(100);
@ -371,31 +443,22 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerParty()[1].status?.effect).toEqual(StatusEffect.POISON);
confirmSwitch(); confirmSwitch();
expect(game.scene.getPlayerParty()[1].status?.effect).toBe(StatusEffect.POISON);
}); });
it("triggers after last hit of multi hit moves", async () => { it.each<{ type: string; move?: Moves; ability?: Abilities; items?: ModifierOverride[] }>([
game.override.enemyMoveset(Moves.BULLET_SEED).enemyAbility(Abilities.SKILL_LINK); { 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]); 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.move.select(Moves.ENDURE);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
@ -405,58 +468,39 @@ describe("Abilities - Wimp Out", () => {
expect(enemyPokemon.turnData.hitsLeft).toBe(0); expect(enemyPokemon.turnData.hitsLeft).toBe(0);
expect(enemyPokemon.turnData.hitCount).toBe(2); expect(enemyPokemon.turnData.hitCount).toBe(2);
confirmSwitch(); confirmSwitch();
});
it("triggers after last hit of Parental Bond", async () => { // Switch triggered after the MEPs for both hits finished
game.override.enemyMoveset(Moves.TACKLE).enemyAbility(Abilities.PARENTAL_BOND); 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]); 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.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase"); 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);
await game.phaseInterceptor.to("TurnEndPhase");
}
confirmNoSwitch(); confirmNoSwitch();
}); });
it("should not activate on wave X0 bosses", async () => { 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]); await game.classicMode.startBattle([Species.GOLISOPOD]);
const enemyPokemon = game.scene.getEnemyPokemon()!; 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); game.move.select(Moves.FALSE_SWIPE);
await game.toNextTurn(); await game.toNextTurn();
const isVisible = enemyPokemon.visible; expect(enemyPokemon.visible).toBe(true);
const hasFled = enemyPokemon.switchOutStatus; expect(enemyPokemon.switchOutStatus).toBe(false);
expect(isVisible && !hasFled).toBe(true);
}); });
it("should not skip battles when triggered in a double battle", async () => { 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 // Self-damage tests
it.each([ 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-KO", move: Moves.EXPLOSION },
{ moveType: "Self-Deduction", move: Moves.CURSE }, { moveType: "Ghost-type Curse", move: Moves.CURSE },
{ moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN }, { moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN },
])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => {
game.override game.override

View File

@ -1,8 +1,6 @@
import { BattlerIndex } from "#app/battle"; import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/moves/move"; import { allMoves } from "#app/data/moves/move";
import { Status } from "#app/data/status-effect";
import { Challenges } from "#enums/challenges"; import { Challenges } from "#enums/challenges";
import { StatusEffect } from "#enums/status-effect";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
@ -10,6 +8,11 @@ import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager"; import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
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", () => { describe("Moves - Dragon Tail", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -38,23 +41,6 @@ describe("Moves - Dragon Tail", () => {
vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100); 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 () => { it("should cause opponent to flee, display ability, and not crash", async () => {
game.override.enemyAbility(Abilities.ROUGH_SKIN); game.override.enemyAbility(Abilities.ROUGH_SKIN);
await game.classicMode.startBattle([Species.DRATINI]); await game.classicMode.startBattle([Species.DRATINI]);
@ -68,7 +54,8 @@ describe("Moves - Dragon Tail", () => {
const isVisible = enemyPokemon.visible; const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.switchOutStatus; const hasFled = enemyPokemon.switchOutStatus;
expect(!isVisible && hasFled).toBe(true); expect(isVisible).toBe(false);
expect(hasFled).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
}); });
@ -101,6 +88,47 @@ describe("Moves - Dragon Tail", () => {
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); 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 () => { it("should redirect targets upon opponent flee", async () => {
game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN); game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN);
await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); 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()); 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); game.override.enemyAbility(Abilities.SUCTION_CUPS);
await game.classicMode.startBattle([Species.REGIELEKI]); await game.classicMode.startBattle([Species.REGIELEKI]);
@ -137,9 +165,25 @@ describe("Moves - Dragon Tail", () => {
game.move.select(Moves.DRAGON_TAIL); game.move.select(Moves.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isOnField()).toBe(true);
expect(enemy.isFullHp()).toBe(false); 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 () => { 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 game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([Species.DRATINI]); await game.classicMode.startBattle([Species.DRATINI]);
@ -227,81 +271,55 @@ describe("Moves - Dragon Tail", () => {
expect(charmander.getInverseHp()).toBeGreaterThan(0); 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); game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1);
// Mono-Water challenge, Eevee is ineligible // Mono-Water challenge, Eevee is ineligible
game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0);
await game.challengeMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]); await game.challengeMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]);
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); 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) => { vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => {
return min; return min;
}); });
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.killPokemon(toxapex);
await game.toNextTurn(); await game.toNextTurn();
expect(lapras.isOnField()).toBe(false); expect(lapras.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false); expect(eevee.isOnField()).toBe(false);
expect(toxapex.isOnField()).toBe(true); expect(toxapex.isOnField()).toBe(false);
expect(primarina.isOnField()).toBe(false); expect(primarina.isOnField()).toBe(true);
expect(lapras.getInverseHp()).toBeGreaterThan(0); expect(lapras.getInverseHp()).toBeGreaterThan(0);
}); });
it("should not force a switch to a fainted Pokemon", async () => { it("should deal damage without switching if there are no available backup Pokemon to switch into", async () => {
game.override.enemyMoveset([Moves.SPLASH, Moves.DRAGON_TAIL]).startingLevel(100).enemyLevel(1); game.override.enemyMoveset(Moves.DRAGON_TAIL).battleStyle("double").startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]); // 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 game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
eevee.hp = 0; game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
eevee.status = new Status(StatusEffect.FAINT); await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER);
expect(eevee.isFainted()).toBe(true); await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER_2);
game.move.select(Moves.SPLASH); await game.killPokemon(cloyster);
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);
await game.toNextTurn(); 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(lapras.isOnField()).toBe(true);
expect(kyogre.isOnField()).toBe(true);
expect(eevee.isOnField()).toBe(false); expect(eevee.isOnField()).toBe(false);
expect(cloyster.isOnField()).toBe(false);
expect(lapras.getInverseHp()).toBeGreaterThan(0); 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 { BerryPhase } from "#app/phases/berry-phase";
import { MoveResult, PokemonMove } from "#app/field/pokemon"; import { MoveResult, PokemonMove } from "#app/field/pokemon";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import { BattlerIndex } from "#app/battle"; import { BattlerIndex } from "#app/battle";
import { toDmgValue } from "#app/utils/common";
describe("Moves - Powder", () => { describe("Moves - Powder", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -168,18 +168,13 @@ describe("Moves - Powder", () => {
game.move.select(Moves.FIERY_DANCE, 0, BattlerIndex.ENEMY); game.move.select(Moves.FIERY_DANCE, 0, BattlerIndex.ENEMY);
game.move.select(Moves.POWDER, 1, BattlerIndex.ENEMY); game.move.select(Moves.POWDER, 1, BattlerIndex.ENEMY);
await game.phaseInterceptor.to(MoveEffectPhase); await game.phaseInterceptor.to("BerryPhase", false);
const enemyStartingHp = enemyPokemon.hp;
await game.phaseInterceptor.to(BerryPhase, false);
// player should not take damage // player should not take damage
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
// enemy should have taken damage from player's Fiery Dance + 2 Powder procs // enemy should have taken damage from player's Fiery Dance + 2 Powder procs
expect(enemyPokemon.hp).toBe( expect(enemyPokemon.hp).toBeLessThan(2 * toDmgValue(enemyPokemon.getMaxHp() / 4));
enemyStartingHp - playerPokemon.turnData.lastMoveDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4),
);
}); });
it("should cancel Fiery Dance, then prevent it from triggering Dancer", async () => { it("should cancel Fiery Dance, then prevent it from triggering Dancer", async () => {