diff --git a/src/constants.ts b/src/constants.ts index 927575c0a28..dc901e4a766 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,3 +9,8 @@ export const SESSION_ID_COOKIE_NAME: string = "pokerogue_sessionId"; /** Max value for an integer attribute in {@linkcode SystemSaveData} */ export const MAX_INT_ATTR_VALUE = 0x80000000; + +/** The min and max waves for mystery encounters to spawn in classic mode */ +export const CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180] as const; +/** The min and max waves for mystery encounters to spawn in challenge mode */ +export const CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180] as const; diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 27c3cb69073..53d024ac655 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -653,8 +653,8 @@ export class MoveImmunityStatStageChangeAbAttr extends MoveImmunityAbAttr { */ export class ReverseDrainAbAttr extends PostDefendAbAttr { - override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { - return move.hasAttr(HitHealAttr) && !move.hitsSubstitute(attacker, pokemon); + override canApplyPostDefend(_pokemon: Pokemon, _passive: boolean, _simulated: boolean, _attacker: Pokemon, move: Move, _hitResult: HitResult | null, args: any[]): boolean { + return move.hasAttr(HitHealAttr); } /** @@ -693,7 +693,7 @@ export class PostDefendStatStageChangeAbAttr extends PostDefendAbAttr { } override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { - return this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon); + return this.condition(pokemon, attacker, move); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { @@ -734,7 +734,7 @@ export class PostDefendHpGatedStatStageChangeAbAttr extends PostDefendAbAttr { const hpGateFlat: number = Math.ceil(pokemon.getMaxHp() * this.hpGate); const lastAttackReceived = pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1]; const damageReceived = lastAttackReceived?.damage || 0; - return this.condition(pokemon, attacker, move) && (pokemon.hp <= hpGateFlat && (pokemon.hp + damageReceived) > hpGateFlat) && !move.hitsSubstitute(attacker, pokemon); + return this.condition(pokemon, attacker, move) && (pokemon.hp <= hpGateFlat && (pokemon.hp + damageReceived) > hpGateFlat); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { @@ -757,7 +757,7 @@ export class PostDefendApplyArenaTrapTagAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { const tag = globalScene.arena.getTag(this.tagType) as ArenaTrapTag; - return (this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon)) + return (this.condition(pokemon, attacker, move)) && (!globalScene.arena.getTag(this.tagType) || tag.layers < tag.maxLayers); } @@ -779,7 +779,7 @@ export class PostDefendApplyBattlerTagAbAttr extends PostDefendAbAttr { } override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { - return this.condition(pokemon, attacker, move) && !move.hitsSubstitute(attacker, pokemon); + return this.condition(pokemon, attacker, move); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { @@ -796,7 +796,7 @@ export class PostDefendTypeChangeAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { this.type = attacker.getMoveType(move); const pokemonTypes = pokemon.getTypes(true); - return hitResult < HitResult.NO_EFFECT && !move.hitsSubstitute(attacker, pokemon) && (simulated || pokemonTypes.length !== 1 || pokemonTypes[0] !== this.type); + return hitResult < HitResult.NO_EFFECT && (simulated || pokemonTypes.length !== 1 || pokemonTypes[0] !== this.type); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, _args: any[]): void { @@ -823,7 +823,7 @@ export class PostDefendTerrainChangeAbAttr extends PostDefendAbAttr { } override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { - return hitResult < HitResult.NO_EFFECT && !move.hitsSubstitute(attacker, pokemon) && globalScene.arena.canSetTerrain(this.terrainType); + return hitResult < HitResult.NO_EFFECT && globalScene.arena.canSetTerrain(this.terrainType); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, _args: any[]): void { @@ -847,7 +847,7 @@ export class PostDefendContactApplyStatusEffectAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { const effect = this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randSeedInt(this.effects.length)]; return move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) && !attacker.status - && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance) && !move.hitsSubstitute(attacker, pokemon) + && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance) && attacker.canSetStatus(effect, true, false, pokemon); } @@ -887,7 +887,7 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { return move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) && pokemon.randSeedInt(100) < this.chance - && !move.hitsSubstitute(attacker, pokemon) && attacker.canAddTag(this.tagType); + && attacker.canAddTag(this.tagType); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { @@ -908,10 +908,6 @@ export class PostDefendCritStatStageChangeAbAttr extends PostDefendAbAttr { this.stages = stages; } - override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { - return !move.hitsSubstitute(attacker, pokemon); - } - override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { if (!simulated) { globalScene.unshiftPhase(new StatStageChangePhase(pokemon.getBattlerIndex(), true, [ this.stat ], this.stages)); @@ -934,7 +930,7 @@ export class PostDefendContactDamageAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { return !simulated && move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) - && !attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr) && !move.hitsSubstitute(attacker, pokemon); + && !attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr); } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { @@ -993,7 +989,7 @@ export class PostDefendWeatherChangeAbAttr extends PostDefendAbAttr { } override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { - return (!(this.condition && !this.condition(pokemon, attacker, move) || move.hitsSubstitute(attacker, pokemon)) + return (!(this.condition && !this.condition(pokemon, attacker, move)) && !globalScene.arena.weather?.isImmutable() && globalScene.arena.canSetWeather(this.weatherType)); } @@ -1011,7 +1007,7 @@ export class PostDefendAbilitySwapAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { return move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) - && attacker.getAbility().isSwappable && !move.hitsSubstitute(attacker, pokemon); + && attacker.getAbility().isSwappable; } override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, args: any[]): void { @@ -1037,10 +1033,10 @@ export class PostDefendAbilityGiveAbAttr extends PostDefendAbAttr { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { return move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) && attacker.getAbility().isSuppressable - && !attacker.getAbility().hasAttr(PostDefendAbilityGiveAbAttr) && !move.hitsSubstitute(attacker, pokemon); + && !attacker.getAbility().hasAttr(PostDefendAbilityGiveAbAttr); } - override applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { + override applyPostDefend(_pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): void { if (!simulated) { attacker.setTempAbility(allAbilities[this.ability]); } @@ -1066,7 +1062,7 @@ export class PostDefendMoveDisableAbAttr extends PostDefendAbAttr { } override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { - return attacker.getTag(BattlerTagType.DISABLED) === null && !move.hitsSubstitute(attacker, pokemon) + return attacker.getTag(BattlerTagType.DISABLED) === null && move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance); } @@ -1770,7 +1766,6 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr { override canApplyPostAttack(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { if ( super.canApplyPostAttack(pokemon, passive, simulated, attacker, move, hitResult, args) - && !(pokemon !== attacker && move.hitsSubstitute(attacker, pokemon)) && (simulated || !attacker.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && pokemon !== attacker && (!this.contactRequired || move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon})) && pokemon.randSeedInt(100) < this.chance && !pokemon.status) ) { @@ -1837,8 +1832,7 @@ export class PostDefendStealHeldItemAbAttr extends PostDefendAbAttr { if ( !simulated && hitResult < HitResult.NO_EFFECT && - (!this.condition || this.condition(pokemon, attacker, move)) && - !move.hitsSubstitute(attacker, pokemon) + (!this.condition || this.condition(pokemon, attacker, move)) ) { const heldItems = this.getTargetHeldItems(attacker).filter((i) => i.isTransferable); if (heldItems.length) { @@ -5063,6 +5057,8 @@ export class PostSummonStatStageChangeOnArenaAbAttr extends PostSummonStatStageC /** * Takes no damage from the first hit of a damaging move. * This is used in the Disguise and Ice Face abilities. + * + * Does not apply to a user's substitute * @extends ReceivedMoveDamageMultiplierAbAttr */ export class FormBlockDamageAbAttr extends ReceivedMoveDamageMultiplierAbAttr { @@ -7410,4 +7406,4 @@ export function initAbilities() { .unreplaceable() // TODO is this true? .attr(ConfusionOnStatusEffectAbAttr, StatusEffect.POISON, StatusEffect.TOXIC) ); -} +} \ No newline at end of file diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 2ef98723cea..ff9e4068292 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -7,7 +7,7 @@ import { MoveTarget } from "#enums/MoveTarget"; import { MoveCategory } from "#enums/MoveCategory"; import { getPokemonNameWithAffix } from "#app/messages"; import type Pokemon from "#app/field/pokemon"; -import { HitResult, PokemonMove } from "#app/field/pokemon"; +import { HitResult } from "#app/field/pokemon"; import { StatusEffect } from "#enums/status-effect"; import type { BattlerIndex } from "#app/battle"; import { @@ -335,7 +335,7 @@ export class ConditionalProtectTag extends ArenaTag { * @param arena the {@linkcode Arena} containing this tag * @param simulated `true` if the tag is applied quietly; `false` otherwise. * @param isProtected a {@linkcode BooleanHolder} used to flag if the move is protected against - * @param attacker the attacking {@linkcode Pokemon} + * @param _attacker the attacking {@linkcode Pokemon} * @param defender the defending {@linkcode Pokemon} * @param moveId the {@linkcode Moves | identifier} for the move being used * @param ignoresProtectBypass a {@linkcode BooleanHolder} used to flag if a protection effect supercedes effects that ignore protection @@ -345,7 +345,7 @@ export class ConditionalProtectTag extends ArenaTag { arena: Arena, simulated: boolean, isProtected: BooleanHolder, - attacker: Pokemon, + _attacker: Pokemon, defender: Pokemon, moveId: Moves, ignoresProtectBypass: BooleanHolder, @@ -354,8 +354,6 @@ export class ConditionalProtectTag extends ArenaTag { if (!isProtected.value) { isProtected.value = true; if (!simulated) { - attacker.stopMultiHit(defender); - new CommonBattleAnim(CommonAnim.PROTECT, defender).play(); globalScene.queueMessage( i18next.t("arenaTag:conditionalProtectApply", { @@ -899,7 +897,7 @@ export class DelayedAttackTag extends ArenaTag { if (!ret) { globalScene.unshiftPhase( - new MoveEffectPhase(this.sourceId!, [this.targetIndex], new PokemonMove(this.sourceMove!, 0, 0, true)), + new MoveEffectPhase(this.sourceId!, [this.targetIndex], allMoves[this.sourceMove!], false, true), ); // TODO: are those bangs correct? } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 3b2421897c9..ee41f0435b9 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2637,7 +2637,7 @@ export class GulpMissileTag extends BattlerTag { return false; } - if (moveEffectPhase.move.getMove().hitsSubstitute(attacker, pokemon)) { + if (moveEffectPhase.move.hitsSubstitute(attacker, pokemon)) { return true; } @@ -2993,7 +2993,7 @@ export class SubstituteTag extends BattlerTag { if (!attacker) { return; } - const move = moveEffectPhase.move.getMove(); + const move = moveEffectPhase.move; const firstHit = attacker.turnData.hitCount === attacker.turnData.hitsLeft; if (firstHit && move.hitsSubstitute(attacker, pokemon)) { @@ -3681,7 +3681,7 @@ function getMoveEffectPhaseData(_pokemon: Pokemon): { phase: MoveEffectPhase; at return { phase: phase, attacker: phase.getPokemon(), - move: phase.move.getMove(), + move: phase.move, }; } return null; diff --git a/src/data/moves/move-utils.ts b/src/data/moves/move-utils.ts new file mode 100644 index 00000000000..3323d6f4a0c --- /dev/null +++ b/src/data/moves/move-utils.ts @@ -0,0 +1,20 @@ +import { MoveTarget } from "#enums/MoveTarget"; +import type Move from "./move"; + +/** + * Return whether the move targets the field + * + * Examples include + * - Hazard moves like spikes + * - Weather moves like rain dance + * - User side moves like reflect and safeguard + */ +export function isFieldTargeted(move: Move): boolean { + switch (move.moveTarget) { + case MoveTarget.BOTH_SIDES: + case MoveTarget.USER_SIDE: + case MoveTarget.ENEMY_SIDE: + return true; + } + return false; +} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 26654fee18f..35d98f6f781 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -60,6 +60,7 @@ import { MoveTypeChangeAbAttr, PostDamageForceSwitchAbAttr, PostItemLostAbAttr, + ReflectStatusMoveAbAttr, ReverseDrainAbAttr, UserFieldMoveTypePowerBoostAbAttr, VariableMovePowerAbAttr, @@ -665,6 +666,17 @@ export default class Move implements Localizable { return true; } break; + case MoveFlags.REFLECTABLE: + // If the target is not semi-invulnerable and either has magic coat active or an unignored magic bounce ability + if ( + target?.getTag(SemiInvulnerableTag) || + !(target?.getTag(BattlerTagType.MAGIC_COAT) || + (!this.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) && + target?.hasAbilityWithAttr(ReflectStatusMoveAbAttr))) + ) { + return false; + } + break; } return !!(this.flags & flag); @@ -1716,7 +1728,7 @@ export class SacrificialAttr extends MoveEffectAttr { **/ export class SacrificialAttrOnHit extends MoveEffectAttr { constructor() { - super(true, { trigger: MoveEffectTrigger.HIT }); + super(true); } /** @@ -1955,6 +1967,14 @@ export class PartyStatusCureAttr extends MoveEffectAttr { * @extends MoveEffectAttr */ export class FlameBurstAttr extends MoveEffectAttr { + constructor() { + /** + * This is self-targeted to bypass immunity to target-facing secondary + * effects when the target has an active Substitute doll. + * TODO: Find a more intuitive way to implement Substitute bypassing. + */ + super(true); + } /** * @param user - n/a * @param target - The target Pokémon. @@ -2177,7 +2197,7 @@ export class HitHealAttr extends MoveEffectAttr { private healStat: EffectiveStat | null; constructor(healRatio?: number | null, healStat?: EffectiveStat) { - super(true, { trigger: MoveEffectTrigger.HIT }); + super(true); this.healRatio = healRatio ?? 0.5; this.healStat = healStat ?? null; @@ -2426,7 +2446,7 @@ export class StatusEffectAttr extends MoveEffectAttr { public overrideStatus: boolean = false; constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) { - super(selfTarget, { trigger: MoveEffectTrigger.HIT }); + super(selfTarget); this.effect = effect; this.turnsRemaining = turnsRemaining; @@ -2434,10 +2454,6 @@ export class StatusEffectAttr extends MoveEffectAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!this.selfTarget && move.hitsSubstitute(user, target)) { - return false; - } - const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance; if (statusCheck) { @@ -2495,7 +2511,7 @@ export class MultiStatusEffectAttr extends StatusEffectAttr { export class PsychoShiftEffectAttr extends MoveEffectAttr { constructor() { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); } /** @@ -2534,15 +2550,11 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { private chance: number; constructor(chance: number) { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); this.chance = chance; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (move.hitsSubstitute(user, target)) { - return false; - } - const rand = Phaser.Math.RND.realInRange(0, 1); if (rand >= this.chance) { return false; @@ -2590,7 +2602,7 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { private berriesOnly: boolean; constructor(berriesOnly: boolean) { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); this.berriesOnly = berriesOnly; } @@ -2600,17 +2612,13 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { * @param target Target {@linkcode Pokemon} that the moves applies to * @param move {@linkcode Move} that is used * @param args N/A - * @returns {boolean} True if an item was removed + * @returns True if an item was removed */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (!this.berriesOnly && target.isPlayer()) { // "Wild Pokemon cannot knock off Player Pokemon's held items" (See Bulbapedia) return false; } - if (move.hitsSubstitute(user, target)) { - return false; - } - const cancelled = new BooleanHolder(false); applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft @@ -2664,8 +2672,8 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { */ export class EatBerryAttr extends MoveEffectAttr { protected chosenBerry: BerryModifier | undefined; - constructor() { - super(true, { trigger: MoveEffectTrigger.HIT }); + constructor(selfTarget: boolean) { + super(selfTarget); } /** * Causes the target to eat a berry. @@ -2680,17 +2688,20 @@ export class EatBerryAttr extends MoveEffectAttr { return false; } - const heldBerries = this.getTargetHeldBerries(target); + const pokemon = this.selfTarget ? user : target; + + const heldBerries = this.getTargetHeldBerries(pokemon); if (heldBerries.length <= 0) { return false; } this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)]; const preserve = new BooleanHolder(false); - globalScene.applyModifiers(PreserveBerryModifier, target.isPlayer(), target, preserve); // check for berry pouch preservation + // check for berry pouch preservation + globalScene.applyModifiers(PreserveBerryModifier, pokemon.isPlayer(), pokemon, preserve); if (!preserve.value) { - this.reduceBerryModifier(target); + this.reduceBerryModifier(pokemon); } - this.eatBerry(target); + this.eatBerry(pokemon); return true; } @@ -2718,20 +2729,17 @@ export class EatBerryAttr extends MoveEffectAttr { */ export class StealEatBerryAttr extends EatBerryAttr { constructor() { - super(); + super(false); } /** * User steals a random berry from the target and then eats it. - * @param {Pokemon} user Pokemon that used the move and will eat the stolen berry - * @param {Pokemon} target Pokemon that will have its berry stolen - * @param {Move} move Move being used - * @param {any[]} args Unused - * @returns {boolean} true if the function succeeds + * @param user - Pokemon that used the move and will eat the stolen berry + * @param target - Pokemon that will have its berry stolen + * @param move - Move being used + * @param args Unused + * @returns true if the function succeeds */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (move.hitsSubstitute(user, target)) { - return false; - } const cancelled = new BooleanHolder(false); applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft if (cancelled.value === true) { @@ -2782,10 +2790,6 @@ export class HealStatusEffectAttr extends MoveEffectAttr { return false; } - if (!this.selfTarget && move.hitsSubstitute(user, target)) { - return false; - } - // Special edge case for shield dust blocking Sparkling Aria curing burn const moveTargets = getMoveTargets(user, move.id); if (target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && move.id === Moves.SPARKLING_ARIA && moveTargets.targets.length === 1) { @@ -3162,15 +3166,7 @@ export class StatStageChangeAttr extends MoveEffectAttr { private get showMessage () { return this.options?.showMessage ?? true; } - - /** - * Indicates when the stat change should trigger - * @default MoveEffectTrigger.HIT - */ - public override get trigger () { - return this.options?.trigger ?? MoveEffectTrigger.HIT; - } - + /** * Attempts to change stats of the user or target (depending on value of selfTarget) if conditions are met * @param user {@linkcode Pokemon} the user of the move @@ -3184,10 +3180,6 @@ export class StatStageChangeAttr extends MoveEffectAttr { return false; } - if (!this.selfTarget && move.hitsSubstitute(user, target)) { - return false; - } - const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) { const stages = this.getLevels(user); @@ -3471,7 +3463,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr { */ export class OrderUpStatBoostAttr extends MoveEffectAttr { constructor() { - super(true, { trigger: MoveEffectTrigger.HIT }); + super(true); } override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean { @@ -3548,17 +3540,15 @@ export class ResetStatsAttr extends MoveEffectAttr { this.targetAllPokemon = targetAllPokemon; } - override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + override apply(_user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { if (this.targetAllPokemon) { // Target all pokemon on the field when Freezy Frost or Haze are used const activePokemon = globalScene.getField(true); activePokemon.forEach((p) => this.resetStats(p)); globalScene.queueMessage(i18next.t("moveTriggers:statEliminated")); } else { // Affects only the single target when Clear Smog is used - if (!move.hitsSubstitute(user, target)) { - this.resetStats(target); - globalScene.queueMessage(i18next.t("moveTriggers:resetStats", { pokemonName: getPokemonNameWithAffix(target) })); - } + this.resetStats(target); + globalScene.queueMessage(i18next.t("moveTriggers:resetStats", { pokemonName: getPokemonNameWithAffix(target) })); } return true; } @@ -4217,7 +4207,8 @@ export class PresentPowerAttr extends VariablePowerAttr { (args[0] as NumberHolder).value = 120; } else if (80 < powerSeed && powerSeed <= 100) { // If this move is multi-hit, disable all other hits - user.stopMultiHit(); + user.turnData.hitCount = 1; + user.turnData.hitsLeft = 1; globalScene.unshiftPhase(new PokemonHealPhase(target.getBattlerIndex(), toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) }), true)); } @@ -4811,8 +4802,8 @@ export class ShellSideArmCategoryAttr extends VariableMoveCategoryAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const category = (args[0] as NumberHolder); - const predictedPhysDmg = target.getBaseDamage(user, move, MoveCategory.PHYSICAL, true, true, true, true); - const predictedSpecDmg = target.getBaseDamage(user, move, MoveCategory.SPECIAL, true, true, true, true); + const predictedPhysDmg = target.getBaseDamage({source: user, move, moveCategory: MoveCategory.PHYSICAL, ignoreAbility: true, ignoreSourceAbility: true, ignoreAllyAbility: true, ignoreSourceAllyAbility: true, simulated: true}); + const predictedSpecDmg = target.getBaseDamage({source: user, move, moveCategory: MoveCategory.SPECIAL, ignoreAbility: true, ignoreSourceAbility: true, ignoreAllyAbility: true, ignoreSourceAllyAbility: true, simulated: true}); if (predictedPhysDmg > predictedSpecDmg) { category.value = MoveCategory.PHYSICAL; @@ -5371,7 +5362,7 @@ export class BypassRedirectAttr extends MoveAttr { export class FrenzyAttr extends MoveEffectAttr { constructor() { - super(true, { trigger: MoveEffectTrigger.HIT, lastHitOnly: true }); + super(true, { lastHitOnly: true }); } canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]) { @@ -5443,22 +5434,20 @@ export class AddBattlerTagAttr extends MoveEffectAttr { protected cancelOnFail: boolean; private failOnOverlap: boolean; - constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false, cancelOnFail: boolean = false) { + constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false) { super(selfTarget, { lastHitOnly: lastHitOnly }); this.tagType = tagType; this.turnCountMin = turnCountMin; this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin; this.failOnOverlap = !!failOnOverlap; - this.cancelOnFail = cancelOnFail; } canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.canApply(user, target, move, args) || (this.cancelOnFail === true && user.getLastXMoves(1)[0]?.result === MoveResult.FAIL)) { + if (!super.canApply(user, target, move, args)) { return false; - } else { - return true; } + return true; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -5549,19 +5538,6 @@ export class LeechSeedAttr extends AddBattlerTagAttr { constructor() { super(BattlerTagType.SEEDED); } - - /** - * Adds a Seeding effect to the target if the target does not have an active Substitute. - * @param user the {@linkcode Pokemon} using the move - * @param target the {@linkcode Pokemon} targeted by the move - * @param move the {@linkcode Move} invoking this effect - * @param args n/a - * @returns `true` if the effect successfully applies; `false` otherwise - */ - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - return !move.hitsSubstitute(user, target) - && super.apply(user, target, move, args); - } } /** @@ -5737,13 +5713,6 @@ export class FlinchAttr extends AddBattlerTagAttr { constructor() { super(BattlerTagType.FLINCHED, false); } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!move.hitsSubstitute(user, target)) { - return super.apply(user, target, move, args); - } - return false; - } } export class ConfuseAttr extends AddBattlerTagAttr { @@ -5759,16 +5728,13 @@ export class ConfuseAttr extends AddBattlerTagAttr { return false; } - if (!move.hitsSubstitute(user, target)) { - return super.apply(user, target, move, args); - } - return false; + return super.apply(user, target, move, args); } } export class RechargeAttr extends AddBattlerTagAttr { constructor() { - super(BattlerTagType.RECHARGING, true, false, 1, 1, true, true); + super(BattlerTagType.RECHARGING, true, false, 1, 1, true); } } @@ -6151,7 +6117,7 @@ export class AddPledgeEffectAttr extends AddArenaTagAttr { * @see {@linkcode apply} */ export class RevivalBlessingAttr extends MoveEffectAttr { - constructor(user?: boolean) { + constructor() { super(true); } @@ -6392,10 +6358,6 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { const player = switchOutTarget instanceof PlayerPokemon; if (!this.selfSwitch) { - if (move.hitsSubstitute(user, target)) { - return false; - } - // Dondozo with an allied Tatsugiri in its mouth cannot be forced out const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED); if (commandedTag?.getSourcePokemon()?.isActive(true)) { @@ -6650,7 +6612,7 @@ export class ChangeTypeAttr extends MoveEffectAttr { private type: PokemonType; constructor(type: PokemonType) { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); this.type = type; } @@ -6673,7 +6635,7 @@ export class AddTypeAttr extends MoveEffectAttr { private type: PokemonType; constructor(type: PokemonType) { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); this.type = type; } @@ -7369,7 +7331,7 @@ export class AbilityChangeAttr extends MoveEffectAttr { public ability: Abilities; constructor(ability: Abilities, selfTarget?: boolean) { - super(selfTarget, { trigger: MoveEffectTrigger.HIT }); + super(selfTarget); this.ability = ability; } @@ -7400,7 +7362,7 @@ export class AbilityCopyAttr extends MoveEffectAttr { public copyToPartner: boolean; constructor(copyToPartner: boolean = false) { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); this.copyToPartner = copyToPartner; } @@ -7441,7 +7403,7 @@ export class AbilityGiveAttr extends MoveEffectAttr { public copyToPartner: boolean; constructor() { - super(false, { trigger: MoveEffectTrigger.HIT }); + super(false); } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -7720,7 +7682,7 @@ export class DiscourageFrequentUseAttr extends MoveAttr { export class MoneyAttr extends MoveEffectAttr { constructor() { - super(true, { trigger: MoveEffectTrigger.HIT, firstHitOnly: true }); + super(true, {firstHitOnly: true }); } apply(user: Pokemon, target: Pokemon, move: Move): boolean { @@ -7787,7 +7749,7 @@ export class StatusIfBoostedAttr extends MoveEffectAttr { public effect: StatusEffect; constructor(effect: StatusEffect) { - super(true, { trigger: MoveEffectTrigger.HIT }); + super(true); this.effect = effect; } @@ -10566,7 +10528,7 @@ export function initMoves() { .attr(JawLockAttr) .bitingMove(), new SelfStatusMove(Moves.STUFF_CHEEKS, PokemonType.NORMAL, -1, 10, -1, 0, 8) - .attr(EatBerryAttr) + .attr(EatBerryAttr, true) .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true) .condition((user) => { const userBerries = globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()); @@ -10590,7 +10552,7 @@ export function initMoves() { .makesContact(false) .partial(), // smart targetting is unimplemented new StatusMove(Moves.TEATIME, PokemonType.NORMAL, -1, 10, -1, 0, 8) - .attr(EatBerryAttr) + .attr(EatBerryAttr, false) .target(MoveTarget.ALL), new StatusMove(Moves.OCTOLOCK, PokemonType.FIGHTING, 100, 15, -1, 0, 8) .condition(failIfGhostTypeCondition) diff --git a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts index d8af7b6aac8..48b36369190 100644 --- a/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts +++ b/src/data/mystery-encounters/encounters/a-trainers-test-encounter.ts @@ -22,7 +22,7 @@ import { EggTier } from "#enums/egg-type"; import { PartyHealPhase } from "#app/phases/party-heal-phase"; import { ModifierTier } from "#app/modifier/modifier-tier"; import { modifierTypes } from "#app/modifier/modifier-type"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/aTrainersTest"; diff --git a/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts b/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts index 0a270aebf37..e0486c83e77 100644 --- a/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts +++ b/src/data/mystery-encounters/encounters/absolute-avarice-encounter.ts @@ -37,7 +37,7 @@ import type HeldModifierConfig from "#app/interfaces/held-modifier-config"; import type { BerryType } from "#enums/berry-type"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import { Stat } from "#enums/stat"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import i18next from "i18next"; /** the i18n namespace for this encounter */ diff --git a/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts b/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts index b66052cfd16..b403c5f291c 100644 --- a/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts +++ b/src/data/mystery-encounters/encounters/an-offer-you-cant-refuse-encounter.ts @@ -23,7 +23,7 @@ import { speciesStarterCosts } from "#app/data/balance/starters"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import i18next from "i18next"; /** the i18n namespace for this encounter */ diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts index bf49dfdea91..7f54e51565e 100644 --- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -36,7 +36,7 @@ import i18next from "#app/plugins/i18n"; import { BerryType } from "#enums/berry-type"; import { PERMANENT_STATS, Stat } from "#enums/stat"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/berriesAbound"; diff --git a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts index 8dfd1a270bd..001faf3a67f 100644 --- a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts +++ b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts @@ -52,7 +52,7 @@ import i18next from "i18next"; import MoveInfoOverlay from "#app/ui/move-info-overlay"; import { allMoves } from "#app/data/moves/move"; import { ModifierTier } from "#app/modifier/modifier-tier"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { getSpriteKeysFromSpecies } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; /** the i18n namespace for the encounter */ diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts index 07688db4583..24c076f750e 100644 --- a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts +++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts @@ -46,7 +46,7 @@ import { Moves } from "#enums/moves"; import { EncounterBattleAnim } from "#app/data/battle-anims"; import { MoveCategory } from "#enums/MoveCategory"; import { CustomPokemonData } from "#app/data/custom-pokemon-data"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { EncounterAnim } from "#enums/encounter-anims"; import { Challenges } from "#enums/challenges"; diff --git a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts index 75527e1f8c1..bdd4bfaacaa 100644 --- a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts +++ b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts @@ -24,7 +24,7 @@ import { TrainerSlot } from "#enums/trainer-slot"; import type { PlayerPokemon } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { EnemyPokemon, PokemonMove } from "#app/field/pokemon"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { modifierTypes } from "#app/modifier/modifier-type"; import { LearnMovePhase } from "#app/phases/learn-move-phase"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; diff --git a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts index 85ebf175f43..e746b13c6a5 100644 --- a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts +++ b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts @@ -19,7 +19,7 @@ import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; import type { PokemonHeldItemModifier } from "#app/modifier/modifier"; import { PokemonFormChangeItemModifier } from "#app/modifier/modifier"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { Challenges } from "#enums/challenges"; /** i18n namespace for encounter */ diff --git a/src/data/mystery-encounters/encounters/delibirdy-encounter.ts b/src/data/mystery-encounters/encounters/delibirdy-encounter.ts index e57955c324a..7040bb47d19 100644 --- a/src/data/mystery-encounters/encounters/delibirdy-encounter.ts +++ b/src/data/mystery-encounters/encounters/delibirdy-encounter.ts @@ -18,7 +18,7 @@ import { applyModifierTypeToPlayerPokemon } from "#app/data/mystery-encounters/u import { getPokemonSpecies } from "#app/data/pokemon-species"; import type { PlayerPokemon } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import type { PokemonHeldItemModifier, PokemonInstantReviveModifier } from "#app/modifier/modifier"; import { BerryModifier, diff --git a/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts b/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts index 6a26cf19d7f..39341bef2d5 100644 --- a/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts +++ b/src/data/mystery-encounters/encounters/department-store-sale-encounter.ts @@ -10,7 +10,7 @@ import { Species } from "#enums/species"; import type MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; import { MysteryEncounterBuilder } from "#app/data/mystery-encounters/mystery-encounter"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** i18n namespace for encounter */ const namespace = "mysteryEncounters/departmentStoreSale"; diff --git a/src/data/mystery-encounters/encounters/field-trip-encounter.ts b/src/data/mystery-encounters/encounters/field-trip-encounter.ts index a1964aa5ab4..2cd6123838b 100644 --- a/src/data/mystery-encounters/encounters/field-trip-encounter.ts +++ b/src/data/mystery-encounters/encounters/field-trip-encounter.ts @@ -18,7 +18,7 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { Stat } from "#enums/stat"; import i18next from "i18next"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** i18n namespace for the encounter */ const namespace = "mysteryEncounters/fieldTrip"; diff --git a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts index f0fb6398334..0364b98abe2 100644 --- a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts +++ b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts @@ -41,7 +41,7 @@ import { import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { EncounterAnim } from "#enums/encounter-anims"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { Abilities } from "#enums/abilities"; import { BattlerTagType } from "#enums/battler-tag-type"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; diff --git a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts index d9b4140c6ee..ecc2e17a06f 100644 --- a/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts +++ b/src/data/mystery-encounters/encounters/fight-or-flight-encounter.ts @@ -33,7 +33,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; import { randSeedInt } from "#app/utils/common"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/fightOrFlight"; diff --git a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts index 282c6c149ff..2d0828b8c0c 100644 --- a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts +++ b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts @@ -30,7 +30,7 @@ import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; import { PostSummonPhase } from "#app/phases/post-summon-phase"; import { modifierTypes } from "#app/modifier/modifier-type"; import { Nature } from "#enums/nature"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { isPokemonValidForEncounterOptionSelection } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; /** the i18n namespace for the encounter */ diff --git a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts index 63db5c7c5d6..b0721ddfee9 100644 --- a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts +++ b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts @@ -48,7 +48,7 @@ import { Gender, getGenderSymbol } from "#app/data/gender"; import { getNatureName } from "#app/data/nature"; import { getPokeballAtlasKey, getPokeballTintColor } from "#app/data/pokeball"; import { getEncounterText, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { addPokemonDataToDexAndValidateAchievements } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import type { PokeballType } from "#enums/pokeball"; import { doShinySparkleAnim } from "#app/field/anims"; diff --git a/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts index 97fd5783ebb..6d8a1fc8c6b 100644 --- a/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts +++ b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts @@ -10,7 +10,7 @@ import { leaveEncounterWithoutBattle, setEncounterExp } from "../utils/encounter import { applyDamageToPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { PokemonMove } from "#app/field/pokemon"; const OPTION_1_REQUIRED_MOVE = Moves.SURF; diff --git a/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts index b10f2f3dba2..6907e18cfdc 100644 --- a/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts +++ b/src/data/mystery-encounters/encounters/mysterious-challengers-encounter.ts @@ -16,7 +16,7 @@ import { randSeedInt } from "#app/utils/common"; import type MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; import { MysteryEncounterBuilder } from "#app/data/mystery-encounters/mystery-encounter"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/mysteriousChallengers"; diff --git a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts index 8877bf36ce8..e9976ba04aa 100644 --- a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts +++ b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts @@ -15,7 +15,7 @@ import { koPlayerPokemon, } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import { getPokemonSpecies } from "#app/data/pokemon-species"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { ModifierTier } from "#app/modifier/modifier-tier"; import { GameOverPhase } from "#app/phases/game-over-phase"; import { randSeedInt } from "#app/utils/common"; diff --git a/src/data/mystery-encounters/encounters/part-timer-encounter.ts b/src/data/mystery-encounters/encounters/part-timer-encounter.ts index 61b48353997..1074eaf8c81 100644 --- a/src/data/mystery-encounters/encounters/part-timer-encounter.ts +++ b/src/data/mystery-encounters/encounters/part-timer-encounter.ts @@ -20,7 +20,7 @@ import { showEncounterDialogue, showEncounterText } from "#app/data/mystery-enco import i18next from "i18next"; import type { PlayerPokemon } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { isPokemonValidForEncounterOptionSelection } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; /** the i18n namespace for the encounter */ diff --git a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts index 602a8d397db..7a12c86edff 100644 --- a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts +++ b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts @@ -31,7 +31,7 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { ScanIvsPhase } from "#app/phases/scan-ivs-phase"; import { SummonPhase } from "#app/phases/summon-phase"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { NON_LEGEND_PARADOX_POKEMON } from "#app/data/balance/special-species-groups"; /** the i18n namespace for the encounter */ diff --git a/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts b/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts index 79f4b53a73e..daf4d860cdf 100644 --- a/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts +++ b/src/data/mystery-encounters/encounters/shady-vitamin-dealer-encounter.ts @@ -26,7 +26,7 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import type { Nature } from "#enums/nature"; import { getNatureName } from "#app/data/nature"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import i18next from "i18next"; /** the i18n namespace for this encounter */ diff --git a/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts b/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts index bfa1204a8ba..41c20f35ba1 100644 --- a/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts +++ b/src/data/mystery-encounters/encounters/slumbering-snorlax-encounter.ts @@ -26,7 +26,7 @@ import { getPokemonSpecies } from "#app/data/pokemon-species"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { PartyHealPhase } from "#app/phases/party-heal-phase"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { BerryType } from "#enums/berry-type"; import { CustomPokemonData } from "#app/data/custom-pokemon-data"; diff --git a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts index ef3532b080e..28c7fe4644f 100644 --- a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts +++ b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts @@ -29,7 +29,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { getPokemonNameWithAffix } from "#app/messages"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import { Stat } from "#enums/stat"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { getEncounterPokemonLevelForWave, STANDARD_ENCOUNTER_BOOSTED_LEVEL_MODIFIER, diff --git a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts index ab2f19cfb77..076171b3e5e 100644 --- a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts @@ -11,7 +11,7 @@ import { randSeedShuffle } from "#app/utils/common"; import type MysteryEncounter from "../mystery-encounter"; import { MysteryEncounterBuilder } from "../mystery-encounter"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { Biome } from "#enums/biome"; import { TrainerType } from "#enums/trainer-type"; import i18next from "i18next"; diff --git a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts index cfff59b45f5..bfba553af5d 100644 --- a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts @@ -26,7 +26,7 @@ import { showEncounterDialogue } from "#app/data/mystery-encounters/utils/encoun import PokemonData from "#app/system/pokemon-data"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { Abilities } from "#enums/abilities"; import { NON_LEGEND_PARADOX_POKEMON, NON_LEGEND_ULTRA_BEASTS } from "#app/data/balance/special-species-groups"; import { timedEventManager } from "#app/global-event-manager"; diff --git a/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts b/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts index c994c6e993f..294f1a78b34 100644 --- a/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-strong-stuff-encounter.ts @@ -28,7 +28,7 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { CustomPokemonData } from "#app/data/custom-pokemon-data"; import { Stat } from "#enums/stat"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/theStrongStuff"; diff --git a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts index 41bf87351f4..bc7c570abca 100644 --- a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts @@ -32,7 +32,7 @@ import { ShowTrainerPhase } from "#app/phases/show-trainer-phase"; import { ReturnPhase } from "#app/phases/return-phase"; import i18next from "i18next"; import { ModifierTier } from "#app/modifier/modifier-tier"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { BattlerTagType } from "#enums/battler-tag-type"; /** the i18n namespace for the encounter */ diff --git a/src/data/mystery-encounters/encounters/training-session-encounter.ts b/src/data/mystery-encounters/encounters/training-session-encounter.ts index 11d00f1dd8c..597a6b009b3 100644 --- a/src/data/mystery-encounters/encounters/training-session-encounter.ts +++ b/src/data/mystery-encounters/encounters/training-session-encounter.ts @@ -28,7 +28,7 @@ import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode import type HeldModifierConfig from "#app/interfaces/held-modifier-config"; import i18next from "i18next"; import { getStatKey } from "#enums/stat"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { isPokemonValidForEncounterOptionSelection } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import type { Nature } from "#enums/nature"; diff --git a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts index 1ff96f21edc..1e1db14705a 100644 --- a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts +++ b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts @@ -26,7 +26,7 @@ import { getPokemonSpecies } from "#app/data/pokemon-species"; import { Moves } from "#enums/moves"; import { BattlerIndex } from "#app/battle"; import { PokemonMove } from "#app/field/pokemon"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; import { randSeedInt } from "#app/utils/common"; /** the i18n namespace for this encounter */ diff --git a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts index 66c7f7afc56..f4eec5b0923 100644 --- a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts +++ b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts @@ -37,7 +37,7 @@ import { queueEncounterMessage } from "#app/data/mystery-encounters/utils/encoun import { BerryModifier } from "#app/modifier/modifier"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import { Stat } from "#enums/stat"; -import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/uncommonBreed"; diff --git a/src/enums/MoveEffectTrigger.ts b/src/enums/MoveEffectTrigger.ts index 1e7753d94fa..d22953c3690 100644 --- a/src/enums/MoveEffectTrigger.ts +++ b/src/enums/MoveEffectTrigger.ts @@ -1,7 +1,6 @@ export enum MoveEffectTrigger { PRE_APPLY, POST_APPLY, - HIT, /** Triggers one time after all target effects have applied */ POST_TARGET } diff --git a/src/enums/hit-check-result.ts b/src/enums/hit-check-result.ts new file mode 100644 index 00000000000..cf8a2b17194 --- /dev/null +++ b/src/enums/hit-check-result.ts @@ -0,0 +1,23 @@ +/** The result of a hit check calculation */ +export const HitCheckResult = { + /** Hit checks haven't been evaluated yet in this pass */ + PENDING: 0, + /** The move hits the target successfully */ + HIT: 1, + /** The move has no effect on the target */ + NO_EFFECT: 2, + /** The move has no effect on the target, but doesn't proc the default "no effect" message */ + NO_EFFECT_NO_MESSAGE: 3, + /** The target protected itself against the move */ + PROTECTED: 4, + /** The move missed the target */ + MISS: 5, + /** The move is reflected by magic coat or magic bounce */ + REFLECTED: 6, + /** The target is no longer on the field */ + TARGET_NOT_ON_FIELD: 7, + /** The move failed unexpectedly */ + ERROR: 8, +} as const; + +export type HitCheckResult = typeof HitCheckResult[keyof typeof HitCheckResult]; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 86d74ea5555..d565a590792 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -277,6 +277,36 @@ export enum FieldPosition { RIGHT, } +/** Base typeclass for damage parameter methods, used for DRY */ +type damageParams = { + /** The attacking {@linkcode Pokemon} */ + source: Pokemon; + /** The move used in the attack */ + move: Move; + /** The move's {@linkcode MoveCategory} after variable-category effects are applied */ + moveCategory: MoveCategory; + /** If `true`, ignores this Pokemon's defensive ability effects */ + ignoreAbility?: boolean; + /** If `true`, ignores the attacking Pokemon's ability effects */ + ignoreSourceAbility?: boolean; + /** If `true`, ignores the ally Pokemon's ability effects */ + ignoreAllyAbility?: boolean; + /** If `true`, ignores the ability effects of the attacking pokemon's ally */ + ignoreSourceAllyAbility?: boolean; + /** If `true`, calculates damage for a critical hit */ + isCritical?: boolean; + /** If `true`, suppresses changes to game state during the calculation */ + simulated?: boolean; + /** If defined, used in place of calculated effectiveness values */ + effectiveness?: number; +} + +/** Type for the parameters of {@linkcode Pokemon#getBaseDamage | getBaseDamage} */ +type getBaseDamageParams = Omit + +/** Type for the parameters of {@linkcode Pokemon#getAttackDamage | getAttackDamage} */ +type getAttackDamageParams = Omit; + export default abstract class Pokemon extends Phaser.GameObjects.Container { public id: number; public name: string; @@ -1441,25 +1471,16 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * Calculate the critical-hit stage of a move used against this pokemon by * the given source * - * @param source the {@linkcode Pokemon} who using the move - * @param move the {@linkcode Move} being used - * @returns the final critical-hit stage value + * @param source - The {@linkcode Pokemon} who using the move + * @param move - The {@linkcode Move} being used + * @returns The final critical-hit stage value */ getCritStage(source: Pokemon, move: Move): number { const critStage = new NumberHolder(0); applyMoveAttrs(HighCritAttr, source, this, move, critStage); - globalScene.applyModifiers( - CritBoosterModifier, - source.isPlayer(), - source, - critStage, - ); - globalScene.applyModifiers( - TempCritBoosterModifier, - source.isPlayer(), - critStage, - ); - applyAbAttrs(BonusCritAbAttr, source, null, false, critStage) + globalScene.applyModifiers(CritBoosterModifier, source.isPlayer(), source, critStage); + globalScene.applyModifiers(TempCritBoosterModifier, source.isPlayer(), critStage); + applyAbAttrs(BonusCritAbAttr, source, null, false, critStage); const critBoostTag = source.getTag(CritBoostTag); if (critBoostTag) { if (critBoostTag instanceof DragonCheerTag) { @@ -1475,6 +1496,19 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return critStage.value; } + /** + * Calculates the category of a move when used by this pokemon after + * category-changing move effects are applied. + * @param target - The {@linkcode Pokemon} using the move + * @param move - The {@linkcode Move} being used + * @returns The given move's final category + */ + getMoveCategory(target: Pokemon, move: Move): MoveCategory { + const moveCategory = new NumberHolder(move.category); + applyMoveAttrs(VariableMoveCategoryAttr, this, target, move, moveCategory); + return moveCategory.value; + } + /** * Calculates and retrieves the final value of a stat considering any held * items, move effects, opponent abilities, and whether there was a critical @@ -2584,7 +2618,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param simulated Whether to apply abilities via simulated calls (defaults to `true`) * @param cancelled {@linkcode BooleanHolder} Stores whether the move was cancelled by a non-type-based immunity. * @param useIllusion - Whether we want the attack move effectiveness on the illusion or not - * Currently only used by {@linkcode Pokemon.apply} to determine whether a "No effect" message should be shown. * @returns The type damage multiplier, indicating the effectiveness of the move */ getMoveEffectiveness( @@ -4075,27 +4108,28 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Calculates the base damage of the given move against this Pokemon when attacked by the given source. * Used during damage calculation and for Shell Side Arm's forecasting effect. - * @param source the attacking {@linkcode Pokemon}. - * @param move the {@linkcode Move} used in the attack. - * @param moveCategory the move's {@linkcode MoveCategory} after variable-category effects are applied. - * @param ignoreAbility if `true`, ignores this Pokemon's defensive ability effects (defaults to `false`). - * @param ignoreSourceAbility if `true`, ignore's the attacking Pokemon's ability effects (defaults to `false`). - * @param ignoreAllyAbility if `true`, ignores the ally Pokemon's ability effects (defaults to `false`). - * @param ignoreSourceAllyAbility if `true`, ignores the attacking Pokemon's ally's ability effects (defaults to `false`). - * @param isCritical if `true`, calculates effective stats as if the hit were critical (defaults to `false`). - * @param simulated if `true`, suppresses changes to game state during calculation (defaults to `true`). + * @param source - The attacking {@linkcode Pokemon}. + * @param move - The {@linkcode Move} used in the attack. + * @param moveCategory - The move's {@linkcode MoveCategory} after variable-category effects are applied. + * @param ignoreAbility - If `true`, ignores this Pokemon's defensive ability effects (defaults to `false`). + * @param ignoreSourceAbility - If `true`, ignore's the attacking Pokemon's ability effects (defaults to `false`). + * @param ignoreAllyAbility - If `true`, ignores the ally Pokemon's ability effects (defaults to `false`). + * @param ignoreSourceAllyAbility - If `true`, ignores the attacking Pokemon's ally's ability effects (defaults to `false`). + * @param isCritical - if `true`, calculates effective stats as if the hit were critical (defaults to `false`). + * @param simulated - if `true`, suppresses changes to game state during calculation (defaults to `true`). * @returns The move's base damage against this Pokemon when used by the source Pokemon. */ getBaseDamage( - source: Pokemon, - move: Move, - moveCategory: MoveCategory, + { + source, + move, + moveCategory, ignoreAbility = false, ignoreSourceAbility = false, ignoreAllyAbility = false, ignoreSourceAllyAbility = false, isCritical = false, - simulated = true, + simulated = true}: getBaseDamageParams ): number { const isPhysical = moveCategory === MoveCategory.PHYSICAL; @@ -4222,27 +4256,27 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Calculates the damage of an attack made by another Pokemon against this Pokemon * @param source {@linkcode Pokemon} the attacking Pokemon - * @param move {@linkcode Pokemon} the move used in the attack + * @param move The {@linkcode Move} used in the attack * @param ignoreAbility If `true`, ignores this Pokemon's defensive ability effects * @param ignoreSourceAbility If `true`, ignores the attacking Pokemon's ability effects * @param ignoreAllyAbility If `true`, ignores the ally Pokemon's ability effects * @param ignoreSourceAllyAbility If `true`, ignores the ability effects of the attacking pokemon's ally * @param isCritical If `true`, calculates damage for a critical hit. * @param simulated If `true`, suppresses changes to game state during the calculation. - * @returns a {@linkcode DamageCalculationResult} object with three fields: - * - `cancelled`: `true` if the move was cancelled by another effect. - * - `result`: {@linkcode HitResult} indicates the attack's type effectiveness. - * - `damage`: `number` the attack's final damage output. + * @param effectiveness If defined, used in place of calculated effectiveness values + * @returns The {@linkcode DamageCalculationResult} */ getAttackDamage( - source: Pokemon, - move: Move, - ignoreAbility = false, - ignoreSourceAbility = false, - ignoreAllyAbility = false, - ignoreSourceAllyAbility = false, - isCritical = false, - simulated = true, + { + source, + move, + ignoreAbility = false, + ignoreSourceAbility = false, + ignoreAllyAbility = false, + ignoreSourceAllyAbility = false, + isCritical = false, + simulated = true, + effectiveness}: getAttackDamageParams, ): DamageCalculationResult { const damage = new NumberHolder(0); const defendingSide = this.isPlayer() @@ -4272,7 +4306,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * * Note that the source's abilities are not ignored here */ - const typeMultiplier = this.getMoveEffectiveness( + const typeMultiplier = effectiveness ?? this.getMoveEffectiveness( source, move, ignoreAbility, @@ -4344,7 +4378,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * The attack's base damage, as determined by the source's level, move power * and Attack stat as well as this Pokemon's Defense stat */ - const baseDamage = this.getBaseDamage( + const baseDamage = this.getBaseDamage({ source, move, moveCategory, @@ -4354,7 +4388,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { ignoreSourceAllyAbility, isCritical, simulated, - ); + }); /** 25% damage debuff on moves hitting more than one non-fainted target (regardless of immunities) */ const { targets, multiple } = getMoveTargets(source, move.id); @@ -4565,211 +4599,36 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { }; } - /** - * Applies the results of a move to this pokemon - * @param source The {@linkcode Pokemon} using the move - * @param move The {@linkcode Move} being used - * @returns The {@linkcode HitResult} of the attack - */ - apply(source: Pokemon, move: Move): HitResult { - const defendingSide = this.isPlayer() - ? ArenaTagSide.PLAYER - : ArenaTagSide.ENEMY; - const moveCategory = new NumberHolder(move.category); - applyMoveAttrs(VariableMoveCategoryAttr, source, this, move, moveCategory); - if (moveCategory.value === MoveCategory.STATUS) { - const cancelled = new BooleanHolder(false); - const typeMultiplier = this.getMoveEffectiveness( - source, - move, - false, - false, - cancelled, - ); - - if (!cancelled.value && typeMultiplier === 0) { - globalScene.queueMessage( - i18next.t("battle:hitResultNoEffect", { - pokemonName: getPokemonNameWithAffix(this), - }), - ); - } - return typeMultiplier === 0 ? HitResult.NO_EFFECT : HitResult.STATUS; + /** Calculate whether the given move critically hits this pokemon + * @param source - The {@linkcode Pokemon} using the move + * @param move - The {@linkcode Move} being used + * @param simulated - If `true`, suppresses changes to game state during calculation (defaults to `true`) + * @returns whether the move critically hits the pokemon + */ + getCriticalHitResult(source: Pokemon, move: Move, simulated: boolean = true): boolean { + const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + const noCritTag = globalScene.arena.getTagOnSide(NoCritTag, defendingSide); + if (noCritTag || Overrides.NEVER_CRIT_OVERRIDE || move.hasAttr(FixedDamageAttr)) { + return false; } - /** Determines whether the attack critically hits */ - let isCritical: boolean; - const critOnly = new BooleanHolder(false); - const critAlways = source.getTag(BattlerTagType.ALWAYS_CRIT); - applyMoveAttrs(CritOnlyAttr, source, this, move, critOnly); - applyAbAttrs( - ConditionalCritAbAttr, - source, - null, - false, - critOnly, - this, - move, - ); - if (critOnly.value || critAlways) { - isCritical = true; - } else { + const isCritical = new BooleanHolder(false); + + if (source.getTag(BattlerTagType.ALWAYS_CRIT)) { + isCritical.value = true; + } + applyMoveAttrs(CritOnlyAttr, source, this, move, isCritical); + applyAbAttrs(ConditionalCritAbAttr, source, null, simulated, isCritical, this, move); + if (!isCritical.value) { const critChance = [24, 8, 2, 1][ Math.max(0, Math.min(this.getCritStage(source, move), 3)) ]; - isCritical = - critChance === 1 || !globalScene.randBattleSeedInt(critChance); + isCritical.value = critChance === 1 || !globalScene.randBattleSeedInt(critChance); } - const noCritTag = globalScene.arena.getTagOnSide(NoCritTag, defendingSide); - const blockCrit = new BooleanHolder(false); - applyAbAttrs(BlockCritAbAttr, this, null, false, blockCrit); - if (noCritTag || blockCrit.value || Overrides.NEVER_CRIT_OVERRIDE) { - isCritical = false; - } + applyAbAttrs(BlockCritAbAttr, this, null, simulated, isCritical); - /** - * Applies stat changes from {@linkcode move} and gives it to {@linkcode source} - * before damage calculation - */ - applyMoveAttrs(StatChangeBeforeDmgCalcAttr, source, this, move); - - const { - cancelled, - result, - damage: dmg, - } = this.getAttackDamage(source, move, false, false, false, false, isCritical, false); - - const typeBoost = source.findTag( - t => - t instanceof TypeBoostTag && t.boostedType === source.getMoveType(move), - ) as TypeBoostTag; - if (typeBoost?.oneUse) { - source.removeTag(typeBoost.tagType); - } - - if ( - cancelled || - result === HitResult.IMMUNE || - result === HitResult.NO_EFFECT - ) { - source.stopMultiHit(this); - - if (!cancelled) { - if (result === HitResult.IMMUNE) { - globalScene.queueMessage( - i18next.t("battle:hitResultImmune", { - pokemonName: getPokemonNameWithAffix(this), - }), - ); - } else { - globalScene.queueMessage( - i18next.t("battle:hitResultNoEffect", { - pokemonName: getPokemonNameWithAffix(this), - }), - ); - } - } - return result; - } - - // In case of fatal damage, this tag would have gotten cleared before we could lapse it. - const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); - const grudgeTag = this.getTag(BattlerTagType.GRUDGE); - - if (dmg) { - this.lapseTags(BattlerTagLapseType.HIT); - - const substitute = this.getTag(SubstituteTag); - const isBlockedBySubstitute = - !!substitute && move.hitsSubstitute(source, this); - if (isBlockedBySubstitute) { - substitute.hp -= dmg; - } - if (!this.isPlayer() && dmg >= this.hp) { - globalScene.applyModifiers(EnemyEndureChanceModifier, false, this); - } - - /** - * We explicitly require to ignore the faint phase here, as we want to show the messages - * about the critical hit and the super effective/not very effective messages before the faint phase. - */ - const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, - { - result: result as DamageResult, - isCritical, - ignoreFaintPhase: true, - source - }); - - if (damage > 0) { - if (source.isPlayer()) { - globalScene.validateAchvs(DamageAchv, new NumberHolder(damage)); - if (damage > globalScene.gameData.gameStats.highestDamage) { - globalScene.gameData.gameStats.highestDamage = damage; - } - } - source.turnData.totalDamageDealt += damage; - source.turnData.singleHitDamageDealt = damage; - this.turnData.damageTaken += damage; - this.battleData.hitCount++; - - const attackResult = { - move: move.id, - result: result as DamageResult, - damage: damage, - critical: isCritical, - sourceId: source.id, - sourceBattlerIndex: source.getBattlerIndex(), - }; - this.turnData.attacksReceived.unshift(attackResult); - if (source.isPlayer() && !this.isPlayer()) { - globalScene.applyModifiers( - DamageMoneyRewardModifier, - true, - source, - new NumberHolder(damage), - ); - } - } - } - - if (isCritical) { - globalScene.queueMessage(i18next.t("battle:hitResultCriticalHit")); - } - - // want to include is.Fainted() in case multi hit move ends early, still want to render message - if (source.turnData.hitsLeft === 1 || this.isFainted()) { - switch (result) { - case HitResult.SUPER_EFFECTIVE: - globalScene.queueMessage(i18next.t("battle:hitResultSuperEffective")); - break; - case HitResult.NOT_VERY_EFFECTIVE: - globalScene.queueMessage( - i18next.t("battle:hitResultNotVeryEffective"), - ); - break; - case HitResult.ONE_HIT_KO: - globalScene.queueMessage(i18next.t("battle:hitResultOneHitKO")); - break; - } - } - - if (this.isFainted()) { - // set splice index here, so future scene queues happen before FaintedPhase - globalScene.setPhaseQueueSplice(); - globalScene.unshiftPhase( - new FaintPhase( - this.getBattlerIndex(), - false, - source, - ), - ); - - this.destroySubstitute(); - this.lapseTag(BattlerTagType.COMMANDED); - } - - return result; + return isCritical.value; + } /** @@ -4833,7 +4692,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Called by apply(), given the damage, adds a new DamagePhase and actually updates HP values, etc. + * Given the damage, adds a new DamagePhase and update HP values, etc. + * * Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly * @param damage integer - passed to damage() * @param result an enum if it's super effective, not very, etc. @@ -5136,8 +4996,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Gets whether the given move is currently disabled for this Pokemon. * - * @param {Moves} moveId {@linkcode Moves} ID of the move to check - * @returns {boolean} `true` if the move is disabled for this Pokemon, otherwise `false` + * @param moveId - The {@linkcode Moves} ID of the move to check + * @returns `true` if the move is disabled for this Pokemon, otherwise `false` * * @see {@linkcode MoveRestrictionBattlerTag} */ @@ -5148,9 +5008,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Gets whether the given move is currently disabled for the user based on the player's target selection * - * @param {Moves} moveId {@linkcode Moves} ID of the move to check - * @param {Pokemon} user {@linkcode Pokemon} the move user - * @param {Pokemon} target {@linkcode Pokemon} the target of the move + * @param moveId - The {@linkcode Moves} ID of the move to check + * @param user - The move user + * @param target - The target of the move * * @returns {boolean} `true` if the move is disabled for this Pokemon due to the player's target selection * @@ -5180,10 +5040,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Gets the {@link MoveRestrictionBattlerTag} that is restricting a move, if it exists. * - * @param {Moves} moveId {@linkcode Moves} ID of the move to check - * @param {Pokemon} user {@linkcode Pokemon} the move user, optional and used when the target is a factor in the move's restricted status - * @param {Pokemon} target {@linkcode Pokemon} the target of the move, optional and used when the target is a factor in the move's restricted status - * @returns {MoveRestrictionBattlerTag | null} the first tag on this Pokemon that restricts the move, or `null` if the move is not restricted. + * @param moveId - {@linkcode Moves} ID of the move to check + * @param user - {@linkcode Pokemon} the move user, optional and used when the target is a factor in the move's restricted status + * @param target - {@linkcode Pokemon} the target of the move, optional and used when the target is a factor in the move's restricted status + * @returns The first tag on this Pokemon that restricts the move, or `null` if the move is not restricted. */ getRestrictingTag( moveId: Moves, @@ -5245,20 +5105,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.summonData.moveQueue; } - /** - * If this Pokemon is using a multi-hit move, cancels all subsequent strikes - * @param {Pokemon} target If specified, this only cancels subsequent strikes against the given target - */ - stopMultiHit(target?: Pokemon): void { - const effectPhase = globalScene.getCurrentPhase(); - if ( - effectPhase instanceof MoveEffectPhase && - effectPhase.getUserPokemon() === this - ) { - effectPhase.stopMultiHit(target); - } - } - changeForm(formChange: SpeciesFormChange): Promise { return new Promise(resolve => { this.formIndex = Math.max( @@ -5676,7 +5522,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * cancel the attack's subsequent hits. */ if (effect === StatusEffect.SLEEP || effect === StatusEffect.FREEZE) { - this.stopMultiHit(); + const currentPhase = globalScene.getCurrentPhase(); + if (currentPhase instanceof MoveEffectPhase && currentPhase.getUserPokemon() === this) { + this.turnData.hitCount = 1; + this.turnData.hitsLeft = 1; + } } if (asPhase) { @@ -7311,14 +7161,15 @@ export class EnemyPokemon extends Pokemon { ].includes(move.id); return ( doesNotFail && - p.getAttackDamage( - this, + p.getAttackDamage({ + source: this, move, - !p.battleData.abilityRevealed, - false, - !p.getAlly()?.battleData.abilityRevealed, - false, + ignoreAbility: !p.battleData.abilityRevealed, + ignoreSourceAbility: false, + ignoreAllyAbility: !p.getAlly()?.battleData.abilityRevealed, + ignoreSourceAllyAbility: false, isCritical, + } ).damage >= p.hp ); }) diff --git a/src/game-mode.ts b/src/game-mode.ts index dfe6b8cf123..ec7171b0024 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -13,6 +13,7 @@ import { Species } from "#enums/species"; import { Challenges } from "./enums/challenges"; import { globalScene } from "#app/global-scene"; import { getDailyStartingBiome } from "./data/daily-run"; +import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES, CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES } from "./constants"; export enum GameModes { CLASSIC, @@ -36,10 +37,6 @@ interface GameModeConfig { hasMysteryEncounters?: boolean; } -// Describes min and max waves for MEs in specific game modes -export const CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180]; -export const CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180]; - export class GameMode implements GameModeConfig { public modeId: GameModes; public isClassic: boolean; diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 5a25cf6330d..4c99a609b11 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -35,19 +35,19 @@ import { BattlerTagType } from "#enums/battler-tag-type"; export class FaintPhase extends PokemonPhase { /** - * Whether or not enduring (for this phase's purposes, Reviver Seed) should be prevented + * Whether or not instant revive should be prevented */ - private preventEndure: boolean; + private preventInstantRevive: boolean; /** * The source Pokemon that dealt fatal damage */ private source?: Pokemon; - constructor(battlerIndex: BattlerIndex, preventEndure = false, source?: Pokemon) { + constructor(battlerIndex: BattlerIndex, preventInstantRevive = false, source?: Pokemon) { super(battlerIndex); - this.preventEndure = preventEndure; + this.preventInstantRevive = preventInstantRevive; this.source = source; } @@ -63,7 +63,7 @@ export class FaintPhase extends PokemonPhase { faintPokemon.resetSummonData(); - if (!this.preventEndure) { + if (!this.preventInstantRevive) { const instantReviveModifier = globalScene.applyModifier( PokemonInstantReviveModifier, this.player, diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index c29e3fe5cda..01085834ba5 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -13,7 +13,6 @@ import { PostDamageAbAttr, PostDefendAbAttr, ReflectStatusMoveAbAttr, - TypeImmunityAbAttr, } from "#app/data/abilities/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; @@ -23,10 +22,10 @@ import { ProtectedTag, SemiInvulnerableTag, SubstituteTag, + TypeBoostTag, } from "#app/data/battler-tags"; import type { MoveAttr } from "#app/data/moves/move"; import { - AddArenaTrapTagAttr, applyFilteredMoveAttrs, applyMoveAttrs, AttackMove, @@ -40,8 +39,8 @@ import { NoEffectAttr, OneHitKOAttr, OverrideMoveEffectAttr, + StatChangeBeforeDmgCalcAttr, ToxicAccuracyAttr, - VariableTargetAttr, } from "#app/data/moves/move"; import { MoveEffectTrigger } from "#enums/MoveEffectTrigger"; import { MoveFlags } from "#enums/MoveFlags"; @@ -49,13 +48,15 @@ import { MoveTarget } from "#enums/MoveTarget"; import { MoveCategory } from "#enums/MoveCategory"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { PokemonType } from "#enums/pokemon-type"; -import { PokemonMove } from "#app/field/pokemon"; +import { DamageResult, PokemonMove, type TurnMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { HitResult, MoveResult } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { ContactHeldItemTransferChanceModifier, + DamageMoneyRewardModifier, EnemyAttackStatusEffectChanceModifier, + EnemyEndureChanceModifier, FlinchChanceModifier, HitHealModifier, PokemonMultiHitModifier, @@ -64,36 +65,182 @@ import { PokemonPhase } from "#app/phases/pokemon-phase"; import { BooleanHolder, isNullOrUndefined, NumberHolder } from "#app/utils/common"; import type { nil } from "#app/utils/common"; import { BattlerTagType } from "#enums/battler-tag-type"; -import type { Moves } from "#enums/moves"; +import { Moves } from "#enums/moves"; import i18next from "i18next"; import type { Phase } from "#app/phase"; import { ShowAbilityPhase } from "./show-ability-phase"; import { MovePhase } from "./move-phase"; import { MoveEndPhase } from "./move-end-phase"; import { HideAbilityPhase } from "#app/phases/hide-ability-phase"; +import { TypeDamageMultiplier } from "#app/data/type"; +import { HitCheckResult } from "#enums/hit-check-result"; +import type Move from "#app/data/moves/move"; +import { isFieldTargeted } from "#app/data/moves/move-utils"; +import { FaintPhase } from "./faint-phase"; +import { DamageAchv } from "#app/system/achv"; + +type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; export class MoveEffectPhase extends PokemonPhase { - public move: PokemonMove; + public move: Move; + private virtual = false; protected targets: BattlerIndex[]; protected reflected = false; + /** The result of the hit check against each target */ + private hitChecks: HitCheckEntry[]; + + /** The move history entry for the move */ + private moveHistoryEntry: TurnMove; + + /** Is this the first strike of a move? */ + private firstHit: boolean; + /** Is this the last strike of a move? */ + private lastHit: boolean; + + /** Phases queued during moves */ + private queuedPhases: Phase[] = []; + /** * @param reflected Indicates that the move was reflected by the user due to magic coat or magic bounce + * @param virtual Indicates that the move is a virtual move (i.e. called by metronome) */ - constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove, reflected = false) { + constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: Move, reflected = false, virtual = false) { super(battlerIndex); this.move = move; + this.virtual = virtual; + this.reflected = reflected; /** * 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) { + if (targets.includes(battlerIndex) && this.move.moveTarget === MoveTarget.ALL_NEAR_OTHERS) { const i = targets.indexOf(battlerIndex); targets.splice(i, i + 1); } this.targets = targets; + + this.hitChecks = Array(this.targets.length).fill([HitCheckResult.PENDING, 0]); + } + + /** + * Compute targets and the results of hit checks of the invoked move against all targets, + * organized by battler index. + * + * **This is *not* a pure function**; it has the following side effects + * - `this.hitChecks` - The results of the hit checks against each target + * - `this.moveHistoryEntry` - Sets success or failure based on the hit check results + * - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the + * move was unsuccessful against all targets + * + * @returns The targets of the invoked move + * @see {@linkcode hitCheck} + */ + private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] { + /** All Pokemon targeted by this phase's invoked move */ + /** Whether any hit check ended in a success */ + let anySuccess = false; + /** Whether the attack missed all of its targets */ + let allMiss = true; + + let targets = this.getTargets(); + + // For field targeted moves, we only look for the first target that may magic bounce + + for (const [i, target] of targets.entries()) { + const hitCheck = this.hitCheck(target); + // If the move bounced and was a field targeted move, + // then immediately stop processing other targets + if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) { + targets = [target]; + this.hitChecks = [hitCheck]; + break; + } + if (hitCheck[0] === HitCheckResult.HIT) { + anySuccess = true; + } else { + allMiss ||= hitCheck[0] === HitCheckResult.MISS; + } + this.hitChecks[i] = hitCheck; + } + + if (anySuccess) { + this.moveHistoryEntry.result = MoveResult.SUCCESS; + } else { + user.turnData.hitCount = 1; + user.turnData.hitsLeft = 1; + this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL; + } + + return targets; + } + + /** + * Queue the phaes that should occur when the target reflects the move back to the user + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - The {@linkcode Pokemon} that is reflecting the move + * + */ + private queueReflectedMove(user: Pokemon, target: Pokemon): void { + const newTargets = this.move.isMultiTarget() + ? getMoveTargets(target, this.move.id).targets + : [user.getBattlerIndex()]; + // TODO: ability displays should be handled by the ability + if (!target.getTag(BattlerTagType.MAGIC_COAT)) { + this.queuedPhases.push( + new ShowAbilityPhase(target.getBattlerIndex(), target.getPassiveAbility().hasAttr(ReflectStatusMoveAbAttr)), + ); + this.queuedPhases.push(new HideAbilityPhase()); + } + + this.queuedPhases.push( + new MovePhase(target, newTargets, new PokemonMove(this.move.id, 0, 0, true), true, true, true), + ); + } + + /** + * Apply the move to each of the resolved targets. + * @param targets - The resolved set of targets of the move + * @throws Error if there was an unexpected hit check result + */ + private applyToTargets(user: Pokemon, targets: Pokemon[]): void { + for (const [i, target] of targets.entries()) { + const [hitCheckResult, effectiveness] = this.hitChecks[i]; + switch (hitCheckResult) { + case HitCheckResult.HIT: + this.applyMoveEffects(target, effectiveness); + if (isFieldTargeted(this.move)) { + // Stop processing other targets if the move is a field move + return; + } + break; + case HitCheckResult.NO_EFFECT: + globalScene.queueMessage( + i18next.t(this.move.id === Moves.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", { + pokemonName: getPokemonNameWithAffix(target), + }), + ); + case HitCheckResult.NO_EFFECT_NO_MESSAGE: + case HitCheckResult.PROTECTED: + case HitCheckResult.TARGET_NOT_ON_FIELD: + applyMoveAttrs(NoEffectAttr, user, target, this.move); + break; + case HitCheckResult.MISS: + globalScene.queueMessage( + i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }), + ); + applyMoveAttrs(MissEffectAttr, user, target, this.move); + break; + case HitCheckResult.REFLECTED: + this.queueReflectedMove(user, target); + break; + case HitCheckResult.PENDING: + case HitCheckResult.ERROR: + throw new Error("Unexpected hit check result"); + } + } } public override start(): void { @@ -101,11 +248,10 @@ export class MoveEffectPhase extends PokemonPhase { /** The Pokemon using this phase's invoked move */ const user = this.getUserPokemon(); - /** All Pokemon targeted by this phase's invoked move */ - const targets = this.getTargets(); if (!user) { - return super.end(); + super.end(); + return; } /** If an enemy used this move, set this as last enemy that used move or ability */ @@ -115,23 +261,24 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.currentBattle.lastPlayerInvolved = this.fieldIndex; } - const isDelayedAttack = this.move.getMove().hasAttr(DelayedAttackAttr); + const isDelayedAttack = this.move.hasAttr(DelayedAttackAttr); /** If the user was somehow removed from the field and it's not a delayed attack, end this phase */ if (!user.isOnField()) { if (!isDelayedAttack) { - return super.end(); - } else { - if (!user.scene) { - /** - * This happens if the Pokemon that used the delayed attack gets caught and released - * on the turn the attack would have triggered. Having access to the global scene - * in the future may solve this entirely, so for now we just cancel the hit - */ - return super.end(); - } - if (isNullOrUndefined(user.turnData)) { - user.resetTurnData(); - } + super.end(); + return; + } + if (!user.scene) { + /* + * This happens if the Pokemon that used the delayed attack gets caught and released + * on the turn the attack would have triggered. Having access to the global scene + * in the future may solve this entirely, so for now we just cancel the hit + */ + super.end(); + return; + } + if (isNullOrUndefined(user.turnData)) { + user.resetTurnData(); } } @@ -140,17 +287,17 @@ export class MoveEffectPhase extends PokemonPhase { * e.g. Charging moves (Fly, etc.) on their first turn of use. */ const overridden = new BooleanHolder(false); - /** The {@linkcode Move} object from {@linkcode allMoves} invoked by this phase */ - const move = this.move.getMove(); + const move = this.move; // Assume single target for override - applyMoveAttrs(OverrideMoveEffectAttr, user, this.getFirstTarget() ?? null, move, overridden, this.move.virtual); + applyMoveAttrs(OverrideMoveEffectAttr, user, this.getFirstTarget() ?? null, move, overridden, this.virtual); // If other effects were overriden, stop this phase before they can be applied if (overridden.value) { return this.end(); } + // Lapse `MOVE_EFFECT` effects (i.e. semi-invulnerability) when applicable user.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // If the user is acting again (such as due to Instruct), reset hitsLeft/hitCount so that @@ -179,339 +326,75 @@ export class MoveEffectPhase extends PokemonPhase { user.turnData.hitsLeft = hitCount.value; } - /** + /* * 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 + * Note that `result` 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, + this.moveHistoryEntry = { + move: this.move.id, targets: this.targets, result: MoveResult.PENDING, - virtual: this.move.virtual, + virtual: this.virtual, }; - /** - * 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)); + const fieldMove = isFieldTargeted(move); - /** Check if the target is immune via ability to the attacking move, and NOT in semi invulnerable state */ - const isImmune = - targets[0]?.hasAbilityWithAttr(TypeImmunityAbAttr) && - targets[0]?.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move) && - !targets[0]?.getTag(SemiInvulnerableTag); + const targets = this.conductHitChecks(user, fieldMove); - const mayBounce = - move.hasFlag(MoveFlags.REFLECTABLE) && - !this.reflected && - targets.some(t => t.hasAbilityWithAttr(ReflectStatusMoveAbAttr) || !!t.getTag(BattlerTagType.MAGIC_COAT)); + this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft; + this.lastHit = user.turnData.hitsLeft === 1 || !targets.some(t => t.isActive(true)); - /** - * If no targets are left for the move to hit and it is not a hazard move (FAIL), or the invoked move is non-reflectable, 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. - */ + // Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct) if ( - (!hasActiveTargets && !move.hasAttr(AddArenaTrapTagAttr)) || - (!mayBounce && - !move.hasAttr(VariableTargetAttr) && - !move.isMultiTarget() && - !targetHitChecks[this.targets[0]] && - !targets[0].getTag(ProtectedTag) && - !isImmune) + this.moveHistoryEntry.result === MoveResult.SUCCESS || + move.getAttrs(MoveEffectAttr).some(attr => attr.trigger === MoveEffectTrigger.POST_TARGET) ) { - this.stopMultiHit(); - if (hasActiveTargets) { - globalScene.queueMessage( - i18next.t("battle:attackMissed", { - pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "", - }), - ); - moveHistoryEntry.result = MoveResult.MISS; - applyMoveAttrs(MissEffectAttr, user, null, this.move.getMove()); - } else { - globalScene.queueMessage(i18next.t("battle:attackFailed")); - moveHistoryEntry.result = MoveResult.FAIL; - } - user.pushMoveHistory(moveHistoryEntry); - return this.end(); + const firstTarget = this.getFirstTarget(); + new MoveAnim( + move.id as Moves, + user, + firstTarget?.getBattlerIndex() ?? BattlerIndex.ATTACKER, + // Field moves and some moves used in mystery encounters should be played even on an empty field + fieldMove || (globalScene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false), + ).play(move.hitsSubstitute(user, firstTarget), () => this.postAnimCallback(user, targets)); + + return; + } + this.postAnimCallback(user, targets); + } + + /** + * Callback to be called after the move animation is played + */ + private postAnimCallback(user: Pokemon, targets: Pokemon[]) { + // Add to the move history entry + if (this.firstHit) { + user.pushMoveHistory(this.moveHistoryEntry); } - const playOnEmptyField = - (globalScene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false) || - (!hasActiveTargets && move.hasAttr(AddArenaTrapTagAttr)); - // Move animation only needs one target. The attacker is used as a fallback. - new MoveAnim( - move.id as Moves, - user, - this.getFirstTarget()?.getBattlerIndex() ?? BattlerIndex.ATTACKER, - playOnEmptyField, - ).play(move.hitsSubstitute(user, this.getFirstTarget()!), () => { - /** Has the move successfully hit a target (for damage) yet? */ - let hasHit = false; - - // Prevent ENEMY_SIDE targeted moves from occurring twice in double battles - // and check which target will magic bounce. - // In the event that the move is a hazard move, there may be no target and the move should still succeed. - // In this case, the user is used as the "target" to prevent a crash. - // This should not affect normal execution of the move otherwise. - const trueTargets: Pokemon[] = - !hasActiveTargets && move.hasAttr(AddArenaTrapTagAttr) - ? [user] - : move.moveTarget !== MoveTarget.ENEMY_SIDE - ? targets - : (() => { - const magicCoatTargets = targets.filter( - t => t.getTag(BattlerTagType.MAGIC_COAT) || t.hasAbilityWithAttr(ReflectStatusMoveAbAttr), - ); - - // only magic coat effect cares about order - if (!mayBounce || magicCoatTargets.length === 0) { - return [targets[0]]; - } - return [magicCoatTargets[0]]; - })(); - - const queuedPhases: Phase[] = []; - for (const target of trueTargets) { - /** The {@linkcode ArenaTagSide} to which the target belongs */ - const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ - const hasConditionalProtectApplied = new BooleanHolder(false); - /** Does the applied conditional protection bypass Protect-ignoring effects? */ - const bypassIgnoreProtect = new BooleanHolder(false); - /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ - if (!this.move.getMove().isAllyTarget()) { - globalScene.arena.applyTagsForSide( - ConditionalProtectTag, - targetSide, - false, - hasConditionalProtectApplied, - user, - target, - move.id, - bypassIgnoreProtect, - ); - } - - /** Is the target protected by Protect, etc. or a relevant conditional protection effect? */ - const isProtected = - ![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.getMove().moveTarget) && - (bypassIgnoreProtect.value || - !this.move.getMove().doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) && - (hasConditionalProtectApplied.value || - (!target.findTags(t => t instanceof DamageProtectedTag).length && - target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) || - (this.move.getMove().category !== MoveCategory.STATUS && - target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); - - /** Is the target hidden by the effects of its Commander ability? */ - const isCommanding = - globalScene.currentBattle.double && - target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target; - - /** Is the target reflecting status moves from the magic coat move? */ - const isReflecting = !!target.getTag(BattlerTagType.MAGIC_COAT); - - /** Is the target's magic bounce ability not ignored and able to reflect this move? */ - const canMagicBounce = - !isReflecting && - !move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) && - target.hasAbilityWithAttr(ReflectStatusMoveAbAttr); - - const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); - - /** Is the target reflecting the effect, not protected, and not in an semi-invulnerable state?*/ - const willBounce = - !isProtected && - !this.reflected && - !isCommanding && - move.hasFlag(MoveFlags.REFLECTABLE) && - (isReflecting || canMagicBounce) && - !semiInvulnerableTag; - - // If the move will bounce, then queue the bounce and move on to the next target - if (!target.switchOutStatus && willBounce) { - const newTargets = move.isMultiTarget() ? getMoveTargets(target, move.id).targets : [user.getBattlerIndex()]; - if (!isReflecting) { - // TODO: Ability displays should be handled by the ability - queuedPhases.push( - new ShowAbilityPhase( - target.getBattlerIndex(), - target.getPassiveAbility().hasAttr(ReflectStatusMoveAbAttr), - ), - ); - queuedPhases.push(new HideAbilityPhase()); - } - - queuedPhases.push(new MovePhase(target, newTargets, new PokemonMove(move.id, 0, 0, true), true, true, true)); - continue; - } - - /** Is the pokemon immune due to an ablility, and also not in a semi invulnerable state? */ - const isImmune = - target.hasAbilityWithAttr(TypeImmunityAbAttr) && - target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move) && - !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 ( - target.switchOutStatus || - isCommanding || - (!isImmune && - !isProtected && - !targetHitChecks[target.getBattlerIndex()] && - !move.hasAttr(AddArenaTrapTagAttr)) - ) { - this.stopMultiHit(target); - if (!target.switchOutStatus) { - globalScene.queueMessage( - i18next.t("battle:attackMissed", { - pokemonNameWithAffix: getPokemonNameWithAffix(target), - }), - ); - } - if (moveHistoryEntry.result === MoveResult.PENDING) { - moveHistoryEntry.result = MoveResult.MISS; - } - user.pushMoveHistory(moveHistoryEntry); - applyMoveAttrs(MissEffectAttr, user, null, move); - continue; - } - - /** Does this phase represent the invoked move's first strike? */ - const firstHit = user.turnData.hitsLeft === user.turnData.hitCount; - - // Only log the move's result on the first strike - if (firstHit) { - user.pushMoveHistory(moveHistoryEntry); - } - - /** - * 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. - */ - const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT; - - /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ - const dealsDamage = [ - HitResult.EFFECTIVE, - HitResult.SUPER_EFFECTIVE, - HitResult.NOT_VERY_EFFECTIVE, - HitResult.ONE_HIT_KO, - ].includes(hitResult); - - /** Is this target the first one hit by the move on its current strike? */ - const firstTarget = dealsDamage && !hasHit; - if (firstTarget) { - hasHit = true; - } - - /** - * 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; - } - - /** Does this phase represent the invoked move's last strike? */ - const lastHit = user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.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 (lastHit) { - globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); - /** - * Multi-Lens, Multi Hit move and Parental Bond check for PostDamageAbAttr - * other damage source are calculated in damageAndUpdate in pokemon.ts - */ - if (user.turnData.hitCount > 1) { - applyPostDamageAbAttrs(PostDamageAbAttr, target, 0, target.hasPassive(), false, [], user); - } - } - - applyFilteredMoveAttrs( - (attr: MoveAttr) => - attr instanceof MoveEffectAttr && - attr.trigger === MoveEffectTrigger.PRE_APPLY && - (!attr.firstHitOnly || firstHit) && - (!attr.lastHitOnly || lastHit) && - hitResult !== HitResult.NO_EFFECT, - user, - target, - move, - ); - - if (hitResult !== HitResult.FAIL) { - this.applySelfTargetEffects(user, target, firstHit, lastHit); - - if (hitResult !== HitResult.NO_EFFECT) { - this.applyPostApplyEffects(user, target, firstHit, lastHit); - this.applyHeldItemFlinchCheck(user, target, dealsDamage); - this.applySuccessfulAttackEffects(user, target, firstHit, lastHit, !!isProtected, hitResult, firstTarget); - } else { - applyMoveAttrs(NoEffectAttr, user, null, move); - } - } - } - - // Apply queued phases - if (queuedPhases.length) { - globalScene.appendToPhase(queuedPhases, MoveEndPhase); - } - // Apply the move's POST_TARGET effects on the move's last hit, after all targeted effects have resolved - if (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) { - applyFilteredMoveAttrs( - (attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, - user, - null, - move, - ); - } - - /** - * Remove the target's substitute (if it exists and has expired) - * after all targeted effects have applied. - * This prevents blocked effects from applying until after this hit resolves. - */ - targets.forEach(target => { - const substitute = target.getTag(SubstituteTag); - if (substitute && substitute.hp <= 0) { - target.lapseTag(BattlerTagType.SUBSTITUTE); - } - }); - - const moveType = user.getMoveType(move, true); - if (move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) { - user.stellarTypesBoosted.push(moveType); - } - + try { + this.applyToTargets(user, targets); + } catch (e) { + console.warn(e.message || "Unexpected error in move effect phase"); this.end(); - }); + return; + } + + if (this.queuedPhases.length) { + globalScene.appendToPhase(this.queuedPhases, MoveEndPhase); + } + const moveType = user.getMoveType(this.move, true); + if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) { + user.stellarTypesBoosted.push(moveType); + } + + if (this.lastHit) { + this.triggerMoveEffects(MoveEffectTrigger.POST_TARGET, user, null); + } + + this.updateSubstitutes(); + this.end(); } public override end(): void { @@ -535,7 +418,6 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal })); } globalScene.applyModifiers(HitHealModifier, this.player, user); - // Clear all cached move effectiveness values among targets this.getTargets().forEach(target => (target.turnData.moveEffectiveness = null)); } } @@ -543,82 +425,6 @@ export class MoveEffectPhase extends PokemonPhase { super.end(); } - /** - * Apply self-targeted effects that trigger `POST_APPLY` - * - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @returns a function intended to be passed into a `then()` call. - */ - protected applySelfTargetEffects(user: Pokemon, target: Pokemon, firstHit: boolean, lastHit: boolean): void { - applyFilteredMoveAttrs( - (attr: MoveAttr) => - attr instanceof MoveEffectAttr && - attr.trigger === MoveEffectTrigger.POST_APPLY && - attr.selfTarget && - (!attr.firstHitOnly || firstHit) && - (!attr.lastHitOnly || lastHit), - user, - target, - this.move.getMove(), - ); - } - - /** - * Applies non-self-targeted effects that trigger `POST_APPLY` - * (i.e. Smelling Salts curing Paralysis, and the forced switch from U-Turn, Dragon Tail, etc) - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @returns a function intended to be passed into a `then()` call. - */ - protected applyPostApplyEffects(user: Pokemon, target: Pokemon, firstHit: boolean, lastHit: boolean): void { - applyFilteredMoveAttrs( - (attr: MoveAttr) => - attr instanceof MoveEffectAttr && - attr.trigger === MoveEffectTrigger.POST_APPLY && - !attr.selfTarget && - (!attr.firstHitOnly || firstHit) && - (!attr.lastHitOnly || lastHit), - user, - target, - this.move.getMove(), - ); - } - - /** - * Applies effects that trigger on HIT - * (i.e. Final Gambit, Power-Up Punch, Drain Punch) - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @param firstTarget - `true` if {@linkcode target} is the first target hit by this strike of {@linkcode move} - * @returns a function intended to be passed into a `then()` call. - */ - protected applyOnHitEffects( - user: Pokemon, - target: Pokemon, - firstHit: boolean, - lastHit: boolean, - firstTarget: boolean, - ): void { - applyFilteredMoveAttrs( - (attr: MoveAttr) => - attr instanceof MoveEffectAttr && - attr.trigger === MoveEffectTrigger.HIT && - (!attr.firstHitOnly || firstHit) && - (!attr.lastHitOnly || lastHit) && - (!attr.firstTargetOnly || firstTarget), - user, - target, - this.move.getMove(), - ); - } - /** * Applies reactive effects that occur when a Pokémon is hit. * (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast) @@ -627,51 +433,9 @@ export class MoveEffectPhase extends PokemonPhase { * @param hitResult - The {@linkcode HitResult} of the attempted move * @returns a `Promise` intended to be passed into a `then()` call. */ - protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult) { - const hitsSubstitute = this.move.getMove().hitsSubstitute(user, target); - if (!target.isFainted() || target.canApplyAbility()) { - applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult); - - if (!hitsSubstitute) { - if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { - globalScene.applyShuffledModifiers(EnemyAttackStatusEffectChanceModifier, false, target); - } - } - } - if (!hitsSubstitute) { - target.lapseTags(BattlerTagLapseType.AFTER_HIT); - } - } - - /** - * Applies all effects and attributes that require a move to connect with a target, - * namely reactive effects like Weak Armor, on-hit effects like that of Power-Up Punch, and item stealing effects - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @param isProtected - `true` if the target is protected by effects such as Protect - * @param hitResult - The {@linkcode HitResult} of the attempted move - * @param firstTarget - `true` if {@linkcode target} is the first target hit by this strike of {@linkcode move} - * @returns a function intended to be passed into a `then()` call. - */ - protected applySuccessfulAttackEffects( - user: Pokemon, - target: Pokemon, - firstHit: boolean, - lastHit: boolean, - isProtected: boolean, - hitResult: HitResult, - firstTarget: boolean, - ): void { - if (!isProtected) { - this.applyOnHitEffects(user, target, firstHit, lastHit, firstTarget); - this.applyOnGetHitAbEffects(user, target, hitResult); - applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult); - if (this.move.getMove() instanceof AttackMove && hitResult !== HitResult.STATUS) { - globalScene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); - } - } + protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult): void { + applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move, hitResult); + target.lapseTags(BattlerTagLapseType.AFTER_HIT); } /** @@ -682,80 +446,162 @@ export class MoveEffectPhase extends PokemonPhase { * @returns a function intended to be passed into a `then()` call. */ protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void { - if (this.move.getMove().hasAttr(FlinchAttr)) { + if (this.move.hasAttr(FlinchAttr)) { return; } - if ( - dealsDamage && - !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && - !this.move.getMove().hitsSubstitute(user, target) - ) { + if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !this.move.hitsSubstitute(user, target)) { const flinched = new BooleanHolder(false); globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); if (flinched.value) { - target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); + target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id); } } } - /** - * Resolves whether this phase's invoked move hits the given target - * @param target - The {@linkcode Pokemon} targeted by the invoked move - * @returns `true` if the move hits the target + /** Return whether the target is protected by protect or a relevant conditional protection + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the target to check for protection + * @param move - The {@linkcode Move} being used */ - public hitCheck(target: Pokemon): boolean { - // Moves targeting the user and entry hazards can't miss - if ([MoveTarget.USER, MoveTarget.ENEMY_SIDE].includes(this.move.getMove().moveTarget)) { - return true; + private protectedCheck(user: Pokemon, target: Pokemon) { + /** The {@linkcode ArenaTagSide} to which the target belongs */ + const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ + const hasConditionalProtectApplied = new BooleanHolder(false); + /** Does the applied conditional protection bypass Protect-ignoring effects? */ + const bypassIgnoreProtect = new BooleanHolder(false); + /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ + if (!this.move.isAllyTarget()) { + globalScene.arena.applyTagsForSide( + ConditionalProtectTag, + targetSide, + false, + hasConditionalProtectApplied, + user, + target, + this.move.id, + bypassIgnoreProtect, + ); } + return ( + ![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) && + (bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) && + (hasConditionalProtectApplied.value || + (!target.findTags(t => t instanceof DamageProtectedTag).length && + target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) || + (this.move.category !== MoveCategory.STATUS && + target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))) + ); + } + + /** + * Conduct the hit check and type effectiveness for this move against the target + * + * Checks occur in the following order: + * 1. if the move is self-target + * 2. if the target is on the field + * 3. if the target is hidden by the effects of its commander ability + * 4. if the target is in an applicable semi-invulnerable state + * 5. if the target has an applicable protection effect + * 6. if the move is reflected by magic coat or magic bounce + * 7. type effectiveness calculation, including immunities from abilities and typing + * 9. if accuracy is checked, whether the roll passes the accuracy check + * @param target - The {@linkcode Pokemon} targeted by the invoked move + * @returns a {@linkcode HitCheckEntry} containing the attack's {@linkcode HitCheckResult} + * and {@linkcode TypeDamageMultiplier | effectiveness} against the target. + */ + public hitCheck(target: Pokemon): HitCheckEntry { const user = this.getUserPokemon(); + const move = this.move; if (!user) { - return false; + return [HitCheckResult.ERROR, 0]; } - // Hit check only calculated on first hit for multi-hit moves unless flag is set to check all hits. - // However, if an ability with the MaxMultiHitAbAttr, namely Skill Link, is present, act as a normal - // multi-hit move and proceed with all hits + // Moves targeting the user bypass all checks + if (move.moveTarget === MoveTarget.USER) { + return [HitCheckResult.HIT, 1]; + } + + const fieldTargeted = isFieldTargeted(move); + + if (!target.isActive(true) && !fieldTargeted) { + return [HitCheckResult.TARGET_NOT_ON_FIELD, 0]; + } + + // Commander causes moves used against the target to miss + if ( + !fieldTargeted && + globalScene.currentBattle.double && + target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target + ) { + return [HitCheckResult.MISS, 0]; + } + + /** Whether both accuracy and invulnerability checks can be skipped */ + const bypassAccAndInvuln = fieldTargeted || this.checkBypassAccAndInvuln(target); + const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); + + if (semiInvulnerableTag && !bypassAccAndInvuln && !this.checkBypassSemiInvuln(semiInvulnerableTag)) { + return [HitCheckResult.MISS, 0]; + } + + if (!fieldTargeted && this.protectedCheck(user, target)) { + return [HitCheckResult.PROTECTED, 0]; + } + + if (!this.reflected && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) { + return [HitCheckResult.REFLECTED, 0]; + } + + // After the magic bounce check, field targeted moves are always successful + if (fieldTargeted) { + return [HitCheckResult.HIT, 1]; + } + + const cancelNoEffectMessage = new BooleanHolder(false); + + /** + * The effectiveness of the move against the given target. + * Accounts for type and move immunities from defensive typing, abilities, and other effects. + */ + const effectiveness = target.getMoveEffectiveness(user, move, false, false, cancelNoEffectMessage); + if (effectiveness === 0) { + return [ + cancelNoEffectMessage.value ? HitCheckResult.NO_EFFECT_NO_MESSAGE : HitCheckResult.NO_EFFECT, + effectiveness, + ]; + } + + const moveAccuracy = move.calculateBattleAccuracy(user, target); + + // Strikes after the first in a multi-strike move are guaranteed to hit, + // unless the move is flagged to check all hits and the user does not have Skill Link. if (user.turnData.hitsLeft < user.turnData.hitCount) { - if (!this.move.getMove().hasFlag(MoveFlags.CHECK_ALL_HITS) || user.hasAbilityWithAttr(MaxMultiHitAbAttr)) { - return true; + if (!move.hasFlag(MoveFlags.CHECK_ALL_HITS) || user.hasAbilityWithAttr(MaxMultiHitAbAttr)) { + return [HitCheckResult.HIT, effectiveness]; } } - if (this.checkBypassAccAndInvuln(target)) { - return true; + const bypassAccuracy = + bypassAccAndInvuln || + target.getTag(BattlerTagType.ALWAYS_GET_HIT) || + (target.getTag(BattlerTagType.TELEKINESIS) && !this.move.hasAttr(OneHitKOAttr)); + + if (moveAccuracy === -1 || bypassAccuracy) { + return [HitCheckResult.HIT, effectiveness]; } - if (target.getTag(BattlerTagType.ALWAYS_GET_HIT)) { - return true; - } - - const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); - if ( - target.getTag(BattlerTagType.TELEKINESIS) && - !semiInvulnerableTag && - !this.move.getMove().hasAttr(OneHitKOAttr) - ) { - return true; - } - - if (semiInvulnerableTag && !this.checkBypassSemiInvuln(semiInvulnerableTag)) { - return false; - } - - const moveAccuracy = this.move.getMove().calculateBattleAccuracy(user, target); - - if (moveAccuracy === -1) { - return true; - } - - const accuracyMultiplier = user.getAccuracyMultiplier(target, this.move.getMove()); + const accuracyMultiplier = user.getAccuracyMultiplier(target, this.move); const rand = user.randSeedInt(100); - return rand < moveAccuracy * accuracyMultiplier; + if (rand < moveAccuracy * accuracyMultiplier) { + return [HitCheckResult.HIT, effectiveness]; + } + + return [HitCheckResult.MISS, 0]; } /** @@ -767,6 +613,7 @@ export class MoveEffectPhase extends PokemonPhase { * - An ability like {@linkcode Abilities.NO_GUARD | No Guard} * - A poison type using {@linkcode Moves.TOXIC | Toxic} * - A move like {@linkcode Moves.LOCK_ON | Lock-On} or {@linkcode Moves.MIND_READER | Mind Reader}. + * - A field-targeted move like spikes * * Does *not* check against effects {@linkcode Moves.GLAIVE_RUSH | Glaive Rush} status (which * should not bypass semi-invulnerability), or interactions like Earthquake hitting against Dig, @@ -782,7 +629,7 @@ export class MoveEffectPhase extends PokemonPhase { if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) { return true; } - if (this.move.getMove().hasAttr(ToxicAccuracyAttr) && user.isOfType(PokemonType.POISON)) { + if (this.move.hasAttr(ToxicAccuracyAttr) && user.isOfType(PokemonType.POISON)) { return true; } // TODO: Fix lock on / mind reader check. @@ -792,18 +639,21 @@ export class MoveEffectPhase extends PokemonPhase { ) { return true; } + if (isFieldTargeted(this.move)) { + return true; + } } /** * Check whether the move is able to ignore the given `semiInvulnerableTag` - * @param semiInvulnerableTag - The semiInvulnerbale tag to check against + * @param semiInvulnerableTag - The semiInvulnerable tag to check against * @returns `true` if the move can ignore the semi-invulnerable state */ public checkBypassSemiInvuln(semiInvulnerableTag: SemiInvulnerableTag | nil): boolean { if (!semiInvulnerableTag) { return false; } - const move = this.move.getMove(); + const move = this.move; return move.getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType); } @@ -862,6 +712,282 @@ export class MoveEffectPhase extends PokemonPhase { /** @returns A new `MoveEffectPhase` with the same properties as this phase */ protected getNewHitPhase(): MoveEffectPhase { - return new MoveEffectPhase(this.battlerIndex, this.targets, this.move); + return new MoveEffectPhase(this.battlerIndex, this.targets, this.move, this.reflected, this.virtual); + } + + /** Removes all substitutes that were broken by this phase's invoked move */ + protected updateSubstitutes(): void { + const targets = this.getTargets(); + for (const target of targets) { + const substitute = target.getTag(SubstituteTag); + if (substitute && substitute.hp <= 0) { + target.lapseTag(BattlerTagType.SUBSTITUTE); + } + } + } + + /** + * Triggers move effects of the given move effect trigger. + * @param triggerType The {@linkcode MoveEffectTrigger} being applied + * @param user The {@linkcode Pokemon} using the move + * @param target The {@linkcode Pokemon} targeted by the move + * @param firstTarget Whether the target is the first to be hit by the current strike + * @param selfTarget If defined, limits the effects triggered to either self-targeted + * effects (if set to `true`) or targeted effects (if set to `false`). + * @returns a `Promise` applying the relevant move effects. + */ + protected triggerMoveEffects( + triggerType: MoveEffectTrigger, + user: Pokemon, + target: Pokemon | null, + firstTarget?: boolean | null, + selfTarget?: boolean, + ): void { + return applyFilteredMoveAttrs( + (attr: MoveAttr) => + attr instanceof MoveEffectAttr && + attr.trigger === triggerType && + (isNullOrUndefined(selfTarget) || attr.selfTarget === selfTarget) && + (!attr.firstHitOnly || this.firstHit) && + (!attr.lastHitOnly || this.lastHit) && + (!attr.firstTargetOnly || (firstTarget ?? true)), + user, + target, + this.move, + ); + } + + /** + * Applies all move effects that trigger in the event of a successful hit: + * + * - {@linkcode MoveEffectTrigger.PRE_APPLY | PRE_APPLY} effects` + * - Applying damage to the target + * - {@linkcode MoveEffectTrigger.POST_APPLY | POST_APPLY} effects + * - Invoking {@linkcode applyOnTargetEffects} if the move does not hit a substitute + * - Triggering form changes and emergency exit / wimp out if this is the last hit + * + * @param target the {@linkcode Pokemon} hit by this phase's move. + * @param effectiveness the effectiveness of the move (as previously evaluated in {@linkcode hitCheck}) + */ + protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier): void { + const user = this.getUserPokemon(); + + /** The first target hit by the move */ + const firstTarget = target === this.getTargets().find((_, i) => this.hitChecks[i][1] > 0); + + if (isNullOrUndefined(user)) { + return; + } + + this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); + + const hitResult = this.applyMove(user, target, effectiveness); + + this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true); + if (!this.move.hitsSubstitute(user, target)) { + this.applyOnTargetEffects(user, target, hitResult, firstTarget); + } + if (this.lastHit) { + globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); + + // Multi-hit check for Wimp Out/Emergency Exit + if (user.turnData.hitCount > 1) { + applyPostDamageAbAttrs(PostDamageAbAttr, target, 0, target.hasPassive(), false, [], user); + } + } + } + + /** + * Sub-method of for {@linkcode applyMoveEffects} that applies damage to the target. + * + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - The {@linkcode Pokemon} targeted by the move + * @param effectiveness - The effectiveness of the move against the target + */ + protected applyMoveDamage(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): HitResult { + const isCritical = target.getCriticalHitResult(user, this.move, false); + + /* + * Apply stat changes from {@linkcode move} and gives it to {@linkcode source} + * before damage calculation + */ + applyMoveAttrs(StatChangeBeforeDmgCalcAttr, user, target, this.move); + + const { result: result, damage: dmg } = target.getAttackDamage({ + source: user, + move: this.move, + ignoreAbility: false, + ignoreSourceAbility: false, + ignoreAllyAbility: false, + ignoreSourceAllyAbility: false, + simulated: false, + effectiveness, + isCritical, + }); + + const typeBoost = user.findTag( + t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move), + ) as TypeBoostTag; + if (typeBoost?.oneUse) { + user.removeTag(typeBoost.tagType); + } + + const isOneHitKo = result === HitResult.ONE_HIT_KO; + + if (!dmg) { + return result; + } + + target.lapseTags(BattlerTagLapseType.HIT); + + const substitute = target.getTag(SubstituteTag); + const isBlockedBySubstitute = substitute && this.move.hitsSubstitute(user, target); + if (isBlockedBySubstitute) { + substitute.hp -= dmg; + } else if (!target.isPlayer() && dmg >= target.hp) { + globalScene.applyModifiers(EnemyEndureChanceModifier, false, target); + } + + const damage = isBlockedBySubstitute + ? 0 + : target.damageAndUpdate(dmg, { + result: result as DamageResult, + ignoreFaintPhase: true, + ignoreSegments: isOneHitKo, + isCritical, + source: user, + }); + + if (isCritical) { + globalScene.queueMessage(i18next.t("battle:criticalHit")); + } + + if (damage <= 0) { + return result; + } + + if (user.isPlayer()) { + globalScene.validateAchvs(DamageAchv, new NumberHolder(damage)); + + if (damage > globalScene.gameData.gameStats.highestDamage) { + globalScene.gameData.gameStats.highestDamage = damage; + } + } + + user.turnData.totalDamageDealt += damage; + user.turnData.singleHitDamageDealt = damage; + target.battleData.hitCount++; + target.turnData.damageTaken += damage; + + target.turnData.attacksReceived.unshift({ + move: this.move.id, + result: result as DamageResult, + damage: damage, + critical: isCritical, + sourceId: user.id, + sourceBattlerIndex: user.getBattlerIndex(), + }); + + if (user.isPlayer() && !target.isPlayer()) { + globalScene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage)); + } + + return result; + } + + /** + * Sub-method of {@linkcode applyMove} that handles the event of a target fainting. + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - The {@linkcode Pokemon} that fainted + */ + protected onFaintTarget(user: Pokemon, target: Pokemon): void { + // set splice index here, so future scene queues happen before FaintedPhase + globalScene.setPhaseQueueSplice(); + + globalScene.unshiftPhase(new FaintPhase(target.getBattlerIndex(), false, user)); + + target.destroySubstitute(); + target.lapseTag(BattlerTagType.COMMANDED); + } + + /** + * Sub-method of {@linkcode applyMove} that queues the hit-result message + * on the final strike of the move against a target + * @param result - The {@linkcode HitResult} of the move + */ + protected queueHitResultMessage(result: HitResult) { + let msg: string | undefined; + switch (result) { + case HitResult.SUPER_EFFECTIVE: + msg = i18next.t("battle:hitResultSuperEffective"); + break; + case HitResult.NOT_VERY_EFFECTIVE: + msg = i18next.t("battle:hitResultNotVeryEffective"); + break; + case HitResult.ONE_HIT_KO: + msg = i18next.t("battle:hitResultOneHitKO"); + break; + } + if (msg) { + globalScene.queueMessage(msg); + } + } + + /** Apply the result of this phase's move to the given target + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - The {@linkcode Pokemon} struck by the move + * @param effectiveness - The effectiveness of the move against the target + */ + protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): HitResult { + const moveCategory = user.getMoveCategory(target, this.move); + + if (moveCategory === MoveCategory.STATUS) { + return HitResult.STATUS; + } + + const result = this.applyMoveDamage(user, target, effectiveness); + + if (user.turnData.hitsLeft === 1 && target.isFainted()) { + this.queueHitResultMessage(result); + } + + if (target.isFainted()) { + this.onFaintTarget(user, target); + } + + return result; + } + + /** + * Applies all effects aimed at the move's target. + * To be used when the target is successfully and directly hit by the move. + * @param user - The {@linkcode Pokemon} using the move + * @param target - The {@linkcode Pokemon} targeted by the move + * @param hitResult - The {@linkcode HitResult} obtained from applying the move + * @param firstTarget - `true` if the target is the first Pokemon hit by the attack + */ + protected applyOnTargetEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, firstTarget: boolean): void { + /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ + const dealsDamage = [ + HitResult.EFFECTIVE, + HitResult.SUPER_EFFECTIVE, + HitResult.NOT_VERY_EFFECTIVE, + HitResult.ONE_HIT_KO, + ].includes(hitResult); + + this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, false); + this.applyHeldItemFlinchCheck(user, target, dealsDamage); + this.applyOnGetHitAbEffects(user, target, hitResult); + applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move, hitResult); + + // We assume only enemy Pokemon are able to have the EnemyAttackStatusEffectChanceModifier from tokens + if (!user.isPlayer() && this.move instanceof AttackMove) { + globalScene.applyShuffledModifiers(EnemyAttackStatusEffectChanceModifier, false, target); + } + + // Apply Grip Claw's chance to steal an item from the target + if (this.move instanceof AttackMove) { + globalScene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); + } } } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 7d2848a5d70..b24d7b61ebb 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -404,9 +404,10 @@ export class MovePhase extends BattlePhase { * if the move fails. */ if (success) { - applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + const move = this.move.getMove(); + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, move); globalScene.unshiftPhase( - new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move, this.reflected), + new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, move, this.reflected, this.move.virtual), ); } else { if ([Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE].includes(this.move.moveId)) { diff --git a/test/abilities/friend_guard.test.ts b/test/abilities/friend_guard.test.ts index 302343c167b..43a378c47a2 100644 --- a/test/abilities/friend_guard.test.ts +++ b/test/abilities/friend_guard.test.ts @@ -50,7 +50,11 @@ describe("Moves - Friend Guard", () => { // Get the last return value from `getAttackDamage` const turn1Damage = spy.mock.results[spy.mock.results.length - 1].value.damage; // Making sure the test is controlled; turn 1 damage is equal to base damage (after rounding) - expect(turn1Damage).toBe(Math.floor(player1.getBaseDamage(enemy1, allMoves[Moves.TACKLE], MoveCategory.PHYSICAL))); + expect(turn1Damage).toBe( + Math.floor( + player1.getBaseDamage({ source: enemy1, move: allMoves[Moves.TACKLE], moveCategory: MoveCategory.PHYSICAL }), + ), + ); vi.spyOn(player2, "getAbility").mockReturnValue(allAbilities[Abilities.FRIEND_GUARD]); @@ -64,7 +68,10 @@ describe("Moves - Friend Guard", () => { const turn2Damage = spy.mock.results[spy.mock.results.length - 1].value.damage; // With the ally's Friend Guard, damage should have been reduced from base damage by 25% expect(turn2Damage).toBe( - Math.floor(player1.getBaseDamage(enemy1, allMoves[Moves.TACKLE], MoveCategory.PHYSICAL) * 0.75), + Math.floor( + player1.getBaseDamage({ source: enemy1, move: allMoves[Moves.TACKLE], moveCategory: MoveCategory.PHYSICAL }) * + 0.75, + ), ); }); diff --git a/test/abilities/galvanize.test.ts b/test/abilities/galvanize.test.ts index 438ec498aa1..5db8b642197 100644 --- a/test/abilities/galvanize.test.ts +++ b/test/abilities/galvanize.test.ts @@ -4,7 +4,6 @@ import { PokemonType } from "#enums/pokemon-type"; import { Abilities } from "#app/enums/abilities"; import { Moves } from "#app/enums/moves"; import { Species } from "#app/enums/species"; -import { HitResult } from "#app/field/pokemon"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -38,13 +37,13 @@ describe("Abilities - Galvanize", () => { }); it("should change Normal-type attacks to Electric type and boost their power", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; vi.spyOn(playerPokemon, "getMoveType"); const enemyPokemon = game.scene.getEnemyPokemon()!; - vi.spyOn(enemyPokemon, "apply"); + const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness"); const move = allMoves[Moves.TACKLE]; vi.spyOn(move, "calculateBattlePower"); @@ -54,21 +53,23 @@ describe("Abilities - Galvanize", () => { await game.phaseInterceptor.to("BerryPhase", false); expect(playerPokemon.getMoveType).toHaveLastReturnedWith(PokemonType.ELECTRIC); - expect(enemyPokemon.apply).toHaveReturnedWith(HitResult.EFFECTIVE); + expect(spy).toHaveReturnedWith(1); expect(move.calculateBattlePower).toHaveReturnedWith(48); expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + + spy.mockRestore(); }); it("should cause Normal-type attacks to activate Volt Absorb", async () => { game.override.enemyAbility(Abilities.VOLT_ABSORB); - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; vi.spyOn(playerPokemon, "getMoveType"); const enemyPokemon = game.scene.getEnemyPokemon()!; - vi.spyOn(enemyPokemon, "apply"); + const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness"); enemyPokemon.hp = Math.floor(enemyPokemon.getMaxHp() * 0.8); @@ -77,37 +78,37 @@ describe("Abilities - Galvanize", () => { await game.phaseInterceptor.to("BerryPhase", false); expect(playerPokemon.getMoveType).toHaveLastReturnedWith(PokemonType.ELECTRIC); - expect(enemyPokemon.apply).toHaveReturnedWith(HitResult.NO_EFFECT); + expect(spy).toHaveReturnedWith(0); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); }); it("should not change the type of variable-type moves", async () => { game.override.enemySpecies(Species.MIGHTYENA); - await game.startBattle([Species.ESPEON]); + await game.classicMode.startBattle([Species.ESPEON]); const playerPokemon = game.scene.getPlayerPokemon()!; vi.spyOn(playerPokemon, "getMoveType"); const enemyPokemon = game.scene.getEnemyPokemon()!; - vi.spyOn(enemyPokemon, "apply"); + const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness"); game.move.select(Moves.REVELATION_DANCE); await game.phaseInterceptor.to("BerryPhase", false); expect(playerPokemon.getMoveType).not.toHaveLastReturnedWith(PokemonType.ELECTRIC); - expect(enemyPokemon.apply).toHaveReturnedWith(HitResult.NO_EFFECT); + expect(spy).toHaveReturnedWith(0); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); }); it("should affect all hits of a Normal-type multi-hit move", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; vi.spyOn(playerPokemon, "getMoveType"); const enemyPokemon = game.scene.getEnemyPokemon()!; - vi.spyOn(enemyPokemon, "apply"); + const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness"); game.move.select(Moves.FURY_SWIPES); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); @@ -125,6 +126,6 @@ describe("Abilities - Galvanize", () => { expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); } - expect(enemyPokemon.apply).not.toHaveReturnedWith(HitResult.NO_EFFECT); + expect(spy).not.toHaveReturnedWith(0); }); }); diff --git a/test/abilities/infiltrator.test.ts b/test/abilities/infiltrator.test.ts index 10353f35391..48671e54020 100644 --- a/test/abilities/infiltrator.test.ts +++ b/test/abilities/infiltrator.test.ts @@ -61,11 +61,11 @@ describe("Abilities - Infiltrator", () => { const player = game.scene.getPlayerPokemon()!; const enemy = game.scene.getEnemyPokemon()!; - const preScreenDmg = enemy.getAttackDamage(player, allMoves[move]).damage; + const preScreenDmg = enemy.getAttackDamage({ source: player, move: allMoves[move] }).damage; game.scene.arena.addTag(tagType, 1, Moves.NONE, enemy.id, ArenaTagSide.ENEMY, true); - const postScreenDmg = enemy.getAttackDamage(player, allMoves[move]).damage; + const postScreenDmg = enemy.getAttackDamage({ source: player, move: allMoves[move] }).damage; expect(postScreenDmg).toBe(preScreenDmg); expect(player.battleData.abilitiesApplied[0]).toBe(Abilities.INFILTRATOR); diff --git a/test/abilities/no_guard.test.ts b/test/abilities/no_guard.test.ts index b34007bc700..a09e16388ee 100644 --- a/test/abilities/no_guard.test.ts +++ b/test/abilities/no_guard.test.ts @@ -4,6 +4,7 @@ import { MoveEndPhase } from "#app/phases/move-end-phase"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; +import { HitCheckResult } from "#enums/hit-check-result"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; @@ -28,6 +29,7 @@ describe("Abilities - No Guard", () => { .moveset(Moves.ZAP_CANNON) .ability(Abilities.NO_GUARD) .enemyLevel(200) + .enemySpecies(Species.SNORLAX) .enemyAbility(Abilities.BALL_FETCH) .enemyMoveset(Moves.SPLASH); }); @@ -48,7 +50,7 @@ describe("Abilities - No Guard", () => { await game.phaseInterceptor.to(MoveEndPhase); - expect(moveEffectPhase.hitCheck).toHaveReturnedWith(true); + expect(moveEffectPhase.hitCheck).toHaveReturnedWith([HitCheckResult.HIT, 1]); }); it("should guarantee double battle with any one LURE", async () => { diff --git a/test/abilities/shield_dust.test.ts b/test/abilities/shield_dust.test.ts index 0b96640a29f..4ab58e8c2a6 100644 --- a/test/abilities/shield_dust.test.ts +++ b/test/abilities/shield_dust.test.ts @@ -52,7 +52,7 @@ describe("Abilities - Shield Dust", () => { // Shield Dust negates secondary effect const phase = game.scene.getCurrentPhase() as MoveEffectPhase; - const move = phase.move.getMove(); + const move = phase.move; expect(move.id).toBe(Moves.AIR_SLASH); const chance = new NumberHolder(move.chance); diff --git a/test/abilities/super_luck.test.ts b/test/abilities/super_luck.test.ts index 9e0b6485734..fbcbd02bdd2 100644 --- a/test/abilities/super_luck.test.ts +++ b/test/abilities/super_luck.test.ts @@ -25,7 +25,6 @@ describe("Abilities - Super Luck", () => { .moveset([Moves.TACKLE]) .ability(Abilities.SUPER_LUCK) .battleStyle("single") - .disableCrits() .enemySpecies(Species.MAGIKARP) .enemyAbility(Abilities.BALL_FETCH) .enemyMoveset(Moves.SPLASH); diff --git a/test/abilities/tera_shell.test.ts b/test/abilities/tera_shell.test.ts index c387da30166..fdbcb14947d 100644 --- a/test/abilities/tera_shell.test.ts +++ b/test/abilities/tera_shell.test.ts @@ -2,7 +2,6 @@ import { BattlerIndex } from "#app/battle"; import { Abilities } from "#app/enums/abilities"; import { Moves } from "#app/enums/moves"; import { Species } from "#app/enums/species"; -import { HitResult } from "#app/field/pokemon"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -87,13 +86,15 @@ describe("Abilities - Tera Shell", () => { await game.classicMode.startBattle([Species.CHARIZARD]); const playerPokemon = game.scene.getPlayerPokemon()!; - vi.spyOn(playerPokemon, "apply"); + const spy = vi.spyOn(playerPokemon, "getMoveEffectiveness"); game.move.select(Moves.SPLASH); await game.phaseInterceptor.to("BerryPhase", false); - expect(playerPokemon.apply).toHaveLastReturnedWith(HitResult.EFFECTIVE); + expect(spy).toHaveLastReturnedWith(1); expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp() - 40); + + spy.mockRestore(); }); it("should change the effectiveness of all strikes of a multi-strike move", async () => { @@ -102,7 +103,7 @@ describe("Abilities - Tera Shell", () => { await game.classicMode.startBattle([Species.SNORLAX]); const playerPokemon = game.scene.getPlayerPokemon()!; - vi.spyOn(playerPokemon, "apply"); + const spy = vi.spyOn(playerPokemon, "getMoveEffectiveness"); game.move.select(Moves.SPLASH); @@ -110,8 +111,9 @@ describe("Abilities - Tera Shell", () => { await game.move.forceHit(); for (let i = 0; i < 2; i++) { await game.phaseInterceptor.to("MoveEffectPhase"); - expect(playerPokemon.apply).toHaveLastReturnedWith(HitResult.NOT_VERY_EFFECTIVE); + expect(spy).toHaveLastReturnedWith(0.5); } - expect(playerPokemon.apply).toHaveReturnedTimes(2); + expect(spy).toHaveReturnedTimes(2); + spy.mockRestore(); }); }); diff --git a/test/battle/damage_calculation.test.ts b/test/battle/damage_calculation.test.ts index e8b3b65bd29..26772cbc4f0 100644 --- a/test/battle/damage_calculation.test.ts +++ b/test/battle/damage_calculation.test.ts @@ -47,7 +47,9 @@ describe("Battle Mechanics - Damage Calculation", () => { // expected base damage = [(2*level/5 + 2) * power * playerATK / enemyDEF / 50] + 2 // = 31.8666... - expect(enemyPokemon.getAttackDamage(playerPokemon, allMoves[Moves.TACKLE]).damage).toBeCloseTo(31); + expect(enemyPokemon.getAttackDamage({ source: playerPokemon, move: allMoves[Moves.TACKLE] }).damage).toBeCloseTo( + 31, + ); }); it("Attacks deal 1 damage at minimum", async () => { @@ -91,7 +93,7 @@ describe("Battle Mechanics - Damage Calculation", () => { const magikarp = game.scene.getPlayerPokemon()!; const dragonite = game.scene.getEnemyPokemon()!; - expect(dragonite.getAttackDamage(magikarp, allMoves[Moves.DRAGON_RAGE]).damage).toBe(40); + expect(dragonite.getAttackDamage({ source: magikarp, move: allMoves[Moves.DRAGON_RAGE] }).damage).toBe(40); }); it("One-hit KO moves ignore damage multipliers", async () => { @@ -102,7 +104,7 @@ describe("Battle Mechanics - Damage Calculation", () => { const magikarp = game.scene.getPlayerPokemon()!; const aggron = game.scene.getEnemyPokemon()!; - expect(aggron.getAttackDamage(magikarp, allMoves[Moves.FISSURE]).damage).toBe(aggron.hp); + expect(aggron.getAttackDamage({ source: magikarp, move: allMoves[Moves.FISSURE] }).damage).toBe(aggron.hp); }); it("When the user fails to use Jump Kick with Wonder Guard ability, the damage should be 1.", async () => { diff --git a/test/battlerTags/substitute.test.ts b/test/battlerTags/substitute.test.ts index fca3dc5ef7e..d2df5511c0a 100644 --- a/test/battlerTags/substitute.test.ts +++ b/test/battlerTags/substitute.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PokemonTurnData, TurnMove, PokemonMove } from "#app/field/pokemon"; +import type { PokemonTurnData, TurnMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { MoveResult } from "#app/field/pokemon"; import type BattleScene from "#app/battle-scene"; @@ -186,12 +186,8 @@ describe("BattlerTag - SubstituteTag", () => { vi.spyOn(mockPokemon.scene as BattleScene, "triggerPokemonBattleAnim").mockReturnValue(true); vi.spyOn(mockPokemon.scene as BattleScene, "queueMessage").mockReturnValue(); - const pokemonMove = { - getMove: vi.fn().mockReturnValue(allMoves[Moves.TACKLE]) as PokemonMove["getMove"], - } as PokemonMove; - const moveEffectPhase = { - move: pokemonMove, + move: allMoves[Moves.TACKLE], getUserPokemon: vi.fn().mockReturnValue(undefined) as MoveEffectPhase["getUserPokemon"], } as MoveEffectPhase; diff --git a/test/items/dire_hit.test.ts b/test/items/dire_hit.test.ts index b409b2ac7cb..6e20bc723e5 100644 --- a/test/items/dire_hit.test.ts +++ b/test/items/dire_hit.test.ts @@ -36,8 +36,7 @@ describe("Items - Dire Hit", () => { .enemyMoveset(Moves.SPLASH) .moveset([Moves.POUND]) .startingHeldItems([{ name: "DIRE_HIT" }]) - .battleStyle("single") - .disableCrits(); + .battleStyle("single"); }, 20000); it("should raise CRIT stage by 1", async () => { diff --git a/test/items/leek.test.ts b/test/items/leek.test.ts index 7589b89bc15..9bde2c86339 100644 --- a/test/items/leek.test.ts +++ b/test/items/leek.test.ts @@ -28,7 +28,6 @@ describe("Items - Leek", () => { .enemyMoveset([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]) .startingHeldItems([{ name: "LEEK" }]) .moveset([Moves.TACKLE]) - .disableCrits() .battleStyle("single"); }); diff --git a/test/items/scope_lens.test.ts b/test/items/scope_lens.test.ts index 4d2fd63f87b..f67966ea3c9 100644 --- a/test/items/scope_lens.test.ts +++ b/test/items/scope_lens.test.ts @@ -27,8 +27,7 @@ describe("Items - Scope Lens", () => { .enemyMoveset(Moves.SPLASH) .moveset([Moves.POUND]) .startingHeldItems([{ name: "SCOPE_LENS" }]) - .battleStyle("single") - .disableCrits(); + .battleStyle("single"); }, 20000); it("should raise CRIT stage by 1", async () => { diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index a53456ec083..80d51a5c2d5 100644 --- a/test/moves/dig.test.ts +++ b/test/moves/dig.test.ts @@ -97,14 +97,20 @@ describe("Moves - Dig", () => { const playerPokemon = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; - const preDigEarthquakeDmg = playerPokemon.getAttackDamage(enemyPokemon, allMoves[Moves.EARTHQUAKE]).damage; + const preDigEarthquakeDmg = playerPokemon.getAttackDamage({ + source: enemyPokemon, + move: 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; + const postDigEarthquakeDmg = playerPokemon.getAttackDamage({ + source: enemyPokemon, + move: 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/test/moves/dynamax_cannon.test.ts b/test/moves/dynamax_cannon.test.ts index 94f07ae500f..84def8a821f 100644 --- a/test/moves/dynamax_cannon.test.ts +++ b/test/moves/dynamax_cannon.test.ts @@ -50,7 +50,7 @@ describe("Moves - Dynamax Cannon", () => { game.move.select(dynamaxCannon.id); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(dynamaxCannon.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(dynamaxCannon.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(100); }, 20000); @@ -62,7 +62,7 @@ describe("Moves - Dynamax Cannon", () => { game.move.select(dynamaxCannon.id); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(dynamaxCannon.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(dynamaxCannon.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(100); }, 20000); @@ -75,7 +75,7 @@ describe("Moves - Dynamax Cannon", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); const phase = game.scene.getCurrentPhase() as MoveEffectPhase; - expect(phase.move.moveId).toBe(dynamaxCannon.id); + expect(phase.move.id).toBe(dynamaxCannon.id); // Force level cap to be 100 vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamageAnimPhase, false); @@ -90,7 +90,7 @@ describe("Moves - Dynamax Cannon", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); const phase = game.scene.getCurrentPhase() as MoveEffectPhase; - expect(phase.move.moveId).toBe(dynamaxCannon.id); + expect(phase.move.id).toBe(dynamaxCannon.id); // Force level cap to be 100 vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamageAnimPhase, false); @@ -105,7 +105,7 @@ describe("Moves - Dynamax Cannon", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); const phase = game.scene.getCurrentPhase() as MoveEffectPhase; - expect(phase.move.moveId).toBe(dynamaxCannon.id); + expect(phase.move.id).toBe(dynamaxCannon.id); // Force level cap to be 100 vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamageAnimPhase, false); @@ -120,7 +120,7 @@ describe("Moves - Dynamax Cannon", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); const phase = game.scene.getCurrentPhase() as MoveEffectPhase; - expect(phase.move.moveId).toBe(dynamaxCannon.id); + expect(phase.move.id).toBe(dynamaxCannon.id); // Force level cap to be 100 vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamageAnimPhase, false); @@ -135,7 +135,7 @@ describe("Moves - Dynamax Cannon", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); const phase = game.scene.getCurrentPhase() as MoveEffectPhase; - expect(phase.move.moveId).toBe(dynamaxCannon.id); + expect(phase.move.id).toBe(dynamaxCannon.id); // Force level cap to be 100 vi.spyOn(game.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamageAnimPhase, false); @@ -150,7 +150,7 @@ describe("Moves - Dynamax Cannon", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(dynamaxCannon.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(dynamaxCannon.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); diff --git a/test/moves/fusion_flare_bolt.test.ts b/test/moves/fusion_flare_bolt.test.ts index 697ac57e739..ce6bb62d1d0 100644 --- a/test/moves/fusion_flare_bolt.test.ts +++ b/test/moves/fusion_flare_bolt.test.ts @@ -57,12 +57,12 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(100); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); @@ -77,12 +77,12 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(100); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); @@ -97,7 +97,7 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(100); @@ -107,7 +107,7 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.phaseInterceptor.runFrom(MovePhase).to(MoveEndPhase); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); @@ -123,7 +123,7 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(100); @@ -132,7 +132,7 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.phaseInterceptor.runFrom(MovePhase).to(MoveEndPhase); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(100); }, 20000); @@ -147,12 +147,12 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(100); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); @@ -191,22 +191,22 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(100); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(200); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); @@ -245,22 +245,22 @@ describe("Moves - Fusion Flare and Fusion Bolt", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY]); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(100); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionBolt.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionBolt.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionBolt.calculateBattlePower).toHaveLastReturnedWith(200); await game.phaseInterceptor.to(MoveEffectPhase, false); - expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.moveId).toBe(fusionFlare.id); + expect((game.scene.getCurrentPhase() as MoveEffectPhase).move.id).toBe(fusionFlare.id); await game.phaseInterceptor.to(DamageAnimPhase, false); expect(fusionFlare.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); diff --git a/test/moves/spectral_thief.test.ts b/test/moves/spectral_thief.test.ts index 2e52b118a74..2654ab1ad8d 100644 --- a/test/moves/spectral_thief.test.ts +++ b/test/moves/spectral_thief.test.ts @@ -71,7 +71,7 @@ describe("Moves - Spectral Thief", () => { const player = game.scene.getPlayerPokemon()!; const enemy = game.scene.getEnemyPokemon()!; const moveToCheck = allMoves[Moves.SPECTRAL_THIEF]; - const dmgBefore = enemy.getAttackDamage(player, moveToCheck, false, false, false, false).damage; + const dmgBefore = enemy.getAttackDamage({ source: player, move: moveToCheck }).damage; enemy.setStatStage(Stat.ATK, 6); @@ -80,7 +80,7 @@ describe("Moves - Spectral Thief", () => { game.move.select(Moves.SPECTRAL_THIEF); await game.phaseInterceptor.to(TurnEndPhase); - expect(dmgBefore).toBeLessThan(enemy.getAttackDamage(player, moveToCheck, false, false, false, false).damage); + expect(dmgBefore).toBeLessThan(enemy.getAttackDamage({ source: player, move: moveToCheck }).damage); }); it("should steal stat stages as a negative value with Contrary.", async () => { diff --git a/test/moves/tera_blast.test.ts b/test/moves/tera_blast.test.ts index 5dc3a914a2e..8817f12b8cf 100644 --- a/test/moves/tera_blast.test.ts +++ b/test/moves/tera_blast.test.ts @@ -4,7 +4,6 @@ import { allMoves, TeraMoveCategoryAttr } from "#app/data/moves/move"; import type Move from "#app/data/moves/move"; import { PokemonType } from "#enums/pokemon-type"; import { Abilities } from "#app/enums/abilities"; -import { HitResult } from "#app/field/pokemon"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; @@ -49,9 +48,9 @@ describe("Moves - Tera Blast", () => { it("changes type to match user's tera type", async () => { game.override.enemySpecies(Species.FURRET); - await game.startBattle(); + await game.classicMode.startBattle(); const enemyPokemon = game.scene.getEnemyPokemon()!; - vi.spyOn(enemyPokemon, "apply"); + const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness"); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.teraType = PokemonType.FIGHTING; @@ -61,11 +60,11 @@ describe("Moves - Tera Blast", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to("MoveEffectPhase"); - expect(enemyPokemon.apply).toHaveReturnedWith(HitResult.SUPER_EFFECTIVE); + expect(spy).toHaveReturnedWith(2); }, 20000); it("increases power if user is Stellar tera type", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.teraType = PokemonType.STELLAR; @@ -79,25 +78,25 @@ describe("Moves - Tera Blast", () => { }, 20000); it("is super effective against terastallized targets if user is Stellar tera type", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.teraType = PokemonType.STELLAR; playerPokemon.isTerastallized = true; const enemyPokemon = game.scene.getEnemyPokemon()!; - vi.spyOn(enemyPokemon, "apply"); + const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness"); enemyPokemon.isTerastallized = true; game.move.select(Moves.TERA_BLAST); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to("MoveEffectPhase"); - expect(enemyPokemon.apply).toHaveReturnedWith(HitResult.SUPER_EFFECTIVE); + expect(spy).toHaveReturnedWith(2); }); it("uses the higher ATK for damage calculation", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.stats[Stat.ATK] = 100; @@ -112,7 +111,7 @@ describe("Moves - Tera Blast", () => { }); it("uses the higher SPATK for damage calculation", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.stats[Stat.ATK] = 1; @@ -127,7 +126,7 @@ describe("Moves - Tera Blast", () => { it("should stay as a special move if ATK turns lower than SPATK mid-turn", async () => { game.override.enemyMoveset([Moves.CHARM]); - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.stats[Stat.ATK] = 51; @@ -145,7 +144,7 @@ describe("Moves - Tera Blast", () => { game.override .startingHeldItems([{ name: "SPECIES_STAT_BOOSTER", type: "THICK_CLUB" }]) .starterSpecies(Species.CUBONE); - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; @@ -163,7 +162,7 @@ describe("Moves - Tera Blast", () => { it("does not change its move category from stat changes due to abilities", async () => { game.override.ability(Abilities.HUGE_POWER); - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.stats[Stat.ATK] = 50; @@ -178,7 +177,7 @@ describe("Moves - Tera Blast", () => { }); it("causes stat drops if user is Stellar tera type", async () => { - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; playerPokemon.teraType = PokemonType.STELLAR; diff --git a/test/testUtils/helpers/moveHelper.ts b/test/testUtils/helpers/moveHelper.ts index edade109966..0f3d75c6268 100644 --- a/test/testUtils/helpers/moveHelper.ts +++ b/test/testUtils/helpers/moveHelper.ts @@ -18,29 +18,29 @@ import { vi } from "vitest"; */ export class MoveHelper extends GameManagerHelper { /** - * Intercepts {@linkcode MoveEffectPhase} and mocks the - * {@linkcode MoveEffectPhase.hitCheck | hitCheck}'s return value to `true`. - * Used to force a move to hit. + * Intercepts {@linkcode MoveEffectPhase} and mocks the phase's move's + * accuracy to -1, guaranteeing a hit. */ public async forceHit(): Promise { await this.game.phaseInterceptor.to(MoveEffectPhase, false); - vi.spyOn(this.game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + const moveEffectPhase = this.game.scene.getCurrentPhase() as MoveEffectPhase; + vi.spyOn(moveEffectPhase.move, "calculateBattleAccuracy").mockReturnValue(-1); } /** - * Intercepts {@linkcode MoveEffectPhase} and mocks the - * {@linkcode MoveEffectPhase.hitCheck | hitCheck}'s return value to `false`. - * Used to force a move to miss. + * Intercepts {@linkcode MoveEffectPhase} and mocks the phase's move's accuracy + * to 0, guaranteeing a miss. * @param firstTargetOnly - Whether the move should force miss on the first target only, in the case of multi-target moves. */ public async forceMiss(firstTargetOnly = false): Promise { await this.game.phaseInterceptor.to(MoveEffectPhase, false); - const hitCheck = vi.spyOn(this.game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck"); + const moveEffectPhase = this.game.scene.getCurrentPhase() as MoveEffectPhase; + const accuracy = vi.spyOn(moveEffectPhase.move, "calculateBattleAccuracy"); if (firstTargetOnly) { - hitCheck.mockReturnValueOnce(false); + accuracy.mockReturnValueOnce(0); } else { - hitCheck.mockReturnValue(false); + accuracy.mockReturnValue(0); } }