[Ability] [Move] Implement Magic Bounce and Magic Coat (#5225)

* Add unit tests for magic bounce

* Add reflectable tag and apply to moves

* Add BattlerTagType for Magic Coat

* Add more magic bounce tests

* Add magic bounce test for sticky web source

* Mostly working magic bounce and magic coat

* Fix missing negation on mayBounce check

* Move onto the next target after bouncing

* Fix magic bounce accuracy check test

* Finish magic bounce impl

* Make spikes use leftmost magic bounce target

* Add magic coat tests

* Add MagicCoatTag to battler-tags.ts

* Add final set of tests for Magic Coat / Bounce

* Fix semi invulnerbale check in hitCheck

* Fix magic bounce semi-invulnerable interaction

This was based on smogon's incorrect handling of this situation

* Magic bounce should not bounce anything during semi-invulnerable state

* Activate mirror armor interaction test

Also update i18 locales key to `magicCoatActivated`
This commit is contained in:
Sirz Benjie 2025-02-11 05:25:36 -06:00 committed by GitHub
parent 702a6ba482
commit 5296966f70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1005 additions and 131 deletions

View File

@ -2353,14 +2353,14 @@ export default class BattleScene extends SceneBase {
}
/**
* Adds Phase to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
* @param phase {@linkcode Phase} the phase to add
* Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex
* @param phases {@linkcode Phase} the phase(s) to add
*/
unshiftPhase(phase: Phase): void {
unshiftPhase(...phases: Phase[]): void {
if (this.phaseQueuePrependSpliceIndex === -1) {
this.phaseQueuePrepend.push(phase);
this.phaseQueuePrepend.push(...phases);
} else {
this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, phase);
this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, ...phases);
}
}
@ -2498,32 +2498,38 @@ export default class BattleScene extends SceneBase {
* @param targetPhase {@linkcode Phase} the type of phase to search for in phaseQueue
* @returns boolean if a targetPhase was found and added
*/
prependToPhase(phase: Phase, targetPhase: Constructor<Phase>): boolean {
prependToPhase(phase: Phase | Phase [], targetPhase: Constructor<Phase>): boolean {
if (!Array.isArray(phase)) {
phase = [ phase ];
}
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase);
if (targetIndex !== -1) {
this.phaseQueue.splice(targetIndex, 0, phase);
this.phaseQueue.splice(targetIndex, 0, ...phase);
return true;
} else {
this.unshiftPhase(phase);
this.unshiftPhase(...phase);
return false;
}
}
/**
* Tries to add the input phase to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()}
* @param phase {@linkcode Phase} the phase to be added
* Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()}
* @param phase {@linkcode Phase} the phase(s) to be added
* @param targetPhase {@linkcode Phase} the type of phase to search for in {@linkcode phaseQueue}
* @returns `true` if a `targetPhase` was found to append to
*/
appendToPhase(phase: Phase, targetPhase: Constructor<Phase>): boolean {
appendToPhase(phase: Phase | Phase[], targetPhase: Constructor<Phase>): boolean {
if (!Array.isArray(phase)) {
phase = [ phase ];
}
const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase);
if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) {
this.phaseQueue.splice(targetIndex + 1, 0, phase);
this.phaseQueue.splice(targetIndex + 1, 0, ...phase);
return true;
} else {
this.unshiftPhase(phase);
this.unshiftPhase(...phase);
return false;
}
}

View File

@ -4484,6 +4484,13 @@ export class InfiltratorAbAttr extends AbAttr {
}
}
/**
* Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Magic_Bounce_(ability) | Magic Bounce}.
* Allows the source to bounce back {@linkcode MoveFlags.REFLECTABLE | Reflectable}
* moves as if the user had used {@linkcode Moves.MAGIC_COAT | Magic Coat}.
*/
export class ReflectStatusMoveAbAttr extends AbAttr { }
export class UncopiableAbilityAbAttr extends AbAttr {
constructor() {
super(false);
@ -5805,8 +5812,11 @@ export function initAbilities() {
}, Stat.SPD, 1)
.attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1),
new Ability(Abilities.MAGIC_BOUNCE, 5)
.attr(ReflectStatusMoveAbAttr)
.ignorable()
.unimplemented(),
// Interactions with stomping tantrum, instruct, encore, and probably other moves that
// rely on move history
.edgeCase(),
new Ability(Abilities.SAP_SIPPER, 5)
.attr(TypeImmunityStatStageChangeAbAttr, Type.GRASS, Stat.ATK, 1)
.ignorable(),

View File

