Added MoveUseType and refactored MEP

This commit is contained in:
Bertie690 2025-05-10 10:35:38 -04:00
parent 718c8cfbb9
commit 3f02493f79
33 changed files with 1389 additions and 1064 deletions

View File

@ -58,7 +58,7 @@
},
"style": {
"noVar": "error",
"useEnumInitializers": "off", // large enums like Moves/Species would make this cumbersome
"useEnumInitializers": "off", // large enums like Moves/Species would make this cumbersome
"useBlockStatements": "error",
"useConst": "error",
"useImportType": "error",
@ -73,9 +73,9 @@
},
"suspicious": {
"noDoubleEquals": "error",
// While this would be a nice rule to enable, the current structure of the codebase makes this infeasible
// While this would be a nice rule to enable, the current structure of the codebase makes this infeasible
// due to being used for move/ability `args` params and save data-related code.
// This can likely be enabled for all non-utils files once these are eventually reworked, but until then we leave it off.
// This can likely be enabled for all non-utils files once these are eventually reworked, but until then we leave it off.
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noPrototypeBuiltins": "off",
@ -112,6 +112,21 @@
}
}
}
},
// Overrides to prevent unused import removal inside `overrides.ts` and enums files (for jsdoc)
{
"include": ["src/overrides.ts", "src/enums/*"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "off"
},
"style": {
"useImportType": "error"
}
}
}
}
]
}

View File

