This commit is contained in:
Bertie690 2025-06-20 02:47:45 -05:00 committed by GitHub
commit 9cd5c79d44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 792 additions and 251 deletions

View File

@ -836,7 +836,7 @@ export default class BattleScene extends SceneBase {
/**
* Returns an array of Pokemon on both sides of the battle - player first, then enemy.
* Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type.
* @param activeOnly - Whether to consider only active pokemon; default `false`
* @param activeOnly - Whether to consider only active pokemon (see {@linkcode Pokemon.isActive()} for more info); default `false`
* @returns An array of {@linkcode Pokemon}, as described above.
*/
public getField(activeOnly = false): Pokemon[] {

View File

@ -11,7 +11,7 @@ import {
coerceArray,
} from "#app/utils/common";
import { getPokemonNameWithAffix } from "#app/messages";
import { GroundedTag } from "#app/data/battler-tags";
import { GroundedTag, SemiInvulnerableTag } from "#app/data/battler-tags";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import {
getNonVolatileStatusEffects,
@ -63,7 +63,7 @@ import type { StatStageChangePhase } from "#app/phases/stat-stage-change-phase";
import type { BattleStat, EffectiveStat } from "#enums/stat";
import type { BerryType } from "#enums/berry-type";
import type { EnemyPokemon } from "#app/field/pokemon";
import type { PokemonMove } from "../moves/pokemon-move";
import { PokemonMove } from "../moves/pokemon-move";
import type Pokemon from "#app/field/pokemon";
import type { Weather } from "#app/data/weather";
import type { BattlerTag } from "#app/data/battler-tags";
@ -78,7 +78,10 @@ import type {
import type { BattlerIndex } from "#enums/battler-index";
import type Move from "#app/data/moves/move";
import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag";
import type { HitCheckEntry } from "#app/phases/move-effect-phase";
import { HitCheckResult } from "#enums/hit-check-result";
import type { Constructor } from "#app/utils/common";
import { getMoveTargets } from "#app/data/moves/move-utils";
import type { Localizable } from "#app/@types/locales";
import { applyAbAttrs } from "./apply-ab-attrs";
@ -3278,11 +3281,11 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr {
private stages: number;
private overwrites: boolean;
constructor(stats: BattleStat[], stages: number, overwrites?: boolean) {
constructor(stats: BattleStat[], stages: number, overwrites = false) {
super(true);
this.stats = stats;
this.stages = stages;
this.overwrites = !!overwrites;
this.overwrites = overwrites;
}
override apply(
@ -3377,11 +3380,17 @@ export class PostSummonRemoveArenaTagAbAttr extends PostSummonAbAttr {
export class PostSummonAddArenaTagAbAttr extends PostSummonAbAttr {
private readonly tagType: ArenaTagType;
private readonly turnCount: number;
private readonly side?: ArenaTagSide;
private readonly quiet?: boolean;
private readonly side: ArenaTagSide;
private readonly quiet: boolean;
private sourceId: number;
constructor(showAbility: boolean, tagType: ArenaTagType, turnCount: number, side?: ArenaTagSide, quiet?: boolean) {
constructor(
showAbility: boolean,
tagType: ArenaTagType,
turnCount: number,
side: ArenaTagSide = ArenaTagSide.BOTH,
quiet = false,
) {
super(showAbility);
this.tagType = tagType;
this.turnCount = turnCount;
@ -3434,7 +3443,7 @@ export class PostSummonAddBattlerTagAbAttr extends PostSummonAbAttr {
private tagType: BattlerTagType;
private turnCount: number;
constructor(tagType: BattlerTagType, turnCount: number, showAbility?: boolean) {
constructor(tagType: BattlerTagType, turnCount: number, showAbility = true) {
super(showAbility);
this.tagType = tagType;
@ -3483,13 +3492,13 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr {
private selfTarget: boolean;
private intimidate: boolean;
constructor(stats: BattleStat[], stages: number, selfTarget?: boolean, intimidate?: boolean) {
constructor(stats: BattleStat[], stages: number, selfTarget = false, intimidate = false) {
super(true);
this.stats = stats;
this.stages = stages;
this.selfTarget = !!selfTarget;
this.intimidate = !!intimidate;
this.selfTarget = selfTarget;
this.intimidate = intimidate;
}
override applyPostSummon(pokemon: Pokemon, _passive: boolean, simulated: boolean, _args: any[]): void {
@ -3532,6 +3541,10 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr {
}
}
/**
* Attribute to heal the user's allies upon entering battle.
* Used by {@linkcode AbilityId.HOSPITALITY}.
*/
export class PostSummonAllyHealAbAttr extends PostSummonAbAttr {
private healRatio: number;
private showAnim: boolean;
@ -4849,7 +4862,7 @@ export class MultCritAbAttr extends AbAttr {
export class ConditionalCritAbAttr extends AbAttr {
private condition: PokemonAttackCondition;
constructor(condition: PokemonAttackCondition, _checkUser?: boolean) {
constructor(condition: PokemonAttackCondition) {
super(false);
this.condition = condition;
@ -5038,10 +5051,10 @@ export class BlockWeatherDamageAttr extends PreWeatherDamageAbAttr {
export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr {
public affectsImmutable: boolean;
constructor(affectsImmutable?: boolean) {
constructor(affectsImmutable = false) {
super(true);
this.affectsImmutable = !!affectsImmutable;
this.affectsImmutable = affectsImmutable;
}
override canApplyPreWeatherEffect(
@ -5884,6 +5897,7 @@ export class PostTurnHurtIfSleepingAbAttr extends PostTurnAbAttr {
!opp.switchOutStatus,
);
}
/**
* Deals damage to all sleeping opponents equal to 1/8 of their max hp (min 1)
* @param pokemon {@linkcode Pokemon} with this ability
@ -5992,106 +6006,152 @@ export class PostBiomeChangeTerrainChangeAbAttr extends PostBiomeChangeAbAttr {
}
/**
* Triggers just after a move is used either by the opponent or the player
* @extends AbAttr
* Attribute to trigger effects after a move is used by either side of the field.
*/
export class PostMoveUsedAbAttr extends AbAttr {
/**
* Check whether this ability can be applied after a move is used.
* @param pokemon - The {@linkcode Pokemon} with the ability
* @param move - The {@linkcode Move} having been been used
* @param source - The {@linkcode Pokemon} who used the move
* @param targets - Array of {@linkcode BattlerIndex}es containing Pokemon targeted by move.
* @param hitChecks - Array of {@linkcode HitCheckEntry | HitCheckEntries} containing results of move usage
* @param simulated - Whether the ability call is simulated
* @param args - Extra arguments passed to the function. Handled by child classes.
* @returns Whether the ability can be successfully applied.
* By default, it requires:
* * The pokemon with the ability is not semi-invulnerable
* * The move was used by another pokemon
* * The move's effect was successfully applied against at least 1 target
*/
canApplyPostMoveUsed(
_pokemon: Pokemon,
_move: PokemonMove,
_source: Pokemon,
_targets: BattlerIndex[],
pokemon: Pokemon,
_move: Move,
source: Pokemon,
targets: BattlerIndex[],
hitChecks: HitCheckEntry[],
_simulated: boolean,
_args: any[],
): boolean {
return true;
return (
!pokemon.getTag(SemiInvulnerableTag) &&
source.getBattlerIndex() !== pokemon.getBattlerIndex() &&
targets.length > 0 &&
hitChecks.some(hr => hr[0] === HitCheckResult.HIT)
);
}
applyPostMoveUsed(
_pokemon: Pokemon,
_move: PokemonMove,
_move: Move,
_source: Pokemon,
_targets: BattlerIndex[],
_hitChecks: HitCheckEntry[],
_simulated: boolean,
_args: any[],
): void {}
}
/**
* Triggers after a dance move is used either by the opponent or the player
* @extends PostMoveUsedAbAttr
* Triggers after a dance move is used either by the opponent or the player.
*/
export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr {
override canApplyPostMoveUsed(
/**
* Check whether this ability can be applied after a move is used.
* @param dancer - The {@linkcode Pokemon} with the ability
* @param move - The {@linkcode Move} having been used
* @param source - The {@linkcode Pokemon} who used the move
* @param targets - Array of {@linkcode BattlerIndex}es containing Pokemon targeted by move.
* @param hitResults - Array of {@linkcode HitCheckEntry | HitCheckEntries} containing results of move usage
* @param simulated - N/A
* @param args - N/A
* @returns `true` if the ability can be applied, `false` otherwise
* @see {@linkcode applyPostMoveUsed}
*/
canApplyPostMoveUsed(
dancer: Pokemon,
_move: PokemonMove,
move: Move,
source: Pokemon,
_targets: BattlerIndex[],
_simulated: boolean,
_args: any[],
targets: BattlerIndex[],
hitResults: HitCheckEntry[],
simulated: boolean,
args: any[],
): boolean {
// List of tags that prevent the Dancer from replicating the move
const forbiddenTags = [
BattlerTagType.FLYING,
BattlerTagType.UNDERWATER,
BattlerTagType.UNDERGROUND,
BattlerTagType.HIDDEN,
];
// The move to replicate cannot come from the Dancer
return (
source.getBattlerIndex() !== dancer.getBattlerIndex() &&
!dancer.summonData.tags.some(tag => forbiddenTags.includes(tag.tagType))
super.canApplyPostMoveUsed(dancer, move, source, targets, hitResults, simulated, args) &&
move.hasFlag(MoveFlags.DANCE_MOVE)
);
}
/**
* Resolves the Dancer ability by replicating the move used by the source of the dance
* either on the source itself or on the target of the dance
* @param dancer {@linkcode Pokemon} with Dancer ability
* @param move {@linkcode PokemonMove} Dancing move used by the source
* @param source {@linkcode Pokemon} that used the dancing move
* @param targets {@linkcode BattlerIndex}Targets of the dancing move
* @param _args N/A
* Resolves the {@linkcode AbilityId.DANCER | Dancer} ability by replicating other Pokemon's dance moves
* on either this Pokemon or the user of the move.
* @param dancer - The {@linkcode Pokemon} with Dancer
* @param move - The {@linkcode Move} having been used
* @param source - The {@linkcode Pokemon} who used the move
* @param targets - Array of {@linkcode BattlerIndex}es containing Pokemon targeted by original move
* @param _hitResults - N/A
* @param _simulated - Whether the ability call is simulated
* @param _args - N/A
*/
override applyPostMoveUsed(
dancer: Pokemon,
move: PokemonMove,
move: Move,
source: Pokemon,
targets: BattlerIndex[],
simulated: boolean,
_hitResults: HitCheckEntry[],
_simulated: boolean,
_args: any[],
): void {
if (!simulated) {
dancer.turnData.extraTurns++;
// If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance
if (move.getMove().is("AttackMove") || move.getMove().is("StatusMove")) {
const target = this.getTarget(dancer, source, targets);
globalScene.phaseManager.unshiftNew("MovePhase", dancer, target, move, MoveUseMode.INDIRECT);
} else if (move.getMove().is("SelfStatusMove")) {
// If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself
globalScene.phaseManager.unshiftNew(
"MovePhase",
dancer,
[dancer.getBattlerIndex()],
move,
MoveUseMode.INDIRECT,
);
}
}
// increment extra turns var to ensure subsequent multi-hit moves don't bork
dancer.turnData.extraTurns++;
globalScene.phaseManager.unshiftNew(
"MovePhase",
dancer,
this.getMoveTargets(dancer, source, move, targets),
new PokemonMove(move.id),
MoveUseMode.INDIRECT,
);
}
/**
* Get the correct targets of Dancer ability
* Get the correct targets of Dancer ability.
*
* @param dancer {@linkcode Pokemon} Pokemon with Dancer ability
* @param source {@linkcode Pokemon} Source of the dancing move
* @param targets {@linkcode BattlerIndex} Targets of the dancing move
* @param dancer - The {@linkcode Pokemon} with Dancer
* @param source - The {@linkcode Pokemon} that used the dancing move
* @param move - The {@linkcode Move} being used
* @param targets - An array of {@linkcode BattlerIndex}es containing original targets of copied move
*/
getTarget(dancer: Pokemon, source: Pokemon, targets: BattlerIndex[]): BattlerIndex[] {
if (dancer.isPlayer()) {
return source.isPlayer() ? targets : [source.getBattlerIndex()];
private getMoveTargets(dancer: Pokemon, source: Pokemon, move: Move, targets: BattlerIndex[]): BattlerIndex[] {
if (move.isMultiTarget()) {
return getMoveTargets(dancer, move.id).targets;
}
return source.isPlayer() ? [source.getBattlerIndex()] : targets;
// Self-targeted status moves (Swords Dance & co.) are always replicated on the user.
if (move.is("SelfStatusMove")) {
return [dancer.getBattlerIndex()];
}
// Attack moves are unleashed on the source of the dance UNLESS they are an ally attacking an enemy
// (in which case we retain the prior move's targets)
if (dancer.isPlayer() !== source.isPlayer() || targets.includes(dancer.getBattlerIndex())) {
targets = [source.getBattlerIndex()];
}
// Attempt to redirect to the prior target's partner if fainted and not our own ally.
const firstTarget = globalScene.getField()[targets[0]];
const ally = firstTarget.getAlly();
if (
globalScene.currentBattle.double &&
firstTarget.isFainted() &&
firstTarget.isPlayer() !== dancer.isPlayer() &&
ally?.isActive()
) {
return [ally.getBattlerIndex()];
}
return targets;
}
}
@ -6193,7 +6253,7 @@ export class BypassBurnDamageReductionAbAttr extends AbAttr {
/**
* Causes Pokemon to take reduced damage from the {@linkcode StatusEffect.BURN | Burn} status
* @param multiplier Multiplied with the damage taken
* @param multiplier - Multiplier for burn damage taken
*/
export class ReduceBurnDamageAbAttr extends AbAttr {
constructor(protected multiplier: number) {
@ -6201,7 +6261,7 @@ export class ReduceBurnDamageAbAttr extends AbAttr {
}
/**
* Applies the damage reduction
* Applies the burn damage reduction
* @param _pokemon N/A
* @param _passive N/A
* @param _cancelled N/A
@ -6650,6 +6710,7 @@ export class RedirectTypeMoveAbAttr extends RedirectMoveAbAttr {
}
}
// TODO: Rework this - currently it's just an empty class used as a marker
export class BlockRedirectAbAttr extends AbAttr {}
/**
@ -6657,6 +6718,7 @@ export class BlockRedirectAbAttr extends AbAttr {}
* @param statusEffect - The {@linkcode StatusEffect} to check for
* @see {@linkcode apply}
*/
// TODO: Make this take no args and affect the status (once public accessors are added)
export class ReduceStatusEffectDurationAbAttr extends AbAttr {
private statusEffect: StatusEffect;
@ -6723,6 +6785,7 @@ export class FlinchStatStageChangeAbAttr extends FlinchEffectAbAttr {
}
}
// TODO: Move PP increasing property from `move-phase` into the ability class
export class IncreasePpAbAttr extends AbAttr {}
export class ForceSwitchOutImmunityAbAttr extends AbAttr {
@ -8664,8 +8727,8 @@ export function initAbilities() {
.attr(PostDancingMoveAbAttr)
/* Incorrect interations with:
* Petal Dance (should not lock in or count down timer; currently does both)
* Flinches (due to tag being removed earlier)
* Failed/protected moves (should not trigger if original move is protected against)
* All status moves whose `apply` function unshifts a phase with eligibility checks
* (this includes stat stage moves as well as Teeter Dance and co.) due to moves being still considered "successful"
*/
.edgeCase(),
new Ability(AbilityId.BATTERY, 7)

View File

@ -1,6 +1,7 @@
import type { AbAttrApplyFunc, AbAttrMap, AbAttrString, AbAttrSuccessFunc } from "#app/@types/ability-types";
import type Pokemon from "#app/field/pokemon";
import { globalScene } from "#app/global-scene";
import type { HitCheckEntry } from "#app/phases/move-effect-phase";
import type { BooleanHolder, NumberHolder } from "#app/utils/common";
import type { BattlerIndex } from "#enums/battler-index";
import type { HitResult } from "#enums/hit-result";
@ -9,7 +10,6 @@ import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type";
import type { BattlerTag } from "../battler-tags";
import type Move from "../moves/move";
import type { PokemonMove } from "../moves/pokemon-move";
import type { TerrainType } from "../terrain";
import type { Weather } from "../weather";
import type {
@ -79,8 +79,6 @@ function applySingleAbAttrs<T extends AbAttrString>(
continue;
}
globalScene.phaseManager.setPhaseQueueSplice();
if (attr.showAbility && !simulated) {
globalScene.phaseManager.queueAbilityDisplay(pokemon, passive, true);
abShown = true;
@ -120,7 +118,6 @@ function applyAbAttrsInternal<T extends AbAttrString>(
for (const passive of [false, true]) {
if (pokemon) {
applySingleAbAttrs(pokemon, passive, attrType, applyFunc, successFunc, args, gainedMidTurn, simulated, messages);
globalScene.phaseManager.clearPhaseQueueSplice();
}
}
}
@ -206,18 +203,20 @@ export function applyPostDefendAbAttrs<K extends AbAttrString>(
export function applyPostMoveUsedAbAttrs<K extends AbAttrString>(
attrType: AbAttrMap[K] extends PostMoveUsedAbAttr ? K : never,
pokemon: Pokemon,
move: PokemonMove,
move: Move,
source: Pokemon,
targets: BattlerIndex[],
hitChecks: HitCheckEntry[],
simulated = false,
...args: any[]
): void {
applyAbAttrsInternal(
attrType,
pokemon,
(attr, _passive) => (attr as PostMoveUsedAbAttr).applyPostMoveUsed(pokemon, move, source, targets, simulated, args),
(attr, _passive) =>
(attr as PostMoveUsedAbAttr).canApplyPostMoveUsed(pokemon, move, source, targets, simulated, args),
(attr as PostMoveUsedAbAttr).applyPostMoveUsed(pokemon, move, source, targets, hitChecks, simulated, args),
(attr, _passive) =>
(attr as PostMoveUsedAbAttr).canApplyPostMoveUsed(pokemon, move, source, targets, hitChecks, simulated, args),
args,
simulated,
);

View File

@ -1387,11 +1387,11 @@ export class EncounterBattleAnim extends BattleAnim {
public encounterAnim: EncounterAnim;
public oppAnim: boolean;
constructor(encounterAnim: EncounterAnim, user: Pokemon, target?: Pokemon, oppAnim?: boolean) {
super(user, target ?? user, true);
constructor(encounterAnim: EncounterAnim, user: Pokemon, target: Pokemon = user, oppAnim = false) {
super(user, target, true);
this.encounterAnim = encounterAnim;
this.oppAnim = oppAnim ?? false;
this.oppAnim = oppAnim;
}
getAnim(): AnimConfig | null {

View File

@ -34,6 +34,7 @@ import { isNullOrUndefined } from "#app/utils/common";
import { MoveUseMode } from "#enums/move-use-mode";
import { invalidEncoreMoves } from "./moves/invalid-moves";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import type { TurnMove } from "#app/field/pokemon";
/**
* A {@linkcode BattlerTag} represents a semi-persistent effect that can be attached to a {@linkcode Pokemon}.
@ -153,21 +154,21 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag {
}
/**
* Gets whether this tag is restricting a move.
* Check if this tag is currently restricting a move's use.
*
* @param move - {@linkcode MoveId} ID to check restriction for.
* @param user - The {@linkcode Pokemon} involved
* @returns `true` if the move is restricted by this tag, otherwise `false`.
* @param move - The {@linkcode MoveId} whose usability is being checked.
* @param user - The {@linkcode Pokemon} using the move.
* @returns Whether the given move is restricted by this tag.
*/
public abstract isMoveRestricted(move: MoveId, user?: Pokemon): boolean;
/**
* Checks if this tag is restricting a move based on a user's decisions during the target selection phase
*
* @param {MoveId} _move {@linkcode MoveId} move ID to check restriction for
* @param {Pokemon} _user {@linkcode Pokemon} the user of the above move
* @param {Pokemon} _target {@linkcode Pokemon} the target of the above move
* @returns {boolean} `false` unless overridden by the child tag
* Check if this tag is restricting a move during target selection.
* Returns `false` by default unless overridden by a child class.
* @param _move - The {@linkcode MoveId} whose selectability is being checked.
* @param _user - The {@linkcode Pokemon} using the move.
* @param _target - The {@linkcode Pokemon} being targeted
* @returns Whether the given move should be unselectable when choosing targets.
*/
isMoveTargetRestricted(_move: MoveId, _user: Pokemon, _target: Pokemon): boolean {
return false;
@ -315,9 +316,10 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
/**
* @override
* @param {Pokemon} pokemon {@linkcode Pokemon} attempting to use the restricted move
* @param {MoveId} move {@linkcode MoveId} ID of the move being interrupted
* @returns {string} text to display when the move is interrupted
* Display the text that occurs when a move is interrupted via Disable.
* @param pokemon - The {@linkcode Pokemon} attempting to use the restricted move.
* @param move - The {@linkcode MoveId} of the move being interrupted.
* @returns The text to display when the given move is interrupted.
*/
override interruptedText(pokemon: Pokemon, move: MoveId): string {
return i18next.t("battle:disableInterruptedMove", {
@ -1876,9 +1878,9 @@ export class TruantTag extends AbilityBattlerTag {
return super.lapse(pokemon, lapseType);
}
const lastMove = pokemon.getLastXMoves()[0];
const lastMove: TurnMove | undefined = pokemon.getLastXMoves()[0];
if (!lastMove) {
if (!lastMove?.move) {
// Don't interrupt move if last move was `Moves.NONE` OR no prior move was found
return true;
}
@ -3419,7 +3421,8 @@ export class PsychoShiftTag extends BattlerTag {
}
/**
* Tag associated with the move Magic Coat.
* Tag associated with {@linkcode Moves.MAGIC_COAT | Magic Coat} that reflects certain status moves directed at the user.
* TODO: Move Reflection code out of `move-effect-phase` and into here
*/
export class MagicCoatTag extends BattlerTag {
constructor() {

View File

@ -181,9 +181,9 @@ export default abstract class Move implements Localizable {
}
/**
* Check if a move has an attribute that matches `attrType`
* @param attrType any attribute that extends {@linkcode MoveAttr}
* @returns true if the move has attribute `attrType`
* Check if a move has an attribute that matches `attrType`.
* @param attrType - The {@linkcode MoveAttrString} for the attribute to check.
* @returns Whether the move has an attribute that is or extends `attrType`.
*/
hasAttr(attrType: MoveAttrString): boolean {
const targetAttr = MoveAttrs[attrType];
@ -635,7 +635,9 @@ export default abstract class Move implements Localizable {
target?: Pokemon;
isFollowUp?: boolean;
}): boolean {
// special cases below, eg: if the move flag is MAKES_CONTACT, and the user pokemon has an ability that ignores contact (like "Long Reach"), then overrides and move does not make contact
// Handle special cases
// Abilities that ignores contact (Long Reach) and substitute blockages
switch (flag) {
case MoveFlags.MAKES_CONTACT:
if (user.hasAbilityWithAttr("IgnoreContactAbAttr") || this.hitsSubstitute(user, target)) {
@ -643,15 +645,17 @@ export default abstract class Move implements Localizable {
}
break;
case MoveFlags.IGNORE_ABILITIES:
// Check for ability based blockages
if (user.hasAbilityWithAttr("MoveAbilityBypassAbAttr")) {
const abilityEffectsIgnored = new BooleanHolder(false);
applyAbAttrs("MoveAbilityBypassAbAttr", user, abilityEffectsIgnored, false, this);
if (abilityEffectsIgnored.value) {
return true;
}
// Sunsteel strike, Moongeist beam, and photon geyser will not ignore abilities if invoked
// by another move, such as via metronome.
}
// Sunsteel strike, Moongeist beam, and photon geyser will not ignore abilities if invoked
// by another move, such as metronome/dancer.
return this.hasFlag(MoveFlags.IGNORE_ABILITIES) && !isFollowUp;
case MoveFlags.IGNORE_PROTECT:
if (user.hasAbilityWithAttr("IgnoreProtectOnContactAbAttr")
@ -2466,6 +2470,7 @@ export class MultiHitAttr extends MoveAttr {
break;
case MultiHitType.BEAT_UP:
// Estimate that half of the party can contribute to beat up.
// TODO: The AI should be able to check this manually?
expectedHits = Math.max(1, partySize / 2);
break;
}

View File

@ -147,3 +147,22 @@ export function isIgnorePP(useMode: MoveUseMode): boolean {
export function isReflected(useMode: MoveUseMode): boolean {
return useMode === MoveUseMode.REFLECTED;
}
/**
* Check if a given {@linkcode MoveUseMode} is capable of being copied by {@linkcode PostDancingMoveAbAttr | Dancer}.
* @param useMode - The {@linkcode MoveUseMode} to check.
* @returns Whether {@linkcode useMode} is dancer copiable.
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `true` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `true` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `false` |
*/
export function isDancerCopiable(useMode: MoveUseMode): boolean {
return !([MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as MoveUseMode[]).includes(useMode)
}

View File

@ -1,4 +1,4 @@
import { type PokeballCounts } from "#app/battle-scene";
import type { PokeballCounts } from "#app/battle-scene";
import { EvolutionItem } from "#app/data/balance/pokemon-evolutions";
import { Gender } from "#app/data/gender";
import { FormChangeItem } from "#enums/form-change-item";
@ -6,21 +6,21 @@ import { type ModifierOverride } from "#app/modifier/modifier-type";
import { Variant } from "#app/sprites/variant";
import { Unlockables } from "#enums/unlockables";
import { AbilityId } from "#enums/ability-id";
import { BattleType } from "#enums/battle-type";
import type { BattleType } from "#enums/battle-type";
import { BerryType } from "#enums/berry-type";
import { BiomeId } from "#enums/biome-id";
import { EggTier } from "#enums/egg-type";
import { MoveId } from "#enums/move-id";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import type { BiomeId } from "#enums/biome-id";
import type { EggTier } from "#enums/egg-type";
import type { MoveId } from "#enums/move-id";
import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import type { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { PokeballType } from "#enums/pokeball";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import type { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { TimeOfDay } from "#enums/time-of-day";
import { TrainerType } from "#enums/trainer-type";
import { VariantTier } from "#enums/variant-tier";
import type { TimeOfDay } from "#enums/time-of-day";
import type { TrainerType } from "#enums/trainer-type";
import type { VariantTier } from "#enums/variant-tier";
import { WeatherType } from "#enums/weather-type";
/**
@ -154,6 +154,10 @@ class DefaultOverrides {
readonly ABILITY_OVERRIDE: AbilityId = AbilityId.NONE;
readonly PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE;
readonly HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null;
/**
* If set, will be added to each newly caught/obtained player Pokemon.
* @remarks If this is set to {@linkcode StatusEffect.SLEEP}, it will always have a constant duration of 4 turns.
*/
readonly STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE;
readonly GENDER_OVERRIDE: Gender | null = null;
readonly MOVESET_OVERRIDE: MoveId | Array<MoveId> = [];

View File

@ -99,6 +99,7 @@ import { UnavailablePhase } from "#app/phases/unavailable-phase";
import { UnlockPhase } from "#app/phases/unlock-phase";
import { VictoryPhase } from "#app/phases/victory-phase";
import { WeatherEffectPhase } from "#app/phases/weather-effect-phase";
import { DancerPhase } from "#app/phases/dancer-phase";
/**
* Manager for phases used by battle scene.
@ -126,6 +127,7 @@ const PHASES = Object.freeze({
CommandPhase,
CommonAnimPhase,
DamageAnimPhase,
DancerPhase,
EggHatchPhase,
EggLapsePhase,
EggSummaryPhase,
@ -567,7 +569,6 @@ export class PhaseManager {
*/
public queueAbilityDisplay(pokemon: Pokemon, passive: boolean, show: boolean): void {
this.unshiftPhase(show ? new ShowAbilityPhase(pokemon.getBattlerIndex(), passive) : new HideAbilityPhase());
this.clearPhaseQueueSplice();
}
/**

View File

@ -0,0 +1,36 @@
import type { BattlerIndex } from "#enums/battler-index";
import { applyPostMoveUsedAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import type Move from "#app/data/moves/move";
import { globalScene } from "#app/global-scene";
import type { HitCheckEntry } from "#app/phases/move-effect-phase";
import { PokemonPhase } from "#app/phases/pokemon-phase";
/** The phase where all on-field Pokemon trigger Dancer and Dancer-like effects. */
export class DancerPhase extends PokemonPhase {
public override readonly phaseName: "DancerPhase";
constructor(
battlerIndex: BattlerIndex,
private targets: BattlerIndex[],
private move: Move,
private hitChecks: HitCheckEntry[],
) {
super(battlerIndex);
}
// TODO: Make iteration occur in speed order
override start(): void {
super.start();
for (const pokemon of globalScene.getField(true)) {
applyPostMoveUsedAbAttrs(
"PostMoveUsedAbAttr",
pokemon,
this.move,
this.getPokemon(),
this.targets,
this.hitChecks,
);
}
super.end();
}
}

View File

@ -54,10 +54,9 @@ import { HitCheckResult } from "#enums/hit-check-result";
import type Move from "#app/data/moves/move";
import { isFieldTargeted } from "#app/data/moves/move-utils";
import { DamageAchv } from "#app/system/achv";
import { isVirtual, isReflected, MoveUseMode } from "#enums/move-use-mode";
import { isVirtual, isReflected, MoveUseMode, isDancerCopiable } from "#enums/move-use-mode";
export type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier];
export class MoveEffectPhase extends PokemonPhase {
public readonly phaseName = "MoveEffectPhase";
public move: Move;
@ -112,11 +111,11 @@ export class MoveEffectPhase extends PokemonPhase {
* Compute targets and the results of hit checks of the invoked move against all targets,
* organized by battler index.
*
* **This is *not* a pure function**; it has the following side effects
* - `this.hitChecks` - The results of the hit checks against each target
* - `this.moveHistoryEntry` - Sets success or failure based on the hit check results
* - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the
* move was unsuccessful against all targets
* **This is *not* a pure function** and has the following side effects:
* - Sets `this.hitChecks` to the results of the hit checks against each target
* - Sets success/failure of `this.moveHistoryEntry` based on the hit check results
* - Sets `user.turnData.hitCount` and `user.turnData.hitsLeft` to 1 if the move
* was unsuccessful against all targets (effectively canceling it)
*
* @returns The targets of the invoked move
* @see {@linkcode hitCheck}
@ -194,9 +193,9 @@ export class MoveEffectPhase extends PokemonPhase {
}
/**
* Apply the move to each of the resolved targets.
* Apply the move to each of its resolved targets.
* @param targets - The resolved set of targets of the move
* @throws Error if there was an unexpected hit check result
* @throws - Error if there was an unexpected hit check result
*/
private applyToTargets(user: Pokemon, targets: Pokemon[]): void {
let firstHit = true;
@ -368,6 +367,7 @@ export class MoveEffectPhase extends PokemonPhase {
*/
private postAnimCallback(user: Pokemon, targets: Pokemon[]) {
// Add to the move history entry
// TODO: Once Truant is fixed to not check history, don't push an entry for reflected/indirect moves
if (this.firstHit) {
user.pushMoveHistory(this.moveHistoryEntry);
applyExecutedMoveAbAttrs("ExecutedMoveAbAttr", user);
@ -428,6 +428,19 @@ export class MoveEffectPhase extends PokemonPhase {
this.getTargets().forEach(target => {
target.turnData.moveEffectiveness = null;
});
// Dancer does not proc on other dancer moves, nor for either occurrence of a reflected move.
// (This blocks copying on the follow-up reflected use; the initial use gets blocked by hit checks)
if (isDancerCopiable(this.useMode)) {
globalScene.phaseManager.appendNewToPhase(
"MoveEndPhase",
"DancerPhase",
this.battlerIndex,
this.targets,
this.move,
this.hitChecks,
);
}
super.end();
}
@ -759,7 +772,7 @@ export class MoveEffectPhase extends PokemonPhase {
user: Pokemon,
target: Pokemon | null,
firstTarget?: boolean | null,
selfTarget?: boolean,
selfTarget = false,
): void {
applyFilteredMoveAttrs(
(attr: MoveAttr) =>

View File

@ -1,6 +1,6 @@
import { BattlerIndex } from "#enums/battler-index";
import { globalScene } from "#app/global-scene";
import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import { applyAbAttrs, applyPreAttackAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import type { DelayedAttackTag } from "#app/data/arena-tag";
import { CommonAnim } from "#enums/move-anims-common";
import { CenterOfAttentionTag } from "#app/data/battler-tags";
@ -431,16 +431,6 @@ export class MovePhase extends BattlePhase {
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
}
// Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`).
// Note the MoveUseMode check here prevents an infinite Dancer loop.
const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const;
if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) {
// TODO: Fix in dancer PR to move to MEP for hit checks
globalScene.getField(true).forEach(pokemon => {
applyPostMoveUsedAbAttrs("PostMoveUsedAbAttr", pokemon, this.move, this.pokemon, this.targets);
});
}
}
/** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */

View File

@ -18,16 +18,16 @@ export class TurnStartPhase extends FieldPhase {
/**
* This orders the active Pokemon on the field by speed into an BattlerIndex array and returns that array.
* It also checks for Trick Room and reverses the array if it is present.
* @returns {@linkcode BattlerIndex[]} the battle indices of all pokemon on the field ordered by speed
* @returns An array of {@linkcode BattlerIndex}es containing all on-field pokemon sorted in speed order.
*/
getSpeedOrder(): BattlerIndex[] {
const playerField = globalScene.getPlayerField().filter(p => p.isActive()) as Pokemon[];
const enemyField = globalScene.getEnemyField().filter(p => p.isActive()) as Pokemon[];
// We shuffle the list before sorting so speed ties produce random results
// Shuffle the list before sorting so speed ties produce random results
// This is seeded with the current turn to prevent an inconsistency with variable turn order
// based on how long since you last reloaded
let orderedTargets: Pokemon[] = playerField.concat(enemyField);
// We seed it with the current turn to prevent an inconsistency where it
// was varying based on how long since you last reloaded
globalScene.executeWithSeedOffset(
() => {
orderedTargets = randSeedShuffle(orderedTargets);
@ -36,11 +36,11 @@ export class TurnStartPhase extends FieldPhase {
globalScene.waveSeed,
);
// Next, a check for Trick Room is applied to determine sort order.
// Check for Trick Room and reverse sort order if active.
// Notably, Pokerogue does NOT have the "outspeed trick room" glitch at >1809 spd.
const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(TrickRoomTag, false, speedReversed);
// Adjust the sort function based on whether Trick Room is active.
orderedTargets.sort((a: Pokemon, b: Pokemon) => {
const aSpeed = a?.getEffectiveStat(Stat.SPD) ?? 0;
const bSpeed = b?.getEffectiveStat(Stat.SPD) ?? 0;
@ -111,7 +111,8 @@ export class TurnStartPhase extends FieldPhase {
}
}
// If there is no difference between the move's calculated priorities, the game checks for differences in battlerBypassSpeed and returns the result.
// If there is no difference between the move's calculated priorities,
// check for differences in battlerBypassSpeed and returns the result.
if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) {
return battlerBypassSpeed[a].value ? -1 : 1;
}

View File

@ -1,13 +1,20 @@
import { BattlerIndex } from "#enums/battler-index";
import type Pokemon from "#app/field/pokemon";
import { MoveResult } from "#enums/move-result";
import type { MovePhase } from "#app/phases/move-phase";
import { MovePhase } from "#app/phases/move-phase";
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveUseMode } from "#enums/move-use-mode";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { toDmgValue } from "#app/utils/common";
import { allMoves, allAbilities } from "#app/data/data-lists";
import { ShowAbilityPhase } from "#app/phases/show-ability-phase";
describe("Abilities - Dancer", () => {
let phaserGame: Phaser.Game;
@ -25,119 +32,517 @@ describe("Abilities - Dancer", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.battleStyle("double").enemyAbility(AbilityId.BALL_FETCH);
game.override
.battleStyle("single")
.disableCrits()
.ability(AbilityId.DANCER)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.enemyLevel(100)
.startingLevel(100);
});
// Reference Link: https://bulbapedia.bulbagarden.net/wiki/Dancer_(Ability)
/**
* Check that the specified {@linkcode Pokemon} is using the specified move
* in the current {@linkcode MovePhase} against the specified targets.
*/
function checkCurrentMoveUser(
pokemon: Pokemon,
move: MoveId,
targets?: BattlerIndex[],
useMode: MoveUseMode = MoveUseMode.INDIRECT,
) {
const currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(currentPhase).not.toBeNull();
expect(currentPhase).toBeInstanceOf(MovePhase);
expect(currentPhase.pokemon).toBe(pokemon);
expect(currentPhase.move.moveId).toBe(move);
if (targets) {
expect(currentPhase.targets).toHaveLength(targets.length);
expect(currentPhase.targets).toEqual(expect.arrayContaining(targets));
}
expect(currentPhase.useMode).toBe(useMode);
}
it("triggers when dance moves are used, doesn't consume extra PP", async () => {
game.override.enemyAbility(AbilityId.DANCER).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset(MoveId.VICTORY_DANCE);
async function toNextMove() {
await game.phaseInterceptor.to("MoveEndPhase");
await game.phaseInterceptor.to("MovePhase", false);
}
// Reference Link: https://bulbapedia.bulbagarden.net/wiki/Dancer_(Ability).
it("should copy dance moves without consuming extra PP", async () => {
game.override.enemyAbility(AbilityId.DANCER);
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
game.move.changeMoveset(oricorio, [MoveId.VICTORY_DANCE, MoveId.SWORDS_DANCE]);
game.move.changeMoveset(shuckle, [MoveId.VICTORY_DANCE, MoveId.SWORDS_DANCE]);
game.move.select(MoveId.SWORDS_DANCE);
await game.move.selectEnemyMove(MoveId.VICTORY_DANCE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
// shpuldn't use PP if copied move is also in moveset
expect(oricorio.moveset.map(m => m.ppUsed)).toEqual([0, 1]);
expect(shuckle.moveset.map(m => m.ppUsed)).toEqual([1, 0]);
// effects were applied correctly
expect(oricorio.getStatStage(Stat.ATK)).toBe(3);
expect(shuckle.getStatStage(Stat.ATK)).toBe(3);
// moves showed up in history
expect(oricorio.getLastXMoves(-1)).toHaveLength(2);
expect(oricorio.getLastXMoves(-1)).toEqual([
expect.objectContaining({ move: MoveId.VICTORY_DANCE, useMode: MoveUseMode.INDIRECT }),
expect.objectContaining({ move: MoveId.SWORDS_DANCE, useMode: MoveUseMode.NORMAL }),
]);
expect(shuckle.getLastXMoves(-1)).toHaveLength(2);
expect(shuckle.getLastXMoves(-1)).toEqual([
expect.objectContaining({ move: MoveId.VICTORY_DANCE, useMode: MoveUseMode.NORMAL }),
expect.objectContaining({ move: MoveId.SWORDS_DANCE, useMode: MoveUseMode.INDIRECT }),
]);
});
it("should redirect copied move if ally target faints", async () => {
game.override.battleStyle("double").startingLevel(500);
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
const [oricorio, feebas] = game.scene.getPlayerField();
game.move.changeMoveset(oricorio, [MoveId.SWORDS_DANCE, MoveId.VICTORY_DANCE, MoveId.SPLASH]);
game.move.changeMoveset(feebas, [MoveId.SWORDS_DANCE, MoveId.SPLASH]);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SWORDS_DANCE, 1);
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("MovePhase"); // feebas uses swords dance
await game.phaseInterceptor.to("MovePhase", false); // oricorio copies swords dance
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(currentPhase.pokemon).toBe(oricorio);
expect(currentPhase.move.moveId).toBe(MoveId.SWORDS_DANCE);
await game.phaseInterceptor.to("MovePhase", false); // feebas rev dance
checkCurrentMoveUser(feebas, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY_2], MoveUseMode.NORMAL);
await toNextMove();
await game.phaseInterceptor.to("MoveEndPhase"); // end oricorio's move
await game.phaseInterceptor.to("MovePhase"); // magikarp 1 copies swords dance
await game.phaseInterceptor.to("MovePhase"); // magikarp 2 copies swords dance
await game.phaseInterceptor.to("MovePhase"); // magikarp (left) uses victory dance
await game.phaseInterceptor.to("MovePhase", false); // oricorio copies magikarp's victory dance
// attack should redirect
const [shuckle1, shuckle2] = game.scene.getEnemyField();
expect(shuckle2.isFainted()).toBe(true);
checkCurrentMoveUser(oricorio, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY]);
currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(currentPhase.pokemon).toBe(oricorio);
expect(currentPhase.move.moveId).toBe(MoveId.VICTORY_DANCE);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase"); // finish the turn
// doesn't use PP if copied move is also in moveset
expect(oricorio.moveset[0]?.ppUsed).toBe(0);
expect(oricorio.moveset[1]?.ppUsed).toBe(0);
expect(shuckle1.isFainted()).toBe(true);
});
// TODO: Enable after Dancer rework to not push to move history
it.todo("should not count as the last move used for mirror move/instruct", async () => {
game.override
.moveset([MoveId.FIERY_DANCE, MoveId.REVELATION_DANCE])
.enemyMoveset([MoveId.INSTRUCT, MoveId.MIRROR_MOVE, MoveId.SPLASH])
.enemySpecies(SpeciesId.SHUCKLE)
.enemyLevel(10);
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
const [oricorio] = game.scene.getPlayerField();
const [, shuckle2] = game.scene.getEnemyField();
game.move.select(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
game.move.select(MoveId.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.move.selectEnemyMove(MoveId.INSTRUCT, BattlerIndex.PLAYER);
await game.move.selectEnemyMove(MoveId.MIRROR_MOVE, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MovePhase"); // Oricorio rev dance
await game.phaseInterceptor.to("MovePhase"); // Feebas fiery dance
await game.phaseInterceptor.to("MovePhase"); // Oricorio fiery dance (from dancer)
await game.phaseInterceptor.to("MoveEndPhase", false);
// dancer copied move doesn't appear in move history
expect(oricorio.getLastXMoves(-1)[0].move).toBe(MoveId.REVELATION_DANCE);
await game.phaseInterceptor.to("MovePhase"); // shuckle 2 mirror moves oricorio
await game.phaseInterceptor.to("MovePhase"); // calls instructed rev dance
let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(currentPhase.pokemon).toBe(shuckle2);
expect(currentPhase.move.moveId).toBe(MoveId.REVELATION_DANCE);
await game.phaseInterceptor.to("MovePhase"); // shuckle 1 instructs oricorio
await game.phaseInterceptor.to("MovePhase");
currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase;
expect(currentPhase.pokemon).toBe(oricorio);
expect(currentPhase.move.moveId).toBe(MoveId.REVELATION_DANCE);
});
it("should not break subsequent last hit only moves", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
const [oricorio, feebas] = game.scene.getPlayerParty();
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.field.getPlayerPokemon()).toBe(feebas);
expect(feebas.getStatStage(Stat.ATK)).toBe(2);
expect(oricorio.isOnField()).toBe(false);
expect(oricorio.visible).toBe(false);
});
it("should not trigger while flinched", async () => {
game.override.battleStyle("double").moveset(MoveId.SPLASH).enemyMoveset([MoveId.SWORDS_DANCE, MoveId.FAKE_OUT]);
it("should redirect copied move if source enemy faints", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.scene.getPlayerPokemon()!;
expect(oricorio).toBeDefined();
const oricorio = game.field.getPlayerPokemon();
const [shuckle1, shuckle2] = game.scene.getEnemyField();
shuckle1.hp = 1;
vi.spyOn(shuckle2, "getAbility").mockReturnValue(allAbilities[AbilityId.ROUGH_SKIN]);
// get faked out and copy swords dance
game.move.select(MoveId.SPLASH);
// Enemy 1 hits enemy 2 and gets pwned
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.AQUA_STEP, BattlerIndex.ENEMY_2);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MovePhase"); // shuckle aqua steps its ally and kills itself
await toNextMove(); // Oricorio copies
expect(shuckle1.isFainted()).toBe(true);
// attack should redirect to other shuckle
checkCurrentMoveUser(oricorio, MoveId.AQUA_STEP, [BattlerIndex.ENEMY_2]);
await game.toEndOfTurn();
expect(oricorio.isFullHp()).toBe(false);
});
it("should target correctly in double battles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
const [oricorio, feebas] = game.scene.getPlayerField();
// oricorio feather dances feebas, everyone else dances like crazy
game.move.use(MoveId.FEATHER_DANCE, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
game.move.use(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.move.forceEnemyMove(MoveId.FIERY_DANCE, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.move.forceEnemyMove(MoveId.FAKE_OUT, BattlerIndex.PLAYER);
await game.phaseInterceptor.to("TurnEndPhase");
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
expect(oricorio.getLastXMoves(-1)[0]).toMatchObject({
move: MoveId.NONE,
result: MoveResult.FAIL,
});
await game.phaseInterceptor.to("MovePhase", false); // oricorio feather dance
checkCurrentMoveUser(oricorio, MoveId.FEATHER_DANCE, [BattlerIndex.PLAYER_2], MoveUseMode.NORMAL);
await toNextMove(); // feebas copies feather dance against oricorio
checkCurrentMoveUser(feebas, MoveId.FEATHER_DANCE, [BattlerIndex.PLAYER]);
await toNextMove(); // feebas uses rev dance on shuckle #2
checkCurrentMoveUser(feebas, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY_2], MoveUseMode.NORMAL);
await toNextMove(); // oricorio copies rev dance against same target
checkCurrentMoveUser(oricorio, MoveId.REVELATION_DANCE, [BattlerIndex.ENEMY_2]);
await toNextMove(); // shuckle 1 uses fiery dance
await toNextMove(); // oricorio copies fiery dance against it
checkCurrentMoveUser(oricorio, MoveId.FIERY_DANCE, [BattlerIndex.ENEMY]);
await toNextMove(); // feebas copies fiery dance
checkCurrentMoveUser(feebas, MoveId.FIERY_DANCE, [BattlerIndex.ENEMY]);
await toNextMove(); // shuckle 2 uses swords dance
await toNextMove(); // oricorio copies swords dance
checkCurrentMoveUser(oricorio, MoveId.SWORDS_DANCE, [BattlerIndex.PLAYER]);
await toNextMove(); // feebas copies swords dance
checkCurrentMoveUser(feebas, MoveId.SWORDS_DANCE, [BattlerIndex.PLAYER_2]);
await game.toEndOfTurn();
});
it("should display ability flyouts right before move use", async () => {
game.override.battleStyle("double").enemyAbility(AbilityId.DANCER);
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
// TODO: uncomment once dynamic spd order added
// game.scene
// .getField()
// .forEach((pkmn, i) => pkmn.setStat(Stat.SPD, 5 - i));
game.move.use(MoveId.SWORDS_DANCE, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("DancerPhase", false);
game.phaseInterceptor.clearLogs();
await game.toEndOfTurn();
const showAbPhases: number[] = [];
const hideAbPhases: number[] = [];
const movePhases: number[] = [];
const moveEndPhases: number[] = [];
const log = game.phaseInterceptor.log;
for (const [index, phase] of log.entries()) {
switch (phase) {
case "ShowAbilityPhase":
showAbPhases.push(index);
break;
case "HideAbilityPhase":
hideAbPhases.push(index);
break;
case "MovePhase":
movePhases.push(index);
break;
case "MoveEndPhase":
case "DancerPhase": // also count the initial dancer phase (which occurs right after the initial usage anyhow)
moveEndPhases.push(index);
break;
}
}
expect(showAbPhases).toHaveLength(3);
expect(hideAbPhases).toHaveLength(3);
// Each dancer's ShowAbilityPhase must be immediately preceded by a MoveEndPhase and HideAbilityPhase,
// and followed by a MovePhase.
// We do not check the move phases directly as other pokemon may have moved themselves.
for (const i of showAbPhases) {
expect(moveEndPhases).toContain(i - 2);
expect(hideAbPhases).toContain(i - 1);
expect(movePhases).toContain(i + 1);
}
});
// TODO: Enable once abilities start proccing in speed order
it.todo("should respect speed order during doubles", async () => {
game.override
.battleStyle("double")
.enemyAbility(AbilityId.DANCER)
.moveset([MoveId.QUIVER_DANCE, MoveId.SPLASH])
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
// Set the mons in reverse speed order - P1, P2, E1, E2
// Used in place of `setTurnOrder` as the latter only applies for turn start phase
game.scene.getField().forEach((pkmn, i) => pkmn.setStat(Stat.SPD, 5 - i));
const orderSpy = vi.spyOn(MovePhase.prototype, "start");
const showAbSpy = vi.spyOn(ShowAbilityPhase.prototype, "start");
game.move.select(MoveId.QUIVER_DANCE, BattlerIndex.PLAYER);
game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
const [oricorio, feebas, shuckle1, shuckle2] = game.scene.getField();
const expectedOrder = [
// Oricorio quiver dance, then copies
oricorio,
feebas,
shuckle1,
shuckle2,
];
const order = (orderSpy.mock.contexts as MovePhase[]).map(mp => mp.pokemon);
const abOrder = (showAbSpy.mock.contexts as ShowAbilityPhase[]).map(sap => sap.getPokemon());
expect(order).toEqual(expectedOrder);
expect(abOrder).toEqual(expectedOrder);
});
// TODO: Currently this is bugged as counter moves don't work at all
it.todo("should count as last attack recieved for counter moves", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.SNIVY]);
const [oricorio, _snivy] = game.scene.getPlayerField();
const [shuckle1, shuckle2] = game.scene.getEnemyField();
const enemyDmgSpy = vi.spyOn(shuckle2, "damageAndUpdate");
// snivy attacks enemy 2, prompting oricorio to do the same
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.killPokemon(shuckle1);
await game.move.forceEnemyMove(MoveId.METAL_BURST);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2]);
// ORDER:
// oricorio splash
// shuckle 1 splash
// snivy rev dance vs shuckle 2
// oricorio copies rev dance vs shuckle 2
// shuckle 2 metal burst vs Oricorio
await game.toEndOfTurn();
expect(shuckle2.getLastXMoves(-1)[0].move).toBe(MoveId.METAL_BURST);
expect(shuckle2.getLastXMoves(-1)[0].targets).toEqual([BattlerIndex.PLAYER]);
expect(enemyDmgSpy).toHaveBeenCalledTimes(2);
const lastDmgTaken = enemyDmgSpy.mock.lastCall?.[0]!;
expect(oricorio.getInverseHp()).toBe(toDmgValue(lastDmgTaken * 1.5));
});
it.each<{ name: string; move?: MoveId; enemyMove: MoveId }>([
{ name: "protected moves", enemyMove: MoveId.FIERY_DANCE, move: MoveId.PROTECT },
{ name: "missed moves", enemyMove: MoveId.AQUA_STEP },
{ name: "ineffective moves", enemyMove: MoveId.REVELATION_DANCE }, // ground type
// TODO: These currently don't work as the moves are still considered "successful"
// if all targets are unaffected
// { name: "failed Teeter Dance", enemyMove: Moves.TEETER_DANCE },
// { name: "capped stat-boosting moves", enemyMove: Moves.FEATHER_DANCE },
// { name: "capped stat-lowering moves", enemyMove: Moves.QUIVER_DANCE },
])("should not trigger on $name", async ({ move = MoveId.SPLASH, enemyMove }) => {
game.override.enemySpecies(SpeciesId.GROUDON);
// force aqua step to whiff
vi.spyOn(allMoves[MoveId.AQUA_STEP], "accuracy", "get").mockReturnValue(0);
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.field.getPlayerPokemon();
oricorio.setStatStage(Stat.ATK, -6);
oricorio.setStatStage(Stat.SPATK, +6);
oricorio.setStatStage(Stat.SPDEF, +6);
oricorio.setStatStage(Stat.SPD, +6);
oricorio.addTag(BattlerTagType.CONFUSED, 12, MoveId.CONFUSE_RAY, game.field.getEnemyPokemon().id);
game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
await game.toNextTurn();
expect(oricorio.getLastXMoves(-1)).toEqual([
expect.objectContaining({ move, result: MoveResult.SUCCESS, useMode: MoveUseMode.NORMAL }),
]);
expect(oricorio.waveData.abilityRevealed).toBe(false);
});
it("should trigger confusion self-damage, even when protected against", async () => {
game.override.confusionActivation(false); // disable confusion unless forced by mocks
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.field.getPlayerPokemon();
// get confused
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.CONFUSE_RAY);
await game.toNextTurn();
expect(oricorio.getTag(BattlerTagType.CONFUSED)).toBeDefined();
// Protect, then copy swords dance
game.move.use(MoveId.PROTECT);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.phaseInterceptor.to("MovePhase"); // protect
await game.phaseInterceptor.to("MoveEndPhase"); // Swords dance
await game.move.forceConfusionActivation(true); // force confusion proc during swords dance copy
await game.toEndOfTurn();
// took damage from confusion instead of using move; player remains confused
expect(oricorio.hp).toBeLessThan(oricorio.getMaxHp());
expect(oricorio.getTag(BattlerTagType.CONFUSED)).toBeDefined();
expect(game.field.getEnemyPokemon()?.getTag(BattlerTagType.CONFUSED)).toBeUndefined();
});
it("should respect full paralysis", async () => {
game.override.statusEffect(StatusEffect.PARALYSIS).statusActivation(true);
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.field.getPlayerPokemon();
expect(oricorio.status).not.toBeNull();
// attempt to copy swords dance and get para'd
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(oricorio.getLastXMoves(-1)[0]).toMatchObject({ move: MoveId.NONE });
expect(oricorio.status).toBeTruthy();
expect(oricorio.getStatStage(Stat.ATK)).toBe(0);
});
it.each<{ name: string; status: StatusEffect }>([
{ name: "Sleep", status: StatusEffect.SLEEP },
{ name: "Freeze", status: StatusEffect.FREEZE },
])("should cure $name when copying move", async ({ status }) => {
game.override.statusEffect(status).statusActivation(true);
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.field.getPlayerPokemon();
expect(oricorio.status?.effect).toBe(status);
game.move.use(MoveId.ACROBATICS);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEffectPhase"); // enemy SD
await game.phaseInterceptor.to("MovePhase"); // player dancer attempt (curing happens inside MP)
expect(oricorio.status).toBeNull();
await game.toEndOfTurn();
expect(oricorio.getStatStage(Stat.ATK)).toBe(2);
expect(game.field.getEnemyPokemon().isFullHp()).toBe(false);
});
// TODO: This more or less requires an overhaul of Frenzy moves
it.todo("should not lock user into Petal Dance or reduce its duration", async () => {
game.override.enemyMoveset(MoveId.PETAL_DANCE);
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
// Mock RNG to make frenzy always last for max duration
const oricorio = game.field.getPlayerPokemon();
vi.spyOn(oricorio, "randBattleSeedIntRange").mockImplementation((_, max) => max);
game.move.changeMoveset(oricorio, [MoveId.SPLASH, MoveId.PETAL_DANCE]);
const shuckle = game.field.getEnemyPokemon();
// Enemy uses petal dance and we copy
game.move.select(MoveId.SPLASH);
await game.toEndOfTurn();
// used petal dance without being locked into move
expect(oricorio.getLastXMoves(-1)[0]).toMatchObject({ move: MoveId.PETAL_DANCE, useMode: MoveUseMode.INDIRECT });
expect(oricorio.getMoveQueue()).toHaveLength(0);
expect(oricorio.getTag(BattlerTagType.FRENZY)).toBeUndefined();
expect(shuckle.turnData.attacksReceived).toHaveLength(1);
await game.toNextTurn();
// Use petal dance ourselves and copy enemy one
game.move.select(MoveId.PETAL_DANCE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
const prevQueueLength = oricorio.getMoveQueue().length;
await game.toEndOfTurn();
// locked into Petal Dance for 2 more turns (not 1)
expect(oricorio.getMoveQueue()).toHaveLength(prevQueueLength);
expect(oricorio.getTag(BattlerTagType.FRENZY)).toBeDefined();
expect(oricorio.getMoveset().find(m => m.moveId === MoveId.PETAL_DANCE)?.ppUsed).toBe(1);
});
it("should lapse Truant and respect its disables", async () => {
game.override.passiveAbility(AbilityId.TRUANT).moveset(MoveId.SPLASH).enemyMoveset(MoveId.SWORDS_DANCE);
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const oricorio = game.field.getPlayerPokemon();
// turn 1: Splash --> truanted Dancer SD
game.move.select(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(oricorio.getLastXMoves(2)).toEqual([
expect.objectContaining({
move: MoveId.NONE,
result: MoveResult.FAIL,
useMode: MoveUseMode.INDIRECT,
}),
expect.objectContaining({
move: MoveId.SPLASH,
result: MoveResult.SUCCESS,
useMode: MoveUseMode.NORMAL,
}),
]);
expect(oricorio.getStatStage(Stat.ATK)).toBe(0);
// Turn 2: Dancer SD --> truanted Splash
game.move.select(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
expect(oricorio.getLastXMoves(2)).toEqual([
expect.objectContaining({
move: MoveId.NONE,
result: MoveResult.FAIL,
useMode: MoveUseMode.NORMAL,
}),
expect.objectContaining({
move: MoveId.SWORDS_DANCE,
result: MoveResult.SUCCESS,
useMode: MoveUseMode.INDIRECT,
}),
]);
expect(oricorio.getStatStage(Stat.ATK)).toBe(2);
});
it.each<{ name: string; move: MoveId }>([
{ name: "Mirror Move", move: MoveId.MIRROR_MOVE },
{ name: "Copycat", move: MoveId.COPYCAT },
])("should not count as last move used for $name", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.ORICORIO]);
const shuckle = game.field.getEnemyPokemon();
// select splash first so we have a clear indicator of what move got copied
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
expect(shuckle.getLastXMoves()[0]).toMatchObject({ move: MoveId.SPLASH, useMode: MoveUseMode.FOLLOW_UP });
});
it("should not count as the last move used for Instruct", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.INSTRUCT, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MovePhase"); // Oricorio uses splash
await game.phaseInterceptor.to("MovePhase"); // Feebas uses fiery dance
await game.phaseInterceptor.to("MovePhase"); // Oricorio copies fiery dance
await game.phaseInterceptor.to("MovePhase"); // shuckle 2 instructs oricorio
await toNextMove(); // instructed move used
checkCurrentMoveUser(game.field.getPlayerPokemon(), MoveId.SPLASH, [BattlerIndex.PLAYER], MoveUseMode.NORMAL);
});
("");
});

View File

@ -91,6 +91,6 @@ export class ClassicModeHelper extends GameManagerHelper {
}
await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]==================");
console.log("==================[New Battle]==================");
}
}

View File

@ -209,9 +209,11 @@ export class OverridesHelper extends GameManagerHelper {
}
/**
* Override the player pokemon's {@linkcode StatusEffect | status-effect}
* @param statusEffect - The {@linkcode StatusEffect | status-effect} to set
* @returns
* Override the player pokemon's {@linkcode StatusEffect | status effect}.
* Will be applied to each newly spawned pokemon on battle start.
* @param statusEffect - The {@linkcode StatusEffect | status effect} to set
* @returns `this`
* @remarks If this is set to {@linkcode StatusEffect.SLEEP}, it will always have a constant duration of 4 turns.
*/
public statusEffect(statusEffect: StatusEffect): this {
vi.spyOn(Overrides, "STATUS_OVERRIDE", "get").mockReturnValue(statusEffect);