[P2] Fix various charge move bugs (#4595)

* Add charge move classes and phase

* Integrate `MoveChargePhase` in battle phase sequence

* Fix Protean + charge move interaction

* Fix effect chance applying to semi-invulnerability

* Remove `ChargeAttr` and fix ChargeAnim loading

* Restore move history entry for charge phases

* Gravity now cancels Fly, etc. after charge turn

* Dig integration tests

* Fly integration tests

* Dive integration test + fix Dive in Harsh Sun bug

* Solar Beam integration tests + `CHARGING` tag fixes

* Fix dive test

* Electro Shot integration tests

* fix import in MoveChargePhase

* Electro Shot Multi Lens test

* Geomancy integration tests

* Fix duplicate move queue

* Update import

* Docs + Fix Meteor Beam being boosted by Sheer Force

* Fix volt absorb test

* Apply PigeonBar's suggested move-phase changes

Co-authored-by: PigeonBar <56974298+PigeonBar@users.noreply.github.com>

* Make Electro Shot Sheer Force boosted again

* Apply PigeonBar's feedback pt. 2

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Fix mistimed/dupe showMoveText and leftover TODO

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: PigeonBar <56974298+PigeonBar@users.noreply.github.com>
This commit is contained in:
innerthunder 2024-10-23 08:08:40 -07:00 committed by GitHub
parent 16b7194366
commit 03025b2674
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1188 additions and 305 deletions

View File

@ -7,7 +7,7 @@ import { Weather, WeatherType } from "./weather";
import { BattlerTag, GroundedTag } from "./battler-tags"; import { BattlerTag, GroundedTag } from "./battler-tags";
import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect";
import { Gender } from "./gender"; import { Gender } from "./gender";
import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move";
import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag";
import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier";
import { TerrainType } from "./terrain"; import { TerrainType } from "./terrain";
@ -1139,7 +1139,9 @@ export class MoveEffectChanceMultiplierAbAttr extends AbAttr {
apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean {
// Disable showAbility during getTargetBenefitScore // Disable showAbility during getTargetBenefitScore
this.showAbility = args[4]; this.showAbility = args[4];
if ((args[0] as Utils.NumberHolder).value <= 0 || (args[1] as Move).id === Moves.ORDER_UP) {
const exceptMoves = [ Moves.ORDER_UP, Moves.ELECTRO_SHOT ];
if ((args[0] as Utils.NumberHolder).value <= 0 || exceptMoves.includes((args[1] as Move).id)) {
return false; return false;
} }
@ -1329,7 +1331,6 @@ export class AddSecondStrikeAbAttr extends PreAttackAbAttr {
*/ */
const exceptAttrs: Constructor<MoveAttr>[] = [ const exceptAttrs: Constructor<MoveAttr>[] = [
MultiHitAttr, MultiHitAttr,
ChargeAttr,
SacrificialAttr, SacrificialAttr,
SacrificialAttrOnHit SacrificialAttrOnHit
]; ];
@ -1345,6 +1346,7 @@ export class AddSecondStrikeAbAttr extends PreAttackAbAttr {
/** Also check if this move is an Attack move and if it's only targeting one Pokemon */ /** Also check if this move is an Attack move and if it's only targeting one Pokemon */
return numTargets === 1 return numTargets === 1
&& !move.isChargingMove()
&& !exceptAttrs.some(attr => move.hasAttr(attr)) && !exceptAttrs.some(attr => move.hasAttr(attr))
&& !exceptMoves.some(id => move.id === id) && !exceptMoves.some(id => move.id === id)
&& move.category !== MoveCategory.STATUS; && move.category !== MoveCategory.STATUS;

View File

@ -970,6 +970,9 @@ export class GravityTag extends ArenaTag {
if (pokemon !== null) { if (pokemon !== null) {
pokemon.removeTag(BattlerTagType.FLOATING); pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS); pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
} }
}); });
} }

View File

