diff --git a/src/data/ability.ts b/src/data/ability.ts index ebdd5105bb4..cc95045f8b7 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -7,7 +7,7 @@ import { Weather, WeatherType } from "./weather"; import { BattlerTag, GroundedTag } from "./battler-tags"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { Gender } from "./gender"; -import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; +import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; import { TerrainType } from "./terrain"; @@ -1139,7 +1139,9 @@ export class MoveEffectChanceMultiplierAbAttr extends AbAttr { apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { // Disable showAbility during getTargetBenefitScore this.showAbility = args[4]; - if ((args[0] as Utils.NumberHolder).value <= 0 || (args[1] as Move).id === Moves.ORDER_UP) { + + const exceptMoves = [ Moves.ORDER_UP, Moves.ELECTRO_SHOT ]; + if ((args[0] as Utils.NumberHolder).value <= 0 || exceptMoves.includes((args[1] as Move).id)) { return false; } @@ -1329,7 +1331,6 @@ export class AddSecondStrikeAbAttr extends PreAttackAbAttr { */ const exceptAttrs: Constructor[] = [ MultiHitAttr, - ChargeAttr, SacrificialAttr, SacrificialAttrOnHit ]; @@ -1345,6 +1346,7 @@ export class AddSecondStrikeAbAttr extends PreAttackAbAttr { /** Also check if this move is an Attack move and if it's only targeting one Pokemon */ return numTargets === 1 + && !move.isChargingMove() && !exceptAttrs.some(attr => move.hasAttr(attr)) && !exceptMoves.some(id => move.id === id) && move.category !== MoveCategory.STATUS; diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index aa6aec6f73a..d2c95b7ccdf 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -970,6 +970,9 @@ export class GravityTag extends ArenaTag { if (pokemon !== null) { pokemon.removeTag(BattlerTagType.FLOATING); pokemon.removeTag(BattlerTagType.TELEKINESIS); + if (pokemon.getTag(BattlerTagType.FLYING)) { + pokemon.addTag(BattlerTagType.INTERRUPTED); + } } }); } diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 03bf0809fa6..37900a3ab5a 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -1,6 +1,6 @@ //import { battleAnimRawData } from "./battle-anim-raw-data"; import BattleScene from "../battle-scene"; -import { AttackMove, BeakBlastHeaderAttr, ChargeAttr, DelayedAttackAttr, MoveFlags, SelfStatusMove, allMoves } from "./move"; +import { AttackMove, BeakBlastHeaderAttr, DelayedAttackAttr, MoveFlags, SelfStatusMove, allMoves } from "./move"; import Pokemon from "../field/pokemon"; import * as Utils from "../utils"; import { BattlerIndex } from "../battle"; @@ -476,8 +476,11 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise { } else { const loadedCheckTimer = setInterval(() => { if (moveAnims.get(move) !== null) { - const chargeAttr = allMoves[move].getAttrs(ChargeAttr)[0] || allMoves[move].getAttrs(DelayedAttackAttr)[0]; - if (chargeAttr && chargeAnims.get(chargeAttr.chargeAnim) === null) { + const chargeAnimSource = (allMoves[move].isChargingMove()) + ? allMoves[move] + : (allMoves[move].getAttrs(DelayedAttackAttr)[0] + ?? allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]); + if (chargeAnimSource && chargeAnims.get(chargeAnimSource.chargeAnim) === null) { return; } clearInterval(loadedCheckTimer); @@ -507,11 +510,12 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise { } else { populateMoveAnim(move, ba); } - const chargeAttr = allMoves[move].getAttrs(ChargeAttr)[0] - || allMoves[move].getAttrs(DelayedAttackAttr)[0] - || allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]; - if (chargeAttr) { - initMoveChargeAnim(scene, chargeAttr.chargeAnim).then(() => resolve()); + const chargeAnimSource = (allMoves[move].isChargingMove()) + ? allMoves[move] + : (allMoves[move].getAttrs(DelayedAttackAttr)[0] + ?? allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]); + if (chargeAnimSource) { + initMoveChargeAnim(scene, chargeAnimSource.chargeAnim).then(() => resolve()); } else { resolve(); } @@ -638,11 +642,12 @@ export function loadMoveAnimAssets(scene: BattleScene, moveIds: Moves[], startLo return new Promise(resolve => { const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat(); for (const moveId of moveIds) { - const chargeAttr = allMoves[moveId].getAttrs(ChargeAttr)[0] - || allMoves[moveId].getAttrs(DelayedAttackAttr)[0] - || allMoves[moveId].getAttrs(BeakBlastHeaderAttr)[0]; - if (chargeAttr) { - const moveChargeAnims = chargeAnims.get(chargeAttr.chargeAnim); + const chargeAnimSource = (allMoves[moveId].isChargingMove()) + ? allMoves[moveId] + : (allMoves[moveId].getAttrs(DelayedAttackAttr)[0] + ?? allMoves[moveId].getAttrs(BeakBlastHeaderAttr)[0]); + if (chargeAnimSource) { + const moveChargeAnims = chargeAnims.get(chargeAnimSource.chargeAnim); moveAnimations.push(moveChargeAnims instanceof AnimConfig ? moveChargeAnims : moveChargeAnims![0]); // TODO: is the bang correct? if (Array.isArray(moveChargeAnims)) { moveAnimations.push(moveChargeAnims[1]); diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index c3b7765d062..d671c56ab26 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -11,7 +11,6 @@ import { ChargeAnim, CommonAnim, CommonBattleAnim, MoveChargeAnim } from "#app/d import Move, { allMoves, applyMoveAttrs, - ChargeAttr, ConsecutiveUseDoublePowerAttr, HealOnAllyAttr, MoveCategory, @@ -949,10 +948,6 @@ export class EncoreTag extends BattlerTag { return false; } - if (allMoves[repeatableMove.move].hasAttr(ChargeAttr) && repeatableMove.result === MoveResult.OTHER) { - return false; - } - this.moveId = repeatableMove.move; return true; @@ -2591,7 +2586,7 @@ export class TormentTag extends MoveRestrictionBattlerTag { // This checks for locking / momentum moves like Rollout and Hydro Cannon + if the user is under the influence of BattlerTagType.FRENZY // Because Uproar's unique behavior is not implemented, it does not check for Uproar. Torment has been marked as partial in moves.ts const moveObj = allMoves[lastMove.move]; - const isUnaffected = moveObj.hasAttr(ConsecutiveUseDoublePowerAttr) || user.getTag(BattlerTagType.FRENZY) || moveObj.hasAttr(ChargeAttr); + const isUnaffected = moveObj.hasAttr(ConsecutiveUseDoublePowerAttr) || user.getTag(BattlerTagType.FRENZY); const validLastMoveResult = (lastMove.result === MoveResult.SUCCESS) || (lastMove.result === MoveResult.MISS); if (lastMove.move === move && validLastMoveResult && lastMove.move !== Moves.STRUGGLE && !isUnaffected) { return true; diff --git a/src/data/move.ts b/src/data/move.ts index ec25844909e..cf88fad0ac5 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -289,10 +289,9 @@ export default class Move implements Localizable { } /** - * Getter function that returns if the move targets itself or an ally + * Getter function that returns if the move targets the user or its ally * @returns boolean */ - isAllyTarget(): boolean { switch (this.moveTarget) { case MoveTarget.USER: @@ -306,6 +305,10 @@ export default class Move implements Localizable { return false; } + isChargingMove(): this is ChargingMove { + return false; + } + /** * Checks if the move is immune to certain types. * Currently looks at cases of Grass types with powder moves and Dark types with moves affected by Prankster. @@ -893,6 +896,85 @@ export class SelfStatusMove extends Move { } } +type SubMove = new (...args: any[]) => Move; + +function ChargeMove(Base: TBase) { + return class extends Base { + /** The animation to play during the move's charging phase */ + public readonly chargeAnim: ChargeAnim = ChargeAnim[`${Moves[this.id]}_CHARGING`]; + /** The message to show during the move's charging phase */ + private _chargeText: string; + + /** Move attributes that apply during the move's charging phase */ + public chargeAttrs: MoveAttr[] = []; + + override isChargingMove(): this is ChargingMove { + return true; + } + + /** + * Sets the text to be displayed during this move's charging phase. + * References to the user Pokemon should be written as "{USER}", and + * references to the target Pokemon should be written as "{TARGET}". + * @param chargeText the text to set + * @returns this {@linkcode Move} (for chaining API purposes) + */ + chargeText(chargeText: string): this { + this._chargeText = chargeText; + return this; + } + + /** + * Queues the charge text to display to the player + * @param user the {@linkcode Pokemon} using this move + * @param target the {@linkcode Pokemon} targeted by this move (optional) + */ + showChargeText(user: Pokemon, target?: Pokemon): void { + user.scene.queueMessage(this._chargeText + .replace("{USER}", getPokemonNameWithAffix(user)) + .replace("{TARGET}", getPokemonNameWithAffix(target)) + ); + } + + /** + * Gets all charge attributes of the given attribute type. + * @param attrType any attribute that extends {@linkcode MoveAttr} + * @returns Array of attributes that match `attrType`, or an empty array if + * no matches are found. + */ + getChargeAttrs(attrType: Constructor): T[] { + return this.chargeAttrs.filter((attr): attr is T => attr instanceof attrType); + } + + /** + * Checks if this move has an attribute of the given type. + * @param attrType any attribute that extends {@linkcode MoveAttr} + * @returns `true` if a matching attribute is found; `false` otherwise + */ + hasChargeAttr(attrType: Constructor): boolean { + return this.chargeAttrs.some((attr) => attr instanceof attrType); + } + + /** + * Adds an attribute to this move to be applied during the move's charging phase + * @param ChargeAttrType the type of {@linkcode MoveAttr} being added + * @param args the parameters to construct the given {@linkcode MoveAttr} with + * @returns this {@linkcode Move} (for chaining API purposes) + */ + chargeAttr>(ChargeAttrType: T, ...args: ConstructorParameters): this { + const chargeAttr = new ChargeAttrType(...args); + this.chargeAttrs.push(chargeAttr); + + return this; + } + }; +} + +export class ChargingAttackMove extends ChargeMove(AttackMove) {} +export class ChargingSelfStatusMove extends ChargeMove(SelfStatusMove) {} + +export type ChargingMove = ChargingAttackMove | ChargingSelfStatusMove; + /** * Base class defining all {@linkcode Move} Attributes * @abstract @@ -2574,6 +2656,63 @@ export class OneHitKOAttr extends MoveAttr { } } +/** + * Attribute that allows charge moves to resolve in 1 turn under a given condition. + * Should only be used for {@linkcode ChargingMove | ChargingMoves} as a `chargeAttr`. + * @extends MoveAttr + */ +export class InstantChargeAttr extends MoveAttr { + /** The condition in which the move with this attribute instantly charges */ + protected readonly condition: UserMoveConditionFunc; + + constructor(condition: UserMoveConditionFunc) { + super(true); + this.condition = condition; + } + + /** + * Flags the move with this attribute as instantly charged if this attribute's condition is met. + * @param user the {@linkcode Pokemon} using the move + * @param target n/a + * @param move the {@linkcode Move} associated with this attribute + * @param args + * - `[0]` a {@linkcode Utils.BooleanHolder | BooleanHolder} for the "instant charge" flag + * @returns `true` if the instant charge condition is met; `false` otherwise. + */ + override apply(user: Pokemon, target: Pokemon | null, move: Move, args: any[]): boolean { + const instantCharge = args[0]; + if (!(instantCharge instanceof Utils.BooleanHolder)) { + return false; + } + + if (this.condition(user, move)) { + instantCharge.value = true; + return true; + } + return false; + } +} + +/** + * Attribute that allows charge moves to resolve in 1 turn while specific {@linkcode WeatherType | Weather} + * is active. Should only be used for {@linkcode ChargingMove | ChargingMoves} as a `chargeAttr`. + * @extends InstantChargeAttr + */ +export class WeatherInstantChargeAttr extends InstantChargeAttr { + constructor(weatherTypes: WeatherType[]) { + super((user, move) => { + const currentWeather = user.scene.arena.weather; + + if (Utils.isNullOrUndefined(currentWeather?.weatherType)) { + return false; + } else { + return !currentWeather?.isEffectSuppressed(user.scene) + && weatherTypes.includes(currentWeather?.weatherType); + } + }); + } +} + export class OverrideMoveEffectAttr extends MoveAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean | Promise { //const overridden = args[0] as Utils.BooleanHolder; @@ -2582,111 +2721,6 @@ export class OverrideMoveEffectAttr extends MoveAttr { } } -export class ChargeAttr extends OverrideMoveEffectAttr { - public chargeAnim: ChargeAnim; - private chargeText: string; - private tagType: BattlerTagType | null; - private chargeEffect: boolean; - public followUpPriority: integer | null; - - constructor(chargeAnim: ChargeAnim, chargeText: string, tagType?: BattlerTagType | null, chargeEffect: boolean = false) { - super(); - - this.chargeAnim = chargeAnim; - this.chargeText = chargeText; - this.tagType = tagType!; // TODO: is this bang correct? - this.chargeEffect = chargeEffect; - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { - return new Promise(resolve => { - const lastMove = user.getLastXMoves().find(() => true); - if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && lastMove.turn !== user.scene.currentBattle.turn)) { - (args[0] as Utils.BooleanHolder).value = true; - new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => { - user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); - if (this.tagType) { - user.addTag(this.tagType, 1, move.id, user.id); - } - if (this.chargeEffect) { - applyMoveAttrs(MoveEffectAttr, user, target, move); - } - user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER }); - user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true }); - user.addTag(BattlerTagType.CHARGING, 1, move.id, user.id); - resolve(true); - }); - } else { - user.lapseTag(BattlerTagType.CHARGING); - resolve(false); - } - }); - } - - usedChargeEffect(user: Pokemon, target: Pokemon | null, move: Move): boolean { - if (!this.chargeEffect) { - return false; - } - // Account for move history being populated when this function is called - const lastMoves = user.getLastXMoves(2); - return lastMoves.length === 2 && lastMoves[1].move === move.id && lastMoves[1].result === MoveResult.OTHER; - } -} - -export class SunlightChargeAttr extends ChargeAttr { - constructor(chargeAnim: ChargeAnim, chargeText: string) { - super(chargeAnim, chargeText); - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { - return new Promise(resolve => { - const weatherType = user.scene.arena.weather?.weatherType; - if (!user.scene.arena.weather?.isEffectSuppressed(user.scene) && (weatherType === WeatherType.SUNNY || weatherType === WeatherType.HARSH_SUN)) { - resolve(false); - } else { - super.apply(user, target, move, args).then(result => resolve(result)); - } - }); - } -} - -export class ElectroShotChargeAttr extends ChargeAttr { - private statIncreaseApplied: boolean; - constructor() { - super(ChargeAnim.ELECTRO_SHOT_CHARGING, i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" }), null, true); - // Add a flag because ChargeAttr skills use themselves twice instead of once over one-to-two turns - this.statIncreaseApplied = false; - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { - return new Promise(resolve => { - const weatherType = user.scene.arena.weather?.weatherType; - if (!user.scene.arena.weather?.isEffectSuppressed(user.scene) && (weatherType === WeatherType.RAIN || weatherType === WeatherType.HEAVY_RAIN)) { - // Apply the SPATK increase every call when used in the rain - const statChangeAttr = new StatStageChangeAttr([ Stat.SPATK ], 1, true); - statChangeAttr.apply(user, target, move, args); - // After the SPATK is raised, execute the move resolution e.g. deal damage - resolve(false); - } else { - if (!this.statIncreaseApplied) { - // Apply the SPATK increase only if it hasn't been applied before e.g. on the first turn charge up animation - const statChangeAttr = new StatStageChangeAttr([ Stat.SPATK ], 1, true); - statChangeAttr.apply(user, target, move, args); - // Set the flag to true so that on the following turn it doesn't raise SPATK a second time - this.statIncreaseApplied = true; - } - super.apply(user, target, move, args).then(result => { - if (!result) { - // On the second turn, reset the statIncreaseApplied flag without applying the SPATK increase - this.statIncreaseApplied = false; - } - resolve(result); - }); - } - }); - } -} - export class DelayedAttackAttr extends OverrideMoveEffectAttr { public tagType: ArenaTagType; public chargeAnim: ChargeAnim; @@ -4878,6 +4912,37 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move) return true; }; +/** + * Attribute that grants {@link https://bulbapedia.bulbagarden.net/wiki/Semi-invulnerable_turn | semi-invulnerability} to the user during + * the associated move's charging phase. Should only be used for {@linkcode ChargingMove | ChargingMoves} as a `chargeAttr`. + * @extends MoveEffectAttr + */ +export class SemiInvulnerableAttr extends MoveEffectAttr { + /** The type of {@linkcode SemiInvulnerableTag} to grant to the user */ + public tagType: BattlerTagType; + + constructor(tagType: BattlerTagType) { + super(true); + this.tagType = tagType; + } + + /** + * Grants a {@linkcode SemiInvulnerableTag} to the associated move's user. + * @param user the {@linkcode Pokemon} using the move + * @param target n/a + * @param move the {@linkcode Move} being used + * @param args n/a + * @returns `true` if semi-invulnerability was successfully granted; `false` otherwise. + */ + override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean { + if (!super.apply(user, target, move, args)) { + return false; + } + + return user.addTag(this.tagType, 1, move.id, user.id); + } +} + export class AddBattlerTagAttr extends MoveEffectAttr { public tagType: BattlerTagType; public turnCountMin: integer; @@ -6138,7 +6203,7 @@ const lastMoveCopiableCondition: MoveConditionFunc = (user, target, move) => { return false; } - if (allMoves[copiableMove].hasAttr(ChargeAttr)) { + if (allMoves[copiableMove].isChargingMove()) { return false; } @@ -6286,7 +6351,7 @@ const targetMoveCopiableCondition: MoveConditionFunc = (user, target, move) => { return false; } - if (allMoves[copiableMove.move].hasAttr(ChargeAttr) && copiableMove.result === MoveResult.OTHER) { + if (allMoves[copiableMove.move].isChargingMove() && copiableMove.result === MoveResult.OTHER) { return false; } @@ -6985,6 +7050,20 @@ function applyMoveAttrsInternal(attrFilter: MoveAttrFilter, user: Pokemon | null }); } +function applyMoveChargeAttrsInternal(attrFilter: MoveAttrFilter, user: Pokemon | null, target: Pokemon | null, move: ChargingMove, args: any[]): Promise { + return new Promise(resolve => { + const chargeAttrPromises: Promise[] = []; + const chargeMoveAttrs = move.chargeAttrs.filter(a => attrFilter(a)); + for (const attr of chargeMoveAttrs) { + const result = attr.apply(user, target, move, args); + if (result instanceof Promise) { + chargeAttrPromises.push(result); + } + } + Promise.allSettled(chargeAttrPromises).then(() => resolve()); + }); +} + export function applyMoveAttrs(attrType: Constructor, user: Pokemon | null, target: Pokemon | null, move: Move, ...args: any[]): Promise { return applyMoveAttrsInternal((attr: MoveAttr) => attr instanceof attrType, user, target, move, args); } @@ -6993,6 +7072,10 @@ export function applyFilteredMoveAttrs(attrFilter: MoveAttrFilter, user: Pokemon return applyMoveAttrsInternal(attrFilter, user, target, move, args); } +export function applyMoveChargeAttrs(attrType: Constructor, user: Pokemon | null, target: Pokemon | null, move: ChargingMove, ...args: any[]): Promise { + return applyMoveChargeAttrsInternal((attr: MoveAttr) => attr instanceof attrType, user, target, move, args); +} + export class MoveCondition { protected func: MoveConditionFunc; @@ -7237,8 +7320,8 @@ export function initMoves() { new AttackMove(Moves.GUILLOTINE, Type.NORMAL, MoveCategory.PHYSICAL, 200, 30, 5, -1, 0, 1) .attr(OneHitKOAttr) .attr(OneHitKOAccuracyAttr), - new AttackMove(Moves.RAZOR_WIND, Type.NORMAL, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 1) - .attr(ChargeAttr, ChargeAnim.RAZOR_WIND_CHARGING, i18next.t("moveTriggers:whippedUpAWhirlwind", { pokemonName: "{USER}" })) + new ChargingAttackMove(Moves.RAZOR_WIND, Type.NORMAL, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 1) + .chargeText(i18next.t("moveTriggers:whippedUpAWhirlwind", { pokemonName: "{USER}" })) .attr(HighCritAttr) .windMove() .ignoresVirtual() @@ -7258,8 +7341,9 @@ export function initMoves() { .hidesTarget() .windMove() .partial(), // Should force random switches - new AttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1) - .attr(ChargeAttr, ChargeAnim.FLY_CHARGING, i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" }), BattlerTagType.FLYING) + new ChargingAttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1) + .chargeText(i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING) .condition(failOnGravityCondition) .ignoresVirtual(), new AttackMove(Moves.BIND, Type.NORMAL, MoveCategory.PHYSICAL, 15, 85, 20, -1, 0, 1) @@ -7408,8 +7492,9 @@ export function initMoves() { .makesContact(false) .slicingMove() .target(MoveTarget.ALL_NEAR_ENEMIES), - new AttackMove(Moves.SOLAR_BEAM, Type.GRASS, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 1) - .attr(SunlightChargeAttr, ChargeAnim.SOLAR_BEAM_CHARGING, i18next.t("moveTriggers:tookInSunlight", { pokemonName: "{USER}" })) + new ChargingAttackMove(Moves.SOLAR_BEAM, Type.GRASS, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 1) + .chargeText(i18next.t("moveTriggers:tookInSunlight", { pokemonName: "{USER}" })) + .chargeAttr(WeatherInstantChargeAttr, [ WeatherType.SUNNY, WeatherType.HARSH_SUN ]) .attr(AntiSunlightPowerDecreaseAttr) .ignoresVirtual(), new StatusMove(Moves.POISON_POWDER, Type.POISON, 75, 35, -1, 0, 1) @@ -7458,8 +7543,9 @@ export function initMoves() { .attr(OneHitKOAccuracyAttr) .attr(HitsTagAttr, BattlerTagType.UNDERGROUND) .makesContact(false), - new AttackMove(Moves.DIG, Type.GROUND, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 1) - .attr(ChargeAttr, ChargeAnim.DIG_CHARGING, i18next.t("moveTriggers:dugAHole", { pokemonName: "{USER}" }), BattlerTagType.UNDERGROUND) + new ChargingAttackMove(Moves.DIG, Type.GROUND, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 1) + .chargeText(i18next.t("moveTriggers:dugAHole", { pokemonName: "{USER}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.UNDERGROUND) .ignoresVirtual(), new StatusMove(Moves.TOXIC, Type.POISON, 90, 10, -1, 0, 1) .attr(StatusEffectAttr, StatusEffect.TOXIC) @@ -7555,9 +7641,9 @@ export function initMoves() { .attr(TrapAttr, BattlerTagType.CLAMP), new AttackMove(Moves.SWIFT, Type.NORMAL, MoveCategory.SPECIAL, 60, -1, 20, -1, 0, 1) .target(MoveTarget.ALL_NEAR_ENEMIES), - new AttackMove(Moves.SKULL_BASH, Type.NORMAL, MoveCategory.PHYSICAL, 130, 100, 10, -1, 0, 1) - .attr(ChargeAttr, ChargeAnim.SKULL_BASH_CHARGING, i18next.t("moveTriggers:loweredItsHead", { pokemonName: "{USER}" }), null, true) - .attr(StatStageChangeAttr, [ Stat.DEF ], 1, true) + new ChargingAttackMove(Moves.SKULL_BASH, Type.NORMAL, MoveCategory.PHYSICAL, 130, 100, 10, -1, 0, 1) + .chargeText(i18next.t("moveTriggers:loweredItsHead", { pokemonName: "{USER}" })) + .chargeAttr(StatStageChangeAttr, [ Stat.DEF ], 1, true) .ignoresVirtual(), new AttackMove(Moves.SPIKE_CANNON, Type.NORMAL, MoveCategory.PHYSICAL, 20, 100, 15, -1, 0, 1) .attr(MultiHitAttr) @@ -7594,8 +7680,8 @@ export function initMoves() { .triageMove(), new StatusMove(Moves.LOVELY_KISS, Type.NORMAL, 75, 10, -1, 0, 1) .attr(StatusEffectAttr, StatusEffect.SLEEP), - new AttackMove(Moves.SKY_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 1) - .attr(ChargeAttr, ChargeAnim.SKY_ATTACK_CHARGING, i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" })) + new ChargingAttackMove(Moves.SKY_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 1) + .chargeText(i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" })) .attr(HighCritAttr) .attr(FlinchAttr) .makesContact(false) @@ -8060,9 +8146,10 @@ export function initMoves() { new AttackMove(Moves.SECRET_POWER, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 20, 30, 0, 3) .makesContact(false) .attr(SecretPowerAttr), - new AttackMove(Moves.DIVE, Type.WATER, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 3) - .attr(ChargeAttr, ChargeAnim.DIVE_CHARGING, i18next.t("moveTriggers:hidUnderwater", { pokemonName: "{USER}" }), BattlerTagType.UNDERWATER, true) - .attr(GulpMissileTagAttr) + new ChargingAttackMove(Moves.DIVE, Type.WATER, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 3) + .chargeText(i18next.t("moveTriggers:hidUnderwater", { pokemonName: "{USER}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.UNDERWATER) + .chargeAttr(GulpMissileTagAttr) .ignoresVirtual(), new AttackMove(Moves.ARM_THRUST, Type.FIGHTING, MoveCategory.PHYSICAL, 15, 100, 20, -1, 0, 3) .attr(MultiHitAttr), @@ -8195,8 +8282,9 @@ export function initMoves() { .attr(RechargeAttr), new SelfStatusMove(Moves.BULK_UP, Type.FIGHTING, -1, 20, -1, 0, 3) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], 1, true), - new AttackMove(Moves.BOUNCE, Type.FLYING, MoveCategory.PHYSICAL, 85, 85, 5, 30, 0, 3) - .attr(ChargeAttr, ChargeAnim.BOUNCE_CHARGING, i18next.t("moveTriggers:sprangUp", { pokemonName: "{USER}" }), BattlerTagType.FLYING) + new ChargingAttackMove(Moves.BOUNCE, Type.FLYING, MoveCategory.PHYSICAL, 85, 85, 5, 30, 0, 3) + .chargeText(i18next.t("moveTriggers:sprangUp", { pokemonName: "{USER}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING) .attr(StatusEffectAttr, StatusEffect.PARALYSIS) .condition(failOnGravityCondition) .ignoresVirtual(), @@ -8551,8 +8639,9 @@ export function initMoves() { new AttackMove(Moves.OMINOUS_WIND, Type.GHOST, MoveCategory.SPECIAL, 60, 100, 5, 10, 0, 4) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true) .windMove(), - new AttackMove(Moves.SHADOW_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4) - .attr(ChargeAttr, ChargeAnim.SHADOW_FORCE_CHARGING, i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" }), BattlerTagType.HIDDEN) + new ChargingAttackMove(Moves.SHADOW_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4) + .chargeText(i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.HIDDEN) .ignoresProtect() .ignoresVirtual(), new SelfStatusMove(Moves.HONE_CLAWS, Type.DARK, -1, 15, -1, 0, 5) @@ -8675,12 +8764,13 @@ export function initMoves() { .attr( MovePowerMultiplierAttr, (user, target, move) => target.status || target.hasAbility(Abilities.COMATOSE) ? 2 : 1), - new AttackMove(Moves.SKY_DROP, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) - .partial() // Should immobilize the target, Flying types should take no damage. cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/ - .attr(ChargeAttr, ChargeAnim.SKY_DROP_CHARGING, i18next.t("moveTriggers:tookTargetIntoSky", { pokemonName: "{USER}", targetName: "{TARGET}" }), BattlerTagType.FLYING) // TODO: Add 2nd turn message + new ChargingAttackMove(Moves.SKY_DROP, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) + .chargeText(i18next.t("moveTriggers:tookTargetIntoSky", { pokemonName: "{USER}", targetName: "{TARGET}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING) .condition(failOnGravityCondition) .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE)) - .ignoresVirtual(), + .ignoresVirtual() + .partial(), // Should immobilize the target, Flying types should take no damage. cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/ new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5) .attr(StatStageChangeAttr, [ Stat.ATK ], 1, true) .attr(StatStageChangeAttr, [ Stat.SPD ], 2, true), @@ -8830,12 +8920,12 @@ export function initMoves() { new AttackMove(Moves.FIERY_DANCE, Type.FIRE, MoveCategory.SPECIAL, 80, 100, 10, 50, 0, 5) .attr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) .danceMove(), - new AttackMove(Moves.FREEZE_SHOCK, Type.ICE, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 5) - .attr(ChargeAttr, ChargeAnim.FREEZE_SHOCK_CHARGING, i18next.t("moveTriggers:becameCloakedInFreezingLight", { pokemonName: "{USER}" })) + new ChargingAttackMove(Moves.FREEZE_SHOCK, Type.ICE, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 5) + .chargeText(i18next.t("moveTriggers:becameCloakedInFreezingLight", { pokemonName: "{USER}" })) .attr(StatusEffectAttr, StatusEffect.PARALYSIS) .makesContact(false), - new AttackMove(Moves.ICE_BURN, Type.ICE, MoveCategory.SPECIAL, 140, 90, 5, 30, 0, 5) - .attr(ChargeAttr, ChargeAnim.ICE_BURN_CHARGING, i18next.t("moveTriggers:becameCloakedInFreezingAir", { pokemonName: "{USER}" })) + new ChargingAttackMove(Moves.ICE_BURN, Type.ICE, MoveCategory.SPECIAL, 140, 90, 5, 30, 0, 5) + .chargeText(i18next.t("moveTriggers:becameCloakedInFreezingAir", { pokemonName: "{USER}" })) .attr(StatusEffectAttr, StatusEffect.BURN) .ignoresVirtual(), new AttackMove(Moves.SNARL, Type.DARK, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 5) @@ -8877,8 +8967,9 @@ export function initMoves() { .target(MoveTarget.ENEMY_SIDE), new AttackMove(Moves.FELL_STINGER, Type.BUG, MoveCategory.PHYSICAL, 50, 100, 25, -1, 0, 6) .attr(PostVictoryStatStageChangeAttr, [ Stat.ATK ], 3, true ), - new AttackMove(Moves.PHANTOM_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) - .attr(ChargeAttr, ChargeAnim.PHANTOM_FORCE_CHARGING, i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" }), BattlerTagType.HIDDEN) + new ChargingAttackMove(Moves.PHANTOM_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) + .chargeText(i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" })) + .chargeAttr(SemiInvulnerableAttr, BattlerTagType.HIDDEN) .ignoresProtect() .ignoresVirtual(), new StatusMove(Moves.TRICK_OR_TREAT, Type.GHOST, 100, 20, -1, 0, 6) @@ -8988,8 +9079,8 @@ export function initMoves() { .ignoresSubstitute() .powderMove() .unimplemented(), - new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6) - .attr(ChargeAttr, ChargeAnim.GEOMANCY_CHARGING, i18next.t("moveTriggers:isChargingPower", { pokemonName: "{USER}" })) + new ChargingSelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6) + .chargeText(i18next.t("moveTriggers:isChargingPower", { pokemonName: "{USER}" })) .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true) .ignoresVirtual(), new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6) @@ -9198,8 +9289,9 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK ], -1) .condition((user, target, move) => target.getStatStage(Stat.ATK) > -6) .triageMove(), - new AttackMove(Moves.SOLAR_BLADE, Type.GRASS, MoveCategory.PHYSICAL, 125, 100, 10, -1, 0, 7) - .attr(SunlightChargeAttr, ChargeAnim.SOLAR_BLADE_CHARGING, i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" })) + new ChargingAttackMove(Moves.SOLAR_BLADE, Type.GRASS, MoveCategory.PHYSICAL, 125, 100, 10, -1, 0, 7) + .chargeText(i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" })) + .chargeAttr(WeatherInstantChargeAttr, [ WeatherType.SUNNY, WeatherType.HARSH_SUN ]) .attr(AntiSunlightPowerDecreaseAttr) .slicingMove(), new AttackMove(Moves.LEAFAGE, Type.GRASS, MoveCategory.PHYSICAL, 40, 100, 40, -1, 0, 7) @@ -9625,9 +9717,9 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, null, true, false, MoveEffectTrigger.HIT, false, true) .attr(MultiHitAttr) .makesContact(false), - new AttackMove(Moves.METEOR_BEAM, Type.ROCK, MoveCategory.SPECIAL, 120, 90, 10, 100, 0, 8) - .attr(ChargeAttr, ChargeAnim.METEOR_BEAM_CHARGING, i18next.t("moveTriggers:isOverflowingWithSpacePower", { pokemonName: "{USER}" }), null, true) - .attr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) + new ChargingAttackMove(Moves.METEOR_BEAM, Type.ROCK, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 8) + .chargeText(i18next.t("moveTriggers:isOverflowingWithSpacePower", { pokemonName: "{USER}" })) + .chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) .ignoresVirtual(), new AttackMove(Moves.SHELL_SIDE_ARM, Type.POISON, MoveCategory.SPECIAL, 90, 100, 10, 20, 0, 8) .attr(ShellSideArmCategoryAttr) @@ -10079,8 +10171,10 @@ export function initMoves() { .attr(IvyCudgelTypeAttr) .attr(HighCritAttr) .makesContact(false), - new AttackMove(Moves.ELECTRO_SHOT, Type.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9) - .attr(ElectroShotChargeAttr) + new ChargingAttackMove(Moves.ELECTRO_SHOT, Type.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9) + .chargeText(i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" })) + .chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) + .chargeAttr(WeatherInstantChargeAttr, [ WeatherType.RAIN, WeatherType.HEAVY_RAIN ]) .ignoresVirtual(), new AttackMove(Moves.TERA_STARSTORM, Type.NORMAL, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9) .attr(TeraMoveCategoryAttr) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index a3d7429ed9b..6c4ae3b7ff9 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "#app/battle-scene"; import { Variant, VariantSet, variantColorCache } from "#app/data/variant"; import { variantData } from "#app/data/variant"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "#app/ui/battle-info"; -import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget, CombinedPledgeStabBoostAttr } from "#app/data/move"; +import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget, CombinedPledgeStabBoostAttr } from "#app/data/move"; import { default as PokemonSpecies, PokemonSpeciesForm, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER, getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters"; import { starterPassiveAbilities } from "#app/data/balance/passives"; @@ -2121,7 +2121,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { // Trainers get a weight bump to stat buffing moves movePool = movePool.map(m => [ m[0], m[1] * (allMoves[m[0]].getAttrs(StatStageChangeAttr).some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1) ]); // Trainers get a weight decrease to multiturn moves - movePool = movePool.map(m => [ m[0], m[1] * (!!allMoves[m[0]].hasAttr(ChargeAttr) || !!allMoves[m[0]].hasAttr(RechargeAttr) ? 0.7 : 1) ]); + movePool = movePool.map(m => [ m[0], m[1] * (!!allMoves[m[0]].isChargingMove() || !!allMoves[m[0]].hasAttr(RechargeAttr) ? 0.7 : 1) ]); } // Weight towards higher power moves, by reducing the power of moves below the highest power. diff --git a/src/phases/move-charge-phase.ts b/src/phases/move-charge-phase.ts new file mode 100644 index 00000000000..d1dc340b81b --- /dev/null +++ b/src/phases/move-charge-phase.ts @@ -0,0 +1,84 @@ +import BattleScene from "#app/battle-scene"; +import { BattlerIndex } from "#app/battle"; +import { MoveChargeAnim } from "#app/data/battle-anims"; +import { applyMoveChargeAttrs, MoveEffectAttr, InstantChargeAttr } from "#app/data/move"; +import Pokemon, { MoveResult, PokemonMove } from "#app/field/pokemon"; +import { BooleanHolder } from "#app/utils"; +import { MovePhase } from "#app/phases/move-phase"; +import { PokemonPhase } from "#app/phases/pokemon-phase"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; + +/** + * Phase for the "charging turn" of two-turn moves (e.g. Dig). + * @extends {@linkcode PokemonPhase} + */ +export class MoveChargePhase extends PokemonPhase { + /** The move instance that this phase applies */ + public move: PokemonMove; + /** The field index targeted by the move (Charging moves assume single target) */ + public targetIndex: BattlerIndex; + + constructor(scene: BattleScene, battlerIndex: BattlerIndex, targetIndex: BattlerIndex, move: PokemonMove) { + super(scene, battlerIndex); + this.move = move; + this.targetIndex = targetIndex; + } + + public override start() { + super.start(); + + const user = this.getUserPokemon(); + const target = this.getTargetPokemon(); + const move = this.move.getMove(); + + // If the target is somehow not defined, or the move is somehow not a ChargingMove, + // immediately end this phase. + if (!target || !(move.isChargingMove())) { + console.warn("Invalid parameters for MoveChargePhase"); + return super.end(); + } + + new MoveChargeAnim(move.chargeAnim, move.id, user).play(this.scene, false, () => { + move.showChargeText(user, target); + + applyMoveChargeAttrs(MoveEffectAttr, user, target, move).then(() => { + user.addTag(BattlerTagType.CHARGING, 1, move.id, user.id); + this.end(); + }); + }); + } + + /** Checks the move's instant charge conditions, then ends this phase. */ + public override end() { + const user = this.getUserPokemon(); + const move = this.move.getMove(); + + if (move.isChargingMove()) { + const instantCharge = new BooleanHolder(false); + + applyMoveChargeAttrs(InstantChargeAttr, user, null, move, instantCharge); + + if (instantCharge.value) { + // this MoveEndPhase will be duplicated by the queued MovePhase if not removed + this.scene.tryRemovePhase((phase) => phase instanceof MoveEndPhase && phase.getPokemon() === user); + // queue a new MovePhase for this move's attack phase + this.scene.unshiftPhase(new MovePhase(this.scene, user, [ this.targetIndex ], this.move, false)); + } else { + user.getMoveQueue().push({ move: move.id, targets: [ this.targetIndex ]}); + } + + // Add this move's charging phase to the user's move history + user.pushMoveHistory({ move: this.move.moveId, targets: [ this.targetIndex ], result: MoveResult.OTHER }); + } + super.end(); + } + + public getUserPokemon(): Pokemon { + return (this.player ? this.scene.getPlayerField() : this.scene.getEnemyField())[this.fieldIndex]; + } + + public getTargetPokemon(): Pokemon | undefined { + return this.scene.getField(true).find((p) => this.targetIndex === p.getBattlerIndex()); + } +} diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 8d1a255d268..2b898f7d66b 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -4,7 +4,7 @@ import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; -import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, OneHitKOAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move"; +import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, OneHitKOAttr, MoveEffectTrigger, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { BattlerTagType } from "#app/enums/battler-tag-type"; import { Moves } from "#app/enums/moves"; @@ -24,10 +24,10 @@ export class MoveEffectPhase extends PokemonPhase { super(scene, battlerIndex); this.move = move; /** - * In double battles, if the right Pokemon selects a spread move and the left Pokemon dies - * with no party members available to switch in, then the right Pokemon takes the index - * of the left Pokemon and gets hit unless this is checked. - */ + * In double battles, if the right Pokemon selects a spread move and the left Pokemon dies + * with no party members available to switch in, then the right Pokemon takes the index + * of the left Pokemon and gets hit unless this is checked. + */ if (targets.includes(battlerIndex) && this.move.getMove().moveTarget === MoveTarget.ALL_NEAR_OTHERS) { const i = targets.indexOf(battlerIndex); targets.splice(i, i + 1); @@ -49,9 +49,9 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Does an effect from this move override other effects on this turn? - * e.g. Charging moves (Fly, etc.) on their first turn of use. - */ + * Does an effect from this move override other effects on this turn? + * e.g. Charging moves (Fly, etc.) on their first turn of use. + */ const overridden = new Utils.BooleanHolder(false); /** The {@linkcode Move} object from {@linkcode allMoves} invoked by this phase */ const move = this.move.getMove(); @@ -66,10 +66,10 @@ export class MoveEffectPhase extends PokemonPhase { user.lapseTags(BattlerTagLapseType.MOVE_EFFECT); /** - * If this phase is for the first hit of the invoked move, - * resolve the move's total hit count. This block combines the - * effects of the move itself, Parental Bond, and Multi-Lens to do so. - */ + * If this phase is for the first hit of the invoked move, + * resolve the move's total hit count. This block combines the + * effects of the move itself, Parental Bond, and Multi-Lens to do so. + */ if (user.turnData.hitsLeft === -1) { const hitCount = new Utils.IntegerHolder(1); // Assume single target for multi hit @@ -86,16 +86,16 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Log to be entered into the user's move history once the move result is resolved. - * Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully - * used in the sense of "Does it have an effect on the user?". - */ + * Log to be entered into the user's move history once the move result is resolved. + * Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully + * used in the sense of "Does it have an effect on the user?". + */ const moveHistoryEntry = { move: this.move.moveId, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual }; /** - * Stores results of hit checks of the invoked move against all targets, organized by battler index. - * @see {@linkcode hitCheck} - */ + * Stores results of hit checks of the invoked move against all targets, organized by battler index. + * @see {@linkcode hitCheck} + */ const targetHitChecks = Object.fromEntries(targets.map(p => [ p.getBattlerIndex(), this.hitCheck(p) ])); const hasActiveTargets = targets.some(t => t.isActive(true)); @@ -104,11 +104,10 @@ export class MoveEffectPhase extends PokemonPhase { && !targets[0].getTag(SemiInvulnerableTag); /** - * If no targets are left for the move to hit (FAIL), or the invoked move is single-target - * (and not random target) and failed the hit check against its target (MISS), log the move - * as FAILed or MISSed (depending on the conditions above) and end this phase. - */ - + * If no targets are left for the move to hit (FAIL), or the invoked move is single-target + * (and not random target) and failed the hit check against its target (MISS), log the move + * as FAILed or MISSed (depending on the conditions above) and end this phase. + */ if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) { this.stopMultiHit(); if (hasActiveTargets) { @@ -154,9 +153,9 @@ export class MoveEffectPhase extends PokemonPhase { && !target.getTag(SemiInvulnerableTag); /** - * If the move missed a target, stop all future hits against that target - * and move on to the next target (if there is one). - */ + * If the move missed a target, stop all future hits against that target + * and move on to the next target (if there is one). + */ if (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()]) { this.stopMultiHit(target); this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) })); @@ -177,23 +176,23 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Since all fail/miss checks have applied, the move is considered successfully applied. - * It's worth noting that if the move has no effect or is protected against, this assignment - * is overwritten and the move is logged as a FAIL. - */ + * Since all fail/miss checks have applied, the move is considered successfully applied. + * It's worth noting that if the move has no effect or is protected against, this assignment + * is overwritten and the move is logged as a FAIL. + */ moveHistoryEntry.result = MoveResult.SUCCESS; /** - * Stores the result of applying the invoked move to the target. - * If the target is protected, the result is always `NO_EFFECT`. - * Otherwise, the hit result is based on type effectiveness, immunities, - * and other factors that may negate the attack or status application. - * - * Internally, the call to {@linkcode Pokemon.apply} is where damage is calculated - * (for attack moves) and the target's HP is updated. However, this isn't - * made visible to the user until the resulting {@linkcode DamagePhase} - * is invoked. - */ + * Stores the result of applying the invoked move to the target. + * If the target is protected, the result is always `NO_EFFECT`. + * Otherwise, the hit result is based on type effectiveness, immunities, + * and other factors that may negate the attack or status application. + * + * Internally, the call to {@linkcode Pokemon.apply} is where damage is calculated + * (for attack moves) and the target's HP is updated. However, this isn't + * made visible to the user until the resulting {@linkcode DamagePhase} + * is invoked. + */ const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT; /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ @@ -211,9 +210,9 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * If the move has no effect on the target (i.e. the target is protected or immune), - * change the logged move result to FAIL. - */ + * If the move has no effect on the target (i.e. the target is protected or immune), + * change the logged move result to FAIL. + */ if (hitResult === HitResult.NO_EFFECT) { moveHistoryEntry.result = MoveResult.FAIL; } @@ -222,43 +221,41 @@ export class MoveEffectPhase extends PokemonPhase { const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()); /** - * If the user can change forms by using the invoked move, - * it only changes forms after the move's last hit - * (see Relic Song's interaction with Parental Bond when used by Meloetta). - */ + * If the user can change forms by using the invoked move, + * it only changes forms after the move's last hit + * (see Relic Song's interaction with Parental Bond when used by Meloetta). + */ if (lastHit) { this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); } /** - * Create a Promise that applys *all* effects from the invoked move's MoveEffectAttrs. - * These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger - * type requires different conditions to be met with respect to the move's hit result. - */ + * Create a Promise that applys *all* effects from the invoked move's MoveEffectAttrs. + * These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger + * type requires different conditions to be met with respect to the move's hit result. + */ applyAttrs.push(new Promise(resolve => { // Apply all effects with PRE_MOVE triggers (if the target isn't immune to the move) applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && hitResult !== HitResult.NO_EFFECT, user, target, move).then(() => { // All other effects require the move to not have failed or have been cancelled to trigger if (hitResult !== HitResult.FAIL) { - /** Are the move's effects tied to the first turn of a charge move? */ - const chargeEffect = !!move.getAttrs(ChargeAttr).find(ca => ca.usedChargeEffect(user, this.getTarget() ?? null, move)); /** - * If the invoked move's effects are meant to trigger during the move's "charge turn," - * ignore all effects after this point. - * Otherwise, apply all self-targeted POST_APPLY effects. - */ - Utils.executeIf(!chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY - && attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move)).then(() => { + * If the invoked move's effects are meant to trigger during the move's "charge turn," + * ignore all effects after this point. + * Otherwise, apply all self-targeted POST_APPLY effects. + */ + applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY + && attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move).then(() => { // All effects past this point require the move to have hit the target if (hitResult !== HitResult.NO_EFFECT) { // Apply all non-self-targeted POST_APPLY effects applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { /** - * If the move hit, and the target doesn't have Shield Dust, - * apply the chance to flinch the target gained from King's Rock - */ + * If the move hit, and the target doesn't have Shield Dust, + * apply the chance to flinch the target gained from King's Rock + */ if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) { const flinched = new Utils.BooleanHolder(false); user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); @@ -267,7 +264,7 @@ export class MoveEffectPhase extends PokemonPhase { } } // If the move was not protected against, apply all HIT effects - Utils.executeIf(!isProtected && !chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT + Utils.executeIf(!isProtected, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => { // Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them) return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { @@ -286,9 +283,9 @@ export class MoveEffectPhase extends PokemonPhase { // Apply the user's post-attack ability effects applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => { /** - * If the invoked move is an attack, apply the user's chance to - * steal an item from the target granted by Grip Claw - */ + * If the invoked move is an attack, apply the user's chance to + * steal an item from the target granted by Grip Claw + */ if (this.move.getMove() instanceof AttackMove) { this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); } @@ -343,12 +340,12 @@ export class MoveEffectPhase extends PokemonPhase { end() { const user = this.getUserPokemon(); /** - * If this phase isn't for the invoked move's last strike, - * unshift another MoveEffectPhase for the next strike. - * Otherwise, queue a message indicating the number of times the move has struck - * (if the move has struck more than once), then apply the heal from Shell Bell - * to the user. - */ + * If this phase isn't for the invoked move's last strike, + * unshift another MoveEffectPhase for the next strike. + * Otherwise, queue a message indicating the number of times the move has struck + * (if the move has struck more than once), then apply the heal from Shell Bell + * to the user. + */ if (user) { if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getTarget()?.isActive()) { this.scene.unshiftPhase(this.getNewHitPhase()); @@ -447,9 +444,9 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Removes the given {@linkcode Pokemon} from this phase's target list - * @param target {@linkcode Pokemon} the Pokemon to be removed - */ + * Removes the given {@linkcode Pokemon} from this phase's target list + * @param target {@linkcode Pokemon} the Pokemon to be removed + */ removeTarget(target: Pokemon): void { const targetIndex = this.targets.findIndex(ind => ind === target.getBattlerIndex()); if (targetIndex !== -1) { @@ -458,19 +455,19 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Prevents subsequent strikes of this phase's invoked move from occurring - * @param target {@linkcode Pokemon} if defined, only stop subsequent - * strikes against this Pokemon - */ + * Prevents subsequent strikes of this phase's invoked move from occurring + * @param target {@linkcode Pokemon} if defined, only stop subsequent + * strikes against this Pokemon + */ stopMultiHit(target?: Pokemon): void { /** If given a specific target, remove the target from subsequent strikes */ if (target) { this.removeTarget(target); } /** - * If no target specified, or the specified target was the last of this move's - * targets, completely cancel all subsequent strikes. - */ + * If no target specified, or the specified target was the last of this move's + * targets, completely cancel all subsequent strikes. + */ if (!target || this.targets.length === 0 ) { this.getUserPokemon()!.turnData.hitCount = 1; // TODO: is the bang correct here? this.getUserPokemon()!.turnData.hitsLeft = 1; // TODO: is the bang correct here? diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 66dbd06f5be..a516fd8593d 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -3,13 +3,13 @@ import BattleScene from "#app/battle-scene"; import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr, ReduceStatusEffectDurationAbAttr } from "#app/data/ability"; import { CommonAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags"; -import { allMoves, applyMoveAttrs, BypassRedirectAttr, BypassSleepAttr, ChargeAttr, CopyMoveAttr, HealStatusEffectAttr, MoveFlags, PreMoveMessageAttr } from "#app/data/move"; +import { allMoves, applyMoveAttrs, BypassRedirectAttr, BypassSleepAttr, CopyMoveAttr, HealStatusEffectAttr, MoveFlags, PreMoveMessageAttr } from "#app/data/move"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; import { Type } from "#app/data/type"; import { getTerrainBlockMessage } from "#app/data/weather"; import { MoveUsedEvent } from "#app/events/battle-scene"; -import Pokemon, { MoveResult, PokemonMove, TurnMove } from "#app/field/pokemon"; +import Pokemon, { MoveResult, PokemonMove } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; import { BattlePhase } from "#app/phases/battle-phase"; @@ -23,6 +23,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { StatusEffect } from "#enums/status-effect"; import i18next from "i18next"; +import { MoveChargePhase } from "#app/phases/move-charge-phase"; export class MovePhase extends BattlePhase { protected _pokemon: Pokemon; @@ -135,6 +136,8 @@ export class MovePhase extends BattlePhase { if (this.cancelled || this.failed) { this.handlePreMoveFailures(); + } else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) { + this.chargeMove(); } else { this.useMove(); } @@ -226,12 +229,15 @@ export class MovePhase extends BattlePhase { this.showMoveText(); - // TODO: Clean up implementation of two-turn moves. if (moveQueue.length > 0) { // Using .shift here clears out two turn moves once they've been used this.ignorePp = moveQueue.shift()?.ignorePP ?? false; } + if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) { + this.pokemon.lapseTag(BattlerTagType.CHARGING); + } + // "commit" to using the move, deducting PP. if (!this.ignorePp) { const ppUsed = 1 + this.getPpIncreaseFromPressure(targets); @@ -295,6 +301,9 @@ export class MovePhase extends BattlePhase { } this.showFailedText(failedText); + + // 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()`). @@ -306,6 +315,35 @@ export class MovePhase extends BattlePhase { } } + /** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */ + protected chargeMove() { + const move = this.move.getMove(); + const targets = this.getActiveTargetPokemon(); + + if (move.applyConditions(this.pokemon, targets[0], move)) { + // Protean and Libero apply on the charging turn of charge moves + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + + this.showMoveText(); + this.scene.unshiftPhase(new MoveChargePhase(this.scene, this.pokemon.getBattlerIndex(), this.targets[0], this.move)); + } else { + this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); + + let failedText: string | undefined; + const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false)); + + if (failureMessage) { + failedText = failureMessage; + } + + this.showMoveText(); + this.showFailedText(failedText); + + // Remove the user from its semi-invulnerable state (if applicable) + this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + } + } + /** * Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`, * then ends the phase. @@ -419,8 +457,6 @@ export class MovePhase extends BattlePhase { * - Lapses `AFTER_MOVE` tags: * - This handles the effects of {@link Moves.SUBSTITUTE Substitute} * - Removes the second turn of charge moves - * - * TODO: handle charge moves more gracefully */ protected handlePreMoveFailures(): void { if (this.cancelled || this.failed) { @@ -452,18 +488,7 @@ export class MovePhase extends BattlePhase { return; } - if (this.move.getMove().hasAttr(ChargeAttr)) { - const lastMove = this.pokemon.getLastXMoves() as TurnMove[]; - if (!lastMove.length || lastMove[0].move !== this.move.getMove().id || lastMove[0].result !== MoveResult.OTHER) { - this.scene.queueMessage(i18next.t("battle:useMove", { - pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon), - moveName: this.move.getName() - }), 500); - return; - } - } - - if (this.pokemon.getTag(BattlerTagType.RECHARGING || BattlerTagType.INTERRUPTED)) { + if (this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED)) { return; } diff --git a/src/test/abilities/volt_absorb.test.ts b/src/test/abilities/volt_absorb.test.ts index ec82b00ec5a..4fee7653b99 100644 --- a/src/test/abilities/volt_absorb.test.ts +++ b/src/test/abilities/volt_absorb.test.ts @@ -52,6 +52,7 @@ describe("Abilities - Volt Absorb", () => { expect(playerPokemon.getTag(BattlerTagType.CHARGED)).toBeDefined(); expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); }); + it("should activate regardless of accuracy checks", async () => { game.override.moveset(Moves.THUNDERBOLT); game.override.enemyMoveset(Moves.SPLASH); @@ -71,6 +72,7 @@ describe("Abilities - Volt Absorb", () => { await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); }); + it("regardless of accuracy should not trigger on pokemon in semi invulnerable state", async () => { game.override.moveset(Moves.THUNDERBOLT); game.override.enemyMoveset(Moves.DIVE); @@ -84,9 +86,7 @@ describe("Abilities - Volt Absorb", () => { game.move.select(Moves.THUNDERBOLT); enemyPokemon.hp = enemyPokemon.hp - 1; await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); - await game.phaseInterceptor.to("MoveEffectPhase"); - await game.move.forceMiss(); await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); }); diff --git a/src/test/arena/arena_gravity.test.ts b/src/test/arena/arena_gravity.test.ts index b6982896571..13e9c23a35c 100644 --- a/src/test/arena/arena_gravity.test.ts +++ b/src/test/arena/arena_gravity.test.ts @@ -1,8 +1,8 @@ +import { BattlerIndex } from "#app/battle"; import { allMoves } from "#app/data/move"; -import { Abilities } from "#app/enums/abilities"; -import { ArenaTagType } from "#app/enums/arena-tag-type"; -import { MoveEffectPhase } from "#app/phases/move-effect-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { Abilities } from "#enums/abilities"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; @@ -31,7 +31,8 @@ describe("Arena - Gravity", () => { .ability(Abilities.UNNERVE) .enemyAbility(Abilities.BALL_FETCH) .enemySpecies(Species.SHUCKLE) - .enemyMoveset(Moves.SPLASH); + .enemyMoveset(Moves.SPLASH) + .enemyLevel(5); }); // Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) @@ -42,102 +43,121 @@ describe("Arena - Gravity", () => { vi.spyOn(moveToCheck, "calculateBattleAccuracy"); // Setup Gravity on first turn - await game.startBattle([ Species.PIKACHU ]); + await game.classicMode.startBattle([ Species.PIKACHU ]); game.move.select(Moves.GRAVITY); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); // Use non-OHKO move on second turn await game.toNextTurn(); game.move.select(Moves.TACKLE); - await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to("MoveEffectPhase"); - expect(moveToCheck.calculateBattleAccuracy).toHaveReturnedWith(100 * 1.67); + expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(100 * 1.67); }); it("OHKO move accuracy is not affected", async () => { - game.override.startingLevel(5); - game.override.enemyLevel(5); - /** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */ const moveToCheck = allMoves[Moves.FISSURE]; vi.spyOn(moveToCheck, "calculateBattleAccuracy"); // Setup Gravity on first turn - await game.startBattle([ Species.PIKACHU ]); + await game.classicMode.startBattle([ Species.PIKACHU ]); game.move.select(Moves.GRAVITY); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); // Use OHKO move on second turn await game.toNextTurn(); game.move.select(Moves.FISSURE); - await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to("MoveEffectPhase"); - expect(moveToCheck.calculateBattleAccuracy).toHaveReturnedWith(30); + expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(30); }); describe("Against flying types", () => { it("can be hit by ground-type moves now", async () => { game.override - .startingLevel(5) - .enemyLevel(5) .enemySpecies(Species.PIDGEOT) .moveset([ Moves.GRAVITY, Moves.EARTHQUAKE ]); - await game.startBattle([ Species.PIKACHU ]); + await game.classicMode.startBattle([ Species.PIKACHU ]); const pidgeot = game.scene.getEnemyPokemon()!; vi.spyOn(pidgeot, "getAttackTypeEffectiveness"); // Try earthquake on 1st turn (fails!); game.move.select(Moves.EARTHQUAKE); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(pidgeot.getAttackTypeEffectiveness).toHaveReturnedWith(0); + expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(0); // Setup Gravity on 2nd turn await game.toNextTurn(); game.move.select(Moves.GRAVITY); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); // Use ground move on 3rd turn await game.toNextTurn(); game.move.select(Moves.EARTHQUAKE); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(pidgeot.getAttackTypeEffectiveness).toHaveReturnedWith(1); + expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(1); }); it("keeps super-effective moves super-effective after using gravity", async () => { game.override - .startingLevel(5) - .enemyLevel(5) .enemySpecies(Species.PIDGEOT) .moveset([ Moves.GRAVITY, Moves.THUNDERBOLT ]); - await game.startBattle([ Species.PIKACHU ]); + await game.classicMode.startBattle([ Species.PIKACHU ]); const pidgeot = game.scene.getEnemyPokemon()!; vi.spyOn(pidgeot, "getAttackTypeEffectiveness"); // Setup Gravity on 1st turn game.move.select(Moves.GRAVITY); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); // Use electric move on 2nd turn await game.toNextTurn(); game.move.select(Moves.THUNDERBOLT); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(pidgeot.getAttackTypeEffectiveness).toHaveReturnedWith(2); + expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(2); }); }); + + it("cancels Fly if its user is semi-invulnerable", async () => { + game.override + .enemySpecies(Species.SNORLAX) + .enemyMoveset(Moves.FLY) + .moveset([ Moves.GRAVITY, Moves.SPLASH ]); + + await game.classicMode.startBattle([ Species.CHARIZARD ]); + + const charizard = game.scene.getPlayerPokemon()!; + const snorlax = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SPLASH); + + await game.toNextTurn(); + expect(snorlax.getTag(BattlerTagType.FLYING)).toBeDefined(); + + game.move.select(Moves.GRAVITY); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("MoveEffectPhase"); + expect(snorlax.getTag(BattlerTagType.INTERRUPTED)).toBeDefined(); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(charizard.hp).toBe(charizard.getMaxHp()); + }); }); diff --git a/src/test/moves/dig.test.ts b/src/test/moves/dig.test.ts new file mode 100644 index 00000000000..4c6b5d3b75d --- /dev/null +++ b/src/test/moves/dig.test.ts @@ -0,0 +1,114 @@ +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/move"; +import { Abilities } from "#enums/abilities"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { StatusEffect } from "#enums/status-effect"; +import { MoveResult } from "#app/field/pokemon"; +import { describe, beforeAll, afterEach, beforeEach, it, expect } from "vitest"; +import GameManager from "#test/utils/gameManager"; + +describe("Moves - Dig", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.DIG) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE); + }); + + it("should make the user semi-invulnerable, then attack over 2 turns", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DIG); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeDefined(); + expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveQueue()[0].move).toBe(Moves.DIG); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + + const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIG); + expect(playerDig?.ppUsed).toBe(1); + }); + + it("should not allow the user to evade attacks from Pokemon with No Guard", async () => { + game.override.enemyAbility(Abilities.NO_GUARD); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DIG); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); + expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should not expend PP when the attack phase is cancelled", async () => { + game.override + .enemyAbility(Abilities.NO_GUARD) + .enemyMoveset(Moves.SPORE); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.DIG); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeUndefined(); + expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); + + const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIG); + expect(playerDig?.ppUsed).toBe(0); + }); + + it("should cause the user to take double damage from Earthquake", async () => { + await game.classicMode.startBattle([ Species.DONDOZO ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + const preDigEarthquakeDmg = playerPokemon.getAttackDamage(enemyPokemon, allMoves[Moves.EARTHQUAKE]).damage; + + game.move.select(Moves.DIG); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("MoveEffectPhase"); + + const postDigEarthquakeDmg = playerPokemon.getAttackDamage(enemyPokemon, allMoves[Moves.EARTHQUAKE]).damage; + // these hopefully get avoid rounding errors :shrug: + expect(postDigEarthquakeDmg).toBeGreaterThanOrEqual(2 * preDigEarthquakeDmg); + expect(postDigEarthquakeDmg).toBeLessThan(2 * (preDigEarthquakeDmg + 1)); + }); +}); diff --git a/src/test/moves/dive.test.ts b/src/test/moves/dive.test.ts new file mode 100644 index 00000000000..b60416d7740 --- /dev/null +++ b/src/test/moves/dive.test.ts @@ -0,0 +1,137 @@ +import { BattlerTagType } from "#enums/battler-tag-type"; +import { StatusEffect } from "#enums/status-effect"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; +import { WeatherType } from "#enums/weather-type"; + +describe("Moves - Dive", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.DIVE) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE); + }); + + it("should make the user semi-invulnerable, then attack over 2 turns", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DIVE); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeDefined(); + expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveQueue()[0].move).toBe(Moves.DIVE); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + + const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIVE); + expect(playerDive?.ppUsed).toBe(1); + }); + + it("should not allow the user to evade attacks from Pokemon with No Guard", async () => { + game.override.enemyAbility(Abilities.NO_GUARD); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DIVE); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); + expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should not expend PP when the attack phase is cancelled", async () => { + game.override + .enemyAbility(Abilities.NO_GUARD) + .enemyMoveset(Moves.SPORE); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.DIVE); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeUndefined(); + expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); + + const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIVE); + expect(playerDive?.ppUsed).toBe(0); + }); + + it("should trigger on-contact post-defend ability effects", async () => { + game.override + .enemyAbility(Abilities.ROUGH_SKIN) + .enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DIVE); + + await game.phaseInterceptor.to("TurnEndPhase"); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); + expect(enemyPokemon.battleData.abilitiesApplied[0]).toBe(Abilities.ROUGH_SKIN); + }); + + it("should cancel attack after Harsh Sunlight is set", async () => { + game.override.enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DIVE); + + await game.phaseInterceptor.to("TurnEndPhase"); + await game.phaseInterceptor.to("TurnStartPhase", false); + game.scene.arena.trySetWeather(WeatherType.HARSH_SUN, false); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeUndefined(); + + const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIVE); + expect(playerDive?.ppUsed).toBe(1); + }); +}); diff --git a/src/test/moves/electro_shot.test.ts b/src/test/moves/electro_shot.test.ts new file mode 100644 index 00000000000..1373b4941eb --- /dev/null +++ b/src/test/moves/electro_shot.test.ts @@ -0,0 +1,104 @@ +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Stat } from "#enums/stat"; +import { WeatherType } from "#enums/weather-type"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; + +describe("Moves - Electro Shot", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.ELECTRO_SHOT) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should increase the user's Sp. Atk on the first turn, then attack on the second turn", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.ELECTRO_SHOT); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeDefined(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.OTHER); + expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + + const playerElectroShot = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.ELECTRO_SHOT); + expect(playerElectroShot?.ppUsed).toBe(1); + }); + + it.each([ + { weatherType: WeatherType.RAIN, name: "Rain" }, + { weatherType: WeatherType.HEAVY_RAIN, name: "Heavy Rain" } + ])("should fully resolve in one turn if $name is active", async ({ weatherType }) => { + game.override.weather(weatherType); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.ELECTRO_SHOT); + + await game.phaseInterceptor.to("MoveEffectPhase", false); + expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + + const playerElectroShot = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.ELECTRO_SHOT); + expect(playerElectroShot?.ppUsed).toBe(1); + }); + + it("should only increase Sp. Atk once with Multi-Lens", async () => { + game.override + .weather(WeatherType.RAIN) + .startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.ELECTRO_SHOT); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.turnData.hitCount).toBe(2); + expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1); + }); +}); diff --git a/src/test/moves/fly.test.ts b/src/test/moves/fly.test.ts new file mode 100644 index 00000000000..6ae758fe3dc --- /dev/null +++ b/src/test/moves/fly.test.ts @@ -0,0 +1,122 @@ +import { BattlerTagType } from "#enums/battler-tag-type"; +import { StatusEffect } from "#enums/status-effect"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/move"; + +describe("Moves - Fly", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.FLY) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE); + + vi.spyOn(allMoves[Moves.FLY], "accuracy", "get").mockReturnValue(100); + }); + + it("should make the user semi-invulnerable, then attack over 2 turns", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FLY); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeDefined(); + expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveQueue()[0].move).toBe(Moves.FLY); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + + const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.FLY); + expect(playerFly?.ppUsed).toBe(1); + }); + + it("should not allow the user to evade attacks from Pokemon with No Guard", async () => { + game.override.enemyAbility(Abilities.NO_GUARD); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FLY); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); + expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should not expend PP when the attack phase is cancelled", async () => { + game.override + .enemyAbility(Abilities.NO_GUARD) + .enemyMoveset(Moves.SPORE); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.FLY); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined(); + expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); + + const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.FLY); + expect(playerFly?.ppUsed).toBe(0); + }); + + it("should be cancelled when another Pokemon uses Gravity", async () => { + game.override.enemyMoveset([ Moves.SPLASH, Moves.GRAVITY ]); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FLY); + + await game.forceEnemyMove(Moves.SPLASH); + + await game.toNextTurn(); + await game.forceEnemyMove(Moves.GRAVITY); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + + const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.FLY); + expect(playerFly?.ppUsed).toBe(0); + }); +}); diff --git a/src/test/moves/geomancy.test.ts b/src/test/moves/geomancy.test.ts new file mode 100644 index 00000000000..6e2f40b9144 --- /dev/null +++ b/src/test/moves/geomancy.test.ts @@ -0,0 +1,78 @@ +import { EffectiveStat, Stat } from "#enums/stat"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; + +describe("Moves - Geomancy", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.GEOMANCY) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should boost the user's stats on the second turn of use", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const player = game.scene.getPlayerPokemon()!; + const affectedStats: EffectiveStat[] = [ Stat.SPATK, Stat.SPDEF, Stat.SPD ]; + + game.move.select(Moves.GEOMANCY); + + await game.phaseInterceptor.to("TurnEndPhase"); + affectedStats.forEach((stat) => expect(player.getStatStage(stat)).toBe(0)); + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.OTHER); + + await game.phaseInterceptor.to("TurnEndPhase"); + affectedStats.forEach((stat) => expect(player.getStatStage(stat)).toBe(2)); + expect(player.getMoveHistory()).toHaveLength(2); + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + + const playerGeomancy = player.getMoveset().find((mv) => mv && mv.moveId === Moves.GEOMANCY); + expect(playerGeomancy?.ppUsed).toBe(1); + }); + + it("should execute over 2 turns between waves", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const player = game.scene.getPlayerPokemon()!; + const affectedStats: EffectiveStat[] = [ Stat.SPATK, Stat.SPDEF, Stat.SPD ]; + + game.move.select(Moves.GEOMANCY); + + await game.phaseInterceptor.to("MoveEndPhase", false); + await game.doKillOpponents(); + + await game.toNextWave(); + + await game.phaseInterceptor.to("TurnEndPhase"); + affectedStats.forEach((stat) => expect(player.getStatStage(stat)).toBe(2)); + expect(player.getMoveHistory()).toHaveLength(2); + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + + const playerGeomancy = player.getMoveset().find((mv) => mv && mv.moveId === Moves.GEOMANCY); + expect(playerGeomancy?.ppUsed).toBe(1); + }); +}); diff --git a/src/test/moves/solar_beam.test.ts b/src/test/moves/solar_beam.test.ts new file mode 100644 index 00000000000..ebec338932a --- /dev/null +++ b/src/test/moves/solar_beam.test.ts @@ -0,0 +1,102 @@ +import { allMoves } from "#app/data/move"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { WeatherType } from "#enums/weather-type"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; + +describe("Moves - Solar Beam", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.SOLAR_BEAM) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should deal damage in two turns if no weather is active", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SOLAR_BEAM); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeDefined(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.OTHER); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + + const playerSolarBeam = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.SOLAR_BEAM); + expect(playerSolarBeam?.ppUsed).toBe(1); + }); + + it.each([ + { weatherType: WeatherType.SUNNY, name: "Sun" }, + { weatherType: WeatherType.HARSH_SUN, name: "Harsh Sun" } + ])("should deal damage in one turn if $name is active", async ({ weatherType }) => { + game.override.weather(weatherType); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SOLAR_BEAM); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined(); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(playerPokemon.getMoveHistory()).toHaveLength(2); + expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); + + const playerSolarBeam = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.SOLAR_BEAM); + expect(playerSolarBeam?.ppUsed).toBe(1); + }); + + it.each([ + { weatherType: WeatherType.RAIN, name: "Rain" }, + { weatherType: WeatherType.HEAVY_RAIN, name: "Heavy Rain" } + ])("should have its power halved in $name", async ({ weatherType }) => { + game.override.weather(weatherType); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const solarBeam = allMoves[Moves.SOLAR_BEAM]; + + vi.spyOn(solarBeam, "calculateBattlePower"); + + game.move.select(Moves.SOLAR_BEAM); + + await game.phaseInterceptor.to("TurnEndPhase"); + await game.phaseInterceptor.to("TurnEndPhase"); + expect(solarBeam.calculateBattlePower).toHaveLastReturnedWith(60); + }); +}); diff --git a/src/test/moves/whirlwind.test.ts b/src/test/moves/whirlwind.test.ts index cc31b2591a2..c16f38111f2 100644 --- a/src/test/moves/whirlwind.test.ts +++ b/src/test/moves/whirlwind.test.ts @@ -41,7 +41,8 @@ describe("Moves - Whirlwind", () => { const staraptor = game.scene.getPlayerPokemon()!; game.move.select(move); - await game.toNextTurn(); + + await game.phaseInterceptor.to("BerryPhase", false); expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined(); expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);