@ -1,8 +1,8 @@
import { HitResult, MoveResult, PlayerPokemon } from "#app/field/pokemon";
import { HitResult, MoveResult, PlayerPokemon, type TurnMove } from "#app/field/pokemon";
import { BooleanHolder, NumberHolder, toDmgValue, isNullOrUndefined, randSeedItem, randSeedInt, type Constructor } from "#app/utils/common";
import { getPokemonNameWithAffix } from "#app/messages";
import { BattlerTagLapseType, GroundedTag } from "#app/data/battler-tags";
import { getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "#app/data/status-effect";
import { getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText, type Status } from "#app/data/status-effect";
import { Gender } from "#app/data/gender";
import {
AttackMove,
@ -67,8 +67,8 @@ import { BerryUsedEvent } from "#app/events/battle-scene";
// Type imports
import type { EnemyPokemon, PokemonMove } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon";
import { EnemyPokemon, PokemonMove } from "#app/field/pokemon";
import Pokemon from "#app/field/pokemon";
import type { Weather } from "#app/data/weather";
import type { BattlerTag } from "#app/data/battler-tags";
import type { AbAttrCondition, PokemonDefendCondition, PokemonStatStageChangeCondition, PokemonAttackCondition, AbAttrApplyFunc, AbAttrSuccessFunc } from "#app/@types/ability-types";
@ -76,6 +76,7 @@ import type { BattlerIndex } from "#app/battle";
import type Move from "#app/data/moves/move";
import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag";
import { SelectBiomePhase } from "#app/phases/select-biome-phase";
import { MoveUseType } from "#enums/move-use-type";
import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves";
export class BlockRecoilDamageAttr extends AbAttr {
@ -1067,7 +1068,7 @@ export class PostDefendMoveDisableAbAttr extends PostDefendAbAttr {
}
override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean {
return attacker.getTag(BattlerTagType.DISABLED) === null
return !isNullOrUndefined(attacker.getTag(BattlerTagType.DISABLED))
&& move.doesFlagEffectApply({flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon}) && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance);
}
@ -1246,7 +1247,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
/**
* Determine if the move type change attribute can be applied
*
*
* Can be applied if:
* - The ability's condition is met, e.g. pixilate only boosts normal moves,
* - The move is not forbidden from having its type changed by an ability, e.g. {@linkcode Moves.MULTI_ATTACK}
@ -1262,7 +1263,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
*/
override canApplyPreAttack(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _defender: Pokemon | null, move: Move, _args: [NumberHolder?, NumberHolder?, ...any]): boolean {
return (!this.condition || this.condition(pokemon, _defender, move)) &&
!noAbilityTypeOverrideMoves.has(move.id) &&
!noAbilityTypeOverrideMoves.has(move.id) &&
(!pokemon.isTerastallized ||
(move.id !== Moves.TERA_BLAST &&
(move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS))));
@ -2334,18 +2335,18 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr {
// phase list (which could be after CommandPhase for example)
globalScene.unshiftPhase(new StatStageChangePhase(pokemon.getBattlerIndex(), true, this.stats, this.stages));
} else {
for (const opponent of pokemon.getOpponents()) {
const cancelled = new BooleanHolder(false);
if (this.intimidate) {
applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled, simulated);
applyAbAttrs(PostIntimidateStatStageChangeAbAttr, opponent, cancelled, simulated);
for (const opponent of pokemon.getOpponents()) {
const cancelled = new BooleanHolder(false);
if (this.intimidate) {
applyAbAttrs(IntimidateImmunityAbAttr, opponent, cancelled, simulated);
applyAbAttrs(PostIntimidateStatStageChangeAbAttr, opponent, cancelled, simulated);
if (opponent.getTag(BattlerTagType.SUBSTITUTE)) {
cancelled.value = true;
}
if (opponent.getTag(BattlerTagType.SUBSTITUTE)) {
cancelled.value = true;
}
if (!cancelled.value) {
globalScene.unshiftPhase(new StatStageChangePhase(opponent.getBattlerIndex(), false, this.stats, this.stages));
}
if (!cancelled.value) {
globalScene.unshiftPhase(new StatStageChangePhase(opponent.getBattlerIndex(), false, this.stats, this.stages));
}
}
}
@ -4398,12 +4399,12 @@ export class PostBiomeChangeTerrainChangeAbAttr extends PostBiomeChangeAbAttr {
* @extends AbAttr
*/
export class PostMoveUsedAbAttr extends AbAttr {
canApplyPostMoveUsed(
canApplyPostMoveUsed(
pokemon: Pokemon,
move: PokemonMove,
source: Pokemon,
targets: BattlerIndex[],
simulated: boolean,
simulated: boolean,
args: any[]): boolean {
return true;
}
@ -4413,7 +4414,7 @@ export class PostMoveUsedAbAttr extends AbAttr {
move: PokemonMove,
source: Pokemon,
targets: BattlerIndex[],
simulated: boolean,
simulated: boolean,
args: any[],
): void {}
}
@ -4446,16 +4447,17 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr {
move: PokemonMove,
source: Pokemon,
targets: BattlerIndex[],
simulated: boolean,
simulated: boolean,
args: any[]): void {
if (!simulated) {
// If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance
// TODO: fix in main dancer PR (currently keeping this purely semantic rather than actually fixing bug)
if (move.getMove() instanceof AttackMove || move.getMove() instanceof StatusMove) {
const target = this.getTarget(dancer, source, targets);
globalScene.unshiftPhase(new MovePhase(dancer, target, move, true, true));
globalScene.unshiftPhase(new MovePhase(dancer, target, move, MoveUseType.FOLLOW_UP));
} else if (move.getMove() instanceof SelfStatusMove) {
// If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself
globalScene.unshiftPhase(new MovePhase(dancer, [ dancer.getBattlerIndex() ], move, true, true));
globalScene.unshiftPhase(new MovePhase(dancer, [ dancer.getBattlerIndex() ], move, MoveUseType.FOLLOW_UP))
}
}
}
@ -4897,7 +4899,7 @@ export class BlockRedirectAbAttr extends AbAttr { }
* @see {@linkcode apply}
*/
export class ReduceStatusEffectDurationAbAttr extends AbAttr {
private statusEffect: StatusEffect;
private statusEffect: StatusEffect;
constructor(statusEffect: StatusEffect) {
super(false);
@ -4906,7 +4908,7 @@ export class ReduceStatusEffectDurationAbAttr extends AbAttr {
}
override canApply(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean {
return args[1] instanceof NumberHolder && args[0] === this.statusEffect;
return args[1] instanceof NumberHolder && args[0] === this.statusEffect;
}
/**
@ -5545,6 +5547,7 @@ class ForceSwitchOutHelper {
*
* @param pokemon The {@linkcode Pokemon} attempting to switch out.
* @returns `true` if the switch is successful
* TODO: Make this actually cancel pending move phases on the switched out target
*/
public switchOutLogic(pokemon: Pokemon): boolean {
const switchOutTarget = pokemon;
@ -5554,7 +5557,7 @@ class ForceSwitchOutHelper {
* - If the Pokémon is still alive (hp > 0), and if so, it leaves the field and a new SwitchPhase is initiated.
*/
if (switchOutTarget instanceof PlayerPokemon) {
if (globalScene.getPlayerParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) {
if (globalScene.getPlayerParty().every(p => !p.isActive(true))) {
return false;
}
@ -5887,7 +5890,7 @@ export function applyPostMoveUsedAbAttrs(
move: PokemonMove,
source: Pokemon,
targets: BattlerIndex[],
simulated = false,
simulated = false,
...args: any[]
): void {
applyAbAttrsInternal<PostMoveUsedAbAttr>(
@ -6897,8 +6900,9 @@ export function initAbilities() {
.ignorable(),
new Ability(Abilities.ANALYTIC, 5)
.attr(MovePowerBoostAbAttr, (user, target, move) => {
const movePhase = globalScene.findPhase((phase) => phase instanceof MovePhase && phase.pokemon.id !== user?.id);
return isNullOrUndefined(movePhase);
// Boost power if all other Pokemon have already moved (no other moves are slated to execute)
const laterMovePhase = globalScene.findPhase((phase) => phase instanceof MovePhase && phase.pokemon.id !== user?.id);
return isNullOrUndefined(laterMovePhase);
}, 1.3),
new Ability(Abilities.ILLUSION, 5)
// The Pokemon generate an illusion if it's available

View File

@ -30,6 +30,7 @@ import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase";
import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase";
import { CommonAnimPhase } from "#app/phases/common-anim-phase";
import { MoveUseType } from "#enums/move-use-type";
export enum ArenaTagSide {
BOTH,
@ -892,7 +893,7 @@ export class DelayedAttackTag extends ArenaTag {
if (!ret) {
globalScene.unshiftPhase(
new MoveEffectPhase(this.sourceId!, [this.targetIndex], allMoves[this.sourceMove!], false, true),
new MoveEffectPhase(this.sourceId!, [this.targetIndex], allMoves[this.sourceMove!], MoveUseType.REFLECTED), // Reflected ensures this doesn't check status, use PP or be copied
); // TODO: are those bangs correct?
}

View File

@ -44,12 +44,17 @@ import { EFFECTIVE_STATS, getStatKey, Stat, type BattleStat, type EffectiveStat
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { isNullOrUndefined } from "#app/utils/common";
import { MoveUseType } from "#enums/move-use-type";
export enum BattlerTagLapseType {
FAINT,
MOVE,
PRE_MOVE,
AFTER_MOVE,
/**
* TODO: Stop treating this like a catch-all "semi invulnerability" tag;
* we may want to use this for other stuff later
*/
MOVE_EFFECT,
TURN_END,
HIT,
@ -60,7 +65,7 @@ export enum BattlerTagLapseType {
export class BattlerTag {
public tagType: BattlerTagType;
public lapseTypes: BattlerTagLapseType[];
public lapseTypes: BattlerTagLapseType[]; // TODO: Make this a set
public turnCount: number;
public sourceMove: Moves;
public sourceId?: number;
@ -93,7 +98,7 @@ export class BattlerTag {
onOverlap(_pokemon: Pokemon): void {}
/**
* Tick down this {@linkcode BattlerTag}'s duration.
* Trigger and tick down this {@linkcode BattlerTag}'s duration.
* @returns `true` if the tag should be kept (`turnCount` > 0`)
*/
lapse(_pokemon: Pokemon, _lapseType: BattlerTagLapseType): boolean {
@ -181,21 +186,21 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag {
}
/**
* Gets whether this tag is restricting a move.
* Check if this tag is currently restricting a move's use.
*
* @param move - {@linkcode Moves} ID to check restriction for.
* @param user - The {@linkcode Pokemon} involved
* @returns `true` if the move is restricted by this tag, otherwise `false`.
* @param move - The {@linkcode Moves | move ID} whose usability is being checked.
* @param user - The {@linkcode Pokemon} using the move.
* @returns Whether the given move is restricted by this tag.
*/
public abstract isMoveRestricted(move: Moves, user?: Pokemon): boolean;
/**
* Checks if this tag is restricting a move based on a user's decisions during the target selection phase
*
* @param {Moves} _move {@linkcode Moves} move ID to check restriction for
* @param {Pokemon} _user {@linkcode Pokemon} the user of the above move
* @param {Pokemon} _target {@linkcode Pokemon} the target of the above move
* @returns {boolean} `false` unless overridden by the child tag
* Check if this tag is restricting a move during target selection.
* Returns `false` by default unless overridden by a child class.
* @param _move - The {@linkcode Moves | move ID} whose selectability is being checked
* @param _user - The {@linkcode Pokemon} using the move.
* @param _target - The {@linkcode Pokemon} being targeted
* @returns Whether the given move should be unselectable when choosing targets.
*/
isMoveTargetRestricted(_move: Moves, _user: Pokemon, _target: Pokemon): boolean {
return false;
@ -302,14 +307,14 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
/**
* @override
*
* Ensures that move history exists on `pokemon` and has a valid move. If so, sets the {@linkcode moveId} and shows a message.
* Otherwise the move ID will not get assigned and this tag will get removed next turn.
* Attempt to disable the target's last move by setting this tag's {@linkcode moveId}
* and showing a message.
*/
override onAdd(pokemon: Pokemon): void {
super.onAdd(pokemon);
const move = pokemon.getLastXMoves(-1).find(m => !m.virtual);
if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE || move.move === Moves.NONE) {
// Disable fails against struggle or an empty move history, but we still need the nullish check
// for cursed body
const move = pokemon.getLastNonVirtualMove();
if (isNullOrUndefined(move)) {
return;
}
@ -342,9 +347,10 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
/**
* @override
* @param {Pokemon} pokemon {@linkcode Pokemon} attempting to use the restricted move
* @param {Moves} move {@linkcode Moves} ID of the move being interrupted
* @returns {string} text to display when the move is interrupted
* Display the text that occurs when a move is interrupted via Disable.
* @param pokemon - The {@linkcode Pokemon} attempting to use the restricted move
* @param move - The {@linkcode Moves | move ID} of the move being interrupted
* @returns The text to display when the given move is interrupted
*/
override interruptedText(pokemon: Pokemon, move: Moves): string {
return i18next.t("battle:disableInterruptedMove", {
@ -382,7 +388,7 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
* @returns `true` if the pokemon has a valid move and no existing {@linkcode GorillaTacticsTag}; `false` otherwise
*/
override canAdd(pokemon: Pokemon): boolean {
return this.getLastValidMove(pokemon) !== undefined && !pokemon.getTag(GorillaTacticsTag);
return !isNullOrUndefined(pokemon.getLastNonVirtualMove(true)) && !pokemon.getTag(GorillaTacticsTag);
}
/**
@ -392,13 +398,12 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
* @param {Pokemon} pokemon the {@linkcode Pokemon} to add the tag to
*/
override onAdd(pokemon: Pokemon): void {
const lastValidMove = this.getLastValidMove(pokemon);
if (!lastValidMove) {
const lastValidMove = pokemon.getLastNonVirtualMove(true); // TODO: Check if should work with struggle or not
if (isNullOrUndefined(lastValidMove)) {
return;
}
this.moveId = lastValidMove;
this.moveId = lastValidMove.move;
pokemon.setStat(Stat.ATK, pokemon.getStat(Stat.ATK, false) * 1.5, false);
}
@ -425,17 +430,6 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
pokemonName: getPokemonNameWithAffix(pokemon),
});
}
/**
* Gets the last valid move from the pokemon's move history.
* @param {Pokemon} pokemon {@linkcode Pokemon} to get the last valid move from
* @returns {Moves | undefined} the last valid move from the pokemon's move history
*/
getLastValidMove(pokemon: Pokemon): Moves | undefined {
const move = pokemon.getLastXMoves().find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual);
return move?.move;
}
}
/**
@ -449,8 +443,8 @@ export class RechargingTag extends BattlerTag {
onAdd(pokemon: Pokemon): void {
super.onAdd(pokemon);
// Queue a placeholder move for the Pokemon to "use" next turn
pokemon.getMoveQueue().push({ move: Moves.NONE, targets: [] });
// Queue a placeholder move for the Pokemon to "use" next turn.
pokemon.pushMoveQueue({ move: Moves.NONE, targets: [], useType: MoveUseType.NORMAL });
}
/** Cancels the source's move this turn and queues a "__ must recharge!" message */
@ -649,7 +643,7 @@ class NoRetreatTag extends TrappedTag {
*/
export class FlinchedTag extends BattlerTag {
constructor(sourceMove: Moves) {
super(BattlerTagType.FLINCHED, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 0, sourceMove);
super(BattlerTagType.FLINCHED, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1, sourceMove);
}
onAdd(pokemon: Pokemon): void {
@ -699,6 +693,7 @@ export class InterruptedTag extends BattlerTag {
move: Moves.NONE,
result: MoveResult.OTHER,
targets: [],
useType: MoveUseType.NORMAL,
});
}
@ -1018,42 +1013,45 @@ export class PowderTag extends BattlerTag {
}
/**
* Applies Powder's effects before the tag owner uses a Fire-type move.
* Also causes the tag to expire at the end of turn.
* @param pokemon {@linkcode Pokemon} the owner of this tag
* @param lapseType {@linkcode BattlerTagLapseType} the type of lapse functionality to carry out
* @returns `true` if the tag should not expire after this lapse; `false` otherwise.
* Applies Powder's effects before the tag owner uses a Fire-type move, damaging and canceling its action.
* Lasts until the end of the turn.
* @param pokemon - The {@linkcode Pokemon} with this tag.
* @param lapseType - The {@linkcode BattlerTagLapseType} dictating how this tag is being activated
* @returns `true` if the tag should remain active.
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.PRE_MOVE) {
const movePhase = globalScene.getCurrentPhase();
if (movePhase instanceof MovePhase) {
const move = movePhase.move.getMove();
const weather = globalScene.arena.weather;
if (
pokemon.getMoveType(move) === PokemonType.FIRE &&
!(weather && weather.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed())
) {
movePhase.fail();
movePhase.showMoveText();
if (lapseType !== BattlerTagLapseType.PRE_MOVE) {
return super.lapse(pokemon, lapseType);
}
globalScene.unshiftPhase(
new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.POWDER),
);
const cancelDamage = new BooleanHolder(false);
applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelDamage);
if (!cancelDamage.value) {
pokemon.damageAndUpdate(Math.floor(pokemon.getMaxHp() / 4), { result: HitResult.INDIRECT });
}
// "When the flame touched the powder\non the Pokémon, it exploded!"
globalScene.queueMessage(i18next.t("battlerTags:powderLapse", { moveName: move.name }));
}
}
const movePhase = globalScene.getCurrentPhase() as MovePhase;
const move = movePhase.move.getMove();
const weather = globalScene.arena.weather;
if (
pokemon.getMoveType(move) !== PokemonType.FIRE ||
(weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Heavy rain takes priority over powder
) {
return true;
}
return super.lapse(pokemon, lapseType);
// Disable the target's fire type move and damage it (subject to Magic Guard)
movePhase.showMoveText();
movePhase.fail();
globalScene.unshiftPhase(
new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.POWDER),
);
const cancelDamage = new BooleanHolder(false);
applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelDamage);
if (!cancelDamage.value) {
pokemon.damageAndUpdate(Math.floor(pokemon.getMaxHp() / 4), { result: HitResult.INDIRECT });
}
// "When the flame touched the powder\non the Pokémon, it exploded!"
globalScene.queueMessage(i18next.t("battlerTags:powderLapse", { moveName: move.name }));
return true;
}
}
@ -1128,6 +1126,7 @@ export class FrenzyTag extends BattlerTag {
* Applies the effects of {@linkcode Moves.ENCORE} onto the target Pokemon.
* Encore forces the target Pokemon to use its most-recent move for 3 turns.
*/
// TODO: Refactor and fix the bugs involving struggle and lock ons
export class EncoreTag extends MoveRestrictionBattlerTag {
public moveId: Moves;
@ -1147,29 +1146,26 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
}
canAdd(pokemon: Pokemon): boolean {
const lastMoves = pokemon.getLastXMoves(1);
if (!lastMoves.length) {
const lastMove = pokemon.getLastNonVirtualMove(false);
if (!lastMove) {
return false;
}
const repeatableMove = lastMoves[0];
const unEncoreableMoves = new Set<Moves>([
Moves.MIMIC,
Moves.MIRROR_MOVE,
Moves.TRANSFORM,
Moves.STRUGGLE,
Moves.SKETCH,
Moves.SLEEP_TALK,
Moves.ENCORE,
]);
if (!repeatableMove.move || repeatableMove.virtual) {
if (unEncoreableMoves.has(lastMove.move)) {
return false;
}
switch (repeatableMove.move) {
case Moves.MIMIC:
case Moves.MIRROR_MOVE:
case Moves.TRANSFORM:
case Moves.STRUGGLE:
case Moves.SKETCH:
case Moves.SLEEP_TALK:
case Moves.ENCORE:
return false;
}
this.moveId = repeatableMove.move;
this.moveId = lastMove.move;
return true;
}
@ -1187,10 +1183,11 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
if (movePhase) {
const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
if (movesetMove) {
// TODO: Check encore + calling move interactions and change to `pokemon.getLastNonVirtualMove()` if needed
const lastMove = pokemon.getLastXMoves(1)[0];
globalScene.tryReplacePhase(
m => m instanceof MovePhase && m.pokemon === pokemon,
new MovePhase(pokemon, lastMove.targets ?? [], movesetMove),
new MovePhase(pokemon, lastMove.targets ?? [], movesetMove, MoveUseType.NORMAL),
);
}
}
@ -1487,10 +1484,6 @@ export class WrapTag extends DamagingTrapTag {
}
export abstract class VortexTrapTag extends DamagingTrapTag {
constructor(tagType: BattlerTagType, commonAnim: CommonAnim, turnCount: number, sourceMove: Moves, sourceId: number) {
super(tagType, commonAnim, turnCount, sourceMove, sourceId);
}
getTrapMessage(pokemon: Pokemon): string {
return i18next.t("battlerTags:vortexOnTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
@ -1909,13 +1902,15 @@ export class TruantTag extends AbilityBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (!pokemon.hasAbility(Abilities.TRUANT)) {
// remove tag if mon lacks ability
return super.lapse(pokemon, lapseType);
}
const passive = pokemon.getAbility().id !== Abilities.TRUANT;
const lastMove = pokemon.getLastXMoves().find(() => true);
const lastMove = pokemon.getLastXMoves()[0];
if (lastMove && lastMove.move !== Moves.NONE) {
// ignore if just slacked off OR first turn of battle
const passive = pokemon.getAbility().id !== Abilities.TRUANT;
(globalScene.getCurrentPhase() as MovePhase).cancel();
// TODO: Ability displays should be handled by the ability
globalScene.queueAbilityDisplay(pokemon, passive, true);
@ -2730,7 +2725,7 @@ export class ExposedTag extends BattlerTag {
/**
* Tag that prevents HP recovery from held items and move effects. It also blocks the usage of recovery moves.
* Applied by moves: {@linkcode Moves.HEAL_BLOCK | Heal Block (5 turns)}, {@linkcode Moves.PSYCHIC_NOISE | Psychic Noise (2 turns)}
* Applied by moves: {@linkcode Moves.HEAL_BLOCK | Heal Block (5 turns)}, {@linkcode Moves.PSYCHIC_NOISE | Psychic Noise (2 turns)}
*
* @extends MoveRestrictionBattlerTag
*/
@ -2756,10 +2751,7 @@ export class HealBlockTag extends MoveRestrictionBattlerTag {
* @returns `true` if the move has a TRIAGE_MOVE flag and is a status move
*/
override isMoveRestricted(move: Moves): boolean {
if (allMoves[move].hasFlag(MoveFlags.TRIAGE_MOVE) && allMoves[move].category === MoveCategory.STATUS) {
return true;
}
return false;
return allMoves[move].hasFlag(MoveFlags.TRIAGE_MOVE) && allMoves[move].category === MoveCategory.STATUS;
}
/**
@ -3433,7 +3425,8 @@ export class PsychoShiftTag extends BattlerTag {
}
/**
* Tag associated with the move Magic Coat.
* Tag associated with {@linkcode Moves.MAGIC_COAT | Magic Coat} that reflects certain status moves directed at the user.
* TODO: Move Reflection code out of `move-effect-phase` and into here
*/
export class MagicCoatTag extends BattlerTag {
constructor() {

View File

@ -1,6 +1,6 @@
import { Moves } from "#enums/moves";
/** Set of moves that cannot be called by {@linkcode Moves.METRONOME Metronome} */
/** Set of moves that cannot be called by {@linkcode Moves.METRONOME | Metronome} */
export const invalidMetronomeMoves: ReadonlySet<Moves> = new Set([
Moves.AFTER_YOU,
Moves.ASSIST,
@ -255,3 +255,17 @@ export const noAbilityTypeOverrideMoves: ReadonlySet<Moves> = new Set([
Moves.TECHNO_BLAST,
Moves.HIDDEN_POWER,
]);
/** Set of all moves that cannot be copied by {@linkcode Moves.SKETCH}. */
export const invalidSketchMoves: ReadonlySet<Moves> = new Set([
Moves.NONE,
Moves.CHATTER,
Moves.MIRROR_MOVE,
Moves.SLEEP_TALK,
Moves.STRUGGLE,
Moves.SKETCH,
Moves.REVIVAL_BLESSING,
Moves.TERA_STARSTORM,
Moves.BREAKNECK_BLITZ__PHYSICAL,
Moves.BREAKNECK_BLITZ__SPECIAL,
]);

View File

@ -121,8 +121,9 @@ import { MoveTarget } from "#enums/MoveTarget";
import { MoveFlags } from "#enums/MoveFlags";
import { MoveEffectTrigger } from "#enums/MoveEffectTrigger";
import { MultiHitType } from "#enums/MultiHitType";
import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSleepTalkMoves } from "./invalid-moves";
import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSleepTalkMoves, invalidSketchMoves } from "./invalid-moves";
import { SelectBiomePhase } from "#app/phases/select-biome-phase";
import { MoveUseType } from "#enums/move-use-type";
type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean;
@ -701,10 +702,10 @@ export default class Move implements Localizable {
/**
* Sees if a move has a custom failure text (by looking at each {@linkcode MoveAttr} of this move)
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @returns string of the custom failure text, or `null` if it uses the default text ("But it failed!")
* @param user - The {@linkcode Pokemon} using this move
* @param target - The {@linkcode Pokemon} targeted by this move
* @param move - The {@linkcode Move} being used
* @returns A string containing the custom failure text, or `undefined` if no custom text exists.
*/
getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
for (const attr of this.attrs) {
@ -1185,7 +1186,7 @@ export class MoveEffectAttr extends MoveAttr {
*/
protected options?: MoveEffectAttrOptions;
constructor(selfTarget?: boolean, options?: MoveEffectAttrOptions) {
constructor(selfTarget?: boolean, options?: MoveEffectAttrOptions) {
super(selfTarget);
this.options = options;
}
@ -1372,8 +1373,8 @@ export class PreMoveMessageAttr extends MoveAttr {
/**
* Attribute for moves that can be conditionally interrupted to be considered to
* have failed before their "useMove" message is displayed. Currently used by
* Focus Punch.
* have failed before their "useMove" message is displayed.
* Currently used by {@linkcode Moves.FOCUS_PUNCH}.
* @extends MoveAttr
*/
export class PreUseInterruptAttr extends MoveAttr {
@ -1382,38 +1383,40 @@ export class PreUseInterruptAttr extends MoveAttr {
protected conditionFunc: MoveConditionFunc;
/**
* Create a new MoveInterruptedMessageAttr.
* @param message The message to display when the move is interrupted, or a function that formats the message based on the user, target, and move.
* Create a new PreUseInterruptAttr.
* @param message - Custom failure text to display when the move is interrupted, either as a string or a function producing one.
* If ommitted, will display the default failure text upon cancellation.
* @param conditionFunc - A {@linkcode MoveConditionFunc} that returns `true` if the move should be canceled.
*/
constructor(message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc?: MoveConditionFunc) {
constructor(message: string | undefined | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc: MoveConditionFunc) {
super();
this.message = message;
this.conditionFunc = conditionFunc ?? (() => true);
this.conditionFunc = conditionFunc;
}
/**
* Message to display when a move is interrupted.
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* Conditionally cancel this pokemon's current move.
* @param user - The {@linkcode Pokemon} using this move
* @param target - The {@linkcode Pokemon} targeted by this move
* @param move - The {@linkcode Move} being used
* @returns `true` if the move should be cancelled.
*/
override apply(user: Pokemon, target: Pokemon, move: Move): boolean {
return this.conditionFunc(user, target, move);
}
/**
* Message to display when a move is interrupted.
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* Obtain the text displayed upon this move's interruption.
* @param user - The {@linkcode Pokemon} using this move
* @param target - The {@linkcode Pokemon} targeted by this move
* @param move - The {@linkcode Move} being used
* @returns A string containing the custom failure text, or `undefined` if no custom text exists.
*/
override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
if (this.message && this.conditionFunc(user, target, move)) {
const message =
typeof this.message === "string"
? (this.message as string)
: this.message(user, target, move);
return message;
if (this.conditionFunc(user, target, move)) {
return typeof this.message !== "function"
? this.message
: this.message(user, target, move);
}
}
}
@ -1513,7 +1516,7 @@ export class TargetHalfHpDamageAttr extends FixedDamageAttr {
case 0:
// first hit of move; update initialHp tracker
this.initialHp = target.hp;
default:
default:
// multi lens added hit; use initialHp tracker to ensure correct damage
(args[0] as NumberHolder).value = toDmgValue(this.initialHp / 2);
return true;
@ -3056,7 +3059,17 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr {
this.chargeText = chargeText;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
/**
* Apply the delayed attack, either setting it up or triggering the attack.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} being targeted
* @param move - The {@linkcode Move} being used
* @param args -
* `[0]` - {@linkcode BooleanHolder} containing whether the move was overriden
* `[1]` - Whether the move is supposed to set up a delayed attack (`true`) or activate (`false`)
* @returns always `true`
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: [BooleanHolder, boolean]): boolean {
// Edge case for the move applied on a pokemon that has fainted
if (!target) {
return true;
@ -3069,7 +3082,7 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr {
overridden.value = true;
globalScene.unshiftPhase(new MoveAnimPhase(new MoveChargeAnim(this.chargeAnim, move.id, user)));
globalScene.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER });
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER, useType: MoveUseType.NORMAL });
const side = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
globalScene.arena.addTag(this.tagType, 3, move.id, user.id, side, false, target.getBattlerIndex());
} else {
@ -3092,9 +3105,9 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr {
/**
* If the user's ally is set to use a different move with this attribute,
* defer this move's effects for a combined move on the ally's turn.
* @param user the {@linkcode Pokemon} using this move
* @param target n/a
* @param move the {@linkcode Move} being used
* @param user - The {@linkcode Pokemon} using the move
* @param target - Unused
* @param move - The {@linkcode Move} being used
* @param args
* - [0] a {@linkcode BooleanHolder} indicating whether the move's base
* effects should be overridden this turn.
@ -3683,7 +3696,7 @@ export class LessPPMorePowerAttr extends VariablePowerAttr {
const ppMax = move.pp;
const ppUsed = user.moveset.find((m) => m.moveId === move.id)?.ppUsed ?? 0;
let ppRemains = ppMax - ppUsed;
let ppRemains = ppMax - ppUsed;
/** Reduce to 0 to avoid negative numbers if user has 1PP before attack and target has Ability.PRESSURE */
if (ppRemains < 0) {
ppRemains = 0;
@ -3801,26 +3814,30 @@ export class DoublePowerChanceAttr extends VariablePowerAttr {
export abstract class ConsecutiveUsePowerMultiplierAttr extends MovePowerMultiplierAttr {
constructor(limit: number, resetOnFail: boolean, resetOnLimit?: boolean, ...comboMoves: Moves[]) {
super((user: Pokemon, target: Pokemon, move: Move): number => {
const moveHistory = user.getLastXMoves(limit + 1).slice(1);
super((user: Pokemon, _target: Pokemon, move: Move): number => {
const moveHistory = user.getLastXMoves(-1).slice(1, limit+1); // don't count the first history entry (ie the current move)
let count = 0;
let turnMove: TurnMove | undefined;
let count = 1;
while (
(
(turnMove = moveHistory.shift())?.move === move.id
|| (comboMoves.length && comboMoves.includes(turnMove?.move ?? Moves.NONE))
)
&& (!resetOnFail || turnMove?.result === MoveResult.SUCCESS)
) {
if (count < (limit - 1)) {
count++;
} else if (resetOnLimit) {
count = 0;
} else {
// TODO: Confirm whether mirror moving an echoed voice counts for and/or resets a boost
for (const tm of moveHistory) {
if (
!(tm.move === move.id || comboMoves.includes(tm.move))
|| (resetOnFail && tm.result !== MoveResult.SUCCESS)
) {
break;
}
if (count < limit - 1) {
count++;
continue;
}
if (resetOnLimit) {
count = 0;
}
break;
}
return this.getMultiplier(count);
@ -4000,7 +4017,7 @@ export class HpPowerAttr extends VariablePowerAttr {
/**
* Attribute used for moves whose base power scales with the opponent's HP
* Used for Crush Grip, Wring Out, and Hard Press
* Used for {@linkcode Moves.CRUSH_GRIP}, {@linkcode Moves.WRING_OUT}, and {@linkcode Moves.HARD_PRESS}
* maxBasePower 100 for Hard Press, 120 for others
*/
export class OpponentHighHpPowerAttr extends VariablePowerAttr {
@ -4026,15 +4043,27 @@ export class OpponentHighHpPowerAttr extends VariablePowerAttr {
}
}
/**
* Attribute to double this move's power if the target hasn't acted yet in the current turn.
* Used by {@linkcode Moves.BOLT_BEAK} and {@linkcode Moves.FISHIOUS_REND}
*/
export class FirstAttackDoublePowerAttr extends VariablePowerAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
console.log(target.getLastXMoves(1), globalScene.currentBattle.turn);
if (!target.getLastXMoves(1).find(m => m.turn === globalScene.currentBattle.turn)) {
(args[0] as NumberHolder).value *= 2;
return true;
/**
* Double this move's power if the user is acting before the target.
* @param user - Unused
* @param target - The {@linkcode Pokemon} being targeted by this move
* @param move - Unused
* @param args `[0]` - A {@linkcode NumberHolder} containing move base power
* @returns Whether the attribute was successfully applied
*/
apply(_user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
const lastMove: TurnMove | undefined = target.getLastXMoves()[0]; // undefined needed as array might be empty
if (lastMove?.turn === globalScene.currentBattle.turn) {
return false;
}
return false;
args[0].value *= 2;
return true;
}
}
@ -4304,6 +4333,7 @@ const hasStockpileStacksCondition: MoveConditionFunc = (user) => {
return !!hasStockpilingTag && hasStockpilingTag.stockpiledCount > 0;
};
/**
* Attribute used for multi-hit moves that increase power in increments of the
* move's base power for each hit, namely Triple Kick and Triple Axel.
@ -5403,13 +5433,22 @@ export class FrenzyAttr extends MoveEffectAttr {
return false;
}
if (!user.getTag(BattlerTagType.FRENZY) && !user.getMoveQueue().length) {
const turnCount = user.randSeedIntRange(1, 2);
new Array(turnCount).fill(null).map(() => user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true }));
// TODO: Disable if used via dancer
// TODO: Add support for moves that don't add the frenzy tag (Uproar, Rollout, etc.)
// If frenzy is not in effect and we don't have anything queued up,
// add 1-2 extra instances of the move to the move queue.
// If frenzy is already in effect, tick down the tag.
if (!user.getTag(BattlerTagType.FRENZY) && user.getMoveQueue().length === 0) {
const turnCount = user.randSeedIntRange(1, 2); // excludes initial use
for (let i = 0; i < turnCount; i++) {
user.pushMoveQueue({ move: move.id, targets: [ target.getBattlerIndex() ], useType: MoveUseType.IGNORE_PP });
}
user.addTag(BattlerTagType.FRENZY, turnCount, move.id, user.id);
} else {
applyMoveAttrs(AddBattlerTagAttr, user, target, move, args);
user.lapseTag(BattlerTagType.FRENZY); // if FRENZY is already in effect (moveQueue.length > 0), lapse the tag
user.lapseTag(BattlerTagType.FRENZY);
}
return true;
@ -5778,23 +5817,24 @@ export class ProtectAttr extends AddBattlerTagAttr {
super(tagType, true);
}
/**
* Condition to fail a protect usage based on random chance.
* Chance starts at 100% and is thirded for each prior successful proctect usage.
* @returns a function that fails the function if its proc chance roll fails
*/
getCondition(): MoveConditionFunc {
return ((user, target, move): boolean => {
let timesUsed = 0;
const moveHistory = user.getLastXMoves();
let turnMove: TurnMove | undefined;
const lastMoves = user.getLastXMoves(-1)
while (moveHistory.length) {
turnMove = moveHistory.shift();
if (!allMoves[turnMove?.move ?? Moves.NONE].hasAttr(ProtectAttr) || turnMove?.result !== MoveResult.SUCCESS) {
let threshold = 1;
for (const tm of lastMoves) {
if (!allMoves[tm.move].hasAttr(ProtectAttr) || tm.result !== MoveResult.SUCCESS) {
break;
}
timesUsed++;
threshold *= 3;
}
if (timesUsed) {
return !user.randSeedInt(Math.pow(3, timesUsed));
}
return true;
return threshold === 1 || user.randSeedInt(threshold) === 0;
});
}
}
@ -6180,6 +6220,7 @@ export class RevivalBlessingAttr extends MoveEffectAttr {
globalScene.tryRemovePhase((phase: SwitchSummonPhase) => phase instanceof SwitchSummonPhase && phase.getPokemon() === pokemon);
// If the pokemon being revived was alive earlier in the turn, cancel its move
// (revived pokemon can't move in the turn they're brought back)
// TODO: might make sense to move this to `FaintPhase` rather than handling it in the move
globalScene.findPhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel();
if (user.fieldPosition === FieldPosition.CENTER) {
user.setFieldPosition(FieldPosition.LEFT);
@ -6715,20 +6756,23 @@ export class FirstMoveTypeAttr extends MoveEffectAttr {
class CallMoveAttr extends OverrideMoveEffectAttr {
protected invalidMoves: ReadonlySet<Moves>;
protected hasTarget: boolean;
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// Get eligible targets for move, failing if we can't target anything
const replaceMoveTarget = move.moveTarget === MoveTarget.NEAR_OTHER ? MoveTarget.NEAR_ENEMY : undefined;
const moveTargets = getMoveTargets(user, move.id, replaceMoveTarget);
if (moveTargets.targets.length === 0) {
globalScene.queueMessage(i18next.t("battle:attackFailed"));
console.log("CallMoveAttr failed due to no targets.");
return false;
}
// Spread moves and ones with only 1 valid target will use their normal targeting.
// If not, target the Mirror Move recipient or else a random enemy in our target list
const targets = moveTargets.multiple || moveTargets.targets.length === 1
? moveTargets.targets
: [ this.hasTarget ? target.getBattlerIndex() : moveTargets.targets[user.randSeedInt(moveTargets.targets.length)] ]; // account for Mirror Move having a target already
user.getMoveQueue().push({ move: move.id, targets: targets, virtual: true, ignorePP: true });
globalScene.unshiftPhase(new LoadMoveAnimPhase(move.id));
globalScene.unshiftPhase(new MovePhase(user, targets, new PokemonMove(move.id, 0, 0, true), true, true));
globalScene.unshiftPhase(new MovePhase(user, targets, new PokemonMove(move.id), MoveUseType.FOLLOW_UP));
return true;
}
}
@ -6825,7 +6869,7 @@ export class RandomMovesetMoveAttr extends CallMoveAttr {
export class NaturePowerAttr extends OverrideMoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
let moveId;
let moveId: Moves;
switch (globalScene.arena.getTerrainType()) {
// this allows terrains to 'override' the biome move
case TerrainType.NONE:
@ -6950,14 +6994,14 @@ export class NaturePowerAttr extends OverrideMoveEffectAttr {
moveId = Moves.PSYCHIC;
break;
default:
// Just in case there's no match
// Just in case there's no match
moveId = Moves.TRI_ATTACK;
break;
}
user.getMoveQueue().push({ move: moveId, targets: [ target.getBattlerIndex() ], ignorePP: true });
// Load the move's animation if we didn't already and unshift a new usage phase
globalScene.unshiftPhase(new LoadMoveAnimPhase(moveId));
globalScene.unshiftPhase(new MovePhase(user, [ target.getBattlerIndex() ], new PokemonMove(moveId, 0, 0, true), true));
globalScene.unshiftPhase(new MovePhase(user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseType.FOLLOW_UP));
return true;
}
}
@ -6976,27 +7020,23 @@ export class CopyMoveAttr extends CallMoveAttr {
this.invalidMoves = invalidMoves;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
apply(user: Pokemon, target: Pokemon, _move: Move, args: any[]): boolean {
this.hasTarget = this.mirrorMove;
const lastMove = this.mirrorMove ? target.getLastXMoves()[0].move : globalScene.currentBattle.lastMove;
// TODO: Confirm whether Mirror Move and co. can copy struggle
const lastMove = this.mirrorMove ? target.getLastNonVirtualMove(false, false)!.move : globalScene.currentBattle.lastMove;
return super.apply(user, target, allMoves[lastMove], args);
}
getCondition(): MoveConditionFunc {
return (user, target, move) => {
if (this.mirrorMove) {
const lastMove = target.getLastXMoves()[0]?.move;
return !!lastMove && !this.invalidMoves.has(lastMove);
} else {
const lastMove = globalScene.currentBattle.lastMove;
return lastMove !== undefined && !this.invalidMoves.has(lastMove);
}
return (_user, target, _move) => {
const lastMove = this.mirrorMove ? target.getLastNonVirtualMove(false, true)?.move : globalScene.currentBattle.lastMove;
return !isNullOrUndefined(lastMove) && !this.invalidMoves.has(lastMove);
};
}
}
/**
* Attribute used for moves that causes the target to repeat their last used move.
* Attribute used for moves that cause the target to repeat their last used move.
*
* Used for [Instruct](https://bulbapedia.bulbagarden.net/wiki/Instruct_(move)).
*/
@ -7006,34 +7046,41 @@ export class RepeatMoveAttr extends MoveEffectAttr {
}
/**
* Forces the target to re-use their last used move again
*
* @param user {@linkcode Pokemon} that used the attack
* @param target {@linkcode Pokemon} targeted by the attack
* @param move N/A
* @param args N/A
* Forces the target to re-use their last used move again.
* @param user - The {@linkcode Pokemon} using the attack
* @param target - The {@linkcode Pokemon} being targeted by the attack
* @returns `true` if the move succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
apply(user: Pokemon, target: Pokemon): boolean {
// get the last move used (excluding status based failures) as well as the corresponding moveset slot
const lastMove = target.getLastXMoves(-1).find(m => m.move !== Moves.NONE)!;
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove.move)!;
// TODO: How does instruct work when copying a move called via Copycat that the user itself knows?
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)
// never happens due to condition func, but makes TS compiler not sad
if (!lastMove || !movesetMove) {
return false;
}
// If the last move used can hit more than one target or has variable targets,
// re-compute the targets for the attack
// (mainly for alternating double/single battle shenanigans)
// Rampaging moves (e.g. Outrage) are not included due to being incompatible with Instruct
// TODO: Fix this once dragon darts gets smart targeting
// re-compute the targets for the attack (mainly for alternating double/single battles)
// Rampaging moves (e.g. Outrage) are not included due to being incompatible with Instruct,
// nor is Dragon Darts (due to handling its smart targeting entirely within `MoveEffectPhase`)
let moveTargets = movesetMove.getMove().isMultiTarget() ? getMoveTargets(target, lastMove.move).targets : lastMove.targets;
/** In the event the instructed move's only target is a fainted opponent, redirect it to an alive ally if possible
Normally, all yet-unexecuted move phases would swap over when the enemy in question faints
(see `redirectPokemonMoves` in `battle-scene.ts`),
but since instruct adds a new move phase pre-emptively, we need to handle this interaction manually.
*/
// In the event the instructed move's only target is a fainted opponent, redirect it to an alive ally if possible.
// Normally, all yet-unexecuted move phases would swap targets after any foe faints or flees (see `redirectPokemonMoves` in `battle-scene.ts`),
// but since Instruct adds a new move phase _after_ all that occurs, we need to handle this interaction manually.
const firstTarget = globalScene.getField()[moveTargets[0]];
if (globalScene.currentBattle.double && moveTargets.length === 1 && firstTarget.isFainted() && firstTarget !== target.getAlly()) {
if (
globalScene.currentBattle.double // double battle
&& moveTargets.length === 1
&& firstTarget.isFainted()
&& firstTarget !== target.getAlly()
) {
const ally = firstTarget.getAlly();
if (!isNullOrUndefined(ally) && ally.isActive()) { // ally exists, is not dead and can sponge the blast
if (!isNullOrUndefined(ally) && ally.isActive()) {
// ally exists, is not dead and can sponge the blast
moveTargets = [ ally.getBattlerIndex() ];
}
}
@ -7042,15 +7089,15 @@ export class RepeatMoveAttr extends MoveEffectAttr {
userPokemonName: getPokemonNameWithAffix(user),
targetPokemonName: getPokemonNameWithAffix(target)
}));
target.getMoveQueue().unshift({ move: lastMove.move, targets: moveTargets, ignorePP: false });
target.turnData.extraTurns++;
globalScene.appendToPhase(new MovePhase(target, moveTargets, movesetMove), MoveEndPhase);
globalScene.appendToPhase(new MovePhase(target, moveTargets, movesetMove, MoveUseType.NORMAL), MoveEndPhase);
return true;
}
getCondition(): MoveConditionFunc {
return (user, target, move) => {
const lastMove = target.getLastXMoves(-1).find(m => m.move !== Moves.NONE);
return (_user, target, _move) => {
// TODO: Check instruct behavior with struggle - ignore, fail or success
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move);
const uninstructableMoves = [
// Locking/Continually Executed moves
@ -7109,8 +7156,7 @@ export class RepeatMoveAttr extends MoveEffectAttr {
if (!lastMove?.move // no move to instruct
|| !movesetMove // called move not in target's moveset (forgetting the move, etc.)
|| movesetMove.ppUsed === movesetMove.getMovePp() // move out of pp
|| allMoves[lastMove.move].isChargingMove() // called move is a charging/recharging move
|| !movesetMove.isUsable(target) // Move unusable due to PP shortage or similar
|| uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist
return false;
}
@ -7144,53 +7190,48 @@ export class ReducePpMoveAttr extends MoveEffectAttr {
/**
* Reduces the PP of the target's last-used move by an amount based on this attribute instance's {@linkcode reduction}.
*
* @param user {@linkcode Pokemon} that used the attack
* @param target {@linkcode Pokemon} targeted by the attack
* @param move N/A
* @param args N/A
* @returns `true`
* @param user - N/A
* @param target - The {@linkcode Pokemon} targeted by the attack
* @param move - N/A
* @param args - N/A
* @returns always `true`
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// Null checks can be skipped due to condition function
const lastMove = target.getLastXMoves()[0];
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove.move)!;
/** The last move the target themselves used */
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)!; // bang is correct as condition prevents this from being nullish
const lastPpUsed = movesetMove.ppUsed;
movesetMove.ppUsed = Math.min((lastPpUsed) + this.reduction, movesetMove.getMovePp());
movesetMove.ppUsed = Math.min(lastPpUsed + this.reduction, movesetMove.getMovePp());
const message = i18next.t("battle:ppReduced", { targetName: getPokemonNameWithAffix(target), moveName: movesetMove.getName(), reduction: (movesetMove.ppUsed) - lastPpUsed });
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(target.id, movesetMove.getMove(), movesetMove.ppUsed));
globalScene.queueMessage(message);
globalScene.queueMessage(i18next.t("battle:ppReduced", { targetName: getPokemonNameWithAffix(target), moveName: movesetMove.getName(), reduction: (movesetMove.ppUsed) - lastPpUsed }));
return true;
}
getCondition(): MoveConditionFunc {
return (user, target, move) => {
const lastMove = target.getLastXMoves()[0];
if (lastMove) {
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove.move);
return !!movesetMove?.getPpRatio();
}
return false;
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)
return !!movesetMove?.getPpRatio();
};
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
const lastMove = target.getLastXMoves()[0];
if (lastMove) {
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove.move);
if (movesetMove) {
const maxPp = movesetMove.getMovePp();
const ppLeft = maxPp - movesetMove.ppUsed;
const value = -(8 - Math.ceil(Math.min(maxPp, 30) / 5));
if (ppLeft < 4) {
return (value / 4) * ppLeft;
}
return value;
}
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)
if (!movesetMove) {
return 0;
}
return 0;
const maxPp = movesetMove.getMovePp();
const ppLeft = maxPp - movesetMove.ppUsed;
const value = -(8 - Math.ceil(Math.min(maxPp, 30) / 5));
if (ppLeft < 4) {
return (value / 4) * ppLeft;
}
return value;
}
}
@ -7206,40 +7247,36 @@ export class AttackReducePpMoveAttr extends ReducePpMoveAttr {
/**
* Checks if the target has used a move prior to the attack. PP-reduction is applied through the super class if so.
*
* @param user {@linkcode Pokemon} that used the attack
* @param target {@linkcode Pokemon} targeted by the attack
* @param move {@linkcode Move} being used
* @param args N/A
* @returns {boolean} true
* @param user - The {@linkcode Pokemon} using the move
* @param target -The {@linkcode Pokemon} targeted by the attack
* @param move - The {@linkcode Move} being used
* @param args - N/A
* @returns - always `true`
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const lastMove = target.getLastXMoves().find(() => true);
if (lastMove) {
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove.move);
if (Boolean(movesetMove?.getPpRatio())) {
super.apply(user, target, move, args);
}
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move);
if (movesetMove?.getPpRatio()) {
super.apply(user, target, move, args);
}
return true;
}
// Override condition function to always perform damage. Instead, perform pp-reduction condition check in apply function above
getCondition(): MoveConditionFunc {
return (user, target, move) => true;
/**
* Override condition function to always perform damage.
* Instead, perform pp-reduction condition check in {@linkcode apply}.
* (A failed condition will prevent damage which is not what we want here)
* @returns always `true`
*/
override getCondition(): MoveConditionFunc {
return () => true;
}
}
// TODO: Review this
const targetMoveCopiableCondition: MoveConditionFunc = (user, target, move) => {
const targetMoves = target.getMoveHistory().filter(m => !m.virtual);
if (!targetMoves.length) {
return false;
}
const copiableMove = targetMoves[0];
if (!copiableMove.move) {
const copiableMove = target.getLastNonVirtualMove();
if (!copiableMove?.move) {
return false;
}
@ -7252,14 +7289,18 @@ const targetMoveCopiableCondition: MoveConditionFunc = (user, target, move) => {
return true;
};
/**
* Attribute to temporarily copy the last move in the target's moveset.
* Used by {@linkcode Moves.MIMIC}.
*/
export class MovesetCopyMoveAttr extends OverrideMoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const targetMoves = target.getMoveHistory().filter(m => !m.virtual);
if (!targetMoves.length) {
const lastMove = target.getLastNonVirtualMove()
if (!lastMove?.move) {
return false;
}
const copiedMove = allMoves[targetMoves[0].move];
const copiedMove = allMoves[lastMove.move];
const thisMoveIndex = user.getMoveset().findIndex(m => m.moveId === move.id);
@ -7267,8 +7308,9 @@ export class MovesetCopyMoveAttr extends OverrideMoveEffectAttr {
return false;
}
// Populate summon data with a copy of the current moveset, replacing the copying move with the copied move
user.summonData.moveset = user.getMoveset().slice(0);
user.summonData.moveset[thisMoveIndex] = new PokemonMove(copiedMove.id, 0, 0);
user.summonData.moveset[thisMoveIndex] = new PokemonMove(copiedMove.id);
globalScene.queueMessage(i18next.t("moveTriggers:copiedMove", { pokemonName: getPokemonNameWithAffix(user), moveName: copiedMove.name }));
@ -7292,13 +7334,14 @@ export class SketchAttr extends MoveEffectAttr {
constructor() {
super(true);
}
/**
* User copies the opponent's last used move, if possible
* @param {Pokemon} user Pokemon that used the move and will replace Sketch with the copied move
* @param {Pokemon} target Pokemon that the user wants to copy a move from
* @param {Move} move Move being used
* @param {any[]} args Unused
* @returns {boolean} true if the function succeeds, otherwise false
* User copies the opponent's last used move, if possible.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} being targeted by the move
* @param move - The {@linkcoed Move} being used
* @param args - Unused
* @returns Whether a move was successfully learnt
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
@ -7306,9 +7349,9 @@ export class SketchAttr extends MoveEffectAttr {
return false;
}
const targetMove = target.getLastXMoves(-1)
.find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual);
const targetMove = target.getLastNonVirtualMove()
if (!targetMove) {
// failsafe for TS compiler
return false;
}
@ -7331,32 +7374,10 @@ export class SketchAttr extends MoveEffectAttr {
return false;
}
const targetMove = target.getMoveHistory().filter(m => !m.virtual).at(-1);
if (!targetMove) {
return false;
}
const unsketchableMoves = [
Moves.CHATTER,
Moves.MIRROR_MOVE,
Moves.SLEEP_TALK,
Moves.STRUGGLE,
Moves.SKETCH,
Moves.REVIVAL_BLESSING,
Moves.TERA_STARSTORM,
Moves.BREAKNECK_BLITZ__PHYSICAL,
Moves.BREAKNECK_BLITZ__SPECIAL
];
if (unsketchableMoves.includes(targetMove.move)) {
return false;
}
if (user.getMoveset().find(m => m.moveId === targetMove.move)) {
return false;
}
return true;
const targetMove = target.getLastNonVirtualMove();
return !isNullOrUndefined(targetMove)
&& !invalidSketchMoves.has(targetMove.move)
&& user.getMoveset().every(m => m.moveId !== targetMove.move)
};
}
}
@ -7796,19 +7817,20 @@ export class LastResortAttr extends MoveAttr {
// TODO: Verify behavior as Bulbapedia page is _extremely_ poorly documented
getCondition(): MoveConditionFunc {
return (user: Pokemon, _target: Pokemon, move: Move) => {
const movesInMoveset = new Set<Moves>(user.getMoveset().map(m => m.moveId));
if (!movesInMoveset.delete(move.id) || !movesInMoveset.size) {
const otherMovesInMoveset = new Set<Moves>(user.getMoveset().map(m => m.moveId));
if (!otherMovesInMoveset.delete(move.id) || !otherMovesInMoveset.size) {
return false; // Last resort fails if used when not in user's moveset or no other moves exist
}
const movesInHistory = new Set(
const movesInHistory = new Set<Moves>(
user.getMoveHistory()
.filter(m => !m.virtual) // TODO: Change to (m) => m < MoveUseType.INDIRECT after Dancer PR refactors virtual into enum
.filter(m => m.useType < MoveUseType.INDIRECT) // Last resort ignores virtual moves
.map(m => m.move)
);
// Since `Set.intersection()` is only present in ESNext, we have to coerce it to an array to check inclusion
return [...movesInMoveset].every(m => movesInHistory.has(m))
// Since `Set.intersection()` is only present in ESNext, we have to do this to check inclusion
// grumble mumble grumble mumble...
return [...otherMovesInMoveset].every(m => movesInHistory.has(m))
};
}
}
@ -7830,27 +7852,26 @@ export class VariableTargetAttr extends MoveAttr {
}
/**
* Attribute for {@linkcode Moves.AFTER_YOU}
* Attribute to cause the target to move immediately after the user.
*
* [After You - Move | Bulbapedia](https://bulbapedia.bulbagarden.net/wiki/After_You_(move))
* Used by {@linkcode Moves.AFTER_YOU}.
*/
export class AfterYouAttr extends MoveEffectAttr {
/**
* Allows the target of this move to act right after the user.
*
* @param user {@linkcode Pokemon} that is using the move.
* @param target {@linkcode Pokemon} that will move right after this move is used.
* @param move {@linkcode Move} {@linkcode Moves.AFTER_YOU}
* @param _args N/A
* @returns true
* Cause the target of this move to act right after the user.
* @param user - Unused
* @param target - The {@linkcode Pokemon} targeted by this move
* @param _move - Unused
* @param _args - Unused
* @returns `true`
*/
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
globalScene.queueMessage(i18next.t("moveTriggers:afterYou", { targetName: getPokemonNameWithAffix(target) }));
//Will find next acting phase of the targeted pokémon, delete it and queue it next on successful delete.
const nextAttackPhase = globalScene.findPhase<MovePhase>((phase) => phase.pokemon === target);
if (nextAttackPhase && globalScene.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
globalScene.prependToPhase(new MovePhase(target, [ ...nextAttackPhase.targets ], nextAttackPhase.move), MovePhase);
// Will find next acting phase of the targeted pokémon, delete it and queue it right after us.
const targetNextPhase = globalScene.findPhase<MovePhase>(phase => phase.pokemon === target);
if (targetNextPhase && globalScene.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
globalScene.prependToPhase(targetNextPhase, MovePhase);
}
return true;
@ -7860,7 +7881,6 @@ export class AfterYouAttr extends MoveEffectAttr {
/**
* Move effect to force the target to move last, ignoring priority.
* If applied to multiple targets, they move in speed order after all other moves.
* @extends MoveEffectAttr
*/
export class ForceLastAttr extends MoveEffectAttr {
/**
@ -7875,6 +7895,7 @@ export class ForceLastAttr extends MoveEffectAttr {
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
globalScene.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) }));
// TODO: Refactor this to be more readable
const targetMovePhase = globalScene.findPhase<MovePhase>((phase) => phase.pokemon === target);
if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
// Finding the phase to insert the move in front of -
@ -7887,7 +7908,7 @@ export class ForceLastAttr extends MoveEffectAttr {
globalScene.phaseQueue.splice(
globalScene.phaseQueue.indexOf(prependPhase),
0,
new MovePhase(target, [ ...targetMovePhase.targets ], targetMovePhase.move, false, false, false, true)
new MovePhase(target, [ ...targetMovePhase.targets ], targetMovePhase.move, targetMovePhase.useType, true)
);
}
}
@ -7895,12 +7916,18 @@ export class ForceLastAttr extends MoveEffectAttr {
}
}
/** Returns whether a {@linkcode MovePhase} has been forced last and the corresponding pokemon is slower than {@linkcode target} */
/**
* Returns whether a {@linkcode MovePhase} has been forced last and the corresponding pokemon is slower than {@linkcode target}.
* TODO:
- Make this a class method
- Make this look at speed order from TurnStartPhase
*/
const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean): boolean => {
let slower: boolean;
// quashed pokemon still have speed ties
if (phase.pokemon.getEffectiveStat(Stat.SPD) === target.getEffectiveStat(Stat.SPD)) {
slower = !!target.randSeedInt(2);
slower = !target.randSeedInt(2);
} else {
slower = !trickRoom ? phase.pokemon.getEffectiveStat(Stat.SPD) < target.getEffectiveStat(Stat.SPD) : phase.pokemon.getEffectiveStat(Stat.SPD) > target.getEffectiveStat(Stat.SPD);
}
@ -8042,8 +8069,7 @@ export class UpperHandCondition extends MoveCondition {
super((user, target, move) => {
const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()];
return !!targetCommand
&& targetCommand.command === Command.FIGHT
return targetCommand?.command === Command.FIGHT
&& !target.turnData.acted
&& !!targetCommand.move?.move
&& allMoves[targetCommand.move.move].category !== MoveCategory.STATUS
@ -8076,6 +8102,7 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
constructor() {
super(true);
}
/**
* User changes its type to a random type that resists the target's last used move
* @param {Pokemon} user Pokemon that used the move and will change types
@ -8089,7 +8116,8 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
return false;
}
const [ targetMove ] = target.getLastXMoves(1); // target's most recent move
// TODO: Confirm how this interacts with status-induced failures and called moves
const targetMove = target.getLastXMoves(1)[0]; // target's most recent move
if (!targetMove) {
return false;
}
@ -8131,9 +8159,9 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
}
getCondition(): MoveConditionFunc {
// TODO: Does this count dancer?
return (user, target, move) => {
const moveHistory = target.getLastXMoves();
return moveHistory.length !== 0;
return target.getLastXMoves(-1).some(tm => tm.move !== Moves.NONE);
};
}
}
@ -8395,9 +8423,9 @@ export function initMoves() {
.attr(FixedDamageAttr, 20),
new StatusMove(Moves.DISABLE, PokemonType.NORMAL, 100, 20, -1, 0, 1)
.attr(AddBattlerTagAttr, BattlerTagType.DISABLED, false, true)
.condition((user, target, move) => {
const lastRealMove = target.getLastXMoves(-1).find(m => !m.virtual);
return !isNullOrUndefined(lastRealMove) && lastRealMove.move !== Moves.NONE && lastRealMove.move !== Moves.STRUGGLE;
.condition((_user, target, _move) => {
const lastNonVirtualMove = target.getLastNonVirtualMove();
return !isNullOrUndefined(lastNonVirtualMove) && lastNonVirtualMove.move !== Moves.STRUGGLE;
})
.ignoresSubstitute()
.reflectable(),
@ -8589,7 +8617,8 @@ export function initMoves() {
new SelfStatusMove(Moves.METRONOME, PokemonType.NORMAL, -1, 10, -1, 0, 1)
.attr(RandomMoveAttr, invalidMetronomeMoves),
new StatusMove(Moves.MIRROR_MOVE, PokemonType.FLYING, -1, 20, -1, 0, 1)
.attr(CopyMoveAttr, true, invalidMirrorMoveMoves),
.attr(CopyMoveAttr, true, invalidMirrorMoveMoves)
.edgeCase(), // May or may not have incorrect interactions with Struggle
new AttackMove(Moves.SELF_DESTRUCT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 200, 100, 5, -1, 0, 1)
.attr(SacrificialAttr)
.makesContact(false)
@ -9458,7 +9487,8 @@ export function initMoves() {
.target(MoveTarget.NEAR_ENEMY)
.unimplemented(),
new SelfStatusMove(Moves.COPYCAT, PokemonType.NORMAL, -1, 20, -1, 0, 4)
.attr(CopyMoveAttr, false, invalidCopycatMoves),
.attr(CopyMoveAttr, false, invalidCopycatMoves)
.edgeCase(), // May or may not have incorrect interactions with Struggle
new StatusMove(Moves.POWER_SWAP, PokemonType.PSYCHIC, -1, 10, 100, 0, 4)
.attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ])
.ignoresSubstitute(),
@ -9828,7 +9858,13 @@ export function initMoves() {
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.condition(failOnGravityCondition)
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE))
.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/
.partial(),
/* Cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/:
* Should immobilize and give target semi-invulnerability
* Flying types should take no damage
* Should fail on targets above a certain weight threshold
* Should remove all redirection effects on successful takeoff (Rage Poweder, etc.)
*/
new SelfStatusMove(Moves.SHIFT_GEAR, PokemonType.STEEL, -1, 10, -1, 0, 5)
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true)
.attr(StatStageChangeAttr, [ Stat.SPD ], 2, true),
@ -10399,10 +10435,10 @@ export function initMoves() {
new StatusMove(Moves.INSTRUCT, PokemonType.PSYCHIC, -1, 15, -1, 0, 7)
.ignoresSubstitute()
.attr(RepeatMoveAttr)
// incorrect interactions with Gigaton Hammer, Blood Moon & Torment
// Also has incorrect interactions with Dancer due to the latter
// erroneously adding copied moves to move history.
.edgeCase(),
// incorrect interactions with Gigaton Hammer, Blood Moon & Torment due to them making moves _fail on use_,
// not merely unselectable.
// Also my or may not have incorrect interactions with Struggle (needs verification).
new AttackMove(Moves.BEAK_BLAST, PokemonType.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, -3, 7)
.attr(BeakBlastHeaderAttr)
.ballBombMove()
@ -10459,7 +10495,11 @@ export function initMoves() {
.bitingMove()
.attr(RemoveScreensAttr),
new AttackMove(Moves.STOMPING_TANTRUM, PokemonType.GROUND, MoveCategory.PHYSICAL, 75, 100, 10, -1, 0, 7)
.attr(MovePowerMultiplierAttr, (user, target, move) => user.getLastXMoves(2)[1]?.result === MoveResult.MISS || user.getLastXMoves(2)[1]?.result === MoveResult.FAIL ? 2 : 1),
.attr(MovePowerMultiplierAttr, (user) => {
// TODO: Verify if Stomping Tantrum skips dancer moves and/or copied moves
const lastNonDancerMove = user.getLastXMoves(-1).filter(m => m.useType !== MoveUseType.INDIRECT)[1] as TurnMove | undefined;
return lastNonDancerMove && (lastNonDancerMove.result === MoveResult.MISS || lastNonDancerMove.result === MoveResult.FAIL) ? 2 : 1
}),
new AttackMove(Moves.SHADOW_BONE, PokemonType.GHOST, MoveCategory.PHYSICAL, 85, 100, 10, 20, 0, 7)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1)
.makesContact(false),

View File

@ -76,13 +76,14 @@ export const normalForm: Species[] = [
/**
* Gets the {@linkcode PokemonSpecies} object associated with the {@linkcode Species} enum given
* @param species The species to fetch
* @returns The associated {@linkcode PokemonSpecies} object
* @param species - The {@linkcode Species} to fetch.
* If an array of `Species` is passed (such as for named trainer spawn pools),
* one will be selected at random.
* @returns The {@linkcode PokemonSpecies} object associated with the given species.
*/
export function getPokemonSpecies(species: Species | Species[]): PokemonSpecies {
// If a special pool (named trainers) is used here it CAN happen that they have a array as species (which means choose one of those two). So we catch that with this code block
if (Array.isArray(species)) {
// Pick a random species from the list
// TODO: can't we just use normal int number gen rather than this junk
species = species[Math.floor(Math.random() * species.length)];
}
if (species >= 2000) {

View File

@ -0,0 +1,58 @@
import type { BattlerTagLapseType } from "#app/data/battler-tags";
import type { PostDancingMoveAbAttr } from "#app/data/abilities/ability";
/**
* Enum representing all the possible ways a given move can be executed.
* Each one inherits the properties (or exclusions) of all types preceding it.
* Properties newly found on a given use type will be **bolded**,
* while oddities breaking a previous trend will be listed in _italics_.
*/
export enum MoveUseType {
/**
* This move was used normally (i.e. clicking on the button) or called via Instruct.
* It deducts PP from the user's moveset (failing if out of PP), and interacts normally with other moves and abilities.
*/
NORMAL,
/**
* Identical to {@linkcode MoveUseType.NORMAL}, except the move **does not consume PP** on use
* and **will not fail** if none is left before its execution.
* PP can still be reduced by other effects (such as Spite or Eerie Spell).
*/
IGNORE_PP,
/**
* This move was called indirectly by another effect other than Instruct or the user's previous move.
* Currently only used by {@linkcode PostDancingMoveAbAttr | Dancer}.
* Indirect moves ignore PP checks similar to {@linkcode MoveUseType.IGNORE_PP}, but additionally **cannot be copied**
* by all move-copying effects (barring reflection).
* They are also **"skipped over" by most moveset and move history-related effects** (PP reduction, Last Resort, etc).
* They still respect the user's volatile status conditions and confusion (though will uniquely _cure freeze and sleep before use_).
*/
INDIRECT,
/**
* This move was called as part of another move's effect (such as for most {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves | Move-calling moves}).
* Follow-up moves **bypass cancellation** from all **non-volatile status conditions** and **{@linkcode BattlerTagLapseType.MOVE}-type effects**
* (having been checked already on the calling move).
* They are _not ignored_ by other move-calling moves and abilities (unlike {@linkcode MoveUseType.FOLLOW_UP} and {@linkcode MoveUseType.REFLECTED}),
* but still inherit the former's disregard for moveset-related effects.
*/
FOLLOW_UP,
/**
* This move was reflected by Magic Coat or Magic Bounce.
* Reflected moves ignore all the same cancellation checks as {@linkcode MoveUseType.INDIRECT}
* and retain the same copy prevention as {@linkcode MoveUseType.FOLLOW_UP}, but additionally
* **cannot be reflected by other reflecting effects**.
* Also used for the "attack" portion of Future Sight and Doom Desire
* (in which case the reflection blockage is completely irrelevant.)
*/
REFLECTED
}

View File

@ -260,6 +260,7 @@ import { MoveFlags } from "#enums/MoveFlags";
import { timedEventManager } from "#app/global-event-manager";
import { loadMoveAnimations } from "#app/sprites/pokemon-asset-loader";
import { ResetStatusPhase } from "#app/phases/reset-status-phase";
import { MoveUseType } from "#enums/move-use-type";
export enum LearnMoveSituation {
MISC,
@ -406,7 +407,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
super(globalScene, x, y);
if (!species.isObtainable() && this.isPlayer()) {
throw `Cannot create a player Pokemon for species '${species.getName(formIndex)}'`;
throw `Cannot create a player Pokemon for species "${species.getName(formIndex)}"`;
}
this.species = species;
@ -641,7 +642,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Checks if a pokemon is fainted (ie: its `hp <= 0`).
* It's usually better to call {@linkcode isAllowedInBattle()}
* @param checkStatus `true` to also check that the pokemon's status is {@linkcode StatusEffect.FAINT}
* @param checkStatus - Whether to also check the pokemon's status for {@linkcode StatusEffect.FAINT}; default `false`
* @returns `true` if the pokemon is fainted
*/
public isFainted(checkStatus = false): boolean {
@ -3255,8 +3256,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* If it rolls shiny, or if it's already shiny, also sets a random variant and give the Pokemon the associated luck.
*
* The base shiny odds are {@linkcode BASE_SHINY_CHANCE} / `65536`
* @param thresholdOverride number that is divided by `2^16` (`65536`) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm)
* @param applyModifiersToOverride If {@linkcode thresholdOverride} is set and this is true, will apply Shiny Charm and event modifiers to {@linkcode thresholdOverride}
* @param thresholdOverride - number that is divided by `2^16` (`65536`) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm)
* @param applyModifiersToOverride - Whether to apply Shiny Charm and event modifiers to {@linkcode thresholdOverride}.
* Does nothing if {@linkcode thresholdOverride} is not set.
* @returns `true` if the Pokemon has been set as a shiny, `false` otherwise
*/
public trySetShinySeed(
@ -3711,7 +3713,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
while (rand > stabMovePool[index][1]) {
rand -= stabMovePool[index++][1];
}
this.moveset.push(new PokemonMove(stabMovePool[index][0], 0, 0));
this.moveset.push(new PokemonMove(stabMovePool[index][0]));
}
} else {
// Normal wild pokemon just force a random damaging move
@ -3725,7 +3727,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
while (rand > attackMovePool[index][1]) {
rand -= attackMovePool[index++][1];
}
this.moveset.push(new PokemonMove(attackMovePool[index][0], 0, 0));
this.moveset.push(new PokemonMove(attackMovePool[index][0]));
}
}
@ -3777,7 +3779,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
while (rand > movePool[index][1]) {
rand -= movePool[index++][1];
}
this.moveset.push(new PokemonMove(movePool[index][0], 0, 0));
this.moveset.push(new PokemonMove(movePool[index][0]));
}
// Trigger FormChange, except for enemy Pokemon during Mystery Encounters, to avoid crashes
@ -3810,8 +3812,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
ui =>
ui instanceof BattleInfo &&
(ui as BattleInfo) instanceof PlayerBattleInfo === this.isPlayer(),
)
.find(() => true);
)[0] as Phaser.GameObjects.GameObject | undefined;
if (!otherBattleInfo || !this.getFieldIndex()) {
globalScene.fieldUI.sendToBack(this.battleInfo);
globalScene.sendTextToBack(); // Push the top right text objects behind everything else
@ -4911,7 +4912,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**@overload */
getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | nil;
getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | undefined;
/** @overload */
getTag(tagType: BattlerTagType): BattlerTag | undefined;
@ -5139,8 +5140,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Returns a list of the most recent move entries in this Pokemon's move history.
* The retrieved move entries are sorted in order from NEWEST to OLDEST.
* @param moveCount The number of move entries to retrieve.
* If negative, retrieve the Pokemon's entire move history (equivalent to reversing the output of {@linkcode getMoveHistory()}).
* @param moveCount The number of move entries to retrieve.\
* If negative, retrieves the Pokemon's entire move history (equivalent to reversing the output of {@linkcode getMoveHistory()}).
* Default is `1`.
* @returns A list of {@linkcode TurnMove}, as specified above.
*/
@ -5154,10 +5155,42 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return moveHistory.slice(0).reverse();
}
/**
* Return the most recently executed {@linkcode TurnMove} this {@linkcode Pokemon} has used that is:
* - Not {@linkcode Moves.NONE}
* - Non-virtual ({@linkcode MoveUseType | useType} < {@linkcode MoveUseType.INDIRECT})
* @param ignoreStruggle - Whether to additionally ignore {@linkcode Moves.STRUGGLE}; default `false`
* @param ignoreFollowUp - Whether to ignore moves with a use type of {@linkcode MoveUseType.FOLLOW_UP}
* (Copycat, Mirror Move, etc.); default `true`
* @returns The last move this Pokemon has used satisfying the aforementioned conditions,
* or `undefined` if no applicable moves have been used since switching in.
*/
getLastNonVirtualMove(ignoreStruggle = false, ignoreFollowUp = true): TurnMove | undefined {
return this.getLastXMoves(-1).find(m =>
m.move !== Moves.NONE
&& (m.useType < MoveUseType.INDIRECT ||
(!ignoreFollowUp && m.useType === MoveUseType.FOLLOW_UP))
&& (!ignoreStruggle || m.move !== Moves.STRUGGLE)
);
}
/**
* Return this Pokemon's move queue, consisting of all the moves it is slated to perform.
* @returns An array of {@linkcode TurnMove}, as described above
*/
getMoveQueue(): TurnMove[] {
return this.summonData.moveQueue;
}
/**
* Add a new entry to the end of this Pokemon's move queue.
* @param queuedMove - A {@linkcode TurnMove} to push to this Pokemon's queue.
*/
pushMoveQueue(queuedMove: TurnMove): void {
this.summonData.moveQueue.push(queuedMove);
}
changeForm(formChange: SpeciesFormChange): Promise<void> {
return new Promise(resolve => {
this.formIndex = Math.max(
@ -5627,22 +5660,19 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (effect === StatusEffect.SLEEP) {
sleepTurnsRemaining = new NumberHolder(this.randSeedIntRange(2, 4));
this.setFrameRate(4);
// If the user is invulnerable, lets remove their invulnerability when they fall asleep
const invulnerableTags = [
// If the user is invulnerable, remove their invulnerability when they fall asleep
// and remove the upcoming attack from the move queue.
const tag = [
BattlerTagType.UNDERGROUND,
BattlerTagType.UNDERWATER,
BattlerTagType.HIDDEN,
BattlerTagType.FLYING,
];
const tag = invulnerableTags.find(t => this.getTag(t));
].find(t => this.getTag(t));
if (tag) {
this.removeTag(tag);
this.getMoveQueue().pop();
this.getMoveQueue().shift();
}
}
@ -5675,7 +5705,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Performs the action of clearing a Pokemon's status
*
*
* This is a helper to {@linkcode resetStatus}, which should be called directly instead of this method
*/
public clearStatus(confusion: boolean, reloadAssets: boolean) {
@ -7023,7 +7053,7 @@ export class PlayerPokemon extends Pokemon {
copyMoveset(): PokemonMove[] {
const newMoveset: PokemonMove[] = [];
this.moveset.forEach(move => {
newMoveset.push(new PokemonMove(move.moveId, 0, move.ppUp, move.virtual, move.maxPpOverride));
newMoveset.push(new PokemonMove(move.moveId, 0, move.ppUp, move.maxPpOverride));
});
return newMoveset;
@ -7213,19 +7243,22 @@ export class EnemyPokemon extends Pokemon {
* the Pokemon the move will target.
* @returns this Pokemon's next move in the format {move, moveTargets}
*/
// TODO: Refactor this and split it up
getNextMove(): TurnMove {
// If this Pokemon has a move already queued, return it.
const moveQueue = this.getMoveQueue();
if (moveQueue.length !== 0) {
const queuedMove = moveQueue[0];
if (queuedMove) {
const moveIndex = this.getMoveset().findIndex(m => m.moveId === queuedMove.move);
if ((moveIndex > -1 && this.getMoveset()[moveIndex].isUsable(this, queuedMove.ignorePP)) || queuedMove.virtual) {
return queuedMove;
} else {
this.getMoveQueue().shift();
return this.getNextMove();
}
for (const queuedMove of this.getMoveQueue()) {
const moveIndex = this.getMoveset().findIndex(m => m.moveId === queuedMove.move);
// If the queued move was called indirectly, ignore all PP and usability checks.
// Otherwise, ensure that the move being used is actually usable
if (
queuedMove.useType >= MoveUseType.INDIRECT ||
(moveIndex > -1 &&
this.getMoveset()[moveIndex].isUsable(
this,
queuedMove.useType >= MoveUseType.IGNORE_PP )
)
) {
return queuedMove;
}
}
@ -7235,9 +7268,10 @@ export class EnemyPokemon extends Pokemon {
if (movePool.length) {
// If there's only 1 move in the move pool, use it.
if (movePool.length === 1) {
return { move: movePool[0].moveId, targets: this.getNextTargets(movePool[0].moveId) };
return { move: movePool[0].moveId, targets: this.getNextTargets(movePool[0].moveId) , useType: MoveUseType.NORMAL};
}
// If a move is forced because of Encore, use it.
// Said moves are executed normally
const encoreTag = this.getTag(EncoreTag) as EncoreTag;
if (encoreTag) {
const encoreMove = movePool.find(m => m.moveId === encoreTag.moveId);
@ -7245,13 +7279,14 @@ export class EnemyPokemon extends Pokemon {
return {
move: encoreMove.moveId,
targets: this.getNextTargets(encoreMove.moveId),
useType: MoveUseType.NORMAL
};
}
}
switch (this.aiType) {
case AiType.RANDOM: // No enemy should spawn with this AI type in-game
const moveId = movePool[globalScene.randBattleSeedInt(movePool.length)].moveId;
return { move: moveId, targets: this.getNextTargets(moveId) };
return { move: moveId, targets: this.getNextTargets(moveId), useType: MoveUseType.NORMAL };
case AiType.SMART_RANDOM:
case AiType.SMART:
/**
@ -7430,13 +7465,15 @@ export class EnemyPokemon extends Pokemon {
}
}
console.log(movePool.map(m => m.getName()), moveScores, r, sortedMovePool.map(m => m.getName()));
return { move: sortedMovePool[r]!.moveId, targets: moveTargets[sortedMovePool[r]!.moveId] };
return { move: sortedMovePool[r]!.moveId, targets: moveTargets[sortedMovePool[r]!.moveId], useType: MoveUseType.NORMAL };
}
}
// No moves left means struggle
return {
move: Moves.STRUGGLE,
targets: this.getNextTargets(Moves.STRUGGLE),
useType: MoveUseType.IGNORE_PP,
};
}
@ -7796,10 +7833,9 @@ interface IllusionData {
export interface TurnMove {
move: Moves;
targets: BattlerIndex[];
useType: MoveUseType;
result?: MoveResult;
virtual?: boolean;
turn?: number;
ignorePP?: boolean;
}
export interface AttackMoveResult {
@ -7818,6 +7854,12 @@ export interface AttackMoveResult {
export class PokemonSummonData {
/** [Atk, Def, SpAtk, SpDef, Spd, Acc, Eva] */
public statStages: number[] = [0, 0, 0, 0, 0, 0, 0];
/**
* A queue of moves yet to be executed, used by charging, recharging and frenzy moves.
* So long as this array is nonempty, this Pokemon's corresponding `CommandPhase` will be skipped over entirely
* in favor of using the queued move.
* TODO: Clean up a lot of the code surrounding the move queue. It's intertwined with the
*/
public moveQueue: TurnMove[] = [];
public tags: BattlerTag[] = [];
public abilitySuppressed = false;
@ -7938,7 +7980,6 @@ export class PokemonWaveData {
* Resets at the start of a new turn, as well as on switch.
*/
export class PokemonTurnData {
public flinched = false;
public acted = false;
/** How many times the current move should hit the target(s) */
public hitCount = 0;
@ -7960,8 +8001,9 @@ export class PokemonTurnData {
public failedRunAway = false;
public joinedRound = false;
/**
* The amount of times this Pokemon has acted again and used a move in the current turn.
* Used to make sure multi-hits occur properly when the user is
* forced to act again in the same turn
* forced to act again in the same turn, and must be incremented by any effects that grant extra turns.
*/
public extraTurns = 0;
/**
@ -8038,10 +8080,9 @@ export class PokemonMove {
public moveId: Moves;
public ppUsed: number;
public ppUp: number;
public virtual: boolean;
/**
* If defined and nonzero, overrides the maximum PP of the move (e.g., due to move being copied by Transform).
* If defined and nonzero, overrides the maximum PP of the move (e.g. due to Transform).
* This also nullifies all effects of `ppUp`.
*/
public maxPpOverride?: number;
@ -8050,13 +8091,11 @@ export class PokemonMove {
moveId: Moves,
ppUsed = 0,
ppUp = 0,
virtual = false,
maxPpOverride?: number,
) {
this.moveId = moveId;
this.ppUsed = ppUsed;
this.ppUp = ppUp;
this.virtual = virtual;
this.maxPpOverride = maxPpOverride;
}
@ -8074,6 +8113,7 @@ export class PokemonMove {
ignorePp = false,
ignoreRestrictionTags = false,
): boolean {
// TODO: Add Sky Drop's 1 turn stall
if (
this.moveId &&
!ignoreRestrictionTags &&
@ -8096,10 +8136,10 @@ export class PokemonMove {
}
/**
* Sets {@link ppUsed} for this move and ensures the value does not exceed {@link getMovePp}
* @param count Amount of PP to use
* Increments this move's {@linkcode ppUsed} variable (up to a maximum of {@link getMovePp}).
* @param count - Amount of PP to consume; default `1`
*/
usePp(count: number = 1) {
usePp(count = 1) {
this.ppUsed = Math.min(this.ppUsed + count, this.getMovePp());
}
@ -8128,7 +8168,6 @@ export class PokemonMove {
source.moveId,
source.ppUsed,
source.ppUp,
source.virtual,
source.maxPpOverride,
);
}

View File

@ -23,6 +23,7 @@ import { MysteryEncounterMode } from "#enums/mystery-encounter-mode";
import { isNullOrUndefined } from "#app/utils/common";
import { ArenaTagSide } from "#app/data/arena-tag";
import { ArenaTagType } from "#app/enums/arena-tag-type";
import { MoveUseType } from "#enums/move-use-type";
export class CommandPhase extends FieldPhase {
protected fieldIndex: number;
@ -80,7 +81,7 @@ export class CommandPhase extends FieldPhase {
) {
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: Command.FIGHT,
move: { move: Moves.NONE, targets: [] },
move: { move: Moves.NONE, targets: [], useType: MoveUseType.NORMAL },
skip: true,
};
}
@ -103,29 +104,33 @@ export class CommandPhase extends FieldPhase {
moveQueue.length &&
moveQueue[0] &&
moveQueue[0].move &&
!moveQueue[0].virtual &&
moveQueue[0].useType < MoveUseType.INDIRECT &&
(!playerPokemon.getMoveset().find(m => m.moveId === moveQueue[0].move) ||
!playerPokemon
.getMoveset()
[playerPokemon.getMoveset().findIndex(m => m.moveId === moveQueue[0].move)].isUsable(
playerPokemon,
moveQueue[0].ignorePP,
moveQueue[0].useType >= MoveUseType.IGNORE_PP,
))
) {
moveQueue.shift();
}
// TODO: Refactor this. I did a few simple find/replace matches but this is just ABHORRENTLY structured
if (moveQueue.length > 0) {
const queuedMove = moveQueue[0];
if (!queuedMove.move) {
this.handleCommand(Command.FIGHT, -1);
this.handleCommand(Command.FIGHT, -1, MoveUseType.NORMAL);
} else {
const moveIndex = playerPokemon.getMoveset().findIndex(m => m.moveId === queuedMove.move);
if (
(moveIndex > -1 && playerPokemon.getMoveset()[moveIndex].isUsable(playerPokemon, queuedMove.ignorePP)) ||
queuedMove.virtual
(moveIndex > -1 &&
playerPokemon
.getMoveset()
[moveIndex].isUsable(playerPokemon, queuedMove.useType >= MoveUseType.IGNORE_PP)) ||
queuedMove.useType < MoveUseType.INDIRECT
) {
this.handleCommand(Command.FIGHT, moveIndex, queuedMove.ignorePP, queuedMove);
this.handleCommand(Command.FIGHT, moveIndex, queuedMove.useType, queuedMove);
} else {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
@ -143,18 +148,23 @@ export class CommandPhase extends FieldPhase {
}
}
/**
* TODO: Remove `args` and clean this thing up
* Code will need to be copied over from pkty except replacing the `virtual` and `ignorePP` args with a corresponding `MoveUseType`.
*/
handleCommand(command: Command, cursor: number, ...args: any[]): boolean {
const playerPokemon = globalScene.getPlayerField()[this.fieldIndex];
let success = false;
switch (command) {
// TODO: We don't need 2 args for this - moveUseType is carried over from queuedMove
case Command.TERA:
case Command.FIGHT:
let useStruggle = false;
const turnMove: TurnMove | undefined = args.length === 2 ? (args[1] as TurnMove) : undefined;
if (
cursor === -1 ||
playerPokemon.trySelectMove(cursor, args[0] as boolean) ||
playerPokemon.trySelectMove(cursor, (args[0] as MoveUseType) >= MoveUseType.IGNORE_PP) ||
(useStruggle = cursor > -1 && !playerPokemon.getMoveset().filter(m => m.isUsable(playerPokemon)).length)
) {
let moveId: Moves;
@ -171,7 +181,7 @@ export class CommandPhase extends FieldPhase {
const turnCommand: TurnCommand = {
command: Command.FIGHT,
cursor: cursor,
move: { move: moveId, targets: [], ignorePP: args[0] },
move: { move: moveId, targets: [], useType: args[0] },
args: args,
};
const preTurnCommand: TurnCommand = {

View File

@ -1,7 +1,7 @@
import { globalScene } from "#app/global-scene";
import type { BattlerIndex } from "#app/battle";
import { MoveChargeAnim } from "#app/data/battle-anims";
import { applyMoveChargeAttrs, MoveEffectAttr, InstantChargeAttr } from "#app/data/moves/move";
import { applyMoveChargeAttrs, MoveEffectAttr, InstantChargeAttr, type ChargingMove } from "#app/data/moves/move";
import type { PokemonMove } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
@ -10,10 +10,10 @@ 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";
import type { MoveUseType } from "#enums/move-use-type";
/**
* 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 */
@ -21,10 +21,21 @@ export class MoveChargePhase extends PokemonPhase {
/** The field index targeted by the move (Charging moves assume single target) */
public targetIndex: BattlerIndex;
constructor(battlerIndex: BattlerIndex, targetIndex: BattlerIndex, move: PokemonMove) {
/** The {@linkcode MoveUseType} of the move that triggered the charge; passed on from move phase */
private useType: MoveUseType;
/**
* Create a new MoveChargePhase.
* @param battlerIndex - The {@linkcode BattlerIndex} of the user.
* @param targetIndex - The {@linkcode BattlerIndex} of the target.
* @param move - The {@linkcode PokemonMove} being used
* @param useType - The move's {@linkcode MoveUseType}
*/
constructor(battlerIndex: BattlerIndex, targetIndex: BattlerIndex, move: PokemonMove, useType: MoveUseType) {
super(battlerIndex);
this.move = move;
this.targetIndex = targetIndex;
this.useType = useType;
}
public override start() {
@ -38,7 +49,8 @@ export class MoveChargePhase extends PokemonPhase {
// immediately end this phase.
if (!target || !move.isChargingMove()) {
console.warn("Invalid parameters for MoveChargePhase");
return super.end();
super.end();
return;
}
new MoveChargeAnim(move.chargeAnim, move.id, user).play(false, () => {
@ -53,30 +65,27 @@ export class MoveChargePhase extends PokemonPhase {
/** Checks the move's instant charge conditions, then ends this phase. */
public override end() {
const user = this.getUserPokemon();
const move = this.move.getMove();
const move = this.move.getMove() as ChargingMove;
if (move.isChargingMove()) {
const instantCharge = new BooleanHolder(false);
const instantCharge = new BooleanHolder(false);
applyMoveChargeAttrs(InstantChargeAttr, user, null, move, instantCharge);
applyMoveChargeAttrs(InstantChargeAttr, user, null, move, instantCharge);
if (instantCharge.value) {
// this MoveEndPhase will be duplicated by the queued MovePhase if not removed
globalScene.tryRemovePhase(phase => phase instanceof MoveEndPhase && phase.getPokemon() === user);
// queue a new MovePhase for this move's attack phase
globalScene.unshiftPhase(new MovePhase(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,
});
// If instantly charging, remove the pending MoveEndPhase and queue a new MovePhase for the "attack" portion of the move.
// Otherwise, add the attack portion to the user's move queue to execute next turn.
if (instantCharge.value) {
globalScene.tryRemovePhase(phase => phase instanceof MoveEndPhase && phase.getPokemon() === user);
globalScene.unshiftPhase(new MovePhase(user, [this.targetIndex], this.move, this.useType));
} else {
user.pushMoveQueue({ move: move.id, targets: [this.targetIndex], useType: this.useType });
}
super.end();
// Add this move's charging phase to the user's move history
user.pushMoveHistory({
move: this.move.moveId,
targets: [this.targetIndex],
result: MoveResult.OTHER,
useType: this.useType,
});
}
public getUserPokemon(): Pokemon {

View File

@ -48,7 +48,7 @@ import { MoveTarget } from "#enums/MoveTarget";
import { MoveCategory } from "#enums/MoveCategory";
import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms";
import { PokemonType } from "#enums/pokemon-type";
import { DamageResult, PokemonMove, type TurnMove } from "#app/field/pokemon";
import { type DamageResult, PokemonMove, type TurnMove } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon";
import { HitResult, MoveResult } from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages";
@ -72,25 +72,30 @@ import { ShowAbilityPhase } from "./show-ability-phase";
import { MovePhase } from "./move-phase";
import { MoveEndPhase } from "./move-end-phase";
import { HideAbilityPhase } from "#app/phases/hide-ability-phase";
import { TypeDamageMultiplier } from "#app/data/type";
import type { TypeDamageMultiplier } from "#app/data/type";
import { HitCheckResult } from "#enums/hit-check-result";
import type Move from "#app/data/moves/move";
import { isFieldTargeted } from "#app/data/moves/move-utils";
import { FaintPhase } from "./faint-phase";
import { DamageAchv } from "#app/system/achv";
import { MoveUseType } from "#enums/move-use-type";
type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier];
export type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier];
export class MoveEffectPhase extends PokemonPhase {
public move: Move;
private virtual = false;
protected targets: BattlerIndex[];
protected reflected = false;
protected useType: MoveUseType;
/** The result of the hit check against each target */
private hitChecks: HitCheckEntry[];
/** The move history entry for the move */
/**
* Log to be entered into the user's move history once the move result is resolved.
* Note that `result` logs whether the move was successfully
* used in the sense of "Does it have an effect on the user?".
*/
private moveHistoryEntry: TurnMove;
/** Is this the first strike of a move? */
@ -98,19 +103,20 @@ export class MoveEffectPhase extends PokemonPhase {
/** Is this the last strike of a move? */
private lastHit: boolean;
/** Phases queued during moves */
/**
* Phases queued during moves; used to add a new MovePhase for reflected moves after triggering.
* TODO: Remove this and move the reflection logic to ability-side
*/
private queuedPhases: Phase[] = [];
/**
* @param reflected Indicates that the move was reflected by the user due to magic coat or magic bounce
* @param virtual Indicates that the move is a virtual move (i.e. called by metronome)
* @param useType - The {@linkcode MoveUseType} corresponding to how this move was used.
*/
constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: Move, reflected = false, virtual = false) {
constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: Move, useType: MoveUseType) {
super(battlerIndex);
this.move = move;
this.virtual = virtual;
this.useType = useType;
this.reflected = reflected;
/**
* In double battles, if the right Pokemon selects a spread move and the left Pokemon dies
* with no party members available to switch in, then the right Pokemon takes the index
@ -129,11 +135,11 @@ export class MoveEffectPhase extends PokemonPhase {
* Compute targets and the results of hit checks of the invoked move against all targets,
* organized by battler index.
*
* **This is *not* a pure function**; it has the following side effects
* - `this.hitChecks` - The results of the hit checks against each target
* - `this.moveHistoryEntry` - Sets success or failure based on the hit check results
* - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the
* move was unsuccessful against all targets
* **This is *not* a pure function** and has the following side effects:
* - Sets `this.hitChecks` to the results of the hit checks against each target
* - Sets success/failure of `this.moveHistoryEntry` based on the hit check results
* - Sets `user.turnData.hitCount` and `user.turnData.hitsLeft` to 1 if the move
* was unsuccessful against all targets (effectively canceling it)
*
* @returns The targets of the invoked move
* @see {@linkcode hitCheck}
@ -181,7 +187,7 @@ export class MoveEffectPhase extends PokemonPhase {
* Queue the phaes that should occur when the target reflects the move back to the user
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} that is reflecting the move
*
* TODO: Rework this to use `onApply` of Magic Coat
*/
private queueReflectedMove(user: Pokemon, target: Pokemon): void {
const newTargets = this.move.isMultiTarget()
@ -195,15 +201,13 @@ export class MoveEffectPhase extends PokemonPhase {
this.queuedPhases.push(new HideAbilityPhase());
}
this.queuedPhases.push(
new MovePhase(target, newTargets, new PokemonMove(this.move.id, 0, 0, true), true, true, true),
);
this.queuedPhases.push(new MovePhase(target, newTargets, new PokemonMove(this.move.id), MoveUseType.REFLECTED));
}
/**
* Apply the move to each of the resolved targets.
* Apply the move to each of its resolved targets.
* @param targets - The resolved set of targets of the move
* @throws Error if there was an unexpected hit check result
* @throws - Error if there was an unexpected hit check result
*/
private applyToTargets(user: Pokemon, targets: Pokemon[]): void {
for (const [i, target] of targets.entries()) {
@ -225,6 +229,7 @@ export class MoveEffectPhase extends PokemonPhase {
case HitCheckResult.NO_EFFECT_NO_MESSAGE:
case HitCheckResult.PROTECTED:
case HitCheckResult.TARGET_NOT_ON_FIELD:
// Apply effects for ineffective moves (e.g. High Jump Kick crash dmg)
applyMoveAttrs(NoEffectAttr, user, target, this.move);
break;
case HitCheckResult.MISS:
@ -286,8 +291,18 @@ export class MoveEffectPhase extends PokemonPhase {
const overridden = new BooleanHolder(false);
const move = this.move;
// Assume single target for override
applyMoveAttrs(OverrideMoveEffectAttr, user, this.getFirstTarget() ?? null, move, overridden, this.virtual);
// Apply effects to override a move effect.
// Assuming single target here works as this is (currently)
// only used for Future Sight and Pledge moves.
// TODO: change if any other move effect overrides are introduced
applyMoveAttrs(
OverrideMoveEffectAttr,
user,
this.getFirstTarget() ?? null,
move,
overridden,
this.useType >= MoveUseType.INDIRECT,
);
// If other effects were overriden, stop this phase before they can be applied
if (overridden.value) {
@ -297,8 +312,8 @@ export class MoveEffectPhase extends PokemonPhase {
// Lapse `MOVE_EFFECT` effects (i.e. semi-invulnerability) when applicable
user.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
// If the user is acting again (such as due to Instruct), reset hitsLeft/hitCount so that
// the move executes correctly (ensures all hits of a multi-hit are properly calculated)
// If the user is acting again (such as due to Instruct or Dancer), reset hitsLeft/hitCount and
// recalculate hit count for multi-hit moves.
if (user.turnData.hitsLeft === 0 && user.turnData.hitCount > 0 && user.turnData.extraTurns > 0) {
user.turnData.hitsLeft = -1;
user.turnData.hitCount = 0;
@ -323,16 +338,11 @@ export class MoveEffectPhase extends PokemonPhase {
user.turnData.hitsLeft = hitCount.value;
}
/*
* Log to be entered into the user's move history once the move result is resolved.
* Note that `result` logs whether the move was successfully
* used in the sense of "Does it have an effect on the user?".
*/
this.moveHistoryEntry = {
move: this.move.id,
targets: this.targets,
result: MoveResult.PENDING,
virtual: this.virtual,
useType: this.useType,
};
const fieldMove = isFieldTargeted(move);
@ -396,29 +406,35 @@ export class MoveEffectPhase extends PokemonPhase {
public override end(): void {
const user = this.getUserPokemon();
/**
* If this phase isn't for the invoked move's last strike,
* unshift another MoveEffectPhase for the next strike.
* Otherwise, queue a message indicating the number of times the move has struck
* (if the move has struck more than once), then apply the heal from Shell Bell
* to the user.
*/
if (user) {
if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getFirstTarget()?.isActive()) {
globalScene.unshiftPhase(this.getNewHitPhase());
} else {
// Queue message for number of hits made by multi-move
// If multi-hit attack only hits once, still want to render a message
const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0);
if (hitsTotal > 1 || (user.turnData.hitsLeft && user.turnData.hitsLeft > 0)) {
// If there are multiple hits, or if there are hits of the multi-hit move left
globalScene.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal }));
}
globalScene.applyModifiers(HitHealModifier, this.player, user);
this.getTargets().forEach(target => (target.turnData.moveEffectiveness = null));
}
if (!user) {
super.end();
return;
}
/**
* If this phase isn't for the invoked move's last strike (and we still have something to hit),
* unshift another MoveEffectPhase for the next strike before ending this phase.
*/
if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) {
globalScene.unshiftPhase(this.getNewHitPhase());
super.end();
return;
}
/**
* All hits of the move have resolved by now.
* Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects.
*/
const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0);
if (hitsTotal > 1 || user.turnData.hitsLeft > 0) {
// Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss)
globalScene.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal }));
}
globalScene.applyModifiers(HitHealModifier, this.player, user);
this.getTargets().forEach(target => {
target.turnData.moveEffectiveness = null;
});
super.end();
}
@ -428,7 +444,6 @@ export class MoveEffectPhase extends PokemonPhase {
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param hitResult - The {@linkcode HitResult} of the attempted move
* @returns a `Promise` intended to be passed into a `then()` call.
*/
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult): void {
applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move, hitResult);
@ -440,7 +455,6 @@ export class MoveEffectPhase extends PokemonPhase {
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param dealsDamage - `true` if the attempted move successfully dealt damage
* @returns a function intended to be passed into a `then()` call.
*/
protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void {
if (this.move.hasAttr(FlinchAttr)) {
@ -460,8 +474,9 @@ export class MoveEffectPhase extends PokemonPhase {
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the target to check for protection
* @param move - The {@linkcode Move} being used
* @returns Whether the pokemon was protected
*/
private protectedCheck(user: Pokemon, target: Pokemon) {
private protectedCheck(user: Pokemon, target: Pokemon): boolean {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
@ -482,14 +497,15 @@ export class MoveEffectPhase extends PokemonPhase {
);
}
// TODO: Break up this chunky boolean to make it more palatable
return (
![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) &&
(bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) &&
(hasConditionalProtectApplied.value ||
(!target.findTags(t => t instanceof DamageProtectedTag).length &&
target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) ||
target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) ||
(this.move.category !== MoveCategory.STATUS &&
target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType))))
target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType))))
);
}
@ -549,7 +565,11 @@ export class MoveEffectPhase extends PokemonPhase {
return [HitCheckResult.PROTECTED, 0];
}
if (!this.reflected && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) {
// Reflected moves cannot be reflected again
if (
this.useType < MoveUseType.REFLECTED &&
move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })
) {
return [HitCheckResult.REFLECTED, 0];
}
@ -623,22 +643,19 @@ export class MoveEffectPhase extends PokemonPhase {
if (!user) {
return false;
}
if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) {
return true;
}
if (this.move.hasAttr(ToxicAccuracyAttr) && user.isOfType(PokemonType.POISON)) {
return true;
}
// TODO: Fix lock on / mind reader check.
if (
user.getTag(BattlerTagType.IGNORE_ACCURACY) &&
(user.getLastXMoves().find(() => true)?.targets || []).indexOf(target.getBattlerIndex()) !== -1
) {
return true;
}
if (isFieldTargeted(this.move)) {
return true;
switch (true) {
// No Guard
case user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr):
// Toxic as poison type
case this.move.hasAttr(ToxicAccuracyAttr) && user.isOfType(PokemonType.POISON):
// Lock On/Mind Reader
case !!user.getTag(BattlerTagType.IGNORE_ACCURACY):
// Spikes and company
case isFieldTargeted(this.move):
return true;
}
return false;
}
/**
@ -662,12 +679,17 @@ export class MoveEffectPhase extends PokemonPhase {
return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex];
}
/** @returns An array of all {@linkcode Pokemon} targeted by this phase's invoked move */
/**
* @returns An array of {@linkcode Pokemon} that are:
* - On-field and active
* - Non-fainted
* - Targeted by this phase's invoked move
*/
public getTargets(): Pokemon[] {
return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1);
}
/** @returns The first target of this phase's invoked move */
/** @returns The first active, non-fainted target of this phase's invoked move. */
public getFirstTarget(): Pokemon | undefined {
return this.getTargets()[0];
}
@ -709,7 +731,7 @@ export class MoveEffectPhase extends PokemonPhase {
/** @returns A new `MoveEffectPhase` with the same properties as this phase */
protected getNewHitPhase(): MoveEffectPhase {
return new MoveEffectPhase(this.battlerIndex, this.targets, this.move, this.reflected, this.virtual);
return new MoveEffectPhase(this.battlerIndex, this.targets, this.move, this.useType);
}
/** Removes all substitutes that were broken by this phase's invoked move */
@ -731,7 +753,6 @@ export class MoveEffectPhase extends PokemonPhase {
* @param firstTarget Whether the target is the first to be hit by the current strike
* @param selfTarget If defined, limits the effects triggered to either self-targeted
* effects (if set to `true`) or targeted effects (if set to `false`).
* @returns a `Promise` applying the relevant move effects.
*/
protected triggerMoveEffects(
triggerType: MoveEffectTrigger,
@ -780,6 +801,7 @@ export class MoveEffectPhase extends PokemonPhase {
const hitResult = this.applyMove(user, target, effectiveness);
// Apply effects to the user (always) and the target (if not blocked by substitute).
this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true);
if (!this.move.hitsSubstitute(user, target)) {
this.applyOnTargetEffects(user, target, hitResult, firstTarget);
@ -810,7 +832,7 @@ export class MoveEffectPhase extends PokemonPhase {
*/
applyMoveAttrs(StatChangeBeforeDmgCalcAttr, user, target, this.move);
const { result: result, damage: dmg } = target.getAttackDamage({
const { result, damage: dmg } = target.getAttackDamage({
source: user,
move: this.move,
ignoreAbility: false,

View File

@ -24,9 +24,9 @@ export class MoveEndPhase extends PokemonPhase {
if (!this.wasFollowUp && pokemon?.isActive(true)) {
pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
}
globalScene.arena.setIgnoreAbilities(false);
// Remove effects which were set on a Pokemon which removes them on summon (i.e. via Mold Breaker)
globalScene.arena.setIgnoreAbilities(false);
for (const target of this.targets) {
if (target) {
applyPostSummonAbAttrs(PostSummonRemoveEffectAbAttr, target);

View File

@ -50,17 +50,19 @@ import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves";
import { StatusEffect } from "#enums/status-effect";
import i18next from "i18next";
import { MoveUseType } from "#enums/move-use-type";
export class MovePhase extends BattlePhase {
protected _pokemon: Pokemon;
protected _move: PokemonMove;
protected _targets: BattlerIndex[];
protected followUp: boolean;
protected ignorePp: boolean;
protected _useType: MoveUseType;
protected forcedLast: boolean;
/** Whether the current move should fail but still use PP */
protected failed = false;
/** Whether the current move should cancel and retain PP */
protected cancelled = false;
protected reflected = false;
public get pokemon(): Pokemon {
return this._pokemon;
@ -78,6 +80,14 @@ export class MovePhase extends BattlePhase {
this._move = move;
}
public get useType(): MoveUseType {
return this._useType;
}
protected set useType(useType: MoveUseType) {
this._useType = useType;
}
public get targets(): BattlerIndex[] {
return this._targets;
}
@ -87,51 +97,42 @@ export class MovePhase extends BattlePhase {
}
/**
* @param followUp Indicates that the move being used is a "follow-up" - for example, a move being used by Metronome or Dancer.
* Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc.
* @param reflected Indicates that the move was reflected by Magic Coat or Magic Bounce.
* Reflected moves cannot be reflected again and will not trigger Dancer.
* Create a new MovePhase for using moves.
* @param pokemon - The {@linkcode Pokemon} using the move
* @param move - The {@linkcode PokemonMove} to use
* @param useType - The {@linkcode MoveUseType} corresponding to this move's means of execution (usually `MoveUseType.NORMAL`).
* Not marked optional to ensure callers correctly pass on `useTypes`.
* @param forcedLast - Whether to force this phase to occur last in order (for {@linkcode Moves.QUASH}); default `false`
*/
constructor(
pokemon: Pokemon,
targets: BattlerIndex[],
move: PokemonMove,
followUp = false,
ignorePp = false,
reflected = false,
forcedLast = false,
) {
constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, useType: MoveUseType, forcedLast = false) {
super();
this.pokemon = pokemon;
this.targets = targets;
this.move = move;
this.followUp = followUp;
this.ignorePp = ignorePp;
this.reflected = reflected;
this.useType = useType;
this.forcedLast = forcedLast;
}
/**
* Checks if the pokemon is active, if the move is usable, and that the move is targetting something.
* Checks if the pokemon is active, if the move is usable, and that the move is targeting something.
* @param ignoreDisableTags `true` to not check if the move is disabled
* @returns `true` if all the checks pass
*/
public canMove(ignoreDisableTags = false): boolean {
return (
this.pokemon.isActive(true) &&
this.move.isUsable(this.pokemon, this.ignorePp, ignoreDisableTags) &&
this.move.isUsable(this.pokemon, this.useType >= MoveUseType.IGNORE_PP, ignoreDisableTags) &&
!!this.targets.length
);
}
/**Signifies the current move should fail but still use PP */
/** Signifies the current move should fail but still use PP */
public fail(): void {
this.failed = true;
}
/**Signifies the current move should cancel and retain PP */
/** Signifies the current move should cancel and retain PP */
public cancel(): void {
this.cancelled = true;
}
@ -139,7 +140,7 @@ export class MovePhase extends BattlePhase {
/**
* Shows whether the current move has been forced to the end of the turn
* Needed for speed order, see {@linkcode Moves.QUASH}
* */
*/
public isForcedLast(): boolean {
return this.forcedLast;
}
@ -147,7 +148,7 @@ export class MovePhase extends BattlePhase {
public start(): void {
super.start();
console.log(Moves[this.move.moveId]);
console.log(Moves[this.move.moveId], MoveUseType[this.useType]);
// Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite).
if (!this.canMove(true)) {
@ -156,26 +157,27 @@ export class MovePhase extends BattlePhase {
this.showMoveText();
this.showFailedText();
}
return this.end();
this.end();
return;
}
this.pokemon.turnData.acted = true;
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (this.followUp) {
if (this.useType >= MoveUseType.INDIRECT) {
this.pokemon.turnData.hitsLeft = -1;
this.pokemon.turnData.hitCount = 0;
}
// Check move to see if arena.ignoreAbilities should be true.
if (!this.followUp || this.reflected) {
if (
this.move
.getMove()
.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user: this.pokemon, isFollowUp: this.followUp })
) {
globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex());
}
if (
this.move.getMove().doesFlagEffectApply({
flag: MoveFlags.IGNORE_ABILITIES,
user: this.pokemon,
isFollowUp: this.useType >= MoveUseType.INDIRECT, // Sunsteel strike and co. don't work when called indirectly
})
) {
globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex());
}
this.resolveRedirectTarget();
@ -190,6 +192,7 @@ export class MovePhase extends BattlePhase {
this.resolveFinalPreMoveCancellationChecks();
}
// Cancel, charge or use the move as applicable.
if (this.cancelled || this.failed) {
this.handlePreMoveFailures();
} else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) {
@ -208,7 +211,7 @@ export class MovePhase extends BattlePhase {
if (
(targets.length === 0 && !this.move.getMove().hasAttr(AddArenaTrapTagAttr)) ||
(moveQueue.length && moveQueue[0].move === Moves.NONE)
(moveQueue.length > 0 && moveQueue[0].move === Moves.NONE)
) {
this.showMoveText();
this.showFailedText();
@ -221,81 +224,97 @@ export class MovePhase extends BattlePhase {
}
/**
* Handles {@link StatusEffect.SLEEP Sleep}/{@link StatusEffect.PARALYSIS Paralysis}/{@link StatusEffect.FREEZE Freeze} rolls and side effects.
* Handles {@link StatusEffect.SLEEP | Sleep}/{@link StatusEffect.PARALYSIS | Paralysis}/{@link StatusEffect.FREEZE | Freeze} rolls and side effects.
*/
protected resolvePreMoveStatusEffects(): void {
if (!this.followUp && this.pokemon.status && !this.pokemon.status.isPostTurn()) {
this.pokemon.status.incrementTurn();
let activated = false;
let healed = false;
// Skip for follow ups/reflected moves, no status condition or post turn statuses (e.g. Poison/Toxic)
if (!this.pokemon.status || this.pokemon.status?.isPostTurn() || this.useType >= MoveUseType.FOLLOW_UP) {
return;
}
switch (this.pokemon.status.effect) {
case StatusEffect.PARALYSIS:
activated =
(!this.pokemon.randSeedInt(4) || Overrides.STATUS_ACTIVATION_OVERRIDE === true) &&
Overrides.STATUS_ACTIVATION_OVERRIDE !== false;
break;
case StatusEffect.SLEEP: {
applyMoveAttrs(BypassSleepAttr, this.pokemon, null, this.move.getMove());
const turnsRemaining = new NumberHolder(this.pokemon.status.sleepTurnsRemaining ?? 0);
applyAbAttrs(
ReduceStatusEffectDurationAbAttr,
this.pokemon,
null,
false,
this.pokemon.status.effect,
turnsRemaining,
);
this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value;
healed = this.pokemon.status.sleepTurnsRemaining <= 0;
activated = !healed && !this.pokemon.getTag(BattlerTagType.BYPASS_SLEEP);
break;
}
case StatusEffect.FREEZE:
healed =
!!this.move
.getMove()
.findAttr(
attr => attr instanceof HealStatusEffectAttr && attr.selfTarget && attr.isOfEffect(StatusEffect.FREEZE),
) ||
(!this.pokemon.randSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true) ||
Overrides.STATUS_ACTIVATION_OVERRIDE === false;
if (
this.useType === MoveUseType.INDIRECT &&
[StatusEffect.SLEEP, StatusEffect.FREEZE].includes(this.pokemon.status.effect)
) {
// Dancer thaws out or wakes up a frozen/sleeping user prior to use
this.pokemon.resetStatus(false);
return;
}
activated = !healed;
break;
/** Whether to prevent us from using the move */
let activated = false;
/** Whether to cure the status */
let healed = false;
switch (this.pokemon.status.effect) {
case StatusEffect.PARALYSIS:
activated =
(!this.pokemon.randSeedInt(4) || Overrides.STATUS_ACTIVATION_OVERRIDE === true) &&
Overrides.STATUS_ACTIVATION_OVERRIDE !== false;
break;
case StatusEffect.SLEEP: {
applyMoveAttrs(BypassSleepAttr, this.pokemon, null, this.move.getMove());
const turnsRemaining = new NumberHolder(this.pokemon.status.sleepTurnsRemaining ?? 0);
applyAbAttrs(
ReduceStatusEffectDurationAbAttr,
this.pokemon,
null,
false,
this.pokemon.status.effect,
turnsRemaining,
);
this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value;
healed = this.pokemon.status.sleepTurnsRemaining <= 0;
activated = !healed && !this.pokemon.getTag(BattlerTagType.BYPASS_SLEEP);
break;
}
case StatusEffect.FREEZE:
healed =
!!this.move
.getMove()
.findAttr(
attr => attr instanceof HealStatusEffectAttr && attr.selfTarget && attr.isOfEffect(StatusEffect.FREEZE),
) ||
(!this.pokemon.randSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true) ||
Overrides.STATUS_ACTIVATION_OVERRIDE === false;
if (activated) {
this.cancel();
globalScene.queueMessage(
getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
globalScene.unshiftPhase(
new CommonAnimPhase(
this.pokemon.getBattlerIndex(),
undefined,
CommonAnim.POISON + (this.pokemon.status.effect - 1),
),
);
} else if (healed) {
globalScene.queueMessage(
getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
this.pokemon.resetStatus();
this.pokemon.updateInfo();
}
activated = !healed;
break;
}
if (activated) {
// Cancel move activation and play effect
this.cancel();
globalScene.queueMessage(
getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
globalScene.unshiftPhase(
new CommonAnimPhase(
this.pokemon.getBattlerIndex(),
undefined,
CommonAnim.POISON + (this.pokemon.status.effect - 1), // offset anim # by effect #
),
);
} else if (healed) {
// cure status and play effect
globalScene.queueMessage(
getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
this.pokemon.resetStatus();
this.pokemon.updateInfo();
}
}
/**
* Lapse {@linkcode BattlerTagLapseType.PRE_MOVE PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed.
* Also lapse {@linkcode BattlerTagLapseType.MOVE MOVE} tags if the move should be successful.
* Lapse {@linkcode BattlerTagLapseType.PRE_MOVE | PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed.
* Also lapse {@linkcode BattlerTagLapseType.MOVE | MOVE} tags if the move is successful and not called indirectly.
*/
protected lapsePreMoveAndMoveTags(): void {
this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
// TODO: does this intentionally happen before the no targets/Moves.NONE on queue cancellation case is checked?
if (!this.followUp && this.canMove() && !this.cancelled) {
// (In other words, check if truant can proc on a move w/o targets)
if (this.useType < MoveUseType.FOLLOW_UP && this.canMove() && !this.cancelled) {
this.pokemon.lapseTags(BattlerTagLapseType.MOVE);
}
}
@ -303,64 +322,46 @@ export class MovePhase extends BattlePhase {
protected useMove(): void {
const targets = this.getActiveTargetPokemon();
const moveQueue = this.pokemon.getMoveQueue();
const move = this.move.getMove();
// form changes happen even before we know that the move wll execute.
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
const isDelayedAttack = this.move.getMove().hasAttr(DelayedAttackAttr);
const isDelayedAttack = move.hasAttr(DelayedAttackAttr);
if (isDelayedAttack) {
// Check the player side arena if future sight is active
const futureSightTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.FUTURE_SIGHT);
const doomDesireTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.DOOM_DESIRE);
let fail = false;
// Check the player side arena if another delayed attack is active and hitting the same slot.
const delayedAttackTags = globalScene.arena.findTags(t =>
[ArenaTagType.FUTURE_SIGHT, ArenaTagType.DOOM_DESIRE].includes(t.tagType),
) as DelayedAttackTag[];
const currentTargetIndex = targets[0].getBattlerIndex();
for (const tag of futureSightTags) {
if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) {
fail = true;
break;
}
}
for (const tag of doomDesireTags) {
if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) {
fail = true;
break;
}
}
if (fail) {
if (delayedAttackTags.some(tag => tag.targetIndex === currentTargetIndex)) {
this.showMoveText();
this.showFailedText();
return this.end();
}
}
let success = true;
// Check if there are any attributes that can interrupt the move, overriding the fail message.
for (const move of this.move.getMove().getAttrs(PreUseInterruptAttr)) {
if (move.apply(this.pokemon, targets[0], this.move.getMove())) {
success = false;
break;
this.failMove();
return;
}
}
// Check if the move has any attributes that can interrupt its own use **before** displaying text.
let success = !move.getAttrs(PreUseInterruptAttr).some(attr => attr.apply(this.pokemon, targets[0], move));
if (success) {
this.showMoveText();
}
if (moveQueue.length > 0) {
// Using .shift here clears out two turn moves once they've been used
this.ignorePp = moveQueue.shift()?.ignorePP ?? false;
}
// Clear out any two turn moves once they've been used.
// TODO: Refactor move queues and remove this assignment;
// Move queues should be handled by the calling `CommandPhase` or a manager for it
this.useType = moveQueue.shift()?.useType ?? this.useType;
if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) {
this.pokemon.lapseTag(BattlerTagType.CHARGING);
}
// "commit" to using the move, deducting PP.
if (!this.ignorePp) {
if (this.useType < MoveUseType.IGNORE_PP) {
// "commit" to using the move, deducting PP.
const ppUsed = 1 + this.getPpIncreaseFromPressure(targets);
this.move.usePp(ppUsed);
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed));
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, move, this.move.ppUsed));
}
/**
@ -370,123 +371,125 @@ export class MovePhase extends BattlePhase {
* - The target's `ForceSwitchOutImmunityAbAttr` is not triggered (see {@linkcode Move.prototype.applyConditions})
* - Weather does not block the move
* - Terrain does not block the move
*
* TODO: These steps are straightforward, but the implementation below is extremely convoluted.
*/
const move = this.move.getMove();
/**
* Move conditions assume the move has a single target
* TODO: is this sustainable?
*/
let failedDueToTerrain = false;
let failedDueToWeather = false;
if (success) {
const passesConditions = move.applyConditions(this.pokemon, targets[0], move);
failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
success = passesConditions && !failedDueToWeather && !failedDueToTerrain;
const passesConditions = move.applyConditions(this.pokemon, targets[0], move);
const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
success &&= passesConditions && !failedDueToWeather && !failedDueToTerrain;
if (!success) {
this.failMove(failedDueToWeather, failedDueToTerrain);
return;
}
// Update the battle's "last move" pointer, unless we're currently mimicking a move.
if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) {
// The last move used is unaffected by moves that fail
if (success) {
globalScene.currentBattle.lastMove = this.move.moveId;
}
if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr) && this.useType !== MoveUseType.INDIRECT) {
// Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer.
// TODO: Research how Copycat interacts with the final attacking turn of Future Sight and co.
globalScene.currentBattle.lastMove = this.move.moveId;
}
/**
* If the move has not failed, trigger ability-based user type changes and then execute it.
*
* Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger these type changes even
* if the move fails.
*/
if (success) {
const move = this.move.getMove();
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, move);
globalScene.unshiftPhase(
new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, move, this.reflected, this.move.virtual),
);
} else {
if ([Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE].includes(this.move.moveId)) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
}
this.pokemon.pushMoveHistory({
move: this.move.moveId,
targets: this.targets,
result: MoveResult.FAIL,
virtual: this.move.virtual,
});
const failureMessage = move.getFailedText(this.pokemon, targets[0], move);
let failedText: string | undefined;
if (failureMessage) {
failedText = failureMessage;
} else if (failedDueToTerrain) {
failedText = getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType());
} else if (failedDueToWeather) {
failedText = getWeatherBlockMessage(globalScene.arena.getWeatherType());
}
this.showFailedText(failedText);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
}
// trigger ability-based user type changes and then execute move effects.
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, move);
globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, move, this.useType));
// Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`).
// Note that the `!this.followUp` check here prevents an infinite Dancer loop.
if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !this.followUp) {
// Note the MoveUseType check here prevents an infinite Dancer loop.
if (
this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) &&
![MoveUseType.INDIRECT, MoveUseType.REFLECTED].includes(this.useType)
) {
// TODO: Fix in dancer PR to move to MEP for hit checks
globalScene.getField(true).forEach(pokemon => {
applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets);
});
}
}
/** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */
/**
* Fail the move currently being used.
* Handles failure messages, pushing to move history, etc.
* Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger type changes even on failure.
* @param failedDueToWeather - Whether the move failed due to weather (default `false`)
* @param failedDueToTerrain - Whether the move failed due to terrain (default `false`)
*/
protected failMove(failedDueToWeather = false, failedDueToTerrain = false) {
const move = this.move.getMove();
const targets = this.getActiveTargetPokemon();
if ([Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE].includes(this.move.moveId)) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, move);
}
this.pokemon.pushMoveHistory({
move: this.move.moveId,
targets: this.targets,
result: MoveResult.FAIL,
useType: this.useType,
});
// Use move-specific failure messages if present before checking terrain/weather blockage
// and falling back to the classic "But it failed!".
const failureMessage =
move.getFailedText(this.pokemon, targets[0], move) ??
(failedDueToTerrain
? getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType())
: failedDueToWeather
? getWeatherBlockMessage(globalScene.arena.getWeatherType())
: i18next.t("battle:attackFailed"));
this.showFailedText(failureMessage);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
}
/**
* Queue a {@linkcode MoveChargePhase} for this phase's invoked move.
* Does NOT consume PP (occurs on the 2nd strike of the 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.showMoveText();
globalScene.unshiftPhase(new MoveChargePhase(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,
});
const failureMessage = move.getFailedText(this.pokemon, targets[0], move);
this.showMoveText();
this.showFailedText(failureMessage ?? undefined);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
// Conditions currently assume single target
// TODO: Is this sustainable?
if (!move.applyConditions(this.pokemon, targets[0], move)) {
this.failMove();
return;
}
// Protean and Libero apply on the charging turn of charge moves
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
globalScene.unshiftPhase(
new MoveChargePhase(this.pokemon.getBattlerIndex(), this.targets[0], this.move, this.useType),
);
}
/**
* Queues a {@linkcode MoveEndPhase} and then ends the phase
* Queue a {@linkcode MoveEndPhase} and then end this phase.
*/
public end(): void {
globalScene.unshiftPhase(
new MoveEndPhase(this.pokemon.getBattlerIndex(), this.getActiveTargetPokemon(), this.followUp),
new MoveEndPhase(
this.pokemon.getBattlerIndex(),
this.getActiveTargetPokemon(),
this.useType >= MoveUseType.INDIRECT,
),
);
super.end();
}
/**
* Applies PP increasing abilities (currently only {@link Abilities.PRESSURE Pressure}) if they exist on the target pokemon.
* Applies PP increasing abilities (currently only {@linkcode Abilities.PRESSURE | Pressure}) if they exist on the target pokemon.
* Note that targets must include only active pokemon.
*
* TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability.
@ -562,40 +565,43 @@ export class MovePhase extends BattlePhase {
}
/**
* Counter-attacking moves pass in `[`{@linkcode BattlerIndex.ATTACKER}`]` into the constructor's `targets` param.
* This function modifies `this.targets` to reflect the actual battler index of the user's last
* attacker.
* Update the targets of any counter-attacking moves with `[`{@linkcode BattlerIndex.ATTACKER}`]` set
* to reflect the actual battler index of the user's last attacker.
*
* If there is no last attacker, or they are no longer on the field, a message is displayed and the
* If there is no last attacker or they are no longer on the field, a message is displayed and the
* move is marked for failure.
*/
protected resolveCounterAttackTarget(): void {
if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) {
if (this.pokemon.turnData.attacksReceived.length) {
this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex;
if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) {
return;
}
// account for metal burst and comeuppance hitting remaining targets in double battles
// counterattack will redirect to remaining ally if original attacker faints
if (globalScene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) {
if (globalScene.getField()[this.targets[0]].hp === 0) {
const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField();
this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER;
}
}
}
if (this.pokemon.turnData.attacksReceived.length) {
this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex;
if (this.targets[0] === BattlerIndex.ATTACKER) {
this.fail();
this.showMoveText();
this.showFailedText();
// account for metal burst and comeuppance hitting remaining targets in double battles
// counterattack will redirect to remaining ally if original attacker faints
if (
globalScene.currentBattle.double &&
this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER) &&
globalScene.getField()[this.targets[0]].hp === 0
) {
const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField();
this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER;
}
}
if (this.targets[0] === BattlerIndex.ATTACKER) {
this.fail();
this.showMoveText();
this.showFailedText();
}
}
/**
* Handles the case where the move was cancelled or failed:
* - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link Abilities.PRESSURE Pressure})
* - Records a cancelled OR failed move in move history, so abilities like {@link Abilities.TRUANT Truant} don't trigger on the
* - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link Abilities.PRESSURE | Pressure})
* - Records a cancelled OR failed move in move history, so abilities like {@link Abilities.TRUANT | Truant} don't trigger on the
* next turn and soft-lock.
* - Lapses `MOVE_EFFECT` tags:
* - Semi-invulnerable battler tags (Fly/Dive/etc.) are intended to lapse on move effects, but also need
@ -603,53 +609,57 @@ export class MovePhase extends BattlePhase {
*
* TODO: ...this seems weird.
* - 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
*/
protected handlePreMoveFailures(): void {
if (this.cancelled || this.failed) {
if (this.failed) {
const ppUsed = this.ignorePp ? 0 : 1;
if (ppUsed) {
this.move.usePp();
}
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
}
if (this.cancelled && this.pokemon.summonData.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) {
frenzyMissFunc(this.pokemon, this.move.getMove());
}
this.pokemon.pushMoveHistory({
move: Moves.NONE,
result: MoveResult.FAIL,
targets: this.targets,
});
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
this.pokemon.getMoveQueue().shift();
if (!this.cancelled && !this.failed) {
return;
}
if (this.failed) {
const ppUsed = this.useType >= MoveUseType.IGNORE_PP ? 0 : 1;
this.move.usePp(ppUsed);
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
}
if (this.cancelled && this.pokemon.summonData.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) {
frenzyMissFunc(this.pokemon, this.move.getMove());
}
this.pokemon.pushMoveHistory({
move: Moves.NONE,
result: MoveResult.FAIL,
targets: this.targets,
useType: this.useType,
});
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
// This clears out 2 turn moves after they've been used
// TODO: Remove post move queue refactor
this.pokemon.getMoveQueue().shift();
}
/**
* Displays the move's usage text to the player, unless it's a charge turn (ie: {@link Moves.SOLAR_BEAM Solar Beam}),
* the pokemon is on a recharge turn (ie: {@link Moves.HYPER_BEAM Hyper Beam}), or a 2-turn move was interrupted (ie: {@link Moves.FLY Fly}).
* Displays the move's usage text to the player as applicable for the move being used.
*/
public showMoveText(): void {
if (this.move.moveId === Moves.NONE) {
return;
}
if (this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED)) {
// No text for Moves.NONE, recharging/2-turn moves or interrupted moves
if (
this.move.moveId === Moves.NONE ||
this.pokemon.getTag(BattlerTagType.RECHARGING) ||
this.pokemon.getTag(BattlerTagType.INTERRUPTED)
) {
return;
}
// Play message for magic coat reflection
// TODO: This should be done by the move...
globalScene.queueMessage(
i18next.t(this.reflected ? "battle:magicCoatActivated" : "battle:useMove", {
i18next.t(this.useType === MoveUseType.REFLECTED ? "battle:magicCoatActivated" : "battle:useMove", {
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
moveName: this.move.getName(),
}),
@ -658,6 +668,11 @@ export class MovePhase extends BattlePhase {
applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove());
}
/**
* Display the text for a move failing to execute.
* @param failedText - The failure text to display; defaults to `"battle:attackFailed"` locale key
* ("But it failed!" in english)
*/
public showFailedText(failedText: string = i18next.t("battle:attackFailed")): void {
globalScene.queueMessage(failedText);
}

View File

@ -4,21 +4,23 @@ import type Pokemon from "#app/field/pokemon";
import { FieldPhase } from "./field-phase";
export abstract class PokemonPhase extends FieldPhase {
protected battlerIndex: BattlerIndex | number;
protected battlerIndex: BattlerIndex;
public player: boolean;
public fieldIndex: number;
constructor(battlerIndex?: BattlerIndex | number) {
constructor(battlerIndex?: BattlerIndex) {
super();
battlerIndex =
battlerIndex ??
globalScene
.getField()
.find(p => p?.isActive())! // TODO: is the bang correct here?
.getBattlerIndex();
.find(p => p?.isActive())
?.getBattlerIndex();
if (battlerIndex === undefined) {
console.warn("There are no Pokemon on the field!"); // TODO: figure out a suitable fallback behavior
// TODO: figure out a suitable fallback behavior
console.warn("There are no Pokemon on the field!");
battlerIndex = BattlerIndex.PLAYER;
}
this.battlerIndex = battlerIndex;

View File

@ -51,7 +51,7 @@ export class PokemonTransformPhase extends PokemonPhase {
user.summonData.moveset = target.getMoveset().map(m => {
if (m) {
// If PP value is less than 5, do nothing. If greater, we need to reduce the value to 5.
return new PokemonMove(m.moveId, 0, 0, false, Math.min(m.getMove().pp, 5));
return new PokemonMove(m.moveId, 0, 0, Math.min(m.getMove().pp, 5));
}
console.warn(`Transform: somehow iterating over a ${m} value when copying moveset!`);
return new PokemonMove(Moves.NONE);

View File

@ -27,16 +27,16 @@ export class TurnStartPhase extends FieldPhase {
/**
* This orders the active Pokemon on the field by speed into an BattlerIndex array and returns that array.
* It also checks for Trick Room and reverses the array if it is present.
* @returns {@linkcode BattlerIndex[]} the battle indices of all pokemon on the field ordered by speed
* @returns An array of {@linkcode BattlerIndex}es containing all on-field pokemon sorted in speed order.
*/
getSpeedOrder(): BattlerIndex[] {
const playerField = globalScene.getPlayerField().filter(p => p.isActive()) as Pokemon[];
const enemyField = globalScene.getEnemyField().filter(p => p.isActive()) as Pokemon[];
// We shuffle the list before sorting so speed ties produce random results
// Shuffle the list before sorting so speed ties produce random results
// This is seeded with the current turn to prevent an inconsistency with variable turn order
// based on how long since you last reloaded
let orderedTargets: Pokemon[] = playerField.concat(enemyField);
// We seed it with the current turn to prevent an inconsistency where it
// was varying based on how long since you last reloaded
globalScene.executeWithSeedOffset(
() => {
orderedTargets = randSeedShuffle(orderedTargets);
@ -45,11 +45,11 @@ export class TurnStartPhase extends FieldPhase {
globalScene.waveSeed,
);
// Next, a check for Trick Room is applied to determine sort order.
// Check for Trick Room and reverse sort order if active.
// Notably, Pokerogue does NOT have the "outspeed trick room" glitch at >1809 spd.
const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(TrickRoomTag, false, speedReversed);
// Adjust the sort function based on whether Trick Room is active.
orderedTargets.sort((a: Pokemon, b: Pokemon) => {
const aSpeed = a?.getEffectiveStat(Stat.SPD) ?? 0;
const bSpeed = b?.getEffectiveStat(Stat.SPD) ?? 0;
@ -72,7 +72,7 @@ export class TurnStartPhase extends FieldPhase {
// This occurs before the main loop because of battles with more than two Pokemon
const battlerBypassSpeed = {};
globalScene.getField(true).map(p => {
globalScene.getField(true).forEach(p => {
const bypassSpeed = new BooleanHolder(false);
const canCheckHeldItems = new BooleanHolder(true);
applyAbAttrs(BypassSpeedChanceAbAttr, p, null, false, bypassSpeed);
@ -120,7 +120,8 @@ export class TurnStartPhase extends FieldPhase {
}
}
// If there is no difference between the move's calculated priorities, the game checks for differences in battlerBypassSpeed and returns the result.
// If there is no difference between the move's calculated priorities,
// check for differences in battlerBypassSpeed and returns the result.
if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) {
return battlerBypassSpeed[a].value ? -1 : 1;
}
@ -133,6 +134,8 @@ export class TurnStartPhase extends FieldPhase {
return moveOrder;
}
// TODO: Refactor this alongside `CommandPhase.handleCommand` to use SEPARATE METHODS
// Also need a clearer distinction between "turn command" and queued moves
start() {
super.start();
@ -171,34 +174,19 @@ export class TurnStartPhase extends FieldPhase {
continue;
}
const move =
pokemon.getMoveset().find(m => m.moveId === queuedMove.move && m.ppUsed < m.getMovePp()) ||
pokemon.getMoveset().find(m => m.moveId === queuedMove.move && m.ppUsed < m.getMovePp()) ??
new PokemonMove(queuedMove.move);
if (move.getMove().hasAttr(MoveHeaderAttr)) {
globalScene.unshiftPhase(new MoveHeaderPhase(pokemon, move));
}
if (pokemon.isPlayer()) {
if (turnCommand.cursor === -1) {
globalScene.pushPhase(new MovePhase(pokemon, turnCommand.targets || turnCommand.move!.targets, move)); //TODO: is the bang correct here?
} else {
const playerPhase = new MovePhase(
pokemon,
turnCommand.targets || turnCommand.move!.targets,
move,
false,
queuedMove.ignorePP,
); //TODO: is the bang correct here?
globalScene.pushPhase(playerPhase);
}
if (pokemon.isPlayer() && turnCommand.cursor === -1) {
globalScene.pushPhase(
new MovePhase(pokemon, turnCommand.targets || turnCommand.move!.targets, move, turnCommand.move!.useType),
); //TODO: is the bang correct here?
} else {
globalScene.pushPhase(
new MovePhase(
pokemon,
turnCommand.targets || turnCommand.move!.targets,
move,
false,
queuedMove.ignorePP,
),
); //TODO: is the bang correct here?
new MovePhase(pokemon, turnCommand.targets || turnCommand.move!.targets, move, queuedMove.useType),
); // TODO: is the bang correct here?
}
break;
case Command.BALL:

View File

@ -15,6 +15,7 @@ import type Pokemon from "#app/field/pokemon";
import type { CommandPhase } from "#app/phases/command-phase";
import MoveInfoOverlay from "./move-info-overlay";
import { BattleType } from "#enums/battle-type";
import { MoveUseType } from "#enums/move-use-type";
export default class FightUiHandler extends UiHandler implements InfoToggle {
public static readonly MOVES_CONTAINER_NAME = "moves";
@ -138,51 +139,58 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
return true;
}
/**
* Process the player inputting the selected {@linkcode Button}.
* @param button - The {@linkcode Button} being pressed
* @returns Whether the input was successful (ie did anything).
*/
processInput(button: Button): boolean {
const ui = this.getUi();
const cursor = this.getCursor();
let success = false;
const cursor = this.getCursor();
if (button === Button.CANCEL || button === Button.ACTION) {
if (button === Button.ACTION) {
if ((globalScene.getCurrentPhase() as CommandPhase).handleCommand(this.fromCommand, cursor, false)) {
switch (button) {
case Button.CANCEL:
{
// Attempts to back out of the move selection pane are blocked in certain MEs
const { battleType, mysteryEncounter } = globalScene.currentBattle;
if (battleType === BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) {
ui.setMode(UiMode.COMMAND, this.fieldIndex);
success = true;
}
}
break;
case Button.ACTION:
if (
(globalScene.getCurrentPhase() as CommandPhase).handleCommand(this.fromCommand, cursor, MoveUseType.NORMAL)
) {
success = true;
} else {
ui.playError();
}
} else {
// Cannot back out of fight menu if skipToFightInput is enabled
const { battleType, mysteryEncounter } = globalScene.currentBattle;
if (battleType !== BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) {
ui.setMode(UiMode.COMMAND, this.fieldIndex);
success = true;
break;
case Button.UP:
if (cursor >= 2) {
success = this.setCursor(cursor - 2);
}
}
} else {
switch (button) {
case Button.UP:
if (cursor >= 2) {
success = this.setCursor(cursor - 2);
}
break;
case Button.DOWN:
if (cursor < 2) {
success = this.setCursor(cursor + 2);
}
break;
case Button.LEFT:
if (cursor % 2 === 1) {
success = this.setCursor(cursor - 1);
}
break;
case Button.RIGHT:
if (cursor % 2 === 0) {
success = this.setCursor(cursor + 1);
}
break;
}
break;
case Button.DOWN:
if (cursor < 2) {
success = this.setCursor(cursor + 2);
}
break;
case Button.LEFT:
if (cursor % 2 === 1) {
success = this.setCursor(cursor - 1);
}
break;
case Button.RIGHT:
if (cursor % 2 === 0) {
success = this.setCursor(cursor + 1);
}
break;
default:
// other inputs do nothing while in fight menu
}
if (success) {

View File

@ -1,11 +1,11 @@
import type { PlayerPokemon, PokemonMove } from "#app/field/pokemon";
import type { PlayerPokemon, PokemonMove, TurnMove } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
import { addBBCodeTextObject, addTextObject, getTextColor, TextStyle } from "#app/ui/text";
import { Command } from "#app/ui/command-ui-handler";
import MessageUiHandler from "#app/ui/message-ui-handler";
import { UiMode } from "#enums/ui-mode";
import { BooleanHolder, toReadableString, randInt, getLocalizedSpriteKey } from "#app/utils/common";
import { BooleanHolder, toReadableString, randInt, getLocalizedSpriteKey, isNullOrUndefined } from "#app/utils/common";
import {
PokemonFormChangeItemModifier,
PokemonHeldItemModifier,
@ -1027,12 +1027,12 @@ export default class PartyUiHandler extends MessageUiHandler {
(m as SwitchEffectTransferModifier).pokemonId === globalScene.getPlayerField()[this.fieldIndex].id,
);
const moveHistory = globalScene.getPlayerField()[this.fieldIndex].getMoveHistory();
const lastMove: TurnMove | undefined = globalScene.getPlayerField()[this.fieldIndex].getLastXMoves()[0];
const isBatonPassMove =
this.partyUiMode === PartyUiMode.FAINT_SWITCH &&
moveHistory.length &&
allMoves[moveHistory[moveHistory.length - 1].move].getAttrs(ForceSwitchOutAttr)[0]?.isBatonPass() &&
moveHistory[moveHistory.length - 1].result === MoveResult.SUCCESS;
!isNullOrUndefined(lastMove) &&
allMoves[lastMove.move].getAttrs(ForceSwitchOutAttr)[0]?.isBatonPass() &&
lastMove.result === MoveResult.SUCCESS;
// isBatonPassMove and allowBatonModifierSwitch shouldn't ever be true
// at the same time, because they both explicitly check for a mutually

View File

@ -42,8 +42,8 @@ describe("Moves - Dig", () => {
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());
@ -53,9 +53,22 @@ describe("Moves - Dig", () => {
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.UNDERGROUND)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()).toHaveLength(0);
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
});
const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.DIG);
it("should deduct PP only on the 2nd turn of the move", async () => {
await game.classicMode.startBattle([Species.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.select(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase");
const playerDig = playerPokemon.getMoveset().find(mv => mv?.moveId === Moves.DIG);
expect(playerDig?.ppUsed).toBe(0);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerDig?.ppUsed).toBe(1);
});

View File

@ -1,8 +1,10 @@
import { BattlerIndex } from "#app/battle";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { MoveUseType } from "#enums/move-use-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import GameManager from "#test/testUtils/gameManager";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -28,25 +30,31 @@ describe("Moves - Disable", () => {
.enemyAbility(Abilities.BALL_FETCH)
.moveset([Moves.DISABLE, Moves.SPLASH])
.enemyMoveset(Moves.SPLASH)
.starterSpecies(Species.PIKACHU)
.enemySpecies(Species.SHUCKLE);
});
it("restricts moves", async () => {
await game.classicMode.startBattle();
it("should restrict the last move used", async () => {
game.override.enemyMoveset([Moves.GROWL, Moves.SPLASH]);
await game.classicMode.startBattle([Species.PIKACHU]);
const enemyMon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.GROWL);
await game.toNextTurn();
game.move.select(Moves.DISABLE);
await game.forceEnemyMove(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyMon.getMoveHistory()).toHaveLength(1);
expect(enemyMon.getLastXMoves(-1)).toHaveLength(1);
expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(true);
expect(enemyMon.isMoveRestricted(Moves.GROWL)).toBe(false);
});
it("fails if enemy has no move history", async () => {
await game.classicMode.startBattle();
it("should fail if enemy has no move history", async () => {
await game.classicMode.startBattle([Species.PIKACHU]);
const playerMon = game.scene.getPlayerPokemon()!;
const enemyMon = game.scene.getEnemyPokemon()!;
@ -55,15 +63,15 @@ describe("Moves - Disable", () => {
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(playerMon.getMoveHistory()[0]).toMatchObject({
expect(playerMon.getLastXMoves()[0]).toMatchObject({
move: Moves.DISABLE,
result: MoveResult.FAIL,
});
expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(false);
}, 20000);
});
it("causes STRUGGLE if all usable moves are disabled", async () => {
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.PIKACHU]);
const enemyMon = game.scene.getEnemyPokemon()!;
@ -74,15 +82,14 @@ describe("Moves - Disable", () => {
game.move.select(Moves.SPLASH);
await game.toNextTurn();
const enemyHistory = enemyMon.getMoveHistory();
const enemyHistory = enemyMon.getLastXMoves(-1);
expect(enemyHistory).toHaveLength(2);
expect(enemyHistory[0].move).toBe(Moves.SPLASH);
expect(enemyHistory[1].move).toBe(Moves.STRUGGLE);
}, 20000);
expect(enemyHistory.map(m => m.move)).toEqual([Moves.STRUGGLE, Moves.SPLASH]);
});
it("cannot disable STRUGGLE", async () => {
game.override.enemyMoveset([Moves.STRUGGLE]);
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.PIKACHU]);
const playerMon = game.scene.getPlayerPokemon()!;
const enemyMon = game.scene.getEnemyPokemon()!;
@ -94,33 +101,39 @@ describe("Moves - Disable", () => {
expect(playerMon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyMon.getLastXMoves()[0].move).toBe(Moves.STRUGGLE);
expect(enemyMon.isMoveRestricted(Moves.STRUGGLE)).toBe(false);
}, 20000);
});
it("interrupts target's move when target moves after", async () => {
await game.classicMode.startBattle();
it("should interrupt target's move if used first", async () => {
await game.classicMode.startBattle([Species.PIKACHU]);
const enemyMon = game.scene.getEnemyPokemon()!;
// add splash to enemy move history
enemyMon.pushMoveHistory({
move: Moves.SPLASH,
targets: [BattlerIndex.ENEMY],
useType: MoveUseType.NORMAL,
});
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// Both mons just used Splash last turn; now have player use Disable.
game.move.select(Moves.DISABLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
const enemyHistory = enemyMon.getMoveHistory();
const enemyHistory = enemyMon.getLastXMoves(-1);
expect(enemyHistory).toHaveLength(2);
expect(enemyHistory[0]).toMatchObject({
move: Moves.SPLASH,
result: MoveResult.SUCCESS,
result: MoveResult.FAIL,
});
expect(enemyHistory[1].result).toBe(MoveResult.FAIL);
}, 20000);
});
it("disables NATURE POWER, not the move invoked by it", async () => {
game.override.enemyMoveset([Moves.NATURE_POWER]);
await game.classicMode.startBattle();
it.each([
{ name: "Nature Power", moveId: Moves.NATURE_POWER },
{ name: "Mirror Move", moveId: Moves.MIRROR_MOVE },
{ name: "Copycat", moveId: Moves.COPYCAT },
{ name: "Copycat", moveId: Moves.COPYCAT },
])("should ignore virtual moves called by $name", async ({ moveId }) => {
game.override.enemyMoveset(moveId);
await game.classicMode.startBattle([Species.PIKACHU]);
const enemyMon = game.scene.getEnemyPokemon()!;
@ -128,27 +141,38 @@ describe("Moves - Disable", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyMon.isMoveRestricted(Moves.NATURE_POWER)).toBe(true);
expect(enemyMon.isMoveRestricted(enemyMon.getLastXMoves(2)[0].move)).toBe(false);
}, 20000);
expect.soft(enemyMon.isMoveRestricted(moveId), `calling move ${Moves[moveId]} was not disabled`).toBe(true);
const calledMove = enemyMon.getLastXMoves()[0].move;
expect(
enemyMon.isMoveRestricted(calledMove),
`called move ${Moves[calledMove]} (from ${Moves[moveId]}) was incorrectly disabled`,
).toBe(false);
});
it("disables most recent move", async () => {
game.override.enemyMoveset([Moves.SPLASH, Moves.TACKLE]);
await game.classicMode.startBattle();
it("should ignore dancer copied moves, even if also in moveset", async () => {
game.override
.enemyAbility(Abilities.DANCER)
.moveset([Moves.DISABLE, Moves.SWORDS_DANCE])
.enemyMoveset([Moves.SPLASH, Moves.SWORDS_DANCE]);
const enemyMon = game.scene.getEnemyPokemon()!;
await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.SWORDS_DANCE);
await game.forceEnemyMove(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(Moves.DISABLE);
await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
await game.forceEnemyMove(Moves.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyMon.isMoveRestricted(Moves.TACKLE)).toBe(true);
expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(false);
}, 20000);
// Dancer-induced Swords Dance was ignored in favor of splash,
// leaving the subsequent _normal_ swords dance free to work as normal
const shuckle = game.scene.getEnemyPokemon()!;
expect.soft(shuckle.isMoveRestricted(Moves.SPLASH)).toBe(true);
expect.soft(shuckle.isMoveRestricted(Moves.SWORDS_DANCE)).toBe(false);
expect(shuckle.getLastXMoves()[0]).toMatchObject({ move: Moves.SWORDS_DANCE, result: MoveResult.SUCCESS });
expect(shuckle.getStatStage(Stat.ATK)).toBe(2);
});
});

View File

@ -80,7 +80,7 @@ describe("Moves - Electro Shot", () => {
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);
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
const playerElectroShot = playerPokemon.getMoveset().find(mv => mv && mv.moveId === Moves.ELECTRO_SHOT);
expect(playerElectroShot?.ppUsed).toBe(1);

View File

@ -3,6 +3,7 @@ import type Pokemon from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
import type { MovePhase } from "#app/phases/move-phase";
import { Abilities } from "#enums/abilities";
import { MoveUseType } from "#enums/move-use-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -149,7 +150,7 @@ describe("Moves - Instruct", () => {
game.move.select(Moves.INSTRUCT);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MovePhase");
// force enemy's instructed move to bork and then immediately thaw out
// force enemy's instructed move (and only the instructed move) to bork
await game.move.forceStatusActivation(true);
await game.move.forceStatusActivation(false);
await game.phaseInterceptor.to("TurnEndPhase", false);
@ -200,26 +201,6 @@ describe("Moves - Instruct", () => {
expect(karp1.isFainted()).toBe(true);
expect(karp2.isFainted()).toBe(true);
});
it("should allow for dancer copying of instructed dance move", async () => {
game.override.battleStyle("double").enemyMoveset([Moves.INSTRUCT, Moves.SPLASH]).enemyLevel(1000);
await game.classicMode.startBattle([Species.ORICORIO, Species.VOLCARONA]);
const [oricorio, volcarona] = game.scene.getPlayerField();
game.move.changeMoveset(oricorio, Moves.SPLASH);
game.move.changeMoveset(volcarona, Moves.FIERY_DANCE);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.INSTRUCT, BattlerIndex.PLAYER_2);
await game.forceEnemyMove(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("BerryPhase");
// fiery dance triggered dancer successfully for a total of 4 hits
// Enemy level is set to a high value so that it does not faint even after all 4 hits
instructSuccess(volcarona, Moves.FIERY_DANCE);
expect(game.scene.getEnemyField()[0].turnData.attacksReceived.length).toBe(4);
});
it("should not repeat move when switching out", async () => {
game.override.enemyMoveset(Moves.INSTRUCT).enemySpecies(Species.UNOWN);
@ -228,19 +209,18 @@ describe("Moves - Instruct", () => {
const amoonguss = game.scene.getPlayerPokemon()!;
game.move.changeMoveset(amoonguss, Moves.SEED_BOMB);
amoonguss.summonData.moveHistory = [
{
move: Moves.SEED_BOMB,
targets: [BattlerIndex.ENEMY],
result: MoveResult.SUCCESS,
},
];
amoonguss.pushMoveHistory({
move: Moves.SEED_BOMB,
targets: [BattlerIndex.ENEMY],
result: MoveResult.SUCCESS,
useType: MoveUseType.NORMAL,
});
game.doSwitchPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase", false);
const enemyMoves = game.scene.getEnemyPokemon()!.getLastXMoves(-1)!;
expect(enemyMoves[0].result).toBe(MoveResult.FAIL);
const enemyMoves = game.scene.getEnemyPokemon()?.getLastXMoves(-1)!;
expect(enemyMoves?.[0]?.result).toBe(MoveResult.FAIL);
});
it("should fail if no move has yet been used by target", async () => {
@ -301,14 +281,12 @@ describe("Moves - Instruct", () => {
const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
enemy.summonData.moveHistory = [
{
move: Moves.SONIC_BOOM,
targets: [BattlerIndex.PLAYER],
result: MoveResult.SUCCESS,
virtual: false,
},
];
enemy.pushMoveHistory({
move: Moves.SONIC_BOOM,
targets: [BattlerIndex.PLAYER],
result: MoveResult.SUCCESS,
useType: MoveUseType.NORMAL,
});
game.move.select(Moves.INSTRUCT);
await game.forceEnemyMove(Moves.HYPER_BEAM);
@ -350,14 +328,12 @@ describe("Moves - Instruct", () => {
await game.classicMode.startBattle([Species.LUCARIO, Species.BANETTE]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.summonData.moveHistory = [
{
move: Moves.WHIRLWIND,
targets: [BattlerIndex.PLAYER],
result: MoveResult.SUCCESS,
virtual: false,
},
];
enemyPokemon.pushMoveHistory({
move: Moves.WHIRLWIND,
targets: [BattlerIndex.PLAYER],
result: MoveResult.SUCCESS,
useType: MoveUseType.NORMAL,
});
game.move.select(Moves.INSTRUCT);
await game.forceEnemyMove(Moves.SPLASH);
@ -377,11 +353,20 @@ describe("Moves - Instruct", () => {
.enemyMoveset([Moves.SPLASH, Moves.PSYCHIC_TERRAIN]);
await game.classicMode.startBattle([Species.BANETTE, Species.KLEFKI]);
const banette = game.scene.getPlayerPokemon()!;
game.move.select(Moves.QUICK_ATTACK, BattlerIndex.PLAYER, BattlerIndex.ENEMY); // succeeds due to terrain no
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.forceEnemyMove(Moves.SPLASH);
await game.forceEnemyMove(Moves.PSYCHIC_TERRAIN);
await game.toNextTurn();
expect(banette.getLastXMoves(-1)[0]).toEqual(
expect.objectContaining({
move: Moves.QUICK_ATTACK,
targets: [BattlerIndex.ENEMY],
result: MoveResult.SUCCESS,
}),
);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
@ -389,32 +374,76 @@ describe("Moves - Instruct", () => {
await game.phaseInterceptor.to("TurnEndPhase", false);
// quick attack failed when instructed
const banette = game.scene.getPlayerPokemon()!;
expect(banette.getLastXMoves(-1)[1].move).toBe(Moves.QUICK_ATTACK);
expect(banette.getLastXMoves(-1)[1].result).toBe(MoveResult.FAIL);
});
it("should still work w/ prankster in psychic terrain", async () => {
game.override.battleStyle("double").enemyMoveset([Moves.SPLASH, Moves.PSYCHIC_TERRAIN]);
// TODO: Enable once Sky Drop is fully implemented
it.todo("should not work against Sky Dropped targets, even if user/target have No Guard", async () => {
game.override.battleStyle("double").ability(Abilities.NO_GUARD).enemyMoveset([Moves.ASTONISH, Moves.SKY_DROP]);
await game.classicMode.startBattle([Species.BANETTE, Species.KLEFKI]);
const [banette, klefki] = game.scene.getPlayerField()!;
game.move.changeMoveset(banette, [Moves.VINE_WHIP, Moves.SPLASH]);
game.move.changeMoveset(klefki, [Moves.INSTRUCT, Moves.SPLASH]);
const [banette, klefki] = game.scene.getPlayerField();
game.move.changeMoveset(banette, Moves.VINE_WHIP);
game.move.changeMoveset(klefki, Moves.INSTRUCT);
banette.pushMoveHistory({
move: Moves.VINE_WHIP,
targets: [BattlerIndex.ENEMY],
result: MoveResult.SUCCESS,
useType: MoveUseType.NORMAL,
});
game.move.select(Moves.VINE_WHIP, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.forceEnemyMove(Moves.SPLASH);
await game.forceEnemyMove(Moves.PSYCHIC_TERRAIN);
await game.toNextTurn();
// Attempt to instruct banette after having been sent airborne
game.move.select(Moves.VINE_WHIP, BattlerIndex.PLAYER);
game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
await game.forceEnemyMove(Moves.SKY_DROP, BattlerIndex.PLAYER);
await game.forceEnemyMove(Moves.ASTONISH, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase", false);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
// Klefki instruct fails due to banette being airborne, even though it got hit prior
expect(banette.visible).toBe(false);
expect(banette.isFullHp()).toBe(false);
expect(klefki.getLastXMoves(-1)[0]).toMatchObject({
move: Moves.INSTRUCT,
targets: [BattlerIndex.PLAYER],
result: MoveResult.FAIL,
});
});
it("should still work with prankster in psychic terrain", async () => {
game.override
.battleStyle("double")
.ability(Abilities.PRANKSTER)
.enemyMoveset(Moves.SPLASH)
.enemyAbility(Abilities.PSYCHIC_SURGE);
await game.classicMode.startBattle([Species.BANETTE, Species.KLEFKI]);
const [banette, klefki] = game.scene.getPlayerField();
game.move.changeMoveset(banette, [Moves.VINE_WHIP]);
game.move.changeMoveset(klefki, Moves.INSTRUCT);
banette.pushMoveHistory({
move: Moves.VINE_WHIP,
targets: [BattlerIndex.ENEMY],
result: MoveResult.SUCCESS,
useType: MoveUseType.NORMAL,
});
game.move.select(Moves.VINE_WHIP, BattlerIndex.PLAYER);
game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER); // copies vine whip
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase", false);
// Klefki instructing a non-priority move succeeds, ignoring the priority of Instruct itself
expect(banette.getLastXMoves(-1)[1].move).toBe(Moves.VINE_WHIP);
expect(banette.getLastXMoves(-1)[2].move).toBe(Moves.VINE_WHIP);
expect(banette.getMoveset().find(m => m?.moveId === Moves.VINE_WHIP)?.ppUsed).toBe(2);
expect(klefki.getLastXMoves(-1)[0]).toEqual(
expect.objectContaining({
move: Moves.INSTRUCT,
targets: [BattlerIndex.PLAYER],
result: MoveResult.SUCCESS,
}),
);
});
it("should cause spread moves to correctly hit targets in doubles after singles", async () => {
@ -423,14 +452,15 @@ describe("Moves - Instruct", () => {
.moveset([Moves.BREAKING_SWIPE, Moves.INSTRUCT, Moves.SPLASH])
.enemyMoveset(Moves.SONIC_BOOM)
.enemySpecies(Species.AXEW)
.startingLevel(500);
.startingLevel(500)
.enemyLevel(1);
await game.classicMode.startBattle([Species.KORAIDON, Species.KLEFKI]);
const koraidon = game.scene.getPlayerField()[0]!;
game.move.select(Moves.BREAKING_SWIPE);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(koraidon.getInverseHp()).toBe(0);
expect(koraidon.hp).toBe(koraidon.getMaxHp());
expect(koraidon.getLastXMoves(-1)[0].targets).toEqual([BattlerIndex.ENEMY]);
await game.toNextWave();
@ -438,9 +468,10 @@ describe("Moves - Instruct", () => {
game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase", false);
// did not take damage since enemies died beforehand;
// last move used hit both enemies
expect(koraidon.getInverseHp()).toBe(0);
expect(koraidon.hp).toBe(koraidon.getMaxHp());
expect(koraidon.getLastXMoves(-1)[1].targets?.sort()).toEqual([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
});
@ -450,7 +481,8 @@ describe("Moves - Instruct", () => {
.moveset([Moves.BRUTAL_SWING, Moves.INSTRUCT, Moves.SPLASH])
.enemySpecies(Species.AXEW)
.enemyMoveset(Moves.SONIC_BOOM)
.startingLevel(500);
.startingLevel(500)
.enemyLevel(1);
await game.classicMode.startBattle([Species.KORAIDON, Species.KLEFKI]);
const koraidon = game.scene.getPlayerField()[0]!;
@ -458,22 +490,24 @@ describe("Moves - Instruct", () => {
game.move.select(Moves.BRUTAL_SWING);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(koraidon.getInverseHp()).toBe(0);
expect(koraidon.hp).toBe(koraidon.getMaxHp());
expect(koraidon.getLastXMoves(-1)[0].targets).toEqual([BattlerIndex.ENEMY]);
await game.toNextWave();
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase", false);
// did not take damage since enemies died beforehand;
// last move used hit everything around it
expect(koraidon.getInverseHp()).toBe(0);
expect(koraidon.getLastXMoves(-1)[1].targets?.sort()).toEqual([
BattlerIndex.PLAYER_2,
BattlerIndex.ENEMY,
BattlerIndex.ENEMY_2,
]);
expect(koraidon.hp).toBe(koraidon.getMaxHp());
expect(koraidon.getLastXMoves(-1)[1].targets).toHaveLength(3);
expect(koraidon.getLastXMoves(-1)[1].targets).toEqual(
expect.arrayContaining([BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]),
);
});
it("should cause multi-hit moves to hit the appropriate number of times in singles", async () => {

View File

@ -1,6 +1,7 @@
import { BattlerIndex } from "#app/battle";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { MoveUseType } from "#enums/move-use-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -53,19 +54,19 @@ describe("Moves - Last Resort", () => {
expectLastResortFail();
// Splash (1/3)
blissey.pushMoveHistory({ move: Moves.SPLASH, targets: [BattlerIndex.PLAYER] });
blissey.pushMoveHistory({ move: Moves.SPLASH, targets: [BattlerIndex.PLAYER], useType: MoveUseType.NORMAL });
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail();
// Growl (2/3)
blissey.pushMoveHistory({ move: Moves.GROWL, targets: [BattlerIndex.ENEMY] });
blissey.pushMoveHistory({ move: Moves.GROWL, targets: [BattlerIndex.ENEMY], useType: MoveUseType.NORMAL });
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail(); // Were last resort itself counted, it would error here
// Growth (3/3)
blissey.pushMoveHistory({ move: Moves.GROWTH, targets: [BattlerIndex.PLAYER] });
blissey.pushMoveHistory({ move: Moves.GROWTH, targets: [BattlerIndex.PLAYER], useType: MoveUseType.NORMAL });
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0]).toEqual(
@ -117,11 +118,12 @@ describe("Moves - Last Resort", () => {
expect.objectContaining({
move: Moves.LAST_RESORT,
result: MoveResult.SUCCESS,
virtual: true,
useType: MoveUseType.FOLLOW_UP,
}),
expect.objectContaining({
move: Moves.SLEEP_TALK,
result: MoveResult.SUCCESS,
useType: MoveUseType.NORMAL,
}),
]);
});

View File

@ -1,8 +1,10 @@
import { BattlerIndex } from "#app/battle";
import { RechargingTag, SemiInvulnerableTag } from "#app/data/battler-tags";
import { allMoves, RandomMoveAttr } from "#app/data/moves/move";
import { Abilities } from "#app/enums/abilities";
import { Stat } from "#app/enums/stat";
import { CommandPhase } from "#app/phases/command-phase";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -32,7 +34,6 @@ describe("Moves - Metronome", () => {
.moveset([Moves.METRONOME, Moves.SPLASH])
.battleStyle("single")
.startingLevel(100)
.starterSpecies(Species.REGIELEKI)
.enemyLevel(100)
.enemySpecies(Species.SHUCKLE)
.enemyMoveset(Moves.SPLASH)
@ -40,7 +41,7 @@ describe("Moves - Metronome", () => {
});
it("should have one semi-invulnerable turn and deal damage on the second turn when a semi-invulnerable move is called", async () => {
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.REGIELEKI]);
const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!;
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.DIVE);
@ -56,7 +57,7 @@ describe("Moves - Metronome", () => {
});
it("should apply secondary effects of a move", async () => {
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.REGIELEKI]);
const player = game.scene.getPlayerPokemon()!;
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.WOOD_HAMMER);
@ -67,7 +68,7 @@ describe("Moves - Metronome", () => {
});
it("should recharge after using recharge move", async () => {
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.REGIELEKI]);
const player = game.scene.getPlayerPokemon()!;
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.HYPER_BEAM);
vi.spyOn(allMoves[Moves.HYPER_BEAM], "accuracy", "get").mockReturnValue(100);
@ -78,6 +79,36 @@ describe("Moves - Metronome", () => {
expect(player.getTag(RechargingTag)).toBeTruthy();
});
it("should charge when calling charging moves while still maintaining follow-up status", async () => {
game.override.moveset([]).enemyMoveset(Moves.SPITE);
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.SOLAR_BEAM);
await game.classicMode.startBattle([Species.REGIELEKI]);
const player = game.scene.getPlayerPokemon()!;
game.move.changeMoveset(player, [Moves.METRONOME, Moves.SOLAR_BEAM]);
const [metronomeMove, solarBeamMove] = player.getMoveset();
expect(metronomeMove).toBeDefined();
expect(solarBeamMove).toBeDefined();
game.move.select(Moves.METRONOME);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.getTag(BattlerTagType.CHARGING)).toBeTruthy();
const turn1PpUsed = metronomeMove.ppUsed;
expect.soft(turn1PpUsed).toBeGreaterThan(1);
expect(solarBeamMove.ppUsed).toBe(0);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(player.getTag(BattlerTagType.CHARGING)).toBeFalsy();
const turn2PpUsed = metronomeMove.ppUsed - turn1PpUsed;
expect(turn2PpUsed).toBeGreaterThan(1);
expect(solarBeamMove.ppUsed).toBe(0);
});
it("should only target ally for Aromatic Mist", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([Species.REGIELEKI, Species.RATTATA]);
@ -97,7 +128,7 @@ describe("Moves - Metronome", () => {
});
it("should cause opponent to flee, and not crash for Roar", async () => {
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.REGIELEKI]);
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.ROAR);
const enemyPokemon = game.scene.getEnemyPokemon()!;

View File

@ -7,7 +7,6 @@ import { Species } from "#enums/species";
import { BerryPhase } from "#app/phases/berry-phase";
import { MoveResult, PokemonMove } from "#app/field/pokemon";
import { PokemonType } from "#enums/pokemon-type";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { StatusEffect } from "#enums/status-effect";
import { BattlerIndex } from "#app/battle";
@ -168,10 +167,10 @@ describe("Moves - Powder", () => {
game.move.select(Moves.FIERY_DANCE, 0, BattlerIndex.ENEMY);
game.move.select(Moves.POWDER, 1, BattlerIndex.ENEMY);
await game.phaseInterceptor.to(MoveEffectPhase);
await game.phaseInterceptor.to("MoveEffectPhase");
const enemyStartingHp = enemyPokemon.hp;
await game.phaseInterceptor.to(BerryPhase, false);
await game.phaseInterceptor.to("BerryPhase");
// player should not take damage
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
@ -182,7 +181,7 @@ describe("Moves - Powder", () => {
);
});
it("should cancel Fiery Dance, then prevent it from triggering Dancer", async () => {
it("should cancel Fiery Dance and prevent it from triggering Dancer", async () => {
game.override.ability(Abilities.DANCER).enemyMoveset(Moves.FIERY_DANCE);
await game.classicMode.startBattle([Species.CHARIZARD]);

View File

@ -2,7 +2,6 @@ import { Stat } from "#enums/stat";
import { StockpilingTag } from "#app/data/battler-tags";
import { allMoves } from "#app/data/moves/move";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import type { TurnMove } from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
import GameManager from "#test/testUtils/gameManager";
import { Abilities } from "#enums/abilities";
@ -127,7 +126,7 @@ describe("Moves - Spit Up", () => {
game.move.select(Moves.SPIT_UP);
await game.phaseInterceptor.to(TurnInitPhase);
expect(pokemon.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
move: Moves.SPIT_UP,
result: MoveResult.FAIL,
targets: [game.scene.getEnemyPokemon()!.getBattlerIndex()],
@ -154,7 +153,7 @@ describe("Moves - Spit Up", () => {
await game.phaseInterceptor.to(TurnInitPhase);
expect(pokemon.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
move: Moves.SPIT_UP,
result: MoveResult.SUCCESS,
targets: [game.scene.getEnemyPokemon()!.getBattlerIndex()],
@ -186,7 +185,7 @@ describe("Moves - Spit Up", () => {
game.move.select(Moves.SPIT_UP);
await game.phaseInterceptor.to(TurnInitPhase);
expect(pokemon.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
move: Moves.SPIT_UP,
result: MoveResult.SUCCESS,
targets: [game.scene.getEnemyPokemon()!.getBattlerIndex()],

View File

@ -1,6 +1,5 @@
import { Stat } from "#enums/stat";
import { StockpilingTag } from "#app/data/battler-tags";
import type { TurnMove } from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
import { CommandPhase } from "#app/phases/command-phase";
import { TurnInitPhase } from "#app/phases/turn-init-phase";
@ -73,7 +72,7 @@ describe("Moves - Stockpile", () => {
expect(user.getStatStage(Stat.SPDEF)).toBe(3);
expect(stockpilingTag).toBeDefined();
expect(stockpilingTag.stockpiledCount).toBe(3);
expect(user.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(user.getMoveHistory().at(-1)).toMatchObject({
result: MoveResult.FAIL,
move: Moves.STOCKPILE,
targets: [user.getBattlerIndex()],

View File

@ -1,7 +1,6 @@
import { Stat } from "#enums/stat";
import { StockpilingTag } from "#app/data/battler-tags";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import type { TurnMove } from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
import { MovePhase } from "#app/phases/move-phase";
import { TurnInitPhase } from "#app/phases/turn-init-phase";
@ -135,7 +134,7 @@ describe("Moves - Swallow", () => {
game.move.select(Moves.SWALLOW);
await game.phaseInterceptor.to(TurnInitPhase);
expect(pokemon.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
move: Moves.SWALLOW,
result: MoveResult.FAIL,
targets: [pokemon.getBattlerIndex()],
@ -160,7 +159,7 @@ describe("Moves - Swallow", () => {
await game.phaseInterceptor.to(TurnInitPhase);
expect(pokemon.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
move: Moves.SWALLOW,
result: MoveResult.SUCCESS,
targets: [pokemon.getBattlerIndex()],
@ -191,7 +190,7 @@ describe("Moves - Swallow", () => {
await game.phaseInterceptor.to(TurnInitPhase);
expect(pokemon.getMoveHistory().at(-1)).toMatchObject<TurnMove>({
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
move: Moves.SWALLOW,
result: MoveResult.SUCCESS,
targets: [pokemon.getBattlerIndex()],

View File

@ -116,20 +116,14 @@ const POOL_3_POKEMON: { species: Species; formIndex?: number }[] = [
const POOL_4_POKEMON = [Species.GENESECT, Species.SLITHER_WING, Species.BUZZWOLE, Species.PHEROMOSA];
const PHYSICAL_TUTOR_MOVES = [
Moves.MEGAHORN,
Moves.ATTACK_ORDER,
Moves.BUG_BITE,
Moves.FIRST_IMPRESSION,
Moves.LUNGE
];
const PHYSICAL_TUTOR_MOVES = [Moves.MEGAHORN, Moves.ATTACK_ORDER, Moves.BUG_BITE, Moves.FIRST_IMPRESSION, Moves.LUNGE];
const SPECIAL_TUTOR_MOVES = [
Moves.SILVER_WIND,
Moves.SIGNAL_BEAM,
Moves.BUG_BUZZ,
Moves.POLLEN_PUFF,
Moves.STRUGGLE_BUG
Moves.STRUGGLE_BUG,
];
const STATUS_TUTOR_MOVES = [
@ -137,16 +131,10 @@ const STATUS_TUTOR_MOVES = [
Moves.DEFEND_ORDER,
Moves.RAGE_POWDER,
Moves.STICKY_WEB,
Moves.SILK_TRAP
Moves.SILK_TRAP,
];
const MISC_TUTOR_MOVES = [
Moves.LEECH_LIFE,
Moves.U_TURN,
Moves.HEAL_ORDER,
Moves.QUIVER_DANCE,
Moves.INFESTATION,
];
const MISC_TUTOR_MOVES = [Moves.LEECH_LIFE, Moves.U_TURN, Moves.HEAL_ORDER, Moves.QUIVER_DANCE, Moves.INFESTATION];
describe("Bug-Type Superfan - Mystery Encounter", () => {
let phaserGame: Phaser.Game;

View File

@ -24,6 +24,7 @@ import { FunAndGamesEncounter } from "#app/data/mystery-encounters/encounters/fu
import { Moves } from "#enums/moves";
import { Command } from "#app/ui/command-ui-handler";
import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils";
import { MoveUseType } from "#enums/move-use-type";
const namespace = "mysteryEncounters/funAndGames";
const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA];
@ -152,15 +153,15 @@ describe("Fun And Games! - Mystery Encounter", () => {
});
// Turn 1
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(CommandPhase);
// Turn 2
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(CommandPhase);
// Turn 3
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(SelectModifierPhase, false);
// Rewards
@ -179,7 +180,7 @@ describe("Fun And Games! - Mystery Encounter", () => {
// Skip minigame
scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0;
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(SelectModifierPhase, false);
// Rewards
@ -208,7 +209,7 @@ describe("Fun And Games! - Mystery Encounter", () => {
const wobbuffet = scene.getEnemyPokemon()!;
wobbuffet.hp = Math.floor(0.2 * wobbuffet.getMaxHp());
scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0;
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(SelectModifierPhase, false);
// Rewards
@ -238,7 +239,7 @@ describe("Fun And Games! - Mystery Encounter", () => {
const wobbuffet = scene.getEnemyPokemon()!;
wobbuffet.hp = Math.floor(0.1 * wobbuffet.getMaxHp());
scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0;
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(SelectModifierPhase, false);
// Rewards
@ -268,7 +269,7 @@ describe("Fun And Games! - Mystery Encounter", () => {
const wobbuffet = scene.getEnemyPokemon()!;
wobbuffet.hp = 1;
scene.currentBattle.mysteryEncounter!.misc.turnsRemaining = 0;
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, false);
(game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, 0, MoveUseType.NORMAL);
await game.phaseInterceptor.to(SelectModifierPhase, false);
// Rewards

View File

@ -58,6 +58,7 @@ import { expect, vi } from "vitest";
import { globalScene } from "#app/global-scene";
import type StarterSelectUiHandler from "#app/ui/starter-select-ui-handler";
import { MockFetch } from "#test/testUtils/mocks/mockFetch";
import { MoveUseType } from "#enums/move-use-type";
/**
* Class to manage the game state and transitions between phases.
@ -396,6 +397,7 @@ export default class GameManager {
target !== undefined && !legalTargets.multiple && legalTargets.targets.includes(target)
? [target]
: enemy.getNextTargets(moveId),
useType: MoveUseType.NORMAL,
});
/**
@ -417,9 +419,17 @@ export default class GameManager {
};
}
/** Transition to the next upcoming {@linkcode CommandPhase} */
/**
* Transition to the next upcoming {@linkcode CommandPhase}.
* @returns A promise that resolves once the next {@linkcode CommandPhase} has been reached.
* @remarks
* If all active player Pokemon are using a rampaging, charging, recharging or other move that
* disables user input, this **will not resolve** until at least 1 player pokemon becomes actionable.
*/
async toNextTurn() {
await this.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]==================");
}
/**
@ -429,6 +439,7 @@ export default class GameManager {
async toNextWave() {
this.doSelectModifier();
// forcibly end the message box for switching pokemon
this.onNextPrompt(
"CheckSwitchPhase",
UiMode.CONFIRM,
@ -439,7 +450,8 @@ export default class GameManager {
() => this.isCurrentPhase(TurnInitPhase),
);
await this.toNextTurn();
await this.phaseInterceptor.to(CommandPhase);
console.log("==================[New Wave]==================");
}
/**
@ -507,9 +519,9 @@ export default class GameManager {
* @returns A promise that resolves once the fainted pokemon's FaintPhase finishes running.
*/
async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) {
pokemon.hp = 0;
this.scene.unshiftPhase(new FaintPhase(pokemon.getBattlerIndex(), true));
return new Promise<void>(async (resolve, reject) => {
pokemon.hp = 0;
this.scene.pushPhase(new FaintPhase(pokemon.getBattlerIndex(), true));
await this.phaseInterceptor.to(FaintPhase).catch(e => reject(e));
resolve();
});
@ -541,8 +553,8 @@ export default class GameManager {
}
/**
* Select a pokemon from the party menu during the given phase.
* Only really handles the basic case of "navigate to party slot and press Action twice" -
* Select a pokemon from the party menu during the given phase.
* Only really handles the basic case of "navigate to party slot and press Action twice" -
* any menus that come up afterwards are ignored and must be handled separately by the caller.
* @param slot - The 0-indexed position of the pokemon in your party to switch to
* @param inPhase - Which phase to expect the selection to occur in. Defaults to `SwitchPhase`

View File

@ -12,6 +12,7 @@ import { Moves } from "#enums/moves";
import { getMovePosition } from "#test/testUtils/gameManagerUtils";
import { GameManagerHelper } from "#test/testUtils/helpers/gameManagerHelper";
import { vi } from "vitest";
import { MoveUseType } from "#enums/move-use-type";
/**
* Helper to handle a Pokemon's move
@ -57,7 +58,11 @@ export class MoveHelper extends GameManagerHelper {
this.game.scene.ui.setMode(UiMode.FIGHT, (this.game.scene.getCurrentPhase() as CommandPhase).getFieldIndex());
});
this.game.onNextPrompt("CommandPhase", UiMode.FIGHT, () => {
(this.game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.FIGHT, movePosition, false);
(this.game.scene.getCurrentPhase() as CommandPhase).handleCommand(
Command.FIGHT,
movePosition,
MoveUseType.NORMAL,
);
});
if (targetIndex !== null) {
@ -84,7 +89,7 @@ export class MoveHelper extends GameManagerHelper {
);
});
this.game.onNextPrompt("CommandPhase", UiMode.FIGHT, () => {
(this.game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.TERA, movePosition, false);
(this.game.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.TERA, movePosition, MoveUseType.NORMAL);
});
if (targetIndex !== null) {