@ -1,6 +1,6 @@
//import { battleAnimRawData } from "./battle-anim-raw-data"; //import { battleAnimRawData } from "./battle-anim-raw-data";
import BattleScene from "../battle-scene"; import BattleScene from "../battle-scene";
import { AttackMove, BeakBlastHeaderAttr, ChargeAttr, DelayedAttackAttr, MoveFlags, SelfStatusMove, allMoves } from "./move"; import { AttackMove, BeakBlastHeaderAttr, DelayedAttackAttr, MoveFlags, SelfStatusMove, allMoves } from "./move";
import Pokemon from "../field/pokemon"; import Pokemon from "../field/pokemon";
import * as Utils from "../utils"; import * as Utils from "../utils";
import { BattlerIndex } from "../battle"; import { BattlerIndex } from "../battle";
@ -476,8 +476,11 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise<void> {
} else { } else {
const loadedCheckTimer = setInterval(() => { const loadedCheckTimer = setInterval(() => {
if (moveAnims.get(move) !== null) { if (moveAnims.get(move) !== null) {
const chargeAttr = allMoves[move].getAttrs(ChargeAttr)[0] || allMoves[move].getAttrs(DelayedAttackAttr)[0]; const chargeAnimSource = (allMoves[move].isChargingMove())
if (chargeAttr && chargeAnims.get(chargeAttr.chargeAnim) === null) { ? allMoves[move]
: (allMoves[move].getAttrs(DelayedAttackAttr)[0]
?? allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]);
if (chargeAnimSource && chargeAnims.get(chargeAnimSource.chargeAnim) === null) {
return; return;
} }
clearInterval(loadedCheckTimer); clearInterval(loadedCheckTimer);
@ -507,11 +510,12 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise<void> {
} else { } else {
populateMoveAnim(move, ba); populateMoveAnim(move, ba);
} }
const chargeAttr = allMoves[move].getAttrs(ChargeAttr)[0] const chargeAnimSource = (allMoves[move].isChargingMove())
|| allMoves[move].getAttrs(DelayedAttackAttr)[0] ? allMoves[move]
|| allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]; : (allMoves[move].getAttrs(DelayedAttackAttr)[0]
if (chargeAttr) { ?? allMoves[move].getAttrs(BeakBlastHeaderAttr)[0]);
initMoveChargeAnim(scene, chargeAttr.chargeAnim).then(() => resolve()); if (chargeAnimSource) {
initMoveChargeAnim(scene, chargeAnimSource.chargeAnim).then(() => resolve());
} else { } else {
resolve(); resolve();
} }
@ -638,11 +642,12 @@ export function loadMoveAnimAssets(scene: BattleScene, moveIds: Moves[], startLo
return new Promise(resolve => { return new Promise(resolve => {
const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat(); const moveAnimations = moveIds.map(m => moveAnims.get(m) as AnimConfig).flat();
for (const moveId of moveIds) { for (const moveId of moveIds) {
const chargeAttr = allMoves[moveId].getAttrs(ChargeAttr)[0] const chargeAnimSource = (allMoves[moveId].isChargingMove())
|| allMoves[moveId].getAttrs(DelayedAttackAttr)[0] ? allMoves[moveId]
|| allMoves[moveId].getAttrs(BeakBlastHeaderAttr)[0]; : (allMoves[moveId].getAttrs(DelayedAttackAttr)[0]
if (chargeAttr) { ?? allMoves[moveId].getAttrs(BeakBlastHeaderAttr)[0]);
const moveChargeAnims = chargeAnims.get(chargeAttr.chargeAnim); if (chargeAnimSource) {
const moveChargeAnims = chargeAnims.get(chargeAnimSource.chargeAnim);
moveAnimations.push(moveChargeAnims instanceof AnimConfig ? moveChargeAnims : moveChargeAnims![0]); // TODO: is the bang correct? moveAnimations.push(moveChargeAnims instanceof AnimConfig ? moveChargeAnims : moveChargeAnims![0]); // TODO: is the bang correct?
if (Array.isArray(moveChargeAnims)) { if (Array.isArray(moveChargeAnims)) {
moveAnimations.push(moveChargeAnims[1]); moveAnimations.push(moveChargeAnims[1]);

View File

@ -11,7 +11,6 @@ import { ChargeAnim, CommonAnim, CommonBattleAnim, MoveChargeAnim } from "#app/d
import Move, { import Move, {
allMoves, allMoves,
applyMoveAttrs, applyMoveAttrs,
ChargeAttr,
ConsecutiveUseDoublePowerAttr, ConsecutiveUseDoublePowerAttr,
HealOnAllyAttr, HealOnAllyAttr,
MoveCategory, MoveCategory,
@ -949,10 +948,6 @@ export class EncoreTag extends BattlerTag {
return false; return false;
} }
if (allMoves[repeatableMove.move].hasAttr(ChargeAttr) && repeatableMove.result === MoveResult.OTHER) {
return false;
}
this.moveId = repeatableMove.move; this.moveId = repeatableMove.move;
return true; return true;
@ -2591,7 +2586,7 @@ export class TormentTag extends MoveRestrictionBattlerTag {
// This checks for locking / momentum moves like Rollout and Hydro Cannon + if the user is under the influence of BattlerTagType.FRENZY // This checks for locking / momentum moves like Rollout and Hydro Cannon + if the user is under the influence of BattlerTagType.FRENZY
// Because Uproar's unique behavior is not implemented, it does not check for Uproar. Torment has been marked as partial in moves.ts // Because Uproar's unique behavior is not implemented, it does not check for Uproar. Torment has been marked as partial in moves.ts
const moveObj = allMoves[lastMove.move]; const moveObj = allMoves[lastMove.move];
const isUnaffected = moveObj.hasAttr(ConsecutiveUseDoublePowerAttr) || user.getTag(BattlerTagType.FRENZY) || moveObj.hasAttr(ChargeAttr); const isUnaffected = moveObj.hasAttr(ConsecutiveUseDoublePowerAttr) || user.getTag(BattlerTagType.FRENZY);
const validLastMoveResult = (lastMove.result === MoveResult.SUCCESS) || (lastMove.result === MoveResult.MISS); const validLastMoveResult = (lastMove.result === MoveResult.SUCCESS) || (lastMove.result === MoveResult.MISS);
if (lastMove.move === move && validLastMoveResult && lastMove.move !== Moves.STRUGGLE && !isUnaffected) { if (lastMove.move === move && validLastMoveResult && lastMove.move !== Moves.STRUGGLE && !isUnaffected) {
return true; return true;

View File

@ -289,10 +289,9 @@ export default class Move implements Localizable {
} }
/** /**
* Getter function that returns if the move targets itself or an ally * Getter function that returns if the move targets the user or its ally
* @returns boolean * @returns boolean
*/ */
isAllyTarget(): boolean { isAllyTarget(): boolean {
switch (this.moveTarget) { switch (this.moveTarget) {
case MoveTarget.USER: case MoveTarget.USER:
@ -306,6 +305,10 @@ export default class Move implements Localizable {
return false; return false;
} }
isChargingMove(): this is ChargingMove {
return false;
}
/** /**
* Checks if the move is immune to certain types. * Checks if the move is immune to certain types.
* Currently looks at cases of Grass types with powder moves and Dark types with moves affected by Prankster. * Currently looks at cases of Grass types with powder moves and Dark types with moves affected by Prankster.
@ -893,6 +896,85 @@ export class SelfStatusMove extends Move {
} }
} }
type SubMove = new (...args: any[]) => Move;
function ChargeMove<TBase extends SubMove>(Base: TBase) {
return class extends Base {
/** The animation to play during the move's charging phase */
public readonly chargeAnim: ChargeAnim = ChargeAnim[`${Moves[this.id]}_CHARGING`];
/** The message to show during the move's charging phase */
private _chargeText: string;
/** Move attributes that apply during the move's charging phase */
public chargeAttrs: MoveAttr[] = [];
override isChargingMove(): this is ChargingMove {
return true;
}
/**
* Sets the text to be displayed during this move's charging phase.
* References to the user Pokemon should be written as "{USER}", and
* references to the target Pokemon should be written as "{TARGET}".
* @param chargeText the text to set
* @returns this {@linkcode Move} (for chaining API purposes)
*/
chargeText(chargeText: string): this {
this._chargeText = chargeText;
return this;
}
/**
* Queues the charge text to display to the player
* @param user the {@linkcode Pokemon} using this move
* @param target the {@linkcode Pokemon} targeted by this move (optional)
*/
showChargeText(user: Pokemon, target?: Pokemon): void {
user.scene.queueMessage(this._chargeText
.replace("{USER}", getPokemonNameWithAffix(user))
.replace("{TARGET}", getPokemonNameWithAffix(target))
);
}
/**
* Gets all charge attributes of the given attribute type.
* @param attrType any attribute that extends {@linkcode MoveAttr}
* @returns Array of attributes that match `attrType`, or an empty array if
* no matches are found.
*/
getChargeAttrs<T extends MoveAttr>(attrType: Constructor<T>): T[] {
return this.chargeAttrs.filter((attr): attr is T => attr instanceof attrType);
}
/**
* Checks if this move has an attribute of the given type.
* @param attrType any attribute that extends {@linkcode MoveAttr}
* @returns `true` if a matching attribute is found; `false` otherwise
*/
hasChargeAttr<T extends MoveAttr>(attrType: Constructor<T>): boolean {
return this.chargeAttrs.some((attr) => attr instanceof attrType);
}
/**
* Adds an attribute to this move to be applied during the move's charging phase
* @param ChargeAttrType the type of {@linkcode MoveAttr} being added
* @param args the parameters to construct the given {@linkcode MoveAttr} with
* @returns this {@linkcode Move} (for chaining API purposes)
*/
chargeAttr<T extends Constructor<MoveAttr>>(ChargeAttrType: T, ...args: ConstructorParameters<T>): this {
const chargeAttr = new ChargeAttrType(...args);
this.chargeAttrs.push(chargeAttr);
return this;
}
};
}
export class ChargingAttackMove extends ChargeMove(AttackMove) {}
export class ChargingSelfStatusMove extends ChargeMove(SelfStatusMove) {}
export type ChargingMove = ChargingAttackMove | ChargingSelfStatusMove;
/** /**
* Base class defining all {@linkcode Move} Attributes * Base class defining all {@linkcode Move} Attributes
* @abstract * @abstract
@ -2574,6 +2656,63 @@ export class OneHitKOAttr extends MoveAttr {
} }
} }
/**
* Attribute that allows charge moves to resolve in 1 turn under a given condition.
* Should only be used for {@linkcode ChargingMove | ChargingMoves} as a `chargeAttr`.
* @extends MoveAttr
*/
export class InstantChargeAttr extends MoveAttr {
/** The condition in which the move with this attribute instantly charges */
protected readonly condition: UserMoveConditionFunc;
constructor(condition: UserMoveConditionFunc) {
super(true);
this.condition = condition;
}
/**
* Flags the move with this attribute as instantly charged if this attribute's condition is met.
* @param user the {@linkcode Pokemon} using the move
* @param target n/a
* @param move the {@linkcode Move} associated with this attribute
* @param args
* - `[0]` a {@linkcode Utils.BooleanHolder | BooleanHolder} for the "instant charge" flag
* @returns `true` if the instant charge condition is met; `false` otherwise.
*/
override apply(user: Pokemon, target: Pokemon | null, move: Move, args: any[]): boolean {
const instantCharge = args[0];
if (!(instantCharge instanceof Utils.BooleanHolder)) {
return false;
}
if (this.condition(user, move)) {
instantCharge.value = true;
return true;
}
return false;
}
}
/**
* Attribute that allows charge moves to resolve in 1 turn while specific {@linkcode WeatherType | Weather}
* is active. Should only be used for {@linkcode ChargingMove | ChargingMoves} as a `chargeAttr`.
* @extends InstantChargeAttr
*/
export class WeatherInstantChargeAttr extends InstantChargeAttr {
constructor(weatherTypes: WeatherType[]) {
super((user, move) => {
const currentWeather = user.scene.arena.weather;
if (Utils.isNullOrUndefined(currentWeather?.weatherType)) {
return false;
} else {
return !currentWeather?.isEffectSuppressed(user.scene)
&& weatherTypes.includes(currentWeather?.weatherType);
}
});
}
}
export class OverrideMoveEffectAttr extends MoveAttr { export class OverrideMoveEffectAttr extends MoveAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean | Promise<boolean> { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean | Promise<boolean> {
//const overridden = args[0] as Utils.BooleanHolder; //const overridden = args[0] as Utils.BooleanHolder;
@ -2582,111 +2721,6 @@ export class OverrideMoveEffectAttr extends MoveAttr {
} }
} }
export class ChargeAttr extends OverrideMoveEffectAttr {
public chargeAnim: ChargeAnim;
private chargeText: string;
private tagType: BattlerTagType | null;
private chargeEffect: boolean;
public followUpPriority: integer | null;
constructor(chargeAnim: ChargeAnim, chargeText: string, tagType?: BattlerTagType | null, chargeEffect: boolean = false) {
super();
this.chargeAnim = chargeAnim;
this.chargeText = chargeText;
this.tagType = tagType!; // TODO: is this bang correct?
this.chargeEffect = chargeEffect;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise(resolve => {
const lastMove = user.getLastXMoves().find(() => true);
if (!lastMove || lastMove.move !== move.id || (lastMove.result !== MoveResult.OTHER && lastMove.turn !== user.scene.currentBattle.turn)) {
(args[0] as Utils.BooleanHolder).value = true;
new MoveChargeAnim(this.chargeAnim, move.id, user).play(user.scene, false, () => {
user.scene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
if (this.tagType) {
user.addTag(this.tagType, 1, move.id, user.id);
}
if (this.chargeEffect) {
applyMoveAttrs(MoveEffectAttr, user, target, move);
}
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER });
user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true });
user.addTag(BattlerTagType.CHARGING, 1, move.id, user.id);
resolve(true);
});
} else {
user.lapseTag(BattlerTagType.CHARGING);
resolve(false);
}
});
}
usedChargeEffect(user: Pokemon, target: Pokemon | null, move: Move): boolean {
if (!this.chargeEffect) {
return false;
}
// Account for move history being populated when this function is called
const lastMoves = user.getLastXMoves(2);
return lastMoves.length === 2 && lastMoves[1].move === move.id && lastMoves[1].result === MoveResult.OTHER;
}
}
export class SunlightChargeAttr extends ChargeAttr {
constructor(chargeAnim: ChargeAnim, chargeText: string) {
super(chargeAnim, chargeText);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise(resolve => {
const weatherType = user.scene.arena.weather?.weatherType;
if (!user.scene.arena.weather?.isEffectSuppressed(user.scene) && (weatherType === WeatherType.SUNNY || weatherType === WeatherType.HARSH_SUN)) {
resolve(false);
} else {
super.apply(user, target, move, args).then(result => resolve(result));
}
});
}
}
export class ElectroShotChargeAttr extends ChargeAttr {
private statIncreaseApplied: boolean;
constructor() {
super(ChargeAnim.ELECTRO_SHOT_CHARGING, i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" }), null, true);
// Add a flag because ChargeAttr skills use themselves twice instead of once over one-to-two turns
this.statIncreaseApplied = false;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise<boolean> {
return new Promise(resolve => {
const weatherType = user.scene.arena.weather?.weatherType;
if (!user.scene.arena.weather?.isEffectSuppressed(user.scene) && (weatherType === WeatherType.RAIN || weatherType === WeatherType.HEAVY_RAIN)) {
// Apply the SPATK increase every call when used in the rain
const statChangeAttr = new StatStageChangeAttr([ Stat.SPATK ], 1, true);
statChangeAttr.apply(user, target, move, args);
// After the SPATK is raised, execute the move resolution e.g. deal damage
resolve(false);
} else {
if (!this.statIncreaseApplied) {
// Apply the SPATK increase only if it hasn't been applied before e.g. on the first turn charge up animation
const statChangeAttr = new StatStageChangeAttr([ Stat.SPATK ], 1, true);
statChangeAttr.apply(user, target, move, args);
// Set the flag to true so that on the following turn it doesn't raise SPATK a second time
this.statIncreaseApplied = true;
}
super.apply(user, target, move, args).then(result => {
if (!result) {
// On the second turn, reset the statIncreaseApplied flag without applying the SPATK increase
this.statIncreaseApplied = false;
}
resolve(result);
});
}
});
}
}
export class DelayedAttackAttr extends OverrideMoveEffectAttr { export class DelayedAttackAttr extends OverrideMoveEffectAttr {
public tagType: ArenaTagType; public tagType: ArenaTagType;
public chargeAnim: ChargeAnim; public chargeAnim: ChargeAnim;
@ -4878,6 +4912,37 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move)
return true; return true;
}; };
/**
* Attribute that grants {@link https://bulbapedia.bulbagarden.net/wiki/Semi-invulnerable_turn | semi-invulnerability} to the user during
* the associated move's charging phase. Should only be used for {@linkcode ChargingMove | ChargingMoves} as a `chargeAttr`.
* @extends MoveEffectAttr
*/
export class SemiInvulnerableAttr extends MoveEffectAttr {
/** The type of {@linkcode SemiInvulnerableTag} to grant to the user */
public tagType: BattlerTagType;
constructor(tagType: BattlerTagType) {
super(true);
this.tagType = tagType;
}
/**
* Grants a {@linkcode SemiInvulnerableTag} to the associated move's user.
* @param user the {@linkcode Pokemon} using the move
* @param target n/a
* @param move the {@linkcode Move} being used
* @param args n/a
* @returns `true` if semi-invulnerability was successfully granted; `false` otherwise.
*/
override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean {
if (!super.apply(user, target, move, args)) {
return false;
}
return user.addTag(this.tagType, 1, move.id, user.id);
}
}
export class AddBattlerTagAttr extends MoveEffectAttr { export class AddBattlerTagAttr extends MoveEffectAttr {
public tagType: BattlerTagType; public tagType: BattlerTagType;
public turnCountMin: integer; public turnCountMin: integer;
@ -6138,7 +6203,7 @@ const lastMoveCopiableCondition: MoveConditionFunc = (user, target, move) => {
return false; return false;
} }
if (allMoves[copiableMove].hasAttr(ChargeAttr)) { if (allMoves[copiableMove].isChargingMove()) {
return false; return false;
} }
@ -6286,7 +6351,7 @@ const targetMoveCopiableCondition: MoveConditionFunc = (user, target, move) => {
return false; return false;
} }
if (allMoves[copiableMove.move].hasAttr(ChargeAttr) && copiableMove.result === MoveResult.OTHER) { if (allMoves[copiableMove.move].isChargingMove() && copiableMove.result === MoveResult.OTHER) {
return false; return false;
} }
@ -6985,6 +7050,20 @@ function applyMoveAttrsInternal(attrFilter: MoveAttrFilter, user: Pokemon | null
}); });
} }
function applyMoveChargeAttrsInternal(attrFilter: MoveAttrFilter, user: Pokemon | null, target: Pokemon | null, move: ChargingMove, args: any[]): Promise<void> {
return new Promise(resolve => {
const chargeAttrPromises: Promise<boolean>[] = [];
const chargeMoveAttrs = move.chargeAttrs.filter(a => attrFilter(a));
for (const attr of chargeMoveAttrs) {
const result = attr.apply(user, target, move, args);
if (result instanceof Promise) {
chargeAttrPromises.push(result);
}
}
Promise.allSettled(chargeAttrPromises).then(() => resolve());
});
}
export function applyMoveAttrs(attrType: Constructor<MoveAttr>, user: Pokemon | null, target: Pokemon | null, move: Move, ...args: any[]): Promise<void> { export function applyMoveAttrs(attrType: Constructor<MoveAttr>, user: Pokemon | null, target: Pokemon | null, move: Move, ...args: any[]): Promise<void> {
return applyMoveAttrsInternal((attr: MoveAttr) => attr instanceof attrType, user, target, move, args); return applyMoveAttrsInternal((attr: MoveAttr) => attr instanceof attrType, user, target, move, args);
} }
@ -6993,6 +7072,10 @@ export function applyFilteredMoveAttrs(attrFilter: MoveAttrFilter, user: Pokemon
return applyMoveAttrsInternal(attrFilter, user, target, move, args); return applyMoveAttrsInternal(attrFilter, user, target, move, args);
} }
export function applyMoveChargeAttrs(attrType: Constructor<MoveAttr>, user: Pokemon | null, target: Pokemon | null, move: ChargingMove, ...args: any[]): Promise<void> {
return applyMoveChargeAttrsInternal((attr: MoveAttr) => attr instanceof attrType, user, target, move, args);
}
export class MoveCondition { export class MoveCondition {
protected func: MoveConditionFunc; protected func: MoveConditionFunc;
@ -7237,8 +7320,8 @@ export function initMoves() {
new AttackMove(Moves.GUILLOTINE, Type.NORMAL, MoveCategory.PHYSICAL, 200, 30, 5, -1, 0, 1) new AttackMove(Moves.GUILLOTINE, Type.NORMAL, MoveCategory.PHYSICAL, 200, 30, 5, -1, 0, 1)
.attr(OneHitKOAttr) .attr(OneHitKOAttr)
.attr(OneHitKOAccuracyAttr), .attr(OneHitKOAccuracyAttr),
new AttackMove(Moves.RAZOR_WIND, Type.NORMAL, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 1) new ChargingAttackMove(Moves.RAZOR_WIND, Type.NORMAL, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 1)
.attr(ChargeAttr, ChargeAnim.RAZOR_WIND_CHARGING, i18next.t("moveTriggers:whippedUpAWhirlwind", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:whippedUpAWhirlwind", { pokemonName: "{USER}" }))
.attr(HighCritAttr) .attr(HighCritAttr)
.windMove() .windMove()
.ignoresVirtual() .ignoresVirtual()
@ -7258,8 +7341,9 @@ export function initMoves() {
.hidesTarget() .hidesTarget()
.windMove() .windMove()
.partial(), // Should force random switches .partial(), // Should force random switches
new AttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1) new ChargingAttackMove(Moves.FLY, Type.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1)
.attr(ChargeAttr, ChargeAnim.FLY_CHARGING, i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" }), BattlerTagType.FLYING) .chargeText(i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.condition(failOnGravityCondition) .condition(failOnGravityCondition)
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.BIND, Type.NORMAL, MoveCategory.PHYSICAL, 15, 85, 20, -1, 0, 1) new AttackMove(Moves.BIND, Type.NORMAL, MoveCategory.PHYSICAL, 15, 85, 20, -1, 0, 1)
@ -7408,8 +7492,9 @@ export function initMoves() {
.makesContact(false) .makesContact(false)
.slicingMove() .slicingMove()
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(Moves.SOLAR_BEAM, Type.GRASS, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 1) new ChargingAttackMove(Moves.SOLAR_BEAM, Type.GRASS, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 1)
.attr(SunlightChargeAttr, ChargeAnim.SOLAR_BEAM_CHARGING, i18next.t("moveTriggers:tookInSunlight", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:tookInSunlight", { pokemonName: "{USER}" }))
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.SUNNY, WeatherType.HARSH_SUN ])
.attr(AntiSunlightPowerDecreaseAttr) .attr(AntiSunlightPowerDecreaseAttr)
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.POISON_POWDER, Type.POISON, 75, 35, -1, 0, 1) new StatusMove(Moves.POISON_POWDER, Type.POISON, 75, 35, -1, 0, 1)
@ -7458,8 +7543,9 @@ export function initMoves() {
.attr(OneHitKOAccuracyAttr) .attr(OneHitKOAccuracyAttr)
.attr(HitsTagAttr, BattlerTagType.UNDERGROUND) .attr(HitsTagAttr, BattlerTagType.UNDERGROUND)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.DIG, Type.GROUND, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 1) new ChargingAttackMove(Moves.DIG, Type.GROUND, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 1)
.attr(ChargeAttr, ChargeAnim.DIG_CHARGING, i18next.t("moveTriggers:dugAHole", { pokemonName: "{USER}" }), BattlerTagType.UNDERGROUND) .chargeText(i18next.t("moveTriggers:dugAHole", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.UNDERGROUND)
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.TOXIC, Type.POISON, 90, 10, -1, 0, 1) new StatusMove(Moves.TOXIC, Type.POISON, 90, 10, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.TOXIC) .attr(StatusEffectAttr, StatusEffect.TOXIC)
@ -7555,9 +7641,9 @@ export function initMoves() {
.attr(TrapAttr, BattlerTagType.CLAMP), .attr(TrapAttr, BattlerTagType.CLAMP),
new AttackMove(Moves.SWIFT, Type.NORMAL, MoveCategory.SPECIAL, 60, -1, 20, -1, 0, 1) new AttackMove(Moves.SWIFT, Type.NORMAL, MoveCategory.SPECIAL, 60, -1, 20, -1, 0, 1)
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(Moves.SKULL_BASH, Type.NORMAL, MoveCategory.PHYSICAL, 130, 100, 10, -1, 0, 1) new ChargingAttackMove(Moves.SKULL_BASH, Type.NORMAL, MoveCategory.PHYSICAL, 130, 100, 10, -1, 0, 1)
.attr(ChargeAttr, ChargeAnim.SKULL_BASH_CHARGING, i18next.t("moveTriggers:loweredItsHead", { pokemonName: "{USER}" }), null, true) .chargeText(i18next.t("moveTriggers:loweredItsHead", { pokemonName: "{USER}" }))
.attr(StatStageChangeAttr, [ Stat.DEF ], 1, true) .chargeAttr(StatStageChangeAttr, [ Stat.DEF ], 1, true)
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.SPIKE_CANNON, Type.NORMAL, MoveCategory.PHYSICAL, 20, 100, 15, -1, 0, 1) new AttackMove(Moves.SPIKE_CANNON, Type.NORMAL, MoveCategory.PHYSICAL, 20, 100, 15, -1, 0, 1)
.attr(MultiHitAttr) .attr(MultiHitAttr)
@ -7594,8 +7680,8 @@ export function initMoves() {
.triageMove(), .triageMove(),
new StatusMove(Moves.LOVELY_KISS, Type.NORMAL, 75, 10, -1, 0, 1) new StatusMove(Moves.LOVELY_KISS, Type.NORMAL, 75, 10, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP), .attr(StatusEffectAttr, StatusEffect.SLEEP),
new AttackMove(Moves.SKY_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 1) new ChargingAttackMove(Moves.SKY_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 1)
.attr(ChargeAttr, ChargeAnim.SKY_ATTACK_CHARGING, i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" }))
.attr(HighCritAttr) .attr(HighCritAttr)
.attr(FlinchAttr) .attr(FlinchAttr)
.makesContact(false) .makesContact(false)
@ -8060,9 +8146,10 @@ export function initMoves() {
new AttackMove(Moves.SECRET_POWER, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 20, 30, 0, 3) new AttackMove(Moves.SECRET_POWER, Type.NORMAL, MoveCategory.PHYSICAL, 70, 100, 20, 30, 0, 3)
.makesContact(false) .makesContact(false)
.attr(SecretPowerAttr), .attr(SecretPowerAttr),
new AttackMove(Moves.DIVE, Type.WATER, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 3) new ChargingAttackMove(Moves.DIVE, Type.WATER, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 3)
.attr(ChargeAttr, ChargeAnim.DIVE_CHARGING, i18next.t("moveTriggers:hidUnderwater", { pokemonName: "{USER}" }), BattlerTagType.UNDERWATER, true) .chargeText(i18next.t("moveTriggers:hidUnderwater", { pokemonName: "{USER}" }))
.attr(GulpMissileTagAttr) .chargeAttr(SemiInvulnerableAttr, BattlerTagType.UNDERWATER)
.chargeAttr(GulpMissileTagAttr)
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.ARM_THRUST, Type.FIGHTING, MoveCategory.PHYSICAL, 15, 100, 20, -1, 0, 3) new AttackMove(Moves.ARM_THRUST, Type.FIGHTING, MoveCategory.PHYSICAL, 15, 100, 20, -1, 0, 3)
.attr(MultiHitAttr), .attr(MultiHitAttr),
@ -8195,8 +8282,9 @@ export function initMoves() {
.attr(RechargeAttr), .attr(RechargeAttr),
new SelfStatusMove(Moves.BULK_UP, Type.FIGHTING, -1, 20, -1, 0, 3) new SelfStatusMove(Moves.BULK_UP, Type.FIGHTING, -1, 20, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], 1, true), .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], 1, true),
new AttackMove(Moves.BOUNCE, Type.FLYING, MoveCategory.PHYSICAL, 85, 85, 5, 30, 0, 3) new ChargingAttackMove(Moves.BOUNCE, Type.FLYING, MoveCategory.PHYSICAL, 85, 85, 5, 30, 0, 3)
.attr(ChargeAttr, ChargeAnim.BOUNCE_CHARGING, i18next.t("moveTriggers:sprangUp", { pokemonName: "{USER}" }), BattlerTagType.FLYING) .chargeText(i18next.t("moveTriggers:sprangUp", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS) .attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.condition(failOnGravityCondition) .condition(failOnGravityCondition)
.ignoresVirtual(), .ignoresVirtual(),
@ -8551,8 +8639,9 @@ export function initMoves() {
new AttackMove(Moves.OMINOUS_WIND, Type.GHOST, MoveCategory.SPECIAL, 60, 100, 5, 10, 0, 4) new AttackMove(Moves.OMINOUS_WIND, Type.GHOST, MoveCategory.SPECIAL, 60, 100, 5, 10, 0, 4)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true)
.windMove(), .windMove(),
new AttackMove(Moves.SHADOW_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4) new ChargingAttackMove(Moves.SHADOW_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4)
.attr(ChargeAttr, ChargeAnim.SHADOW_FORCE_CHARGING, i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" }), BattlerTagType.HIDDEN) .chargeText(i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.HIDDEN)
.ignoresProtect() .ignoresProtect()
.ignoresVirtual(), .ignoresVirtual(),
new SelfStatusMove(Moves.HONE_CLAWS, Type.DARK, -1, 15, -1, 0, 5) new SelfStatusMove(Moves.HONE_CLAWS, Type.DARK, -1, 15, -1, 0, 5)
@ -8675,12 +8764,13 @@ export function initMoves() {
.attr( .attr(
MovePowerMultiplierAttr, MovePowerMultiplierAttr,
(user, target, move) => target.status || target.hasAbility(Abilities.COMATOSE) ? 2 : 1), (user, target, move) => target.status || target.hasAbility(Abilities.COMATOSE) ? 2 : 1),
new AttackMove(Moves.SKY_DROP, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) new ChargingAttackMove(Moves.SKY_DROP, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
.partial() // Should immobilize the target, Flying types should take no damage. cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/ .chargeText(i18next.t("moveTriggers:tookTargetIntoSky", { pokemonName: "{USER}", targetName: "{TARGET}" }))
.attr(ChargeAttr, ChargeAnim.SKY_DROP_CHARGING, i18next.t("moveTriggers:tookTargetIntoSky", { pokemonName: "{USER}", targetName: "{TARGET}" }), BattlerTagType.FLYING) // TODO: Add 2nd turn message .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.condition(failOnGravityCondition) .condition(failOnGravityCondition)
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE)) .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE))
.ignoresVirtual(), .ignoresVirtual()
.partial(), // Should immobilize the target, Flying types should take no damage. cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/
new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5) new SelfStatusMove(Moves.SHIFT_GEAR, Type.STEEL, -1, 10, -1, 0, 5)
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true) .attr(StatStageChangeAttr, [ Stat.ATK ], 1, true)
.attr(StatStageChangeAttr, [ Stat.SPD ], 2, true), .attr(StatStageChangeAttr, [ Stat.SPD ], 2, true),
@ -8830,12 +8920,12 @@ export function initMoves() {
new AttackMove(Moves.FIERY_DANCE, Type.FIRE, MoveCategory.SPECIAL, 80, 100, 10, 50, 0, 5) new AttackMove(Moves.FIERY_DANCE, Type.FIRE, MoveCategory.SPECIAL, 80, 100, 10, 50, 0, 5)
.attr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) .attr(StatStageChangeAttr, [ Stat.SPATK ], 1, true)
.danceMove(), .danceMove(),
new AttackMove(Moves.FREEZE_SHOCK, Type.ICE, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 5) new ChargingAttackMove(Moves.FREEZE_SHOCK, Type.ICE, MoveCategory.PHYSICAL, 140, 90, 5, 30, 0, 5)
.attr(ChargeAttr, ChargeAnim.FREEZE_SHOCK_CHARGING, i18next.t("moveTriggers:becameCloakedInFreezingLight", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:becameCloakedInFreezingLight", { pokemonName: "{USER}" }))
.attr(StatusEffectAttr, StatusEffect.PARALYSIS) .attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.ICE_BURN, Type.ICE, MoveCategory.SPECIAL, 140, 90, 5, 30, 0, 5) new ChargingAttackMove(Moves.ICE_BURN, Type.ICE, MoveCategory.SPECIAL, 140, 90, 5, 30, 0, 5)
.attr(ChargeAttr, ChargeAnim.ICE_BURN_CHARGING, i18next.t("moveTriggers:becameCloakedInFreezingAir", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:becameCloakedInFreezingAir", { pokemonName: "{USER}" }))
.attr(StatusEffectAttr, StatusEffect.BURN) .attr(StatusEffectAttr, StatusEffect.BURN)
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.SNARL, Type.DARK, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 5) new AttackMove(Moves.SNARL, Type.DARK, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 5)
@ -8877,8 +8967,9 @@ export function initMoves() {
.target(MoveTarget.ENEMY_SIDE), .target(MoveTarget.ENEMY_SIDE),
new AttackMove(Moves.FELL_STINGER, Type.BUG, MoveCategory.PHYSICAL, 50, 100, 25, -1, 0, 6) new AttackMove(Moves.FELL_STINGER, Type.BUG, MoveCategory.PHYSICAL, 50, 100, 25, -1, 0, 6)
.attr(PostVictoryStatStageChangeAttr, [ Stat.ATK ], 3, true ), .attr(PostVictoryStatStageChangeAttr, [ Stat.ATK ], 3, true ),
new AttackMove(Moves.PHANTOM_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) new ChargingAttackMove(Moves.PHANTOM_FORCE, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
.attr(ChargeAttr, ChargeAnim.PHANTOM_FORCE_CHARGING, i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" }), BattlerTagType.HIDDEN) .chargeText(i18next.t("moveTriggers:vanishedInstantly", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.HIDDEN)
.ignoresProtect() .ignoresProtect()
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.TRICK_OR_TREAT, Type.GHOST, 100, 20, -1, 0, 6) new StatusMove(Moves.TRICK_OR_TREAT, Type.GHOST, 100, 20, -1, 0, 6)
@ -8988,8 +9079,8 @@ export function initMoves() {
.ignoresSubstitute() .ignoresSubstitute()
.powderMove() .powderMove()
.unimplemented(), .unimplemented(),
new SelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6) new ChargingSelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6)
.attr(ChargeAttr, ChargeAnim.GEOMANCY_CHARGING, i18next.t("moveTriggers:isChargingPower", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:isChargingPower", { pokemonName: "{USER}" }))
.attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true) .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true)
.ignoresVirtual(), .ignoresVirtual(),
new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6) new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6)
@ -9198,8 +9289,9 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK ], -1) .attr(StatStageChangeAttr, [ Stat.ATK ], -1)
.condition((user, target, move) => target.getStatStage(Stat.ATK) > -6) .condition((user, target, move) => target.getStatStage(Stat.ATK) > -6)
.triageMove(), .triageMove(),
new AttackMove(Moves.SOLAR_BLADE, Type.GRASS, MoveCategory.PHYSICAL, 125, 100, 10, -1, 0, 7) new ChargingAttackMove(Moves.SOLAR_BLADE, Type.GRASS, MoveCategory.PHYSICAL, 125, 100, 10, -1, 0, 7)
.attr(SunlightChargeAttr, ChargeAnim.SOLAR_BLADE_CHARGING, i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" })) .chargeText(i18next.t("moveTriggers:isGlowing", { pokemonName: "{USER}" }))
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.SUNNY, WeatherType.HARSH_SUN ])
.attr(AntiSunlightPowerDecreaseAttr) .attr(AntiSunlightPowerDecreaseAttr)
.slicingMove(), .slicingMove(),
new AttackMove(Moves.LEAFAGE, Type.GRASS, MoveCategory.PHYSICAL, 40, 100, 40, -1, 0, 7) new AttackMove(Moves.LEAFAGE, Type.GRASS, MoveCategory.PHYSICAL, 40, 100, 40, -1, 0, 7)
@ -9625,9 +9717,9 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, null, true, false, MoveEffectTrigger.HIT, false, true) .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, null, true, false, MoveEffectTrigger.HIT, false, true)
.attr(MultiHitAttr) .attr(MultiHitAttr)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.METEOR_BEAM, Type.ROCK, MoveCategory.SPECIAL, 120, 90, 10, 100, 0, 8) new ChargingAttackMove(Moves.METEOR_BEAM, Type.ROCK, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 8)
.attr(ChargeAttr, ChargeAnim.METEOR_BEAM_CHARGING, i18next.t("moveTriggers:isOverflowingWithSpacePower", { pokemonName: "{USER}" }), null, true) .chargeText(i18next.t("moveTriggers:isOverflowingWithSpacePower", { pokemonName: "{USER}" }))
.attr(StatStageChangeAttr, [ Stat.SPATK ], 1, true) .chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true)
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.SHELL_SIDE_ARM, Type.POISON, MoveCategory.SPECIAL, 90, 100, 10, 20, 0, 8) new AttackMove(Moves.SHELL_SIDE_ARM, Type.POISON, MoveCategory.SPECIAL, 90, 100, 10, 20, 0, 8)
.attr(ShellSideArmCategoryAttr) .attr(ShellSideArmCategoryAttr)
@ -10079,8 +10171,10 @@ export function initMoves() {
.attr(IvyCudgelTypeAttr) .attr(IvyCudgelTypeAttr)
.attr(HighCritAttr) .attr(HighCritAttr)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.ELECTRO_SHOT, Type.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9) new ChargingAttackMove(Moves.ELECTRO_SHOT, Type.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9)
.attr(ElectroShotChargeAttr) .chargeText(i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" }))
.chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true)
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.RAIN, WeatherType.HEAVY_RAIN ])
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.TERA_STARSTORM, Type.NORMAL, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9) new AttackMove(Moves.TERA_STARSTORM, Type.NORMAL, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9)
.attr(TeraMoveCategoryAttr) .attr(TeraMoveCategoryAttr)

View File

@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "#app/battle-scene";
import { Variant, VariantSet, variantColorCache } from "#app/data/variant"; import { Variant, VariantSet, variantColorCache } from "#app/data/variant";
import { variantData } from "#app/data/variant"; import { variantData } from "#app/data/variant";
import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "#app/ui/battle-info"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "#app/ui/battle-info";
import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget, CombinedPledgeStabBoostAttr } from "#app/data/move"; import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget, CombinedPledgeStabBoostAttr } from "#app/data/move";
import { default as PokemonSpecies, PokemonSpeciesForm, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { default as PokemonSpecies, PokemonSpeciesForm, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species";
import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER, getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER, getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters";
import { starterPassiveAbilities } from "#app/data/balance/passives"; import { starterPassiveAbilities } from "#app/data/balance/passives";
@ -2121,7 +2121,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
// Trainers get a weight bump to stat buffing moves // Trainers get a weight bump to stat buffing moves
movePool = movePool.map(m => [ m[0], m[1] * (allMoves[m[0]].getAttrs(StatStageChangeAttr).some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1) ]); movePool = movePool.map(m => [ m[0], m[1] * (allMoves[m[0]].getAttrs(StatStageChangeAttr).some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1) ]);
// Trainers get a weight decrease to multiturn moves // Trainers get a weight decrease to multiturn moves
movePool = movePool.map(m => [ m[0], m[1] * (!!allMoves[m[0]].hasAttr(ChargeAttr) || !!allMoves[m[0]].hasAttr(RechargeAttr) ? 0.7 : 1) ]); movePool = movePool.map(m => [ m[0], m[1] * (!!allMoves[m[0]].isChargingMove() || !!allMoves[m[0]].hasAttr(RechargeAttr) ? 0.7 : 1) ]);
} }
// Weight towards higher power moves, by reducing the power of moves below the highest power. // Weight towards higher power moves, by reducing the power of moves below the highest power.

View File

@ -0,0 +1,84 @@
import BattleScene from "#app/battle-scene";
import { BattlerIndex } from "#app/battle";
import { MoveChargeAnim } from "#app/data/battle-anims";
import { applyMoveChargeAttrs, MoveEffectAttr, InstantChargeAttr } from "#app/data/move";
import Pokemon, { MoveResult, PokemonMove } from "#app/field/pokemon";
import { BooleanHolder } from "#app/utils";
import { MovePhase } from "#app/phases/move-phase";
import { PokemonPhase } from "#app/phases/pokemon-phase";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveEndPhase } from "#app/phases/move-end-phase";
/**
* Phase for the "charging turn" of two-turn moves (e.g. Dig).
* @extends {@linkcode PokemonPhase}
*/
export class MoveChargePhase extends PokemonPhase {
/** The move instance that this phase applies */
public move: PokemonMove;
/** The field index targeted by the move (Charging moves assume single target) */
public targetIndex: BattlerIndex;
constructor(scene: BattleScene, battlerIndex: BattlerIndex, targetIndex: BattlerIndex, move: PokemonMove) {
super(scene, battlerIndex);
this.move = move;
this.targetIndex = targetIndex;
}
public override start() {
super.start();
const user = this.getUserPokemon();
const target = this.getTargetPokemon();
const move = this.move.getMove();
// If the target is somehow not defined, or the move is somehow not a ChargingMove,
// immediately end this phase.
if (!target || !(move.isChargingMove())) {
console.warn("Invalid parameters for MoveChargePhase");
return super.end();
}
new MoveChargeAnim(move.chargeAnim, move.id, user).play(this.scene, false, () => {
move.showChargeText(user, target);
applyMoveChargeAttrs(MoveEffectAttr, user, target, move).then(() => {
user.addTag(BattlerTagType.CHARGING, 1, move.id, user.id);
this.end();
});
});
}
/** Checks the move's instant charge conditions, then ends this phase. */
public override end() {
const user = this.getUserPokemon();
const move = this.move.getMove();
if (move.isChargingMove()) {
const instantCharge = new BooleanHolder(false);
applyMoveChargeAttrs(InstantChargeAttr, user, null, move, instantCharge);
if (instantCharge.value) {
// this MoveEndPhase will be duplicated by the queued MovePhase if not removed
this.scene.tryRemovePhase((phase) => phase instanceof MoveEndPhase && phase.getPokemon() === user);
// queue a new MovePhase for this move's attack phase
this.scene.unshiftPhase(new MovePhase(this.scene, user, [ this.targetIndex ], this.move, false));
} else {
user.getMoveQueue().push({ move: move.id, targets: [ this.targetIndex ]});
}
// Add this move's charging phase to the user's move history
user.pushMoveHistory({ move: this.move.moveId, targets: [ this.targetIndex ], result: MoveResult.OTHER });
}
super.end();
}
public getUserPokemon(): Pokemon {
return (this.player ? this.scene.getPlayerField() : this.scene.getEnemyField())[this.fieldIndex];
}
public getTargetPokemon(): Pokemon | undefined {
return this.scene.getField(true).find((p) => this.targetIndex === p.getBattlerIndex());
}
}