@ -2975,6 +2975,24 @@ export class PsychoShiftTag extends BattlerTag {
}
}
/**
* Tag associated with the move Magic Coat.
*/
export class MagicCoatTag extends BattlerTag {
constructor() {
super(BattlerTagType.MAGIC_COAT, BattlerTagLapseType.TURN_END, 1, Moves.MAGIC_COAT);
}
/**
* Queues the "[PokemonName] shrouded itself with Magic Coat" message when the tag is added.
* @param pokemon - The target {@linkcode Pokemon}
*/
override onAdd(pokemon: Pokemon) {
// "{pokemonNameWithAffix} shrouded itself with Magic Coat!"
globalScene.queueMessage(i18next.t("battlerTags:magicCoatOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }));
}
}
/**
* Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID.
* @param sourceId - The ID of the pokemon adding the tag
@ -3164,6 +3182,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new GrudgeTag();
case BattlerTagType.PSYCHO_SHIFT:
return new PsychoShiftTag();
case BattlerTagType.MAGIC_COAT:
return new MagicCoatTag();
case BattlerTagType.NONE:
default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);

View File

@ -125,7 +125,9 @@ export enum MoveFlags {
/** Indicates a move is able to bypass its target's Substitute (if the target has one) */
IGNORE_SUBSTITUTE = 1 << 17,
/** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */
REDIRECT_COUNTER = 1 << 18,
REDIRECT_COUNTER = 1 << 18,
/** Indicates a move is able to be reflected by {@linkcode Abilities.MAGIC_BOUNCE} and {@linkcode Moves.MAGIC_COAT} */
REFLECTABLE = 1 << 19,
}
type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
@ -610,6 +612,16 @@ export default class Move implements Localizable {
return this;
}
/**
* Sets the {@linkcode MoveFlags.REFLECTABLE} flag for the calling Move
* @see {@linkcode Moves.ATTRACT}
* @returns The {@linkcode Move} that called this function
*/
reflectable(): this {
this.setFlag(MoveFlags.REFLECTABLE, true);
return this;
}
/**
* Checks if the move flag applies to the pokemon(s) using/receiving the move
* @param flag {@linkcode MoveFlags} MoveFlag to check on user and/or target
@ -5332,6 +5344,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
case BattlerTagType.INGRAIN:
case BattlerTagType.IGNORE_ACCURACY:
case BattlerTagType.AQUA_RING:
case BattlerTagType.MAGIC_COAT:
return 3;
case BattlerTagType.PROTECTED:
case BattlerTagType.FLYING:
@ -8334,7 +8347,8 @@ export function initMoves() {
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
.ignoresSubstitute()
.hidesTarget()
.windMove(),
.windMove()
.reflectable(),
new ChargingAttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1)
.chargeText(i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
@ -8358,7 +8372,8 @@ export function initMoves() {
new AttackMove(Moves.ROLLING_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 60, 85, 15, 30, 0, 1)
.attr(FlinchAttr),
new StatusMove(Moves.SAND_ATTACK, Type.GROUND, 100, 15, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.ACC ], -1),
.attr(StatStageChangeAttr, [ Stat.ACC ], -1)
.reflectable(),
new AttackMove(Moves.HEADBUTT, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 15, 30, 0, 1)
.attr(FlinchAttr),
new AttackMove(Moves.HORN_ATTACK, Type.NORMAL, MoveCategory.PHYSICAL, 65, 100, 25, -1, 0, 1),
@ -8387,7 +8402,8 @@ export function initMoves() {
.recklessMove(),
new StatusMove(Moves.TAIL_WHIP, Type.NORMAL, 100, 30, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.POISON_STING, Type.POISON, MoveCategory.PHYSICAL, 15, 100, 35, 30, 0, 1)
.attr(StatusEffectAttr, StatusEffect.POISON)
.makesContact(false),
@ -8400,30 +8416,36 @@ export function initMoves() {
.makesContact(false),
new StatusMove(Moves.LEER, Type.NORMAL, 100, 30, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.BITE, Type.DARK, MoveCategory.PHYSICAL, 60, 100, 25, 30, 0, 1)
.attr(FlinchAttr)
.bitingMove(),
new StatusMove(Moves.GROWL, Type.NORMAL, 100, 40, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1)
.soundBased()
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new StatusMove(Moves.ROAR, Type.NORMAL, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH)
.soundBased()
.hidesTarget(),
.hidesTarget()
.reflectable(),
new StatusMove(Moves.SING, Type.NORMAL, 55, 15, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.soundBased(),
.soundBased()
.reflectable(),
new StatusMove(Moves.SUPERSONIC, Type.NORMAL, 55, 20, -1, 0, 1)
.attr(ConfuseAttr)
.soundBased(),
.soundBased()
.reflectable(),
new AttackMove(Moves.SONIC_BOOM, Type.NORMAL, MoveCategory.SPECIAL, -1, 90, 20, -1, 0, 1)
.attr(FixedDamageAttr, 20),
new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1)
.attr(AddBattlerTagAttr, BattlerTagType.DISABLED, false, true)
.condition((user, target, move) => target.getMoveHistory().reverse().find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual) !== undefined)
.ignoresSubstitute(),
.ignoresSubstitute()
.reflectable(),
new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -1)
.target(MoveTarget.ALL_NEAR_ENEMIES),
@ -8476,7 +8498,8 @@ export function initMoves() {
.triageMove(),
new StatusMove(Moves.LEECH_SEED, Type.GRASS, 90, 10, -1, 0, 1)
.attr(LeechSeedAttr)
.condition((user, target, move) => !target.getTag(BattlerTagType.SEEDED) && !target.isOfType(Type.GRASS)),
.condition((user, target, move) => !target.getTag(BattlerTagType.SEEDED) && !target.isOfType(Type.GRASS))
.reflectable(),
new SelfStatusMove(Moves.GROWTH, Type.NORMAL, -1, 20, -1, 0, 1)
.attr(GrowthStatStageChangeAttr),
new AttackMove(Moves.RAZOR_LEAF, Type.GRASS, MoveCategory.PHYSICAL, 55, 95, 25, -1, 0, 1)
@ -8490,13 +8513,16 @@ export function initMoves() {
.attr(AntiSunlightPowerDecreaseAttr),
new StatusMove(Moves.POISON_POWDER, Type.POISON, 75, 35, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.POISON)
.powderMove(),
.powderMove()
.reflectable(),
new StatusMove(Moves.STUN_SPORE, Type.GRASS, 75, 30, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.powderMove(),
.powderMove()
.reflectable(),
new StatusMove(Moves.SLEEP_POWDER, Type.GRASS, 75, 15, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.powderMove(),
.powderMove()
.reflectable(),
new AttackMove(Moves.PETAL_DANCE, Type.GRASS, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 1)
.attr(FrenzyAttr)
.attr(MissEffectAttr, frenzyMissFunc)
@ -8506,7 +8532,8 @@ export function initMoves() {
.target(MoveTarget.RANDOM_NEAR_ENEMY),
new StatusMove(Moves.STRING_SHOT, Type.BUG, 95, 40, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.SPD ], -2)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.DRAGON_RAGE, Type.DRAGON, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 1)
.attr(FixedDamageAttr, 40),
new AttackMove(Moves.FIRE_SPIN, Type.FIRE, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 1)
@ -8517,7 +8544,8 @@ export function initMoves() {
.attr(StatusEffectAttr, StatusEffect.PARALYSIS),
new StatusMove(Moves.THUNDER_WAVE, Type.ELECTRIC, 90, 20, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.attr(RespectAttackTypeImmunityAttr),
.attr(RespectAttackTypeImmunityAttr)
.reflectable(),
new AttackMove(Moves.THUNDER, Type.ELECTRIC, MoveCategory.SPECIAL, 110, 70, 10, 30, 0, 1)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.attr(ThunderAccuracyAttr)
@ -8539,13 +8567,15 @@ export function initMoves() {
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.UNDERGROUND),
new StatusMove(Moves.TOXIC, Type.POISON, 90, 10, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.TOXIC)
.attr(ToxicAccuracyAttr),
.attr(ToxicAccuracyAttr)
.reflectable(),
new AttackMove(Moves.CONFUSION, Type.PSYCHIC, MoveCategory.SPECIAL, 50, 100, 25, 10, 0, 1)
.attr(ConfuseAttr),
new AttackMove(Moves.PSYCHIC, Type.PSYCHIC, MoveCategory.SPECIAL, 90, 100, 10, 10, 0, 1)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -1),
new StatusMove(Moves.HYPNOSIS, Type.PSYCHIC, 60, 20, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP),
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.reflectable(),
new SelfStatusMove(Moves.MEDITATE, Type.PSYCHIC, -1, 40, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true),
new SelfStatusMove(Moves.AGILITY, Type.PSYCHIC, -1, 30, -1, 0, 1)
@ -8563,7 +8593,8 @@ export function initMoves() {
.ignoresSubstitute(),
new StatusMove(Moves.SCREECH, Type.NORMAL, 85, 40, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], -2)
.soundBased(),
.soundBased()
.reflectable(),
new SelfStatusMove(Moves.DOUBLE_TEAM, Type.NORMAL, -1, 15, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.EVA ], 1, true),
new SelfStatusMove(Moves.RECOVER, Type.NORMAL, -1, 5, -1, 0, 1)
@ -8575,9 +8606,11 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.MINIMIZED, true, false)
.attr(StatStageChangeAttr, [ Stat.EVA ], 2, true),
new StatusMove(Moves.SMOKESCREEN, Type.NORMAL, 100, 20, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.ACC ], -1),
.attr(StatStageChangeAttr, [ Stat.ACC ], -1)
.reflectable(),
new StatusMove(Moves.CONFUSE_RAY, Type.GHOST, 100, 10, -1, 0, 1)
.attr(ConfuseAttr),
.attr(ConfuseAttr)
.reflectable(),
new SelfStatusMove(Moves.WITHDRAW, Type.WATER, -1, 40, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], 1, true),
new SelfStatusMove(Moves.DEFENSE_CURL, Type.NORMAL, -1, 40, -1, 0, 1)
@ -8638,7 +8671,8 @@ export function initMoves() {
new SelfStatusMove(Moves.AMNESIA, Type.PSYCHIC, -1, 20, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], 2, true),
new StatusMove(Moves.KINESIS, Type.PSYCHIC, 80, 15, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.ACC ], -1),
.attr(StatStageChangeAttr, [ Stat.ACC ], -1)
.reflectable(),
new SelfStatusMove(Moves.SOFT_BOILED, Type.NORMAL, -1, 5, -1, 0, 1)
.attr(HealAttr, 0.5)
.triageMove(),
@ -8648,14 +8682,16 @@ export function initMoves() {
.condition(failOnGravityCondition)
.recklessMove(),
new StatusMove(Moves.GLARE, Type.NORMAL, 100, 30, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS),
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.reflectable(),
new AttackMove(Moves.DREAM_EATER, Type.PSYCHIC, MoveCategory.SPECIAL, 100, 100, 15, -1, 0, 1)
.attr(HitHealAttr)
.condition(targetSleptOrComatoseCondition)
.triageMove(),
new StatusMove(Moves.POISON_GAS, Type.POISON, 90, 40, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.POISON)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.BARRAGE, Type.NORMAL, MoveCategory.PHYSICAL, 15, 85, 20, -1, 0, 1)
.attr(MultiHitAttr)
.makesContact(false)
@ -8664,7 +8700,8 @@ export function initMoves() {
.attr(HitHealAttr)
.triageMove(),
new StatusMove(Moves.LOVELY_KISS, Type.NORMAL, 75, 10, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP),
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.reflectable(),
new ChargingAttackMove(Moves.SKY_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 1)
.chargeText(i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" }))
.attr(HighCritAttr)
@ -8683,9 +8720,11 @@ export function initMoves() {
.punchingMove(),
new StatusMove(Moves.SPORE, Type.GRASS, 100, 15, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.powderMove(),
.powderMove()
.reflectable(),
new StatusMove(Moves.FLASH, Type.NORMAL, 100, 20, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.ACC ], -1),
.attr(StatStageChangeAttr, [ Stat.ACC ], -1)
.reflectable(),
new AttackMove(Moves.PSYWAVE, Type.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
.attr(RandomLevelDamageAttr),
new SelfStatusMove(Moves.SPLASH, Type.NORMAL, -1, 40, -1, 0, 1)
@ -8744,7 +8783,8 @@ export function initMoves() {
.attr(StealHeldItemChanceAttr, 0.3),
new StatusMove(Moves.SPIDER_WEB, Type.BUG, -1, 10, -1, 0, 2)
.condition(failIfGhostTypeCondition)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1),
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
.reflectable(),
new StatusMove(Moves.MIND_READER, Type.NORMAL, -1, 5, -1, 0, 2)
.attr(IgnoreAccuracyAttr),
new StatusMove(Moves.NIGHTMARE, Type.GHOST, 100, 15, -1, 0, 2)
@ -8775,12 +8815,14 @@ export function initMoves() {
new StatusMove(Moves.COTTON_SPORE, Type.GRASS, 100, 40, -1, 0, 2)
.attr(StatStageChangeAttr, [ Stat.SPD ], -2)
.powderMove()
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.REVERSAL, Type.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 2)
.attr(LowHpPowerAttr),
new StatusMove(Moves.SPITE, Type.GHOST, 100, 10, -1, 0, 2)
.ignoresSubstitute()
.attr(ReducePpMoveAttr, 4),
.attr(ReducePpMoveAttr, 4)
.reflectable(),
new AttackMove(Moves.POWDER_SNOW, Type.ICE, MoveCategory.SPECIAL, 40, 100, 25, 10, 0, 2)
.attr(StatusEffectAttr, StatusEffect.FREEZE)
.target(MoveTarget.ALL_NEAR_ENEMIES),
@ -8790,10 +8832,12 @@ export function initMoves() {
new AttackMove(Moves.MACH_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 30, -1, 1, 2)
.punchingMove(),
new StatusMove(Moves.SCARY_FACE, Type.NORMAL, 100, 10, -1, 0, 2)
.attr(StatStageChangeAttr, [ Stat.SPD ], -2),
.attr(StatStageChangeAttr, [ Stat.SPD ], -2)
.reflectable(),
new AttackMove(Moves.FEINT_ATTACK, Type.DARK, MoveCategory.PHYSICAL, 60, -1, 20, -1, 0, 2),
new StatusMove(Moves.SWEET_KISS, Type.FAIRY, 75, 10, -1, 0, 2)
.attr(ConfuseAttr),
.attr(ConfuseAttr)
.reflectable(),
new SelfStatusMove(Moves.BELLY_DRUM, Type.NORMAL, -1, 10, -1, 0, 2)
.attr(CutHpStatStageBoostAttr, [ Stat.ATK ], 12, 2, (user) => {
globalScene.queueMessage(i18next.t("moveTriggers:cutOwnHpAndMaximizedStat", { pokemonName: getPokemonNameWithAffix(user), statName: i18next.t(getStatKey(Stat.ATK)) }));
@ -8808,13 +8852,15 @@ export function initMoves() {
.ballBombMove(),
new StatusMove(Moves.SPIKES, Type.GROUND, -1, 20, -1, 0, 2)
.attr(AddArenaTrapTagAttr, ArenaTagType.SPIKES)
.target(MoveTarget.ENEMY_SIDE),
.target(MoveTarget.ENEMY_SIDE)
.reflectable(),
new AttackMove(Moves.ZAP_CANNON, Type.ELECTRIC, MoveCategory.SPECIAL, 120, 50, 5, 100, 0, 2)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.ballBombMove(),
new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST)
.ignoresSubstitute(),
.ignoresSubstitute()
.reflectable(),
new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2)
.ignoresProtect()
.attr(DestinyBondAttr)
@ -8860,7 +8906,8 @@ export function initMoves() {
.attr(ProtectAttr, BattlerTagType.ENDURING)
.condition(failIfLastCondition),
new StatusMove(Moves.CHARM, Type.FAIRY, 100, 20, -1, 0, 2)
.attr(StatStageChangeAttr, [ Stat.ATK ], -2),
.attr(StatStageChangeAttr, [ Stat.ATK ], -2)
.reflectable(),
new AttackMove(Moves.ROLLOUT, Type.ROCK, MoveCategory.PHYSICAL, 30, 90, 20, -1, 0, 2)
.partial() // Does not lock the user, also does not increase damage properly
.attr(ConsecutiveUseDoublePowerAttr, 5, true, true, Moves.DEFENSE_CURL),
@ -8868,7 +8915,8 @@ export function initMoves() {
.attr(SurviveDamageAttr),
new StatusMove(Moves.SWAGGER, Type.NORMAL, 85, 15, -1, 0, 2)
.attr(StatStageChangeAttr, [ Stat.ATK ], 2)
.attr(ConfuseAttr),
.attr(ConfuseAttr)
.reflectable(),
new SelfStatusMove(Moves.MILK_DRINK, Type.NORMAL, -1, 5, -1, 0, 2)
.attr(HealAttr, 0.5)
.triageMove(),
@ -8881,11 +8929,13 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.DEF ], 1, true),
new StatusMove(Moves.MEAN_LOOK, Type.NORMAL, -1, 5, -1, 0, 2)
.condition(failIfGhostTypeCondition)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1),
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
.reflectable(),
new StatusMove(Moves.ATTRACT, Type.NORMAL, 100, 15, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.INFATUATED)
.ignoresSubstitute()
.condition((user, target, move) => user.isOppositeGender(target)),
.condition((user, target, move) => user.isOppositeGender(target))
.reflectable(),
new SelfStatusMove(Moves.SLEEP_TALK, Type.NORMAL, -1, 10, -1, 0, 2)
.attr(BypassSleepAttr)
.attr(RandomMovesetMoveAttr, invalidSleepTalkMoves, false)
@ -8932,7 +8982,8 @@ export function initMoves() {
new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
.ignoresSubstitute()
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target)),
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target))
.reflectable(),
new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2)
.partial(), // No effect implemented
new AttackMove(Moves.RAPID_SPIN, Type.NORMAL, MoveCategory.PHYSICAL, 50, 100, 40, 100, 0, 2)
@ -8953,7 +9004,8 @@ export function initMoves() {
.attr(RemoveArenaTrapAttr),
new StatusMove(Moves.SWEET_SCENT, Type.NORMAL, 100, 20, -1, 0, 2)
.attr(StatStageChangeAttr, [ Stat.EVA ], -2)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.IRON_TAIL, Type.STEEL, MoveCategory.PHYSICAL, 100, 75, 15, 30, 0, 2)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1),
new AttackMove(Moves.METAL_CLAW, Type.STEEL, MoveCategory.PHYSICAL, 50, 95, 35, 10, 0, 2)
@ -9041,12 +9093,15 @@ export function initMoves() {
new StatusMove(Moves.TORMENT, Type.DARK, 100, 15, -1, 0, 3)
.ignoresSubstitute()
.edgeCase() // Incomplete implementation because of Uproar's partial implementation
.attr(AddBattlerTagAttr, BattlerTagType.TORMENT, false, true, 1),
.attr(AddBattlerTagAttr, BattlerTagType.TORMENT, false, true, 1)
.reflectable(),
new StatusMove(Moves.FLATTER, Type.DARK, 100, 15, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPATK ], 1)
.attr(ConfuseAttr),
.attr(ConfuseAttr)
.reflectable(),
new StatusMove(Moves.WILL_O_WISP, Type.FIRE, 85, 15, -1, 0, 3)
.attr(StatusEffectAttr, StatusEffect.BURN),
.attr(StatusEffectAttr, StatusEffect.BURN)
.reflectable(),
new StatusMove(Moves.MEMENTO, Type.DARK, 100, 10, -1, 0, 3)
.attr(SacrificialAttrOnHit)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -2),
@ -9070,7 +9125,8 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.CHARGED, true, false),
new StatusMove(Moves.TAUNT, Type.DARK, 100, 20, -1, 0, 3)
.ignoresSubstitute()
.attr(AddBattlerTagAttr, BattlerTagType.TAUNT, false, true, 4),
.attr(AddBattlerTagAttr, BattlerTagType.TAUNT, false, true, 4)
.reflectable(),
new StatusMove(Moves.HELPING_HAND, Type.NORMAL, -1, 20, -1, 5, 3)
.attr(AddBattlerTagAttr, BattlerTagType.HELPING_HAND)
.ignoresSubstitute()
@ -9093,7 +9149,12 @@ export function initMoves() {
new AttackMove(Moves.SUPERPOWER, Type.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1, true),
new SelfStatusMove(Moves.MAGIC_COAT, Type.PSYCHIC, -1, 15, -1, 4, 3)
.unimplemented(),
.attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true, 0)
.condition(failIfLastCondition)
// Interactions with stomping tantrum, instruct, and other moves that
// rely on move history
// Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr
.edgeCase(),
new SelfStatusMove(Moves.RECYCLE, Type.NORMAL, -1, 10, -1, 0, 3)
.unimplemented(),
new AttackMove(Moves.REVENGE, Type.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, -4, 3)
@ -9102,7 +9163,8 @@ export function initMoves() {
.attr(RemoveScreensAttr),
new StatusMove(Moves.YAWN, Type.NORMAL, -1, 10, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.DROWSY, false, true)
.condition((user, target, move) => !target.status && !target.isSafeguarded(user)),
.condition((user, target, move) => !target.status && !target.isSafeguarded(user))
.reflectable(),
new AttackMove(Moves.KNOCK_OFF, Type.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1)
.attr(RemoveHeldItemAttr, false),
@ -9146,7 +9208,8 @@ export function initMoves() {
.ballBombMove(),
new StatusMove(Moves.FEATHER_DANCE, Type.FLYING, 100, 15, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.ATK ], -2)
.danceMove(),
.danceMove()
.reflectable(),
new StatusMove(Moves.TEETER_DANCE, Type.NORMAL, 100, 20, -1, 0, 3)
.attr(ConfuseAttr)
.danceMove()
@ -9192,7 +9255,8 @@ export function initMoves() {
.attr(PartyStatusCureAttr, i18next.t("moveTriggers:soothingAromaWaftedThroughArea"), Abilities.SAP_SIPPER)
.target(MoveTarget.PARTY),
new StatusMove(Moves.FAKE_TEARS, Type.DARK, 100, 20, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2),
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2)
.reflectable(),
new AttackMove(Moves.AIR_CUTTER, Type.FLYING, MoveCategory.SPECIAL, 60, 95, 25, -1, 0, 3)
.attr(HighCritAttr)
.slicingMove()
@ -9203,7 +9267,8 @@ export function initMoves() {
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE),
new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST)
.ignoresSubstitute(),
.ignoresSubstitute()
.reflectable(),
new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.makesContact(false),
@ -9212,12 +9277,15 @@ export function initMoves() {
.windMove(),
new StatusMove(Moves.METAL_SOUND, Type.STEEL, 85, 40, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2)
.soundBased(),
.soundBased()
.reflectable(),
new StatusMove(Moves.GRASS_WHISTLE, Type.GRASS, 55, 15, -1, 0, 3)
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.soundBased(),
.soundBased()
.reflectable(),
new StatusMove(Moves.TICKLE, Type.NORMAL, 100, 20, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1),
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1)
.reflectable(),
new SelfStatusMove(Moves.COSMIC_POWER, Type.PSYCHIC, -1, 20, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], 1, true),
new AttackMove(Moves.WATER_SPOUT, Type.WATER, MoveCategory.SPECIAL, 150, 100, 5, -1, 0, 3)
@ -9255,7 +9323,8 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true),
new StatusMove(Moves.BLOCK, Type.NORMAL, -1, 5, -1, 0, 3)
.condition(failIfGhostTypeCondition)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1),
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
.reflectable(),
new StatusMove(Moves.HOWL, Type.NORMAL, -1, 40, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.ATK ], 1)
.soundBased()
@ -9318,7 +9387,8 @@ export function initMoves() {
.target(MoveTarget.BOTH_SIDES),
new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK)
.ignoresSubstitute(),
.ignoresSubstitute()
.reflectable(),
new AttackMove(Moves.WAKE_UP_SLAP, Type.FIGHTING, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 4)
.attr(MovePowerMultiplierAttr, (user, target, move) => targetSleptOrComatoseCondition(user, target, move) ? 2 : 1)
.attr(HealStatusEffectAttr, false, StatusEffect.SLEEP),
@ -9364,6 +9434,7 @@ export function initMoves() {
new AttackMove(Moves.ASSURANCE, Type.DARK, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 4)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.turnData.damageTaken > 0 ? 2 : 1),
new StatusMove(Moves.EMBARGO, Type.DARK, 100, 15, -1, 0, 4)
.reflectable()
.unimplemented(),
new AttackMove(Moves.FLING, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 4)
.makesContact(false)
@ -9383,14 +9454,16 @@ export function initMoves() {
.attr(LessPPMorePowerAttr),
new StatusMove(Moves.HEAL_BLOCK, Type.PSYCHIC, 100, 15, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.HEAL_BLOCK, false, true, 5)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.WRING_OUT, Type.NORMAL, MoveCategory.SPECIAL, -1, 100, 5, -1, 0, 4)
.attr(OpponentHighHpPowerAttr, 120)
.makesContact(),
new SelfStatusMove(Moves.POWER_TRICK, Type.PSYCHIC, -1, 10, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.POWER_TRICK, true),
new StatusMove(Moves.GASTRO_ACID, Type.POISON, 100, 10, -1, 0, 4)
.attr(SuppressAbilitiesAttr),
.attr(SuppressAbilitiesAttr)
.reflectable(),
new StatusMove(Moves.LUCKY_CHANT, Type.NORMAL, -1, 30, -1, 0, 4)
.attr(AddArenaTagAttr, ArenaTagType.NO_CRIT, 5, true, true)
.target(MoveTarget.USER_SIDE),
@ -9412,12 +9485,14 @@ export function initMoves() {
new AttackMove(Moves.LAST_RESORT, Type.NORMAL, MoveCategory.PHYSICAL, 140, 100, 5, -1, 0, 4)
.attr(LastResortAttr),
new StatusMove(Moves.WORRY_SEED, Type.GRASS, 100, 10, -1, 0, 4)
.attr(AbilityChangeAttr, Abilities.INSOMNIA),
.attr(AbilityChangeAttr, Abilities.INSOMNIA)
.reflectable(),
new AttackMove(Moves.SUCKER_PUNCH, Type.DARK, MoveCategory.PHYSICAL, 70, 100, 5, -1, 1, 4)
.condition((user, target, move) => globalScene.currentBattle.turnCommands[target.getBattlerIndex()]?.command === Command.FIGHT && !target.turnData.acted && allMoves[globalScene.currentBattle.turnCommands[target.getBattlerIndex()]?.move?.move!].category !== MoveCategory.STATUS), // TODO: is this bang correct?
new StatusMove(Moves.TOXIC_SPIKES, Type.POISON, -1, 20, -1, 0, 4)
.attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES)
.target(MoveTarget.ENEMY_SIDE),
.target(MoveTarget.ENEMY_SIDE)
.reflectable(),
new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4)
.attr(SwapStatStagesAttr, BATTLE_STATS)
.ignoresSubstitute(),
@ -9529,7 +9604,8 @@ export function initMoves() {
.attr(ClearTerrainAttr)
.attr(RemoveScreensAttr, false)
.attr(RemoveArenaTrapAttr, true)
.attr(RemoveArenaTagsAttr, [ ArenaTagType.MIST, ArenaTagType.SAFEGUARD ], false),
.attr(RemoveArenaTagsAttr, [ ArenaTagType.MIST, ArenaTagType.SAFEGUARD ], false)
.reflectable(),
new StatusMove(Moves.TRICK_ROOM, Type.PSYCHIC, -1, 5, -1, -7, 4)
.attr(AddArenaTagAttr, ArenaTagType.TRICK_ROOM, 5)
.ignoresProtect()
@ -9567,10 +9643,12 @@ export function initMoves() {
new StatusMove(Moves.CAPTIVATE, Type.NORMAL, 100, 20, -1, 0, 4)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2)
.condition((user, target, move) => target.isOppositeGender(user))
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new StatusMove(Moves.STEALTH_ROCK, Type.ROCK, -1, 20, -1, 0, 4)
.attr(AddArenaTrapTagAttr, ArenaTagType.STEALTH_ROCK)
.target(MoveTarget.ENEMY_SIDE),
.target(MoveTarget.ENEMY_SIDE)
.reflectable(),
new AttackMove(Moves.GRASS_KNOT, Type.GRASS, MoveCategory.SPECIAL, -1, 100, 20, -1, 0, 4)
.attr(WeightPowerAttr)
.makesContact(),
@ -9614,7 +9692,8 @@ export function initMoves() {
.attr(TrapAttr, BattlerTagType.MAGMA_STORM),
new StatusMove(Moves.DARK_VOID, Type.DARK, 80, 10, -1, 0, 4) //Accuracy from Generations 4-6
.attr(StatusEffectAttr, StatusEffect.SLEEP)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new AttackMove(Moves.SEED_FLARE, Type.GRASS, MoveCategory.SPECIAL, 120, 85, 5, 40, 0, 4)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2),
new AttackMove(Moves.OMINOUS_WIND, Type.GHOST, MoveCategory.SPECIAL, 60, 100, 5, 10, 0, 4)
@ -9654,7 +9733,8 @@ export function initMoves() {
.condition((_user, target, _move) => !(target.species.speciesId === Species.GENGAR && target.getFormKey() === "mega"))
.condition((_user, target, _move) => Utils.isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && Utils.isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING)))
.attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3),
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3)
.reflectable(),
new StatusMove(Moves.MAGIC_ROOM, Type.PSYCHIC, -1, 10, -1, 0, 5)
.ignoresProtect()
.target(MoveTarget.BOTH_SIDES)
@ -9687,7 +9767,8 @@ export function initMoves() {
.attr(ElectroBallPowerAttr)
.ballBombMove(),
new StatusMove(Moves.SOAK, Type.WATER, 100, 20, -1, 0, 5)
.attr(ChangeTypeAttr, Type.WATER),
.attr(ChangeTypeAttr, Type.WATER)
.reflectable(),
new AttackMove(Moves.FLAME_CHARGE, Type.FIRE, MoveCategory.PHYSICAL, 50, 100, 20, 100, 0, 5)
.attr(StatStageChangeAttr, [ Stat.SPD ], 1, true),
new SelfStatusMove(Moves.COIL, Type.POISON, -1, 20, -1, 0, 5)
@ -9700,9 +9781,11 @@ export function initMoves() {
new AttackMove(Moves.FOUL_PLAY, Type.DARK, MoveCategory.PHYSICAL, 95, 100, 15, -1, 0, 5)
.attr(TargetAtkUserAtkAttr),
new StatusMove(Moves.SIMPLE_BEAM, Type.NORMAL, 100, 15, -1, 0, 5)
.attr(AbilityChangeAttr, Abilities.SIMPLE),
.attr(AbilityChangeAttr, Abilities.SIMPLE)
.reflectable(),
new StatusMove(Moves.ENTRAINMENT, Type.NORMAL, 100, 15, -1, 0, 5)
.attr(AbilityGiveAttr),
.attr(AbilityGiveAttr)
.reflectable(),
new StatusMove(Moves.AFTER_YOU, Type.NORMAL, -1, 15, -1, 0, 5)
.ignoresProtect()
.ignoresSubstitute()
@ -9740,7 +9823,8 @@ export function initMoves() {
new StatusMove(Moves.HEAL_PULSE, Type.PSYCHIC, -1, 10, -1, 0, 5)
.attr(HealAttr, 0.5, false, false)
.pulseMove()
.triageMove(),
.triageMove()
.reflectable(),
new AttackMove(Moves.HEX, Type.GHOST, MoveCategory.SPECIAL, 65, 100, 10, -1, 0, 5)
.attr(
MovePowerMultiplierAttr,
@ -9943,7 +10027,8 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => target.isOfType(Type.GRASS) && target.isGrounded() }),
new StatusMove(Moves.STICKY_WEB, Type.BUG, -1, 20, -1, 0, 6)
.attr(AddArenaTrapTagAttr, ArenaTagType.STICKY_WEB)
.target(MoveTarget.ENEMY_SIDE),
.target(MoveTarget.ENEMY_SIDE)
.reflectable(),
new AttackMove(Moves.FELL_STINGER, Type.BUG, MoveCategory.PHYSICAL, 50, 100, 25, -1, 0, 6)
.attr(PostVictoryStatStageChangeAttr, [ Stat.ATK ], 3, true ),
new ChargingAttackMove(Moves.PHANTOM_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
@ -9951,10 +10036,12 @@ export function initMoves() {
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.HIDDEN)
.ignoresProtect(),
new StatusMove(Moves.TRICK_OR_TREAT, Type.GHOST, 100, 20, -1, 0, 6)
.attr(AddTypeAttr, Type.GHOST),
.attr(AddTypeAttr, Type.GHOST)
.reflectable(),
new StatusMove(Moves.NOBLE_ROAR, Type.NORMAL, 100, 30, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1)
.soundBased(),
.soundBased()
.reflectable(),
new StatusMove(Moves.ION_DELUGE, Type.ELECTRIC, -1, 25, -1, 1, 6)
.attr(AddArenaTagAttr, ArenaTagType.ION_DELUGE)
.target(MoveTarget.BOTH_SIDES),
@ -9963,7 +10050,8 @@ export function initMoves() {
.target(MoveTarget.ALL_NEAR_OTHERS)
.triageMove(),
new StatusMove(Moves.FORESTS_CURSE, Type.GRASS, 100, 20, -1, 0, 6)
.attr(AddTypeAttr, Type.GRASS),
.attr(AddTypeAttr, Type.GRASS)
.reflectable(),
new AttackMove(Moves.PETAL_BLIZZARD, Type.GRASS, MoveCategory.PHYSICAL, 90, 100, 15, -1, 0, 6)
.windMove()
.makesContact(false)
@ -9977,9 +10065,11 @@ export function initMoves() {
new StatusMove(Moves.PARTING_SHOT, Type.DARK, 100, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, { trigger: MoveEffectTrigger.PRE_APPLY })
.attr(ForceSwitchOutAttr, true)
.soundBased(),
.soundBased()
.reflectable(),
new StatusMove(Moves.TOPSY_TURVY, Type.DARK, -1, 20, -1, 0, 6)
.attr(InvertStatsAttr),
.attr(InvertStatsAttr)
.reflectable(),
new AttackMove(Moves.DRAINING_KISS, Type.FAIRY, MoveCategory.SPECIAL, 50, 100, 10, -1, 0, 6)
.attr(HitHealAttr, 0.75)
.makesContact()
@ -10018,10 +10108,12 @@ export function initMoves() {
.condition(failIfLastCondition),
new StatusMove(Moves.PLAY_NICE, Type.NORMAL, -1, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1)
.ignoresSubstitute(),
.ignoresSubstitute()
.reflectable(),
new StatusMove(Moves.CONFIDE, Type.NORMAL, -1, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -1)
.soundBased(),
.soundBased()
.reflectable(),
new AttackMove(Moves.DIAMOND_STORM, Type.ROCK, MoveCategory.PHYSICAL, 100, 95, 5, 50, 0, 6)
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true, { firstTargetOnly: true })
.makesContact(false)
@ -10048,14 +10140,17 @@ export function initMoves() {
.condition(failIfSingleBattle)
.target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2),
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2)
.reflectable(),
new StatusMove(Moves.VENOM_DRENCH, Type.POISON, 100, 20, -1, 0, 6)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], -1, false, { condition: (user, target, move) => target.status?.effect === StatusEffect.POISON || target.status?.effect === StatusEffect.TOXIC })
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
.reflectable(),
new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6)
.attr(AddBattlerTagAttr, BattlerTagType.POWDER, false, true)
.ignoresSubstitute()
.powderMove(),
.powderMove()
.reflectable(),
new ChargingSelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6)
.chargeText(i18next.t("moveTriggers:isChargingPower", { pokemonName: "{USER}" }))
.attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true),
@ -10077,7 +10172,8 @@ export function initMoves() {
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY),
new StatusMove(Moves.BABY_DOLL_EYES, Type.FAIRY, 100, 30, -1, 1, 6)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
.attr(StatStageChangeAttr, [ Stat.ATK ], -1)
.reflectable(),
new AttackMove(Moves.NUZZLE, Type.ELECTRIC, MoveCategory.PHYSICAL, 20, 100, 20, 100, 0, 6)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS),
new AttackMove(Moves.HOLD_BACK, Type.NORMAL, MoveCategory.PHYSICAL, 40, 100, 40, -1, 0, 6)
@ -10221,13 +10317,15 @@ export function initMoves() {
.punchingMove(),
new StatusMove(Moves.FLORAL_HEALING, Type.FAIRY, -1, 10, -1, 0, 7)
.attr(BoostHealAttr, 0.5, 2 / 3, true, false, (user, target, move) => globalScene.arena.terrain?.terrainType === TerrainType.GRASSY)
.triageMove(),
.triageMove()
.reflectable(),
new AttackMove(Moves.HIGH_HORSEPOWER, Type.GROUND, MoveCategory.PHYSICAL, 95, 95, 10, -1, 0, 7),
new StatusMove(Moves.STRENGTH_SAP, Type.GRASS, 100, 10, -1, 0, 7)
.attr(HitHealAttr, null, Stat.ATK)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1)
.condition((user, target, move) => target.getStatStage(Stat.ATK) > -6)
.triageMove(),
.triageMove()
.reflectable(),
new ChargingAttackMove(Moves.SOLAR_BLADE, Type.GRASS, MoveCategory.PHYSICAL, 125, 100, 10, -1, 0, 7)
.chargeText(i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" }))
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.SUNNY, WeatherType.HARSH_SUN ])
@ -10237,10 +10335,12 @@ export function initMoves() {
.makesContact(false),
new StatusMove(Moves.SPOTLIGHT, Type.NORMAL, -1, 15, -1, 3, 7)
.attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, false)
.condition(failIfSingleBattle),
.condition(failIfSingleBattle)
.reflectable(),
new StatusMove(Moves.TOXIC_THREAD, Type.POISON, 100, 20, -1, 0, 7)
.attr(StatusEffectAttr, StatusEffect.POISON)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1),
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.reflectable(),
new SelfStatusMove(Moves.LASER_FOCUS, Type.NORMAL, -1, 30, -1, 0, 7)
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false),
new StatusMove(Moves.GEAR_UP, Type.STEEL, -1, 20, -1, 0, 7)
@ -10284,7 +10384,8 @@ export function initMoves() {
(user: Pokemon, target: Pokemon, move: Move) => isNonVolatileStatusEffect(target.status?.effect!)) // TODO: is this bang correct?
.attr(HealAttr, 0.5)
.attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
.triageMove(),
.triageMove()
.reflectable(),
new AttackMove(Moves.REVELATION_DANCE, Type.NORMAL, MoveCategory.SPECIAL, 90, 100, 15, -1, 0, 7)
.danceMove()
.attr(MatchUserTypeAttr),
@ -10373,7 +10474,8 @@ export function initMoves() {
new AttackMove(Moves.MOONGEIST_BEAM, Type.GHOST, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 7)
.ignoresAbilities(),
new StatusMove(Moves.TEARFUL_LOOK, Type.NORMAL, -1, 20, -1, 0, 7)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1),
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1)
.reflectable(),
new AttackMove(Moves.ZING_ZAP, Type.ELECTRIC, MoveCategory.PHYSICAL, 80, 100, 10, 30, 0, 7)
.attr(FlinchAttr),
new AttackMove(Moves.NATURES_MADNESS, Type.FAIRY, MoveCategory.SPECIAL, -1, 90, 10, -1, 0, 7)
@ -10492,10 +10594,12 @@ export function initMoves() {
.condition((user, target, move) => user.getTag(TrappedTag)?.sourceMove !== Moves.NO_RETREAT), // fails if the user is currently trapped by No Retreat
new StatusMove(Moves.TAR_SHOT, Type.ROCK, 100, 15, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.attr(AddBattlerTagAttr, BattlerTagType.TAR_SHOT, false),
.attr(AddBattlerTagAttr, BattlerTagType.TAR_SHOT, false)
.reflectable(),
new StatusMove(Moves.MAGIC_POWDER, Type.PSYCHIC, 100, 20, -1, 0, 8)
.attr(ChangeTypeAttr, Type.PSYCHIC)
.powderMove(),
.powderMove()
.reflectable(),
new AttackMove(Moves.DRAGON_DARTS, Type.DRAGON, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 8)
.attr(MultiHitAttr, MultiHitType._2)
.makesContact(false)
@ -10672,6 +10776,7 @@ export function initMoves() {
.makesContact(false),
new StatusMove(Moves.CORROSIVE_GAS, Type.POISON, 100, 40, -1, 0, 8)
.target(MoveTarget.ALL_NEAR_OTHERS)
.reflectable()
.unimplemented(),
new StatusMove(Moves.COACHING, Type.FIGHTING, -1, 10, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], 1)

View File

@ -94,4 +94,5 @@ export enum BattlerTagType {
PSYCHO_SHIFT = "PSYCHO_SHIFT",
ENDURE_TOKEN = "ENDURE_TOKEN",
POWDER = "POWDER",
MAGIC_COAT = "MAGIC_COAT",
}

View File

@ -12,6 +12,7 @@ import {
PostAttackAbAttr,
PostDamageAbAttr,
PostDefendAbAttr,
ReflectStatusMoveAbAttr,
TypeImmunityAbAttr,
} from "#app/data/ability";
import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag";
@ -31,6 +32,7 @@ import {
AttackMove,
DelayedAttackAttr,
FlinchAttr,
getMoveTargets,
HitsTagAttr,
MissEffectAttr,
MoveCategory,
@ -47,7 +49,7 @@ import {
} from "#app/data/move";
import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms";
import { Type } from "#enums/type";
import type { PokemonMove } from "#app/field/pokemon";
import { PokemonMove } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon";
import { HitResult, MoveResult } from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages";
@ -60,17 +62,27 @@ import {
} from "#app/modifier/modifier";
import { PokemonPhase } from "#app/phases/pokemon-phase";
import { BooleanHolder, executeIf, isNullOrUndefined, NumberHolder } from "#app/utils";
import { type nil } from "#app/utils";
import { BattlerTagType } from "#enums/battler-tag-type";
import type { 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";
export class MoveEffectPhase extends PokemonPhase {
public move: PokemonMove;
protected targets: BattlerIndex[];
protected reflected: boolean = false;
constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove) {
/**
* @param reflected Indicates that the move was reflected by the user due to magic coat or magic bounce
*/
constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove, reflected: boolean = false) {
super(battlerIndex);
this.move = move;
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
@ -184,12 +196,14 @@ export class MoveEffectPhase extends PokemonPhase {
&& (targets[0]?.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move))
&& !targets[0]?.getTag(SemiInvulnerableTag);
const mayBounce = move.hasFlag(MoveFlags.REFLECTABLE) && !this.reflected && targets.some(t => t.hasAbilityWithAttr(ReflectStatusMoveAbAttr) || !!t.getTag(BattlerTagType.MAGIC_COAT));
/**
* If no targets are left for the move to hit (FAIL), or the invoked move is single-target
* If no targets are left for the move to hit (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.
*/
if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) {
if (!hasActiveTargets || (!mayBounce && !move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) {
this.stopMultiHit();
if (hasActiveTargets) {
globalScene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" }));
@ -211,12 +225,21 @@ export class MoveEffectPhase extends PokemonPhase {
new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex(), playOnEmptyField).play(move.hitsSubstitute(user, this.getFirstTarget()!), () => {
/** Has the move successfully hit a target (for damage) yet? */
let hasHit: boolean = false;
for (const target of targets) {
// Prevent ENEMY_SIDE targeted moves from occurring twice in double battles
if (move.moveTarget === MoveTarget.ENEMY_SIDE && target !== targets[targets.length - 1]) {
continue;
}
// Prevent ENEMY_SIDE targeted moves from occurring twice in double battles
// and check which target will magic bounce.
const trueTargets: Pokemon[] = 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)? */
@ -229,7 +252,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
/** Is the target protected by Protect, etc. or a relevant conditional protection effect? */
const isProtected = (
const isProtected = !([ MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES ].includes(this.move.getMove().moveTarget)) && (
bypassIgnoreProtect.value
|| !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target))
&& (hasConditionalProtectApplied.value
@ -238,13 +261,39 @@ export class MoveEffectPhase extends PokemonPhase {
|| (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.checkFlag(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) {
queuedPhases.push(new ShowAbilityPhase(target.getBattlerIndex(), target.getPassiveAbility().hasAttr(ReflectStatusMoveAbAttr)));
}
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))
&& !target.getTag(SemiInvulnerableTag);
&& !semiInvulnerableTag;
/** Is the target hidden by the effects of its Commander ability? */
const isCommanding = globalScene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target;
/**
* If the move missed a target, stop all future hits against that target
@ -371,6 +420,10 @@ export class MoveEffectPhase extends PokemonPhase {
applyAttrs.push(k);
}
// 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
const postTarget = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) ?
applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, user, null, move) :
@ -586,12 +639,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
}
if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) {
return true;
}
// If the user should ignore accuracy on a target, check who the user targeted last turn and see if they match
if (user.getTag(BattlerTagType.IGNORE_ACCURACY) && (user.getLastXMoves().find(() => true)?.targets || []).indexOf(target.getBattlerIndex()) !== -1) {
if (this.checkBypassAccAndInvuln(target)) {
return true;
}
@ -599,15 +647,12 @@ export class MoveEffectPhase extends PokemonPhase {
return true;
}
if (target.getTag(BattlerTagType.TELEKINESIS) && !target.getTag(SemiInvulnerableTag) && !this.move.getMove().hasAttr(OneHitKOAttr)) {
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
if (target.getTag(BattlerTagType.TELEKINESIS) && !semiInvulnerableTag && !this.move.getMove().hasAttr(OneHitKOAttr)) {
return true;
}
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
if (semiInvulnerableTag
&& !this.move.getMove().getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType)
&& !(this.move.getMove().hasAttr(ToxicAccuracyAttr) && user.isOfType(Type.POISON))
) {
if (semiInvulnerableTag && !this.checkBypassSemiInvuln(semiInvulnerableTag)) {
return false;
}
@ -623,6 +668,52 @@ export class MoveEffectPhase extends PokemonPhase {
return rand < (moveAccuracy * accuracyMultiplier);
}
/**
* Check whether the move should bypass *both* the accuracy *and* semi-invulnerable states.
* @param target - The {@linkcode Pokemon} targeted by the invoked move
* @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}.
*
* 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).
*
* @see {@linkcode hitCheck}
*/
public checkBypassAccAndInvuln(target: Pokemon) {
const user = this.getUserPokemon();
if (!user) {
return false;
}
if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) {
return true;
}
if ((this.move.getMove().hasAttr(ToxicAccuracyAttr) && user.isOfType(Type.POISON))) {
return true;
}
// TODO: Fix lock on / mind reader check.
if (user.getTag(BattlerTagType.IGNORE_ACCURACY) && (user.getLastXMoves().find(() => true)?.targets || []).indexOf(target.getBattlerIndex()) !== -1) {
return true;
}
}
/**
* Check whether the move is able to ignore the given `semiInvulnerableTag`
* @param semiInvulnerableTag - The semiInvulnerbale 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();
return move.getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType);
}
/** @returns The {@linkcode Pokemon} using this phase's invoked move */
public getUserPokemon(): Pokemon | null {
if (this.battlerIndex > BattlerIndex.ENEMY_2) {

View File

@ -58,6 +58,7 @@ export class MovePhase extends BattlePhase {
protected ignorePp: boolean;
protected failed: boolean = false;
protected cancelled: boolean = false;
protected reflected: boolean = false;
public get pokemon(): Pokemon {
return this._pokemon;
@ -84,10 +85,12 @@ export class MovePhase extends BattlePhase {
}
/**
* @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer.
* @param followUp Indicates that the move being used is a "follow-up" - for example, a move being used by Metronome or Dancer.
* Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc.
* @param reflected Indicates that the move was reflected by Magic Coat or Magic Bounce.
* Reflected moves cannot be reflected again and will not trigger Dancer.
*/
constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) {
constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false, reflected: boolean = false) {
super();
this.pokemon = pokemon;
@ -95,6 +98,7 @@ export class MovePhase extends BattlePhase {
this.move = move;
this.followUp = followUp;
this.ignorePp = ignorePp;
this.reflected = reflected;
}
/**
@ -140,7 +144,7 @@ export class MovePhase extends BattlePhase {
}
// Check move to see if arena.ignoreAbilities should be true.
if (!this.followUp) {
if (!this.followUp || this.reflected) {
if (this.move.getMove().checkFlag(MoveFlags.IGNORE_ABILITIES, this.pokemon, null)) {
globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex());
}
@ -335,7 +339,7 @@ export class MovePhase extends BattlePhase {
*/
if (success) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move));
globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move, this.reflected));
} else {
if ([ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) {
@ -543,7 +547,7 @@ export class MovePhase extends BattlePhase {
return;
}
globalScene.queueMessage(i18next.t("battle:useMove", {
globalScene.queueMessage(i18next.t(this.reflected ? "battle:magicCoatActivated" : "battle:useMove", {
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
moveName: this.move.getName()
}), 500);

View File

@ -0,0 +1,351 @@
import { BattlerIndex } from "#app/battle";
import { allAbilities } from "#app/data/ability";
import { ArenaTagSide } from "#app/data/arena-tag";
import { allMoves } from "#app/data/move";
import { ArenaTagType } from "#app/enums/arena-tag-type";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Stat } from "#app/enums/stat";
import { StatusEffect } from "#app/enums/status-effect";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Magic Bounce", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(Abilities.BALL_FETCH)
.battleType("single")
.moveset( [ Moves.GROWL, Moves.SPLASH ])
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.MAGIC_BOUNCE)
.enemyMoveset(Moves.SPLASH);
});
it("should reflect basic status moves", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce moves while the target is in the semi-invulnerable state", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.GROWL ]);
game.override.enemyMoveset( [ Moves.FLY ]);
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.FLY);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(0);
});
it("should individually bounce back multi-target moves", async () => {
game.override.battleType("double");
game.override.moveset([ Moves.GROWL, Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
game.move.select(Moves.GROWL, 0);
game.move.select(Moves.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
const user = game.scene.getPlayerField()[0];
expect(user.getStatStage(Stat.ATK)).toBe(-2);
});
it("should still bounce back a move that would otherwise fail", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.scene.getEnemyPokemon()?.setStatStage(Stat.ATK, -6);
game.override.moveset([ Moves.GROWL ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move that was just bounced", async () => {
game.override.ability(Abilities.MAGIC_BOUNCE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should receive the stat change after reflecting a move back to a mirror armor user", async () => {
game.override.ability(Abilities.MIRROR_ARMOR);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move from a mold breaker user", async () => {
game.override.ability(Abilities.MOLD_BREAKER);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should bounce back a spread status move against both pokemon", async () => {
game.override.battleType("double");
game.override.moveset([ Moves.GROWL, Moves.SPLASH ]);
game.override.enemyMoveset([ Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
game.move.select(Moves.GROWL, 0);
game.move.select(Moves.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -2)).toBeTruthy();
});
it("should only bounce spikes back once in doubles when both targets have magic bounce", async () => {
game.override.battleType("double");
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.SPIKES ]);
game.move.select(Moves.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should bounce spikes even when the target is protected", async () => {
game.override.moveset([ Moves.SPIKES ]);
game.override.enemyMoveset([ Moves.PROTECT ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
});
it("should not bounce spikes when the target is in the semi-invulnerable state", async () => {
game.override.moveset([ Moves.SPIKES ]);
game.override.enemyMoveset([ Moves.FLY ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.SPIKES);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)!["layers"]).toBe(1);
});
it("should not bounce back curse", async() => {
game.override.starterSpecies(Species.GASTLY);
await game.classicMode.startBattle([ Species.GASTLY ]);
game.override.moveset([ Moves.CURSE ]);
game.move.select(Moves.CURSE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getTag(BattlerTagType.CURSED)).toBeDefined();
});
it("should not cause encore to be interrupted after bouncing", async () => {
game.override.moveset([ Moves.SPLASH, Moves.GROWL, Moves.ENCORE ]);
game.override.enemyMoveset([ Moves.TACKLE, Moves.GROWL ]);
// game.override.ability(Abilities.MOLD_BREAKER);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[Abilities.MOLD_BREAKER]);
// turn 1
game.move.select(Moves.ENCORE);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
// turn 2
vi.spyOn(playerPokemon, "getAbility").mockRestore();
game.move.select(Moves.GROWL);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(Moves.TACKLE);
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override.moveset([ Moves.SPLASH, Moves.GROWL, Moves.ENCORE ]);
game.override.enemyMoveset([ Moves.GROWL, Moves.TACKLE ]);
game.override.enemyAbility(Abilities.MAGIC_BOUNCE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
// turn 1
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[Abilities.MOLD_BREAKER]);
// turn 2
game.move.select(Moves.ENCORE);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(Moves.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleType("single");
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.STOMPING_TANTRUM, Moves.CHARM ]);
const stomping_tantrum = allMoves[Moves.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(Moves.CHARM);
await game.toNextTurn();
game.move.select(Moves.STOMPING_TANTRUM);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing", async () => {
game.override.enemyMoveset([ Moves.STOMPING_TANTRUM, Moves.SPLASH, Moves.CHARM ]);
await game.classicMode.startBattle([ Species.BULBASAUR ]);
const stomping_tantrum = allMoves[Moves.STOMPING_TANTRUM];
const enemy = game.scene.getEnemyPokemon()!;
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(Moves.SPORE);
await game.forceEnemyMove(Moves.CHARM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
await game.toNextTurn();
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
});
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[Moves.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.moveset([ Moves.THUNDER_WAVE, Moves.GROWL ]);
game.override.ability(Abilities.SOUNDPROOF);
await game.classicMode.startBattle([ Species.PHANPY ]);
// Turn 1 - thunder wave immunity test
game.move.select(Moves.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(0);
});
it("should bounce back a move before the accuracy check", async () => {
game.override.moveset([ Moves.SPORE ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const attacker = game.scene.getPlayerPokemon()!;
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(Moves.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
game.override.moveset([ Moves.SPORE ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const opponent = game.scene.getEnemyPokemon()!;
vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0);
game.move.select(Moves.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status).toBeUndefined();
});
it("should always apply the leftmost available target's magic bounce when bouncing moves like sticky webs in doubles", async () => {
game.override.battleType("double");
game.override.moveset([ Moves.STICKY_WEB, Moves.SPLASH, Moves.TRICK_ROOM ]);
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
const [ enemy_1, enemy_2 ] = game.scene.getEnemyField();
// set speed just incase logic erroneously checks for speed order
enemy_1.setStat(Stat.SPD, enemy_2.getStat(Stat.SPD) + 1);
// turn 1
game.move.select(Moves.STICKY_WEB, 0);
game.move.select(Moves.TRICK_ROOM, 1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex()).toBe(BattlerIndex.ENEMY);
game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true);
// turn 2
game.move.select(Moves.STICKY_WEB, 0);
game.move.select(Moves.TRICK_ROOM, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex()).toBe(BattlerIndex.ENEMY);
});
it("should not bounce back status moves that hit through semi-invulnerable states", async () => {
game.override.moveset([ Moves.TOXIC, Moves.CHARM ]);
await game.classicMode.startBattle([ Species.BULBASAUR ]);
game.move.select(Moves.TOXIC);
await game.forceEnemyMove(Moves.FLY);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.status?.effect).toBe(StatusEffect.TOXIC);
expect(game.scene.getPlayerPokemon()!.status).toBeUndefined();
game.override.ability(Abilities.NO_GUARD);
game.move.select(Moves.CHARM);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(-2);
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(0);
});
});

View File

@ -0,0 +1,286 @@
import { BattlerIndex } from "#app/battle";
import { ArenaTagSide } from "#app/data/arena-tag";
import { allMoves } from "#app/data/move";
import { ArenaTagType } from "#app/enums/arena-tag-type";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Stat } from "#app/enums/stat";
import { StatusEffect } from "#app/enums/status-effect";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Magic Coat", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(Abilities.BALL_FETCH)
.battleType("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.MAGIC_COAT);
});
it("should fail if the user goes last in the turn", async () => {
game.override.moveset([ Moves.PROTECT ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.PROTECT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called again in the same turn due to moves like instruct", async () => {
game.override.moveset([ Moves.INSTRUCT ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.INSTRUCT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should not reflect moves used on the next turn", async () => {
game.override.moveset([ Moves.GROWL, Moves.SPLASH ]);
game.override.enemyMoveset([ Moves.MAGIC_COAT, Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
// turn 1
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should reflect basic status moves", async () => {
game.override.moveset([ Moves.GROWL ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should individually bounce back multi-target moves when used by both targets in doubles", async () => {
game.override.battleType("double");
game.override.moveset([ Moves.GROWL, Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
game.move.select(Moves.GROWL, 0);
game.move.select(Moves.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
const user = game.scene.getPlayerField()[0];
expect(user.getStatStage(Stat.ATK)).toBe(-2);
});
it("should bounce back a spread status move against both pokemon", async () => {
game.override.battleType("double");
game.override.moveset([ Moves.GROWL, Moves.SPLASH ]);
game.override.enemyMoveset([ Moves.SPLASH, Moves.MAGIC_COAT ]);
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
game.move.select(Moves.GROWL, 0);
game.move.select(Moves.SPLASH, 1);
await game.forceEnemyMove(Moves.SPLASH);
await game.forceEnemyMove(Moves.MAGIC_COAT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -1)).toBeTruthy();
});
it("should still bounce back a move that would otherwise fail", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.scene.getEnemyPokemon()?.setStatStage(Stat.ATK, -6);
game.override.moveset([ Moves.GROWL ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move that was just bounced", async () => {
game.override.battleType("double");
game.override.ability(Abilities.MAGIC_BOUNCE);
game.override.moveset([ Moves.GROWL, Moves.MAGIC_COAT ]);
game.override.enemyMoveset([ Moves.SPLASH, Moves.MAGIC_COAT ]);
await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]);
game.move.select(Moves.MAGIC_COAT, 0);
game.move.select(Moves.GROWL, 1);
await game.forceEnemyMove(Moves.MAGIC_COAT);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyField()[0].getStatStage(Stat.ATK)).toBe(0);
});
// todo while Mirror Armor is not implemented
it.todo("should receive the stat change after reflecting a move back to a mirror armor user", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should still bounce back a move from a mold breaker user", async () => {
game.override.ability(Abilities.MOLD_BREAKER);
game.override.moveset([ Moves.GROWL ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(0);
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(-1);
});
it("should only bounce spikes back once when both targets use magic coat in doubles", async () => {
game.override.battleType("double");
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.SPIKES ]);
game.move.select(Moves.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should not bounce back curse", async() => {
game.override.starterSpecies(Species.GASTLY);
await game.classicMode.startBattle([ Species.GASTLY ]);
game.override.moveset([ Moves.CURSE ]);
game.move.select(Moves.CURSE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyPokemon()!.getTag(BattlerTagType.CURSED)).toBeDefined();
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override.moveset([ Moves.GROWL, Moves.ENCORE ]);
game.override.enemyMoveset([ Moves.MAGIC_COAT, Moves.TACKLE ]);
game.override.enemyAbility(Abilities.MAGIC_BOUNCE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
// turn 1
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.select(Moves.ENCORE);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(Moves.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleType("single");
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.STOMPING_TANTRUM, Moves.CHARM ]);
const stomping_tantrum = allMoves[Moves.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(Moves.CHARM);
await game.toNextTurn();
game.move.select(Moves.STOMPING_TANTRUM);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing", async () => {
game.override.enemyMoveset([ Moves.STOMPING_TANTRUM, Moves.SPLASH, Moves.CHARM ]);
await game.classicMode.startBattle([ Species.BULBASAUR ]);
const stomping_tantrum = allMoves[Moves.STOMPING_TANTRUM];
const enemy = game.scene.getEnemyPokemon()!;
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(Moves.SPORE);
await game.forceEnemyMove(Moves.CHARM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
await game.toNextTurn();
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
});
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[Moves.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.moveset([ Moves.THUNDER_WAVE, Moves.GROWL ]);
game.override.ability(Abilities.SOUNDPROOF);
await game.classicMode.startBattle([ Species.PHANPY ]);
// Turn 1 - thunder wave immunity test
game.move.select(Moves.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(0);
});
it("should bounce back a move before the accuracy check", async () => {
game.override.moveset([ Moves.SPORE ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const attacker = game.scene.getPlayerPokemon()!;
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(Moves.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
game.override.moveset([ Moves.SPORE ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const opponent = game.scene.getEnemyPokemon()!;
vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0);
game.move.select(Moves.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status).toBeUndefined();
});
});