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 73e3fbe5ee6..445fc1262e6 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,7 +2729,7 @@ export class EatBerryAttr extends MoveEffectAttr { */ export class StealEatBerryAttr extends EatBerryAttr { constructor() { - super(); + super(true); } /** * User steals a random berry from the target and then eats it. @@ -2729,9 +2740,6 @@ export class StealEatBerryAttr extends EatBerryAttr { * @returns {boolean} 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)); } @@ -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/enums/hit-check-result.ts b/src/enums/hit-check-result.ts index ec7c089c308..cf8a2b17194 100644 --- a/src/enums/hit-check-result.ts +++ b/src/enums/hit-check-result.ts @@ -1,4 +1,4 @@ -/** Represent the result of a hit check against a target. */ +/** The result of a hit check calculation */ export const HitCheckResult = { /** Hit checks haven't been evaluated yet in this pass */ PENDING: 0, @@ -6,14 +6,18 @@ export const HitCheckResult = { 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. */ + /** 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: 6, + 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 bd4fa04b10d..35409191b24 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4600,212 +4600,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, isCritical, simulated: 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; + } /** @@ -4869,7 +4693,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. @@ -5172,8 +4997,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} */ @@ -5184,9 +5009,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 * @@ -5216,10 +5041,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, @@ -5281,20 +5106,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( @@ -5712,7 +5523,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) { diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index c29e3fe5cda..68bd3f763d9 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -23,10 +23,11 @@ import { ProtectedTag, SemiInvulnerableTag, SubstituteTag, + TypeBoostTag, } from "#app/data/battler-tags"; +import type { BattlerTag } from "#app/data/battler-tags"; import type { MoveAttr } from "#app/data/moves/move"; import { - AddArenaTrapTagAttr, applyFilteredMoveAttrs, applyMoveAttrs, AttackMove, @@ -40,8 +41,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 +50,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 +67,191 @@ 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[]; + + /** MOVE EFFECT TRIGGER CONDITIONS */ + + /** 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(); + + let fieldBounce = false; + + // 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]; + fieldBounce = true; + break; + } + if (hitCheck[0] === HitCheckResult.HIT) { + anySuccess = true; + } else { + allMiss ||= hitCheck[0] === HitCheckResult.MISS; + } + this.hitChecks[i] = hitCheck; + } + + if (anySuccess || (fieldMove && !fieldBounce)) { + this.moveHistoryEntry.result = MoveResult.SUCCESS; + // Unreflected field moves have no targets; they target the field + if (fieldMove) { + this.hitChecks = []; + return []; + } + } 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", { + pokemonNameWithAffix: 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); + case HitCheckResult.PENDING: + case HitCheckResult.ERROR: + throw new Error("Unexpected hit check result"); + } + } } public override start(): void { @@ -101,8 +259,6 @@ 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(); @@ -115,23 +271,22 @@ 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(); - } + } + 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(); } } @@ -140,17 +295,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 @@ -184,334 +339,71 @@ export class MoveEffectPhase extends PokemonPhase { * Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully * used in the sense of "Does it have an effect on the user?". */ - const moveHistoryEntry = { - move: this.move.moveId, + 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)); + // only play the animation if the move had at least one successful target - /** - * 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); - } - + // apply the effect to each target + try { + this.applyToTargets(user, targets); + } catch (e) { + console.warn(e.message || "Unexpected error in move effect phase"); this.end(); - }); + } + // + 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(); + + // Apply queued phases + if (this.queuedPhases.length) { + globalScene.appendToPhase(this.queuedPhases, MoveEndPhase); + } + this.end(); } public override end(): void { @@ -562,7 +454,7 @@ export class MoveEffectPhase extends PokemonPhase { (!attr.lastHitOnly || lastHit), user, target, - this.move.getMove(), + this.move, ); } @@ -585,7 +477,7 @@ export class MoveEffectPhase extends PokemonPhase { (!attr.lastHitOnly || lastHit), user, target, - this.move.getMove(), + this.move, ); } @@ -615,7 +507,7 @@ export class MoveEffectPhase extends PokemonPhase { (!attr.firstTargetOnly || firstTarget), user, target, - this.move.getMove(), + this.move, ); } @@ -628,12 +520,12 @@ export class MoveEffectPhase extends PokemonPhase { * @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); + const hitsSubstitute = this.move.hitsSubstitute(user, target); if (!target.isFainted() || target.canApplyAbility()) { - applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult); + applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move, hitResult); - if (!hitsSubstitute) { - if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { + if (!this.move.hitsSubstitute(user, target)) { + if (!user.isPlayer() && this.move instanceof AttackMove) { globalScene.applyShuffledModifiers(EnemyAttackStatusEffectChanceModifier, false, target); } } @@ -667,8 +559,8 @@ export class MoveEffectPhase extends PokemonPhase { 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) { + applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move, hitResult); + if (this.move instanceof AttackMove && hitResult !== HitResult.STATUS) { globalScene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); } } @@ -682,80 +574,156 @@ 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, move: Move) { + /** 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, + 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 or field bypass accuracy and effectiveness checks + if (move.moveTarget === MoveTarget.USER) { + return [HitCheckResult.HIT, 1]; + } + + // If the target is not on the field, cancel the hit check + if (!target.isActive(true)) { + return [HitCheckResult.TARGET_NOT_ON_FIELD, 0]; + } + + // If the target of the move is hidden by the effects of its commander ability, then this misses + if ( + 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 = this.checkBypassAccAndInvuln(target); + const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); + + if (semiInvulnerableTag && !bypassAccAndInvuln && !this.checkBypassSemiInvuln(semiInvulnerableTag)) { + return [HitCheckResult.MISS, 0]; + } + + if (this.protectedCheck(user, target, move)) { + return [HitCheckResult.PROTECTED, 0]; + } + + if (!this.reflected && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) { + return [HitCheckResult.REFLECTED, 0]; + } + + 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; + /** Whether accuracy checks can be skipped */ + 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]; } /** @@ -764,11 +732,12 @@ export class MoveEffectPhase extends PokemonPhase { * @returns `true` if the move should bypass accuracy and semi-invulnerability * * Accuracy and semi-invulnerability can be bypassed by: - * - 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}. + * - 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 + * 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, * (which should not bypass the accuracy check). * @@ -782,7 +751,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 +761,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 +834,281 @@ 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 + * - Preventing + * + * + * @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. + */ + 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; + } + + // Update achievement and stats + 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 + * @param preventEndure - `true` if the endure effect should be prevented + */ + protected onFaintTarget(user: Pokemon, target: Pokemon, preventEndure: boolean): void { + // set splice index here, so future scene queues happen before FaintedPhase + globalScene.setPhaseQueueSplice(); + globalScene.unshiftPhase(new FaintPhase(target.getBattlerIndex(), preventEndure, 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, result === HitResult.ONE_HIT_KO); + } + + 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); + + // Apply status tokens if the user is an enemy Pokemon + 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/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);