View File

@ -4,7 +4,7 @@ import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr,
import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag";
import { MoveAnim } from "#app/data/battle-anims"; import { MoveAnim } from "#app/data/battle-anims";
import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags";
import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, OneHitKOAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move"; import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, OneHitKOAttr, MoveEffectTrigger, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move";
import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms";
import { BattlerTagType } from "#app/enums/battler-tag-type"; import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Moves } from "#app/enums/moves"; import { Moves } from "#app/enums/moves";
@ -24,10 +24,10 @@ export class MoveEffectPhase extends PokemonPhase {
super(scene, battlerIndex); super(scene, battlerIndex);
this.move = move; this.move = move;
/** /**
* In double battles, if the right Pokemon selects a spread move and the left Pokemon dies * 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 * 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. * 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.getMove().moveTarget === MoveTarget.ALL_NEAR_OTHERS) {
const i = targets.indexOf(battlerIndex); const i = targets.indexOf(battlerIndex);
targets.splice(i, i + 1); targets.splice(i, i + 1);
@ -49,9 +49,9 @@ export class MoveEffectPhase extends PokemonPhase {
} }
/** /**
* Does an effect from this move override other effects on this turn? * Does an effect from this move override other effects on this turn?
* e.g. Charging moves (Fly, etc.) on their first turn of use. * e.g. Charging moves (Fly, etc.) on their first turn of use.
*/ */
const overridden = new Utils.BooleanHolder(false); const overridden = new Utils.BooleanHolder(false);
/** The {@linkcode Move} object from {@linkcode allMoves} invoked by this phase */ /** The {@linkcode Move} object from {@linkcode allMoves} invoked by this phase */
const move = this.move.getMove(); const move = this.move.getMove();
@ -66,10 +66,10 @@ export class MoveEffectPhase extends PokemonPhase {
user.lapseTags(BattlerTagLapseType.MOVE_EFFECT); user.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
/** /**
* If this phase is for the first hit of the invoked move, * If this phase is for the first hit of the invoked move,
* resolve the move's total hit count. This block combines the * resolve the move's total hit count. This block combines the
* effects of the move itself, Parental Bond, and Multi-Lens to do so. * effects of the move itself, Parental Bond, and Multi-Lens to do so.
*/ */
if (user.turnData.hitsLeft === -1) { if (user.turnData.hitsLeft === -1) {
const hitCount = new Utils.IntegerHolder(1); const hitCount = new Utils.IntegerHolder(1);
// Assume single target for multi hit // Assume single target for multi hit
@ -86,16 +86,16 @@ export class MoveEffectPhase extends PokemonPhase {
} }
/** /**
* Log to be entered into the user's move history once the move result is resolved. * Log to be entered into the user's move history once the move result is resolved.
* Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully * Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully
* used in the sense of "Does it have an effect on the user?". * used in the sense of "Does it have an effect on the user?".
*/ */
const moveHistoryEntry = { move: this.move.moveId, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual }; const moveHistoryEntry = { move: this.move.moveId, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual };
/** /**
* Stores results of hit checks of the invoked move against all targets, organized by battler index. * Stores results of hit checks of the invoked move against all targets, organized by battler index.
* @see {@linkcode hitCheck} * @see {@linkcode hitCheck}
*/ */
const targetHitChecks = Object.fromEntries(targets.map(p => [ p.getBattlerIndex(), this.hitCheck(p) ])); const targetHitChecks = Object.fromEntries(targets.map(p => [ p.getBattlerIndex(), this.hitCheck(p) ]));
const hasActiveTargets = targets.some(t => t.isActive(true)); const hasActiveTargets = targets.some(t => t.isActive(true));
@ -104,11 +104,10 @@ export class MoveEffectPhase extends PokemonPhase {
&& !targets[0].getTag(SemiInvulnerableTag); && !targets[0].getTag(SemiInvulnerableTag);
/** /**
* 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 single-target
* (and not random target) and failed the hit check against its target (MISS), log the move * (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. * 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 || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) {
this.stopMultiHit(); this.stopMultiHit();
if (hasActiveTargets) { if (hasActiveTargets) {
@ -154,9 +153,9 @@ export class MoveEffectPhase extends PokemonPhase {
&& !target.getTag(SemiInvulnerableTag); && !target.getTag(SemiInvulnerableTag);
/** /**
* If the move missed a target, stop all future hits against that target * If the move missed a target, stop all future hits against that target
* and move on to the next target (if there is one). * and move on to the next target (if there is one).
*/ */
if (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()]) { if (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()]) {
this.stopMultiHit(target); this.stopMultiHit(target);
this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) })); this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }));
@ -177,23 +176,23 @@ export class MoveEffectPhase extends PokemonPhase {
} }
/** /**
* Since all fail/miss checks have applied, the move is considered successfully applied. * 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 * 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. * is overwritten and the move is logged as a FAIL.
*/ */
moveHistoryEntry.result = MoveResult.SUCCESS; moveHistoryEntry.result = MoveResult.SUCCESS;
/** /**
* Stores the result of applying the invoked move to the target. * Stores the result of applying the invoked move to the target.
* If the target is protected, the result is always `NO_EFFECT`. * If the target is protected, the result is always `NO_EFFECT`.
* Otherwise, the hit result is based on type effectiveness, immunities, * Otherwise, the hit result is based on type effectiveness, immunities,
* and other factors that may negate the attack or status application. * and other factors that may negate the attack or status application.
* *
* Internally, the call to {@linkcode Pokemon.apply} is where damage is calculated * 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 * (for attack moves) and the target's HP is updated. However, this isn't
* made visible to the user until the resulting {@linkcode DamagePhase} * made visible to the user until the resulting {@linkcode DamagePhase}
* is invoked. * is invoked.
*/ */
const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT; const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT;
/** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */
@ -211,9 +210,9 @@ export class MoveEffectPhase extends PokemonPhase {
} }
/** /**
* If the move has no effect on the target (i.e. the target is protected or immune), * If the move has no effect on the target (i.e. the target is protected or immune),
* change the logged move result to FAIL. * change the logged move result to FAIL.
*/ */
if (hitResult === HitResult.NO_EFFECT) { if (hitResult === HitResult.NO_EFFECT) {
moveHistoryEntry.result = MoveResult.FAIL; moveHistoryEntry.result = MoveResult.FAIL;
} }
@ -222,43 +221,41 @@ export class MoveEffectPhase extends PokemonPhase {
const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()); const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive());
/** /**
* If the user can change forms by using the invoked move, * If the user can change forms by using the invoked move,
* it only changes forms after the move's last hit * it only changes forms after the move's last hit
* (see Relic Song's interaction with Parental Bond when used by Meloetta). * (see Relic Song's interaction with Parental Bond when used by Meloetta).
*/ */
if (lastHit) { if (lastHit) {
this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
} }
/** /**
* Create a Promise that applys *all* effects from the invoked move's MoveEffectAttrs. * Create a Promise that applys *all* effects from the invoked move's MoveEffectAttrs.
* These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger * These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger
* type requires different conditions to be met with respect to the move's hit result. * type requires different conditions to be met with respect to the move's hit result.
*/ */
applyAttrs.push(new Promise(resolve => { applyAttrs.push(new Promise(resolve => {
// Apply all effects with PRE_MOVE triggers (if the target isn't immune to the move) // Apply all effects with PRE_MOVE triggers (if the target isn't immune to the move)
applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && hitResult !== HitResult.NO_EFFECT, applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && hitResult !== HitResult.NO_EFFECT,
user, target, move).then(() => { user, target, move).then(() => {
// All other effects require the move to not have failed or have been cancelled to trigger // All other effects require the move to not have failed or have been cancelled to trigger
if (hitResult !== HitResult.FAIL) { if (hitResult !== HitResult.FAIL) {
/** Are the move's effects tied to the first turn of a charge move? */
const chargeEffect = !!move.getAttrs(ChargeAttr).find(ca => ca.usedChargeEffect(user, this.getTarget() ?? null, move));
/** /**
* If the invoked move's effects are meant to trigger during the move's "charge turn," * If the invoked move's effects are meant to trigger during the move's "charge turn,"
* ignore all effects after this point. * ignore all effects after this point.
* Otherwise, apply all self-targeted POST_APPLY effects. * Otherwise, apply all self-targeted POST_APPLY effects.
*/ */
Utils.executeIf(!chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY
&& attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move)).then(() => { && attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move).then(() => {
// All effects past this point require the move to have hit the target // All effects past this point require the move to have hit the target
if (hitResult !== HitResult.NO_EFFECT) { if (hitResult !== HitResult.NO_EFFECT) {
// Apply all non-self-targeted POST_APPLY effects // Apply all non-self-targeted POST_APPLY effects
applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY
&& !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => {
/** /**
* If the move hit, and the target doesn't have Shield Dust, * If the move hit, and the target doesn't have Shield Dust,
* apply the chance to flinch the target gained from King's Rock * apply the chance to flinch the target gained from King's Rock
*/ */
if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) { if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) {
const flinched = new Utils.BooleanHolder(false); const flinched = new Utils.BooleanHolder(false);
user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
@ -267,7 +264,7 @@ export class MoveEffectPhase extends PokemonPhase {
} }
} }
// If the move was not protected against, apply all HIT effects // If the move was not protected against, apply all HIT effects
Utils.executeIf(!isProtected && !chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT Utils.executeIf(!isProtected, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT
&& (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => { && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => {
// Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them) // Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them)
return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => {
@ -286,9 +283,9 @@ export class MoveEffectPhase extends PokemonPhase {
// Apply the user's post-attack ability effects // Apply the user's post-attack ability effects
applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => { applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => {
/** /**
* If the invoked move is an attack, apply the user's chance to * If the invoked move is an attack, apply the user's chance to
* steal an item from the target granted by Grip Claw * steal an item from the target granted by Grip Claw
*/ */
if (this.move.getMove() instanceof AttackMove) { if (this.move.getMove() instanceof AttackMove) {
this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target);
} }
@ -343,12 +340,12 @@ export class MoveEffectPhase extends PokemonPhase {
end() { end() {
const user = this.getUserPokemon(); const user = this.getUserPokemon();
/** /**
* If this phase isn't for the invoked move's last strike, * If this phase isn't for the invoked move's last strike,
* unshift another MoveEffectPhase for the next strike. * unshift another MoveEffectPhase for the next strike.
* Otherwise, queue a message indicating the number of times the move has struck * Otherwise, queue a message indicating the number of times the move has struck
* (if the move has struck more than once), then apply the heal from Shell Bell * (if the move has struck more than once), then apply the heal from Shell Bell
* to the user. * to the user.
*/ */
if (user) { if (user) {
if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getTarget()?.isActive()) { if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getTarget()?.isActive()) {
this.scene.unshiftPhase(this.getNewHitPhase()); this.scene.unshiftPhase(this.getNewHitPhase());
@ -447,9 +444,9 @@ export class MoveEffectPhase extends PokemonPhase {
} }
/** /**
* Removes the given {@linkcode Pokemon} from this phase's target list * Removes the given {@linkcode Pokemon} from this phase's target list
* @param target {@linkcode Pokemon} the Pokemon to be removed * @param target {@linkcode Pokemon} the Pokemon to be removed
*/ */
removeTarget(target: Pokemon): void { removeTarget(target: Pokemon): void {
const targetIndex = this.targets.findIndex(ind => ind === target.getBattlerIndex()); const targetIndex = this.targets.findIndex(ind => ind === target.getBattlerIndex());
if (targetIndex !== -1) { if (targetIndex !== -1) {
@ -458,19 +455,19 @@ export class MoveEffectPhase extends PokemonPhase {
} }
/** /**
* Prevents subsequent strikes of this phase's invoked move from occurring * Prevents subsequent strikes of this phase's invoked move from occurring
* @param target {@linkcode Pokemon} if defined, only stop subsequent * @param target {@linkcode Pokemon} if defined, only stop subsequent
* strikes against this Pokemon * strikes against this Pokemon
*/ */
stopMultiHit(target?: Pokemon): void { stopMultiHit(target?: Pokemon): void {
/** If given a specific target, remove the target from subsequent strikes */ /** If given a specific target, remove the target from subsequent strikes */
if (target) { if (target) {
this.removeTarget(target); this.removeTarget(target);
} }
/** /**
* If no target specified, or the specified target was the last of this move's * If no target specified, or the specified target was the last of this move's
* targets, completely cancel all subsequent strikes. * targets, completely cancel all subsequent strikes.
*/ */
if (!target || this.targets.length === 0 ) { if (!target || this.targets.length === 0 ) {
this.getUserPokemon()!.turnData.hitCount = 1; // TODO: is the bang correct here? this.getUserPokemon()!.turnData.hitCount = 1; // TODO: is the bang correct here?
this.getUserPokemon()!.turnData.hitsLeft = 1; // TODO: is the bang correct here? this.getUserPokemon()!.turnData.hitsLeft = 1; // TODO: is the bang correct here?

View File

@ -3,13 +3,13 @@ import BattleScene from "#app/battle-scene";
import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr, ReduceStatusEffectDurationAbAttr } from "#app/data/ability"; import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr, ReduceStatusEffectDurationAbAttr } from "#app/data/ability";
import { CommonAnim } from "#app/data/battle-anims"; import { CommonAnim } from "#app/data/battle-anims";
import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags"; import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags";
import { allMoves, applyMoveAttrs, BypassRedirectAttr, BypassSleepAttr, ChargeAttr, CopyMoveAttr, HealStatusEffectAttr, MoveFlags, PreMoveMessageAttr } from "#app/data/move"; import { allMoves, applyMoveAttrs, BypassRedirectAttr, BypassSleepAttr, CopyMoveAttr, HealStatusEffectAttr, MoveFlags, PreMoveMessageAttr } from "#app/data/move";
import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms";
import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect";
import { Type } from "#app/data/type"; import { Type } from "#app/data/type";
import { getTerrainBlockMessage } from "#app/data/weather"; import { getTerrainBlockMessage } from "#app/data/weather";
import { MoveUsedEvent } from "#app/events/battle-scene"; import { MoveUsedEvent } from "#app/events/battle-scene";
import Pokemon, { MoveResult, PokemonMove, TurnMove } from "#app/field/pokemon"; import Pokemon, { MoveResult, PokemonMove } from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides"; import Overrides from "#app/overrides";
import { BattlePhase } from "#app/phases/battle-phase"; import { BattlePhase } from "#app/phases/battle-phase";
@ -23,6 +23,7 @@ import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import i18next from "i18next"; import i18next from "i18next";
import { MoveChargePhase } from "#app/phases/move-charge-phase";
export class MovePhase extends BattlePhase { export class MovePhase extends BattlePhase {
protected _pokemon: Pokemon; protected _pokemon: Pokemon;
@ -135,6 +136,8 @@ export class MovePhase extends BattlePhase {
if (this.cancelled || this.failed) { if (this.cancelled || this.failed) {
this.handlePreMoveFailures(); this.handlePreMoveFailures();
} else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) {
this.chargeMove();
} else { } else {
this.useMove(); this.useMove();
} }
@ -226,12 +229,15 @@ export class MovePhase extends BattlePhase {
this.showMoveText(); this.showMoveText();
// TODO: Clean up implementation of two-turn moves.
if (moveQueue.length > 0) { if (moveQueue.length > 0) {
// Using .shift here clears out two turn moves once they've been used // Using .shift here clears out two turn moves once they've been used
this.ignorePp = moveQueue.shift()?.ignorePP ?? false; this.ignorePp = moveQueue.shift()?.ignorePP ?? false;
} }
if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) {
this.pokemon.lapseTag(BattlerTagType.CHARGING);
}
// "commit" to using the move, deducting PP. // "commit" to using the move, deducting PP.
if (!this.ignorePp) { if (!this.ignorePp) {
const ppUsed = 1 + this.getPpIncreaseFromPressure(targets); const ppUsed = 1 + this.getPpIncreaseFromPressure(targets);
@ -295,6 +301,9 @@ export class MovePhase extends BattlePhase {
} }
this.showFailedText(failedText); this.showFailedText(failedText);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
} }
// Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`).
@ -306,6 +315,35 @@ export class MovePhase extends BattlePhase {
} }
} }
/** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */
protected chargeMove() {
const move = this.move.getMove();
const targets = this.getActiveTargetPokemon();
if (move.applyConditions(this.pokemon, targets[0], move)) {
// Protean and Libero apply on the charging turn of charge moves
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
this.showMoveText();
this.scene.unshiftPhase(new MoveChargePhase(this.scene, this.pokemon.getBattlerIndex(), this.targets[0], this.move));
} else {
this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual });
let failedText: string | undefined;
const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false));
if (failureMessage) {
failedText = failureMessage;
}
this.showMoveText();
this.showFailedText(failedText);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
}
}
/** /**
* Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`, * Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`,
* then ends the phase. * then ends the phase.
@ -419,8 +457,6 @@ export class MovePhase extends BattlePhase {
* - Lapses `AFTER_MOVE` tags: * - Lapses `AFTER_MOVE` tags:
* - This handles the effects of {@link Moves.SUBSTITUTE Substitute} * - This handles the effects of {@link Moves.SUBSTITUTE Substitute}
* - Removes the second turn of charge moves * - Removes the second turn of charge moves
*
* TODO: handle charge moves more gracefully
*/ */
protected handlePreMoveFailures(): void { protected handlePreMoveFailures(): void {
if (this.cancelled || this.failed) { if (this.cancelled || this.failed) {
@ -452,18 +488,7 @@ export class MovePhase extends BattlePhase {
return; return;
} }
if (this.move.getMove().hasAttr(ChargeAttr)) { if (this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED)) {
const lastMove = this.pokemon.getLastXMoves() as TurnMove[];
if (!lastMove.length || lastMove[0].move !== this.move.getMove().id || lastMove[0].result !== MoveResult.OTHER) {
this.scene.queueMessage(i18next.t("battle:useMove", {
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
moveName: this.move.getName()
}), 500);
return;
}
}
if (this.pokemon.getTag(BattlerTagType.RECHARGING || BattlerTagType.INTERRUPTED)) {
return; return;
} }

View File

@ -52,6 +52,7 @@ describe("Abilities - Volt Absorb", () => {
expect(playerPokemon.getTag(BattlerTagType.CHARGED)).toBeDefined(); expect(playerPokemon.getTag(BattlerTagType.CHARGED)).toBeDefined();
expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase");
}); });
it("should activate regardless of accuracy checks", async () => { it("should activate regardless of accuracy checks", async () => {
game.override.moveset(Moves.THUNDERBOLT); game.override.moveset(Moves.THUNDERBOLT);
game.override.enemyMoveset(Moves.SPLASH); game.override.enemyMoveset(Moves.SPLASH);
@ -71,6 +72,7 @@ describe("Abilities - Volt Absorb", () => {
await game.phaseInterceptor.to("BerryPhase", false); await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
}); });
it("regardless of accuracy should not trigger on pokemon in semi invulnerable state", async () => { it("regardless of accuracy should not trigger on pokemon in semi invulnerable state", async () => {
game.override.moveset(Moves.THUNDERBOLT); game.override.moveset(Moves.THUNDERBOLT);
game.override.enemyMoveset(Moves.DIVE); game.override.enemyMoveset(Moves.DIVE);
@ -84,9 +86,7 @@ describe("Abilities - Volt Absorb", () => {
game.move.select(Moves.THUNDERBOLT); game.move.select(Moves.THUNDERBOLT);
enemyPokemon.hp = enemyPokemon.hp - 1; enemyPokemon.hp = enemyPokemon.hp - 1;
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("MoveEffectPhase");
await game.move.forceMiss();
await game.phaseInterceptor.to("BerryPhase", false); await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
}); });

View File

@ -1,8 +1,8 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/move"; import { allMoves } from "#app/data/move";
import { Abilities } from "#app/enums/abilities"; import { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#app/enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { BattlerTagType } from "#enums/battler-tag-type";
import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager"; import GameManager from "#test/utils/gameManager";
@ -31,7 +31,8 @@ describe("Arena - Gravity", () => {
.ability(Abilities.UNNERVE) .ability(Abilities.UNNERVE)
.enemyAbility(Abilities.BALL_FETCH) .enemyAbility(Abilities.BALL_FETCH)
.enemySpecies(Species.SHUCKLE) .enemySpecies(Species.SHUCKLE)
.enemyMoveset(Moves.SPLASH); .enemyMoveset(Moves.SPLASH)
.enemyLevel(5);
}); });
// Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) // Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move)
@ -42,102 +43,121 @@ describe("Arena - Gravity", () => {
vi.spyOn(moveToCheck, "calculateBattleAccuracy"); vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn // Setup Gravity on first turn
await game.startBattle([ Species.PIKACHU ]); await game.classicMode.startBattle([ Species.PIKACHU ]);
game.move.select(Moves.GRAVITY); game.move.select(Moves.GRAVITY);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use non-OHKO move on second turn // Use non-OHKO move on second turn
await game.toNextTurn(); await game.toNextTurn();
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to(MoveEffectPhase); await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveReturnedWith(100 * 1.67); expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(100 * 1.67);
}); });
it("OHKO move accuracy is not affected", async () => { it("OHKO move accuracy is not affected", async () => {
game.override.startingLevel(5);
game.override.enemyLevel(5);
/** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */ /** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */
const moveToCheck = allMoves[Moves.FISSURE]; const moveToCheck = allMoves[Moves.FISSURE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy"); vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn // Setup Gravity on first turn
await game.startBattle([ Species.PIKACHU ]); await game.classicMode.startBattle([ Species.PIKACHU ]);
game.move.select(Moves.GRAVITY); game.move.select(Moves.GRAVITY);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use OHKO move on second turn // Use OHKO move on second turn
await game.toNextTurn(); await game.toNextTurn();
game.move.select(Moves.FISSURE); game.move.select(Moves.FISSURE);
await game.phaseInterceptor.to(MoveEffectPhase); await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveReturnedWith(30); expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(30);
}); });
describe("Against flying types", () => { describe("Against flying types", () => {
it("can be hit by ground-type moves now", async () => { it("can be hit by ground-type moves now", async () => {
game.override game.override
.startingLevel(5)
.enemyLevel(5)
.enemySpecies(Species.PIDGEOT) .enemySpecies(Species.PIDGEOT)
.moveset([ Moves.GRAVITY, Moves.EARTHQUAKE ]); .moveset([ Moves.GRAVITY, Moves.EARTHQUAKE ]);
await game.startBattle([ Species.PIKACHU ]); await game.classicMode.startBattle([ Species.PIKACHU ]);
const pidgeot = game.scene.getEnemyPokemon()!; const pidgeot = game.scene.getEnemyPokemon()!;
vi.spyOn(pidgeot, "getAttackTypeEffectiveness"); vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Try earthquake on 1st turn (fails!); // Try earthquake on 1st turn (fails!);
game.move.select(Moves.EARTHQUAKE); game.move.select(Moves.EARTHQUAKE);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveReturnedWith(0); expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(0);
// Setup Gravity on 2nd turn // Setup Gravity on 2nd turn
await game.toNextTurn(); await game.toNextTurn();
game.move.select(Moves.GRAVITY); game.move.select(Moves.GRAVITY);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use ground move on 3rd turn // Use ground move on 3rd turn
await game.toNextTurn(); await game.toNextTurn();
game.move.select(Moves.EARTHQUAKE); game.move.select(Moves.EARTHQUAKE);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveReturnedWith(1); expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(1);
}); });
it("keeps super-effective moves super-effective after using gravity", async () => { it("keeps super-effective moves super-effective after using gravity", async () => {
game.override game.override
.startingLevel(5)
.enemyLevel(5)
.enemySpecies(Species.PIDGEOT) .enemySpecies(Species.PIDGEOT)
.moveset([ Moves.GRAVITY, Moves.THUNDERBOLT ]); .moveset([ Moves.GRAVITY, Moves.THUNDERBOLT ]);
await game.startBattle([ Species.PIKACHU ]); await game.classicMode.startBattle([ Species.PIKACHU ]);
const pidgeot = game.scene.getEnemyPokemon()!; const pidgeot = game.scene.getEnemyPokemon()!;
vi.spyOn(pidgeot, "getAttackTypeEffectiveness"); vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Setup Gravity on 1st turn // Setup Gravity on 1st turn
game.move.select(Moves.GRAVITY); game.move.select(Moves.GRAVITY);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use electric move on 2nd turn // Use electric move on 2nd turn
await game.toNextTurn(); await game.toNextTurn();
game.move.select(Moves.THUNDERBOLT); game.move.select(Moves.THUNDERBOLT);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveReturnedWith(2); expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(2);
}); });
}); });
it("cancels Fly if its user is semi-invulnerable", async () => {
game.override
.enemySpecies(Species.SNORLAX)
.enemyMoveset(Moves.FLY)
.moveset([ Moves.GRAVITY, Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const charizard = game.scene.getPlayerPokemon()!;
const snorlax = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SPLASH);
await game.toNextTurn();
expect(snorlax.getTag(BattlerTagType.FLYING)).toBeDefined();
game.move.select(Moves.GRAVITY);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(snorlax.getTag(BattlerTagType.INTERRUPTED)).toBeDefined();
await game.phaseInterceptor.to("TurnEndPhase");
expect(charizard.hp).toBe(charizard.getMaxHp());
});
}); });

114
src/test/moves/dig.test.ts Normal file
View File

@ -0,0 +1,114 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/move";
import { Abilities } from "#enums/abilities";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { StatusEffect } from "#enums/status-effect";
import { MoveResult } from "#app/field/pokemon";
import { describe, beforeAll, afterEach, beforeEach, it, expect } from "vitest";
import GameManager from "#test/utils/gameManager";
describe("Moves - Dig", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.DIG)
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.TACKLE);
});
it("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeDefined();
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()[0].move).toBe(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIG);
expect(playerDig?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(Abilities.NO_GUARD);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override
.enemyAbility(Abilities.NO_GUARD)
.enemyMoveset(Moves.SPORE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIG);
expect(playerDig?.ppUsed).toBe(0);
});
it("should cause the user to take double damage from Earthquake", async () => {
await game.classicMode.startBattle([ Species.DONDOZO ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
const preDigEarthquakeDmg = playerPokemon.getAttackDamage(enemyPokemon, allMoves[Moves.EARTHQUAKE]).damage;
game.move.select(Moves.DIG);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("MoveEffectPhase");
const postDigEarthquakeDmg = playerPokemon.getAttackDamage(enemyPokemon, allMoves[Moves.EARTHQUAKE]).damage;
// these hopefully get avoid rounding errors :shrug:
expect(postDigEarthquakeDmg).toBeGreaterThanOrEqual(2 * preDigEarthquakeDmg);
expect(postDigEarthquakeDmg).toBeLessThan(2 * (preDigEarthquakeDmg + 1));
});
});

137
src/test/moves/dive.test.ts Normal file
View File

@ -0,0 +1,137 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { StatusEffect } from "#enums/status-effect";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest";
import { WeatherType } from "#enums/weather-type";
describe("Moves - Dive", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.DIVE)
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.TACKLE);
});
it("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DIVE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeDefined();
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()[0].move).toBe(Moves.DIVE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIVE);
expect(playerDive?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(Abilities.NO_GUARD);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DIVE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override
.enemyAbility(Abilities.NO_GUARD)
.enemyMoveset(Moves.SPORE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.DIVE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIVE);
expect(playerDive?.ppUsed).toBe(0);
});
it("should trigger on-contact post-defend ability effects", async () => {
game.override
.enemyAbility(Abilities.ROUGH_SKIN)
.enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DIVE);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to("MoveEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.battleData.abilitiesApplied[0]).toBe(Abilities.ROUGH_SKIN);
});
it("should cancel attack after Harsh Sunlight is set", async () => {
game.override.enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DIVE);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to("TurnStartPhase", false);
game.scene.arena.trySetWeather(WeatherType.HARSH_SUN, false);
await game.phaseInterceptor.to("MoveEndPhase");
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getTag(BattlerTagType.UNDERWATER)).toBeUndefined();
const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIVE);
expect(playerDive?.ppUsed).toBe(1);
});
});

View File

@ -0,0 +1,104 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { Stat } from "#enums/stat";
import { WeatherType } from "#enums/weather-type";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest";
describe("Moves - Electro Shot", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.ELECTRO_SHOT)
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("should increase the user's Sp. Atk on the first turn, then attack on the second turn", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.ELECTRO_SHOT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeDefined();
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.OTHER);
expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
const playerElectroShot = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.ELECTRO_SHOT);
expect(playerElectroShot?.ppUsed).toBe(1);
});
it.each([
{ weatherType: WeatherType.RAIN, name: "Rain" },
{ weatherType: WeatherType.HEAVY_RAIN, name: "Heavy Rain" }
])("should fully resolve in one turn if $name is active", async ({ weatherType }) => {
game.override.weather(weatherType);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.ELECTRO_SHOT);
await game.phaseInterceptor.to("MoveEffectPhase", false);
expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1);
await game.phaseInterceptor.to("MoveEndPhase");
expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
const playerElectroShot = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.ELECTRO_SHOT);
expect(playerElectroShot?.ppUsed).toBe(1);
});
it("should only increase Sp. Atk once with Multi-Lens", async () => {
game.override
.weather(WeatherType.RAIN)
.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.ELECTRO_SHOT);
await game.phaseInterceptor.to("MoveEndPhase");
expect(playerPokemon.turnData.hitCount).toBe(2);
expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1);
});
});

