From 3f02493f79f49f1cf7fca311d1ca4fa1418e9918 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 10 May 2025 10:35:38 -0400 Subject: [PATCH] Added `MoveUseType` and refactored MEP --- biome.jsonc | 21 +- src/data/abilities/ability.ts | 62 +- src/data/arena-tag.ts | 3 +- src/data/battler-tags.ts | 193 +++--- src/data/moves/invalid-moves.ts | 16 +- src/data/moves/move.ts | 528 +++++++++-------- src/data/pokemon-species.ts | 9 +- src/enums/move-use-type.ts | 58 ++ src/field/pokemon.ts | 135 +++-- src/phases/command-phase.ts | 28 +- src/phases/move-charge-phase.ts | 59 +- src/phases/move-effect-phase.ts | 182 +++--- src/phases/move-end-phase.ts | 2 +- src/phases/move-phase.ts | 557 +++++++++--------- src/phases/pokemon-phase.ts | 12 +- src/phases/pokemon-transform-phase.ts | 2 +- src/phases/turn-start-phase.ts | 48 +- src/ui/fight-ui-handler.ts | 80 +-- src/ui/party-ui-handler.ts | 12 +- test/moves/dig.test.ts | 17 +- test/moves/disable.test.ts | 110 ++-- test/moves/electro_shot.test.ts | 2 +- test/moves/instruct.test.ts | 174 +++--- test/moves/last-resort.test.ts | 10 +- test/moves/metronome.test.ts | 41 +- test/moves/powder.test.ts | 7 +- test/moves/spit_up.test.ts | 7 +- test/moves/stockpile.test.ts | 3 +- test/moves/swallow.test.ts | 7 +- .../bug-type-superfan-encounter.test.ts | 20 +- .../fun-and-games-encounter.test.ts | 15 +- test/testUtils/gameManager.ts | 24 +- test/testUtils/helpers/moveHelper.ts | 9 +- 33 files changed, 1389 insertions(+), 1064 deletions(-) create mode 100644 src/enums/move-use-type.ts diff --git a/biome.jsonc b/biome.jsonc index 3385614635c..2c49662a76d 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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" + } + } + } } ] } diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 4609ff6ec1a..6a44e01b415 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -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( @@ -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 diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 19c94a8a045..cee247b2bc6 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -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? } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8a512f3c16c..904bdc01bbf 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -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.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() { diff --git a/src/data/moves/invalid-moves.ts b/src/data/moves/invalid-moves.ts index 025c0383f43..bee7b3e8ab0 100644 --- a/src/data/moves/invalid-moves.ts +++ b/src/data/moves/invalid-moves.ts @@ -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 = new Set([ Moves.AFTER_YOU, Moves.ASSIST, @@ -255,3 +255,17 @@ export const noAbilityTypeOverrideMoves: ReadonlySet = new Set([ Moves.TECHNO_BLAST, Moves.HIDDEN_POWER, ]); + +/** Set of all moves that cannot be copied by {@linkcode Moves.SKETCH}. */ +export const invalidSketchMoves: ReadonlySet = 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, +]); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 3ef70fd75be..7a825800f0e 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -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; 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(user.getMoveset().map(m => m.moveId)); - if (!movesInMoveset.delete(move.id) || !movesInMoveset.size) { + const otherMovesInMoveset = new Set(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( 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((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(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((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), diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index 59167ba47f6..de4c630a684 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -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) { diff --git a/src/enums/move-use-type.ts b/src/enums/move-use-type.ts new file mode 100644 index 00000000000..8ea3c934064 --- /dev/null +++ b/src/enums/move-use-type.ts @@ -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 +} diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 6a05bea4c6d..90f09cb1530 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -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 { 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, ); } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index c3e558e1d86..e7f2b63012e 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -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 = { diff --git a/src/phases/move-charge-phase.ts b/src/phases/move-charge-phase.ts index ea43f1ddb88..23ec7788558 100644 --- a/src/phases/move-charge-phase.ts +++ b/src/phases/move-charge-phase.ts @@ -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 { diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index c65e8e15271..c1be34d7827 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -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, diff --git a/src/phases/move-end-phase.ts b/src/phases/move-end-phase.ts index 037596dca59..4f80279aae3 100644 --- a/src/phases/move-end-phase.ts +++ b/src/phases/move-end-phase.ts @@ -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); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index e704b040d20..2b16dd7a512 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -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); } diff --git a/src/phases/pokemon-phase.ts b/src/phases/pokemon-phase.ts index 8c30512cdc4..44f1effa3a0 100644 --- a/src/phases/pokemon-phase.ts +++ b/src/phases/pokemon-phase.ts @@ -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; diff --git a/src/phases/pokemon-transform-phase.ts b/src/phases/pokemon-transform-phase.ts index b33689321b5..2e420e49266 100644 --- a/src/phases/pokemon-transform-phase.ts +++ b/src/phases/pokemon-transform-phase.ts @@ -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); diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index b802780bbb8..08bdf47cdfe 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -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: diff --git a/src/ui/fight-ui-handler.ts b/src/ui/fight-ui-handler.ts index 5a0978a934d..601eef9c51d 100644 --- a/src/ui/fight-ui-handler.ts +++ b/src/ui/fight-ui-handler.ts @@ -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) { diff --git a/src/ui/party-ui-handler.ts b/src/ui/party-ui-handler.ts index 6e947796d63..34b2c45e05c 100644 --- a/src/ui/party-ui-handler.ts +++ b/src/ui/party-ui-handler.ts @@ -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 diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index 80d51a5c2d5..0691d81c23f 100644 --- a/test/moves/dig.test.ts +++ b/test/moves/dig.test.ts @@ -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); }); diff --git a/test/moves/disable.test.ts b/test/moves/disable.test.ts index d21716145a4..bd8f56ef46b 100644 --- a/test/moves/disable.test.ts +++ b/test/moves/disable.test.ts @@ -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); + }); }); diff --git a/test/moves/electro_shot.test.ts b/test/moves/electro_shot.test.ts index 0122bf04281..c95c89bf98f 100644 --- a/test/moves/electro_shot.test.ts +++ b/test/moves/electro_shot.test.ts @@ -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); diff --git a/test/moves/instruct.test.ts b/test/moves/instruct.test.ts index dd25db4ec90..440f8492a61 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -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 () => { diff --git a/test/moves/last-resort.test.ts b/test/moves/last-resort.test.ts index a7b462f3ca4..0d56122c8c5 100644 --- a/test/moves/last-resort.test.ts +++ b/test/moves/last-resort.test.ts @@ -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, }), ]); }); diff --git a/test/moves/metronome.test.ts b/test/moves/metronome.test.ts index bf177fb1a93..b6acd019f1e 100644 --- a/test/moves/metronome.test.ts +++ b/test/moves/metronome.test.ts @@ -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()!; diff --git a/test/moves/powder.test.ts b/test/moves/powder.test.ts index 457beb60f91..40d7180d94d 100644 --- a/test/moves/powder.test.ts +++ b/test/moves/powder.test.ts @@ -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]); diff --git a/test/moves/spit_up.test.ts b/test/moves/spit_up.test.ts index c034117bc64..6cab1dbe0a3 100644 --- a/test/moves/spit_up.test.ts +++ b/test/moves/spit_up.test.ts @@ -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({ + 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({ + 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({ + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SPIT_UP, result: MoveResult.SUCCESS, targets: [game.scene.getEnemyPokemon()!.getBattlerIndex()], diff --git a/test/moves/stockpile.test.ts b/test/moves/stockpile.test.ts index 4b8f51c32b2..1c50ab2af96 100644 --- a/test/moves/stockpile.test.ts +++ b/test/moves/stockpile.test.ts @@ -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({ + expect(user.getMoveHistory().at(-1)).toMatchObject({ result: MoveResult.FAIL, move: Moves.STOCKPILE, targets: [user.getBattlerIndex()], diff --git a/test/moves/swallow.test.ts b/test/moves/swallow.test.ts index d548522068b..8181b351ca4 100644 --- a/test/moves/swallow.test.ts +++ b/test/moves/swallow.test.ts @@ -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({ + 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({ + 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({ + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SWALLOW, result: MoveResult.SUCCESS, targets: [pokemon.getBattlerIndex()], diff --git a/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts b/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts index 455a5d28194..875deefe802 100644 --- a/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts +++ b/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts @@ -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; diff --git a/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts b/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts index f8375c1aa78..30ffd874b61 100644 --- a/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts +++ b/test/mystery-encounter/encounters/fun-and-games-encounter.test.ts @@ -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 diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index 8dd90decf1a..b7870beaf42 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -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(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` diff --git a/test/testUtils/helpers/moveHelper.ts b/test/testUtils/helpers/moveHelper.ts index 269cf65ea56..67056a366a1 100644 --- a/test/testUtils/helpers/moveHelper.ts +++ b/test/testUtils/helpers/moveHelper.ts @@ -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) {