122
src/test/moves/fly.test.ts Normal file
View File

@ -0,0 +1,122 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { StatusEffect } from "#enums/status-effect";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest";
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/move";
describe("Moves - Fly", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.FLY)
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.TACKLE);
vi.spyOn(allMoves[Moves.FLY], "accuracy", "get").mockReturnValue(100);
});
it("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()[0].move).toBe(Moves.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(Abilities.NO_GUARD);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override
.enemyAbility(Abilities.NO_GUARD)
.enemyMoveset(Moves.SPORE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
it("should be cancelled when another Pokemon uses Gravity", async () => {
game.override.enemyMoveset([ Moves.SPLASH, Moves.GRAVITY ]);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FLY);
await game.forceEnemyMove(Moves.SPLASH);
await game.toNextTurn();
await game.forceEnemyMove(Moves.GRAVITY);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
});

View File

@ -0,0 +1,78 @@
import { EffectiveStat, Stat } from "#enums/stat";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest";
describe("Moves - Geomancy", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.GEOMANCY)
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("should boost the user's stats on the second turn of use", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const player = game.scene.getPlayerPokemon()!;
const affectedStats: EffectiveStat[] = [ Stat.SPATK, Stat.SPDEF, Stat.SPD ];
game.move.select(Moves.GEOMANCY);
await game.phaseInterceptor.to("TurnEndPhase");
affectedStats.forEach((stat) => expect(player.getStatStage(stat)).toBe(0));
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.OTHER);
await game.phaseInterceptor.to("TurnEndPhase");
affectedStats.forEach((stat) => expect(player.getStatStage(stat)).toBe(2));
expect(player.getMoveHistory()).toHaveLength(2);
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
const playerGeomancy = player.getMoveset().find((mv) => mv && mv.moveId === Moves.GEOMANCY);
expect(playerGeomancy?.ppUsed).toBe(1);
});
it("should execute over 2 turns between waves", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const player = game.scene.getPlayerPokemon()!;
const affectedStats: EffectiveStat[] = [ Stat.SPATK, Stat.SPDEF, Stat.SPD ];
game.move.select(Moves.GEOMANCY);
await game.phaseInterceptor.to("MoveEndPhase", false);
await game.doKillOpponents();
await game.toNextWave();
await game.phaseInterceptor.to("TurnEndPhase");
affectedStats.forEach((stat) => expect(player.getStatStage(stat)).toBe(2));
expect(player.getMoveHistory()).toHaveLength(2);
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
const playerGeomancy = player.getMoveset().find((mv) => mv && mv.moveId === Moves.GEOMANCY);
expect(playerGeomancy?.ppUsed).toBe(1);
});
});

View File

@ -0,0 +1,102 @@
import { allMoves } from "#app/data/move";
import { BattlerTagType } from "#enums/battler-tag-type";
import { WeatherType } from "#enums/weather-type";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest";
describe("Moves - Solar Beam", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.SOLAR_BEAM)
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("should deal damage in two turns if no weather is active", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SOLAR_BEAM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeDefined();
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.OTHER);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
const playerSolarBeam = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.SOLAR_BEAM);
expect(playerSolarBeam?.ppUsed).toBe(1);
});
it.each([
{ weatherType: WeatherType.SUNNY, name: "Sun" },
{ weatherType: WeatherType.HARSH_SUN, name: "Harsh Sun" }
])("should deal damage in one turn if $name is active", async ({ weatherType }) => {
game.override.weather(weatherType);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SOLAR_BEAM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.CHARGING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
const playerSolarBeam = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.SOLAR_BEAM);
expect(playerSolarBeam?.ppUsed).toBe(1);
});
it.each([
{ weatherType: WeatherType.RAIN, name: "Rain" },
{ weatherType: WeatherType.HEAVY_RAIN, name: "Heavy Rain" }
])("should have its power halved in $name", async ({ weatherType }) => {
game.override.weather(weatherType);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const solarBeam = allMoves[Moves.SOLAR_BEAM];
vi.spyOn(solarBeam, "calculateBattlePower");
game.move.select(Moves.SOLAR_BEAM);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to("TurnEndPhase");
expect(solarBeam.calculateBattlePower).toHaveLastReturnedWith(60);
});
});

View File

@ -41,7 +41,8 @@ describe("Moves - Whirlwind", () => {
const staraptor = game.scene.getPlayerPokemon()!; const staraptor = game.scene.getPlayerPokemon()!;
game.move.select(move); game.move.select(move);
await game.toNextTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined(); expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined();
expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);