From c0827230cbb1744d3668c4816b63e26639bede65 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:24:22 -0500 Subject: [PATCH] Refactor mostly complete, need to recheck tests --- src/data/battler-tags.ts | 1 - src/data/moves/move-condition.ts | 132 +++++++++++- src/data/moves/move-utils.ts | 24 +++ src/data/moves/move.ts | 340 +++++++++++++++++++------------ src/enums/move-category.ts | 3 + src/enums/move-flags.ts | 6 +- src/field/pokemon.ts | 8 +- src/phases/command-phase.ts | 2 +- src/phases/move-phase.ts | 146 ++++++------- src/utils/pokemon-utils.ts | 17 ++ test/moves/throat-chop.test.ts | 3 + 11 files changed, 464 insertions(+), 218 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 69e1812a164..0e272272f71 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1168,7 +1168,6 @@ export class PowderTag extends BattlerTag { } // Disable the target's fire type move and damage it (subject to Magic Guard) - currentPhase.showMoveText(); currentPhase.fail(); const idx = pokemon.getBattlerIndex(); diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 5d03bc7926d..5c06ab9bebe 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -1,13 +1,16 @@ -// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment -import type { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; +import { TrappedTag } from "#data/battler-tags"; import { allMoves } from "#data/data-lists"; import { ArenaTagType } from "#enums/arena-tag-type"; import { Command } from "#enums/command"; -import { MoveCategory } from "#enums/move-category"; +import { MoveCategory, type MoveDamageCategory } from "#enums/move-category"; +import type { MoveId } from "#enums/move-id"; +import { isVirtual } from "#enums/move-use-mode"; +import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import type { Move, MoveConditionFunc, UserMoveConditionFunc } from "#moves/move"; +import { getCounterAttackTarget } from "#moves/move-utils"; import i18next from "i18next"; /** @@ -54,6 +57,81 @@ export class FirstMoveCondition extends MoveCondition { } } +/** + * Condition that fails the move if the user has less than 1/x of their max HP. + * @remarks + * Used by Clangorous Soul and Fillet Away + */ +export class FailIfInsufficientHpCondition extends MoveCondition { + /** + * Condition that fails the move if the user has less than 1/x of their max HP. + * @param ratio - The required HP ratio (the `x` in `1/x`) + */ + constructor(cutRatio: number) { + super(user => user.getHpRatio() > 1 / cutRatio); + } +} + +/** + * Teleport condition checks + * + * @remarks + * For trainer pokemon, just checks if there are any benched pokemon allowed in battle + * + * Wild pokemon cannot teleport if either: + * - The current battle is a double battle + * - They are under the effects of a *move-based* trapping effect like and are neither a ghost type nor have an active run away ability + */ +export const failTeleportCondition = new MoveCondition(user => { + if (user.hasTrainer()) { + const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); + for (const pokemon of party) { + if (!pokemon.isOnField() && pokemon.isAllowedInBattle()) { + return true; + } + } + return false; + } + + // Wild pokemon + + // Cannot teleport in double battles (even if last remaining) + if (globalScene.currentBattle.double) { + return false; + } + // If smoke ball / shed tail items are ever added, checks for them should be placed here + // If a conditional "run away" ability is ever added, then we should use the apply method instead of the `hasAbility` + if (user.isOfType(PokemonType.GHOST, true, true) || user.hasAbilityWithAttr("RunSuccessAbAttr")) { + return true; + } + + // Wild pokemon are prevented from fleeing if they are trapped *specifically* + if (globalScene.arena.hasTag(ArenaTagType.FAIRY_LOCK) || user.getTag(TrappedTag) !== undefined) { + // Fairy Lock prevents teleporting + return false; + } + + return true; +}); + +/** + * Condition that forces moves to fail if the target's selected move is not an attacking move + * + * @remarks + * Used by Sucker Punch and Thunderclap + */ +export const failIfTargetNotAttackingCondition = new MoveCondition((_user, target) => { + const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; + if (!turnCommand || !turnCommand.move) { + return false; + } + return ( + turnCommand.command === Command.FIGHT && + !target.turnData.acted && + allMoves[turnCommand.move.move].category !== MoveCategory.STATUS + ); +}); + /** * Condition that forces moves to fail against the final boss in classic and the major boss in endless * @remarks @@ -86,6 +164,54 @@ export const UpperHandCondition = new MoveCondition((_user, target) => { ); }); +/** + * Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Last_Resort_(move) | Last Resort} + * + * @remarks + * Last resort fails if + * - It is not in the user's moveset + * - The user does not know at least one other move + * - The user has not directly used each other move in its moveset since it was sent into battle + * - A move is considered *used* for this purpose if it passed the first failure check sequence in the move phase + */ +export const lastResortCondition = new MoveCondition((user, _target, move) => { + 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( + user + .getMoveHistory() + .filter(m => !isVirtual(m.useMode)) // Last resort ignores virtual moves + .map(m => m.move), + ); + + // Since `Set.intersection()` is only present in ESNext, we have to do this to check inclusion + return [...otherMovesInMoveset].every(m => movesInHistory.has(m)); +}); + +/** + * Condition used by counter-like moves if the user was hit by at least one qualifying attack this turn. + * Qualifying attacks are those that match the specified category (physical, special or either) + * that did not come from an ally. + */ +class CounterAttackConditon extends MoveCondition { + /** + * @param damageCategory - The category of move to counter (physical or special), or `undefined` to counter both + */ + constructor(damageCategory?: MoveDamageCategory) { + super(user => getCounterAttackTarget(user, damageCategory) !== null); + } +} + +/** Condition check for counterattacks that proc againt physical moves */ +export const counterAttackCondition_Physical = new CounterAttackConditon(MoveCategory.PHYSICAL); +/** Condition check for counterattacks that proc against special moves*/ +export const counterAttackCondition_Special = new CounterAttackConditon(MoveCategory.SPECIAL); +/** Condition check for counterattacks that proc against moves regardless of damage type */ +export const counterAttackCondition_Both = new CounterAttackConditon(); + /** * A restriction that prevents a move from being selected * diff --git a/src/data/moves/move-utils.ts b/src/data/moves/move-utils.ts index 241144599e5..4d59921751d 100644 --- a/src/data/moves/move-utils.ts +++ b/src/data/moves/move-utils.ts @@ -1,6 +1,7 @@ import { allMoves } from "#data/data-lists"; import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveCategory, type MoveDamageCategory } from "#enums/move-category"; import type { MoveId } from "#enums/move-id"; import { MoveTarget } from "#enums/move-target"; import { PokemonType } from "#enums/pokemon-type"; @@ -8,6 +9,7 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import type { Move, MoveTargetSet, UserMoveConditionFunc } from "#moves/move"; import { isNullOrUndefined, NumberHolder } from "#utils/common"; +import { areAllies } from "#utils/pokemon-utils"; /** * Return whether the move targets the field @@ -133,3 +135,25 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move) return true; }; + +/** + * Determine the target for the `user`'s counter-attack move + * @param user - The pokemon using the counter-like move + * @param damageCategory - The category of move to counter (physical or special), or `undefined` to counter both + * @returns - The battler index of the most recent, non-ally attacker using a move that matches the specified category, or `null` if no such attacker exists + */ +export function getCounterAttackTarget(user: Pokemon, damageCategory?: MoveDamageCategory): BattlerIndex | null { + for (const attackRecord of user.turnData.attacksReceived) { + // check if the attacker was an ally + const moveCategory = allMoves[attackRecord.move].category; + const sourceBattlerIndex = attackRecord.sourceBattlerIndex; + if ( + moveCategory !== MoveCategory.STATUS && + !areAllies(sourceBattlerIndex, user.getBattlerIndex()) && + (damageCategory === undefined || moveCategory === damageCategory) + ) { + return sourceBattlerIndex; + } + } + return null; +} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 7ef35de9422..b9dc493064d 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -36,7 +36,7 @@ import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleType } from "#enums/battle-type"; -import type { BattlerIndex } from "#enums/battler-index"; +import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BiomeId } from "#enums/biome-id"; import { ChallengeType } from "#enums/challenge-type"; @@ -48,7 +48,7 @@ import { ChargeAnim } from "#enums/move-anims-common"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; -import { MoveCategory } from "#enums/move-category"; +import { MoveCategory, MoveDamageCategory } from "#enums/move-category"; import { MoveEffectTrigger } from "#enums/move-effect-trigger"; import { MoveFlags } from "#enums/move-flags"; import { MoveTarget } from "#enums/move-target"; @@ -78,13 +78,12 @@ import { } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; -import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; +import { frenzyMissFunc, getCounterAttackTarget, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; import { MoveEndPhase } from "#phases/move-end-phase"; import { MovePhase } from "#phases/move-phase"; import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; import { SwitchSummonPhase } from "#phases/switch-summon-phase"; -import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; @@ -93,7 +92,7 @@ import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { applyChallenges } from "#utils/challenge-utils"; -import { ConsecutiveUseRestriction, failAgainstFinalBossCondition, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; +import { ConsecutiveUseRestriction, counterAttackCondition_Both, counterAttackCondition_Physical, counterAttackCondition_Special, failAgainstFinalBossCondition, FailIfInsufficientHpCondition, failIfTargetNotAttackingCondition, failTeleportCondition, FirstMoveCondition, GravityUseRestriction, lastResortCondition, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; /** * A function used to conditionally determine execution of a given {@linkcode MoveAttr}. @@ -117,15 +116,56 @@ export abstract class Move implements Localizable { public priority: number; public generation: number; public attrs: MoveAttr[] = []; - /** Conditions that must be met for the move to succeed when it is used. - * + /** + * Conditions that must be met for the move to succeed when it is used. + * + * @remarks + * These are the default conditions checked during the move effect phase (aka sequence 4). + * When adding a new condition, if unsure of where it occurs in the failure checks, it should go here. * @remarks Different from {@linkcode restrictions}, which is checked when the move is selected */ private conditions: MoveCondition[] = []; /** - * Move failure conditions that occur during the second check (after move message and before ) + * Move failure conditions that occur during the second sequence (after move message and before ) */ private conditionsSeq2: MoveCondition[] = []; + /** + * Move failure conditions that occur during the third sequence (after accuracy and before move effects). + * + * @remarks + * List of *move-based* conditions that occur in this sequence: + * - Battle mechanics research conducted by Smogon and manual checks from SirzBenjie + * - Steel Roller with no terrain + * - Follow Me / Rage Powder failing due to being used in single battle + * - Stockpile with >= 3 stacks already + * - Spit up / swallow with 0 stockpiles + * - Counter / Mirror Coat / Metal Burst with no damage taken from enemies this turn + * - Last resort's bespoke failure conditions + * - Snore while not asleep + * - Sucker punch failling due to the target having already used their selected move or not having selected a damaging move + * - Magic Coat failing due to being used as the last move in the turn + * - Protect-like moves failing due to consecutive use or being the last move in the turn + * - First turn moves (e.g. mat block, fake out) failing due to not being used on first turn + * - Rest failing due, in this order, to already being asleep (including from comatose), under the effect of heal block, being full hp, having insomnia / vital spirit + * - Teleport failing when used by a wild pokemon that can't use it to flee (because it is trapped, in a double battle, etc) or used by a trainer pokemon with no pokemon to switch to + * - Fling with no usable item (not done as Pokerogue does not yet implement fling) + * - Magnet rise failing while under the effect of ingrain, smack down, or gravity (gravity check not done as redundant with sequence 1) + * - Splash failing when gravity is in effect (Not done in Pokerogue as this is redundant with the check that occurs during sequence 1) + * - Stuff cheeks with no berry + * - Destiny bond failing on consecutive use + * - Quick Guard / Wide Guard failing due to being used as the last move in the turn + * - Ally switch's chance to fail on consecutive use + * - Species specific moves (like hyperspace fury, aura wheel, dark void) being used by a pokemon that is not hoopa (Not applicable to Pokerogue, which permits this) + * - Poltergeist against a target with no item + * - Shell Trap failing due to not being hit by a physical move + * - Aurora veil failing due to no hail + * - Clangorous soul and Fillet Awayfailing due to insufficient HP + * - Upper hand failing due to the target not selecting a priority move + * - (Various moves that fail when used against titans / raid bosses, not listed as pokerogue does not yet implement each) + * + * @see {@link https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-54#post-8548957} + */ + private conditionsSeq3: MoveCondition[] = []; /** Conditions that must be false for a move to be able to be selected. * * @remarks Different from {@linkcode conditions}, which is checked when the move is invoked @@ -387,7 +427,7 @@ export abstract class Move implements Localizable { * @param checkSequence - The sequence number where the failure check occurs * @returns `this` for method chaining */ - condition(condition: MoveCondition | MoveConditionFunc, checkSequence: 2 | 3 = 3): this { + condition(condition: MoveCondition | MoveConditionFunc, checkSequence: 2 | 3 | 4 = 4): this { const conditionsArray = checkSequence === 2 ? this.conditionsSeq2 : this.conditions; if (typeof condition === "function") { condition = new MoveCondition(condition); @@ -412,10 +452,10 @@ export abstract class Move implements Localizable { * @param i18nkey - The i18n key for the restriction text * @param alsoCondition - If `true`, also adds a {@linkcode MoveCondition} that checks the same condition when the * move is used; default `false` + * @param conditionSeq - The sequence number where the failure check occurs; default `4` * @returns `this` for method chaining */ - - public restriction(restriction: UserMoveConditionFunc, i18nkey: string, alsoCondition?: boolean): this; + public restriction(restriction: UserMoveConditionFunc, i18nkey: string, alsoCondition?: boolean, conditionSeq?: number): this; /** * Adds a restriction condition to this move. * The move will not be selectable if at least 1 of its restrictions is met. @@ -424,13 +464,27 @@ export abstract class Move implements Localizable { * @param i18nkey - The i18n key for the restriction text, ignored if `restriction` is a `MoveRestriction` * @param alsoCondition - If `true`, also adds a {@linkcode MoveCondition} that checks the same condition when the * move is used; default `false`. Ignored if `restriction` is a `MoveRestriction`. + * @param conditionSeq - The sequence number where the failure check occurs; default `4`. Ignored if `alsoCondition` + * is false * @returns `this` for method chaining */ - public restriction(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false): this { + public restriction(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false, conditionSeq = 4): this { if (typeof restriction === "function") { this.restrictions.push(new MoveRestriction(restriction)); if (alsoCondition) { - this.conditions.push(new MoveCondition((user, _, move) => restriction(user, move))); + let conditionArray: MoveCondition[]; + switch (conditionSeq) { + case 2: + conditionArray = this.conditionsSeq2; + break; + case 3: + conditionArray = this.conditionsSeq3; + break; + default: + conditionArray = this.conditions; + } + + conditionArray.push(new MoveCondition((user, _, move) => !restriction(user, move))); } } else { this.restrictions.push(restriction); @@ -640,9 +694,14 @@ export abstract class Move implements Localizable { /** * Sets the {@linkcode MoveFlags.GRAVITY} flag for the calling Move and adds {@linkcode GravityUseRestriction} to the * move's restrictions. + * + * @returns The {@linkcode Move} that called this function + * + * @remarks + * No {@linkcode condition} is added, as gravity's condition is already checked + * during the first sequence of a move's failure check, and this would be redundant. * * @see {@linkcode MoveId.GRAVITY} - * @returns The {@linkcode Move} that called this function */ affectedByGravity(): this { this.setFlag(MoveFlags.GRAVITY, true); @@ -680,16 +739,6 @@ export abstract class Move implements Localizable { return this; } - /** - * Sets the {@linkcode MoveFlags.REDIRECT_COUNTER} flag for the calling Move - * @see {@linkcode MoveId.METAL_BURST} - * @returns The {@linkcode Move} that called this function - */ - redirectCounter(): this { - this.setFlag(MoveFlags.REDIRECT_COUNTER, true); - return this; - } - /** * Sets the {@linkcode MoveFlags.REFLECTABLE} flag for the calling Move * @see {@linkcode MoveId.ATTRACT} @@ -771,11 +820,24 @@ export abstract class Move implements Localizable { * @param user - {@linkcode Pokemon} to apply conditions to * @param target - {@linkcode Pokemon} to apply conditions to * @param move - {@linkcode Move} to apply conditions to - * @param sequence - The sequence number where the condition check occurs, defaults to 3 + * @param sequence - The sequence number where the condition check occurs, or `-1` to check all; defaults to 3. Pass -1 to check all * @returns boolean: false if any of the apply()'s return false, else true */ - applyConditions(user: Pokemon, target: Pokemon, sequence: number = 3): boolean { - const conditionsArray = sequence === 2 ? this.conditionsSeq2 : this.conditions; + applyConditions(user: Pokemon, target: Pokemon, sequence: -1 | 2 | 3 | 4 = 4): boolean { + let conditionsArray: MoveCondition[]; + switch (sequence) { + case -1: + conditionsArray = [...this.conditionsSeq2, ...this.conditionsSeq3, ...this.conditions]; + case 2: + conditionsArray = this.conditionsSeq2; + break; + case 3: + conditionsArray = this.conditionsSeq3; + break; + case 4: + default: + conditionsArray = this.conditions; + } return conditionsArray.every(cond => cond.apply(user, target, this)); } @@ -1731,28 +1793,77 @@ export class MatchHpAttr extends FixedDamageAttr { }*/ } -type MoveFilter = (move: Move) => boolean; - export class CounterDamageAttr extends FixedDamageAttr { - private moveFilter: MoveFilter; + /** The damage category of counter attacks to process, or `undefined` for either */ + private moveFilter?: MoveDamageCategory; private multiplier: number; - constructor(moveFilter: MoveFilter, multiplier: number) { + /** + * @param multiplier - The damage multiplier to apply to the total damage received + * @param moveFilter - If set, only damage from moves of this category will be counted, otherwise all damage is counted + */ + constructor(multiplier: number, moveFilter?: MoveDamageCategory) { super(0); - this.moveFilter = moveFilter; this.multiplier = multiplier; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const damage = user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).reduce((total: number, ar: AttackMoveResult) => total + ar.damage, 0); + let damage = 0; + for (const ar of user.turnData.attacksReceived) { + // TODO: Adjust this for moves with variable damage categories + const category = allMoves[ar.move].category; + if (category === MoveCategory.STATUS || (this.moveFilter && category !== this.moveFilter)) { + continue; + } + damage += ar.damage; + + } (args[0] as NumberHolder).value = toDmgValue(damage * this.multiplier); return true; } +} - getCondition(): MoveConditionFunc { - return (user, target, move) => !!user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).length; +/** + * Attribute for counter-like moves to redirect the move to a different target + */ +export class CounterRedirectAttr extends MoveAttr { + declare private moveFilter?: MoveDamageCategory; + constructor(moveFilter? : MoveDamageCategory) { + super(); + if (moveFilter !== undefined) { + this.moveFilter = moveFilter; + } + } + + /** + * Applies the counter redirect attribute to the move + * @param user - The user of the counter move + * @param target - The target of the move (unused) + * @param move - The move being used + * @param args - args[0] holds the battler index of the target that the move will be redirected to + */ + override apply(user: Pokemon, target: Pokemon | null, move: Move, args: [NumberHolder, ...any[]]): boolean { + const desiredTarget = getCounterAttackTarget(user, this.moveFilter); + if (desiredTarget !== null && desiredTarget !== BattlerIndex.ATTACKER) { + // check if the target is still alive + if ( + globalScene.currentBattle.double && + !globalScene.getField()[desiredTarget]?.isActive + ) { + const targetField = desiredTarget >= BattlerIndex.ENEMY ? globalScene.getEnemyField() : globalScene.getPlayerField(); + args[0].value = targetField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; + } else { + args[0].value = desiredTarget; + } + return true; + } + return false; + } + + override canApply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder, ...any[]]): boolean { + return args[0].value === BattlerIndex.ATTACKER; } } @@ -3744,7 +3855,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr { } getCondition(): MoveConditionFunc { - return (user, _target, _move) => user.getHpRatio() > 1 / this.cutRatio && this.stats.some(s => user.getStatStage(s) < 6); + return user => user.getHpRatio() > 1 / this.cutRatio && this.stats.some(s => user.getStatStage(s) < 6); } } @@ -8017,31 +8128,6 @@ export class StatusIfBoostedAttr extends MoveEffectAttr { } } -/** - * Attribute to fail move usage unless all of the user's other moves have been used at least once. - * Used by {@linkcode MoveId.LAST_RESORT}. - */ -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 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( - user.getMoveHistory() - .filter(m => !isVirtual(m.useMode)) // Last resort ignores virtual moves - .map(m => m.move) - ); - - // Since `Set.intersection()` is only present in ESNext, we have to do this to check inclusion - return [...otherMovesInMoveset].every(m => movesInHistory.has(m)) - }; - } -} - export class VariableTargetAttr extends MoveAttr { private targetChangeFunc: (user: Pokemon, target: Pokemon, move: Move) => number; @@ -8330,6 +8416,7 @@ const MoveAttrs = Object.freeze({ TargetHalfHpDamageAttr, MatchHpAttr, CounterDamageAttr, + CounterRedirectAttr, LevelDamageAttr, RandomLevelDamageAttr, ModifiedDamageAttr, @@ -8520,7 +8607,6 @@ const MoveAttrs = Object.freeze({ DestinyBondAttr, AddBattlerTagIfBoostedAttr, StatusIfBoostedAttr, - LastResortAttr, VariableTargetAttr, AfterYouAttr, ForceLastAttr, @@ -8723,7 +8809,8 @@ export function initMoves() { new AttackMove(MoveId.LOW_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(WeightPowerAttr), new AttackMove(MoveId.COUNTER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, -5, 1) - .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.PHYSICAL, 2) + .attr(CounterDamageAttr, 2, MoveCategory.PHYSICAL) + .condition(counterAttackCondition_Physical, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.SEISMIC_TOSS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(LevelDamageAttr), @@ -8823,7 +8910,8 @@ export function initMoves() { .partial(), // No effect implemented new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1) .attr(ForceSwitchOutAttr, true) - .hidesUser(), + .hidesUser() + .condition(failTeleportCondition, 3), new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1) .attr(LevelDamageAttr), new StatusMove(MoveId.MIMIC, PokemonType.NORMAL, -1, 10, -1, 0, 1) @@ -8876,7 +8964,7 @@ export function initMoves() { new AttackMove(MoveId.SELF_DESTRUCT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 200, 100, 5, -1, 0, 1) .attr(SacrificialAttr) .makesContact(false) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(MoveId.EGG_BOMB, PokemonType.NORMAL, MoveCategory.PHYSICAL, 100, 75, 10, -1, 0, 1) .makesContact(false) @@ -8977,7 +9065,7 @@ export function initMoves() { new AttackMove(MoveId.CRABHAMMER, PokemonType.WATER, MoveCategory.PHYSICAL, 100, 90, 10, -1, 0, 1) .attr(HighCritAttr), new AttackMove(MoveId.EXPLOSION, PokemonType.NORMAL, MoveCategory.PHYSICAL, 250, 100, 5, -1, 0, 1) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .attr(SacrificialAttr) .makesContact(false) .target(MoveTarget.ALL_NEAR_OTHERS), @@ -8989,7 +9077,7 @@ export function initMoves() { new SelfStatusMove(MoveId.REST, PokemonType.PSYCHIC, -1, 5, -1, 0, 1) .attr(StatusEffectAttr, StatusEffect.SLEEP, true, 3, true) .attr(HealAttr, 1, true) - .condition((user, target, move) => !user.isFullHp() && user.canSetStatus(StatusEffect.SLEEP, true, true, user)) + .condition((user, target, move) => !user.isFullHp() && user.canSetStatus(StatusEffect.SLEEP, true, true, user), 3) .triageMove(), new AttackMove(MoveId.ROCK_SLIDE, PokemonType.ROCK, MoveCategory.PHYSICAL, 75, 90, 10, 30, 0, 1) .attr(FlinchAttr) @@ -9045,7 +9133,7 @@ export function initMoves() { new AttackMove(MoveId.SNORE, PokemonType.NORMAL, MoveCategory.SPECIAL, 50, 100, 15, 30, 0, 2) .attr(BypassSleepAttr) .attr(FlinchAttr) - .condition(userSleptOrComatoseCondition) + .condition(userSleptOrComatoseCondition, 3) .soundBased(), new StatusMove(MoveId.CURSE, PokemonType.GHOST, -1, 10, -1, 0, 2) .attr(CurseAttr) @@ -9077,7 +9165,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new SelfStatusMove(MoveId.PROTECT, PokemonType.NORMAL, -1, 10, -1, 4, 2) .attr(ProtectAttr) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.MACH_PUNCH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 30, -1, 1, 2) .punchingMove(), new StatusMove(MoveId.SCARY_FACE, PokemonType.NORMAL, 100, 10, -1, 0, 2) @@ -9137,7 +9225,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new SelfStatusMove(MoveId.DETECT, PokemonType.FIGHTING, -1, 5, -1, 4, 2) .attr(ProtectAttr) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.BONE_RUSH, PokemonType.GROUND, MoveCategory.PHYSICAL, 25, 90, 10, -1, 0, 2) .attr(MultiHitAttr) .makesContact(false), @@ -9159,7 +9247,7 @@ export function initMoves() { .triageMove(), new SelfStatusMove(MoveId.ENDURE, PokemonType.NORMAL, -1, 10, -1, 4, 2) .attr(ProtectAttr, BattlerTagType.ENDURING) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.CHARM, PokemonType.FAIRY, 100, 20, -1, 0, 2) .attr(StatStageChangeAttr, [ Stat.ATK ], -2) .reflectable(), @@ -9194,7 +9282,7 @@ export function initMoves() { new SelfStatusMove(MoveId.SLEEP_TALK, PokemonType.NORMAL, -1, 10, -1, 0, 2) .attr(BypassSleepAttr) .attr(RandomMovesetMoveAttr, invalidSleepTalkMoves, false) - .condition(userSleptOrComatoseCondition) + .condition(userSleptOrComatoseCondition, 3) .target(MoveTarget.NEAR_ENEMY), new StatusMove(MoveId.HEAL_BELL, PokemonType.NORMAL, -1, 5, -1, 0, 2) .attr(PartyStatusCureAttr, i18next.t("moveTriggers:bellChimed"), AbilityId.SOUNDPROOF) @@ -9297,7 +9385,8 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.DEF ], -1) .bitingMove(), new AttackMove(MoveId.MIRROR_COAT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 20, -1, -5, 2) - .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2) + .attr(CounterDamageAttr, 2, MoveCategory.SPECIAL) + .condition(counterAttackCondition_Special, 3) .target(MoveTarget.ATTACKER), new StatusMove(MoveId.PSYCH_UP, PokemonType.NORMAL, -1, 10, -1, 0, 2) .ignoresSubstitute() @@ -9326,21 +9415,21 @@ export function initMoves() { .makesContact(false), new AttackMove(MoveId.FAKE_OUT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 40, 100, 10, 100, 3, 3) .attr(FlinchAttr) - .condition(new FirstMoveCondition()), + .condition(new FirstMoveCondition(), 3), new AttackMove(MoveId.UPROAR, PokemonType.NORMAL, MoveCategory.SPECIAL, 90, 100, 10, -1, 0, 3) .soundBased() .target(MoveTarget.RANDOM_NEAR_ENEMY) .partial(), // Does not lock the user, does not stop Pokemon from sleeping // Likely can make use of FrenzyAttr and an ArenaTag (just without the FrenzyMissFunc) new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3) - .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) + .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3, 3) .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3) - .condition(hasStockpileStacksCondition) + .condition(hasStockpileStacksCondition, 3) .attr(SpitUpPowerAttr, 100) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true), new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3) - .condition(hasStockpileStacksCondition) + .condition(hasStockpileStacksCondition, 3) .attr(SwallowHealAttr) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true) .triageMove(), @@ -9379,7 +9468,8 @@ export function initMoves() { .attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1) .attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS), new SelfStatusMove(MoveId.FOLLOW_ME, PokemonType.NORMAL, -1, 20, -1, 2, 3) - .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true), + .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true) + .condition(failIfSingleBattle, 3), new StatusMove(MoveId.NATURE_POWER, PokemonType.NORMAL, -1, 20, -1, 0, 3) .attr(NaturePowerAttr), new SelfStatusMove(MoveId.CHARGE, PokemonType.ELECTRIC, -1, 20, -1, 0, 3) @@ -9414,7 +9504,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1, true), new SelfStatusMove(MoveId.MAGIC_COAT, PokemonType.PSYCHIC, -1, 15, -1, 4, 3) .attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true, 0) - .condition(failIfLastCondition) + .condition(failIfLastCondition, 3) // Interactions with stomping tantrum, instruct, and other moves that // rely on move history // Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr @@ -9713,8 +9803,8 @@ export function initMoves() { .attr(AcupressureStatStageChangeAttr) .target(MoveTarget.USER_OR_NEAR_ALLY), new AttackMove(MoveId.METAL_BURST, PokemonType.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 4) - .attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5) - .redirectCounter() + .attr(CounterDamageAttr, 1.5) + .condition(counterAttackCondition_Both, 3) .makesContact(false) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) @@ -9776,21 +9866,15 @@ export function initMoves() { .makesContact(true) .attr(PunishmentPowerAttr), new AttackMove(MoveId.LAST_RESORT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 140, 100, 5, -1, 0, 4) - .attr(LastResortAttr) - .edgeCase(), // May or may not need to ignore remotely called moves depending on how it works + .condition(lastResortCondition, 3) + .edgeCase(), // When a move is overwritten and later relearned, Last Resort's tracking of it should be reset new StatusMove(MoveId.WORRY_SEED, PokemonType.GRASS, 100, 10, -1, 0, 4) .attr(AbilityChangeAttr, AbilityId.INSOMNIA) // TODO: Enable / remove once balance reaches a consensus on ability overrides during boss fights // .condition(failAgainstFinalBossCondition, 3) .reflectable(), new AttackMove(MoveId.SUCKER_PUNCH, PokemonType.DARK, MoveCategory.PHYSICAL, 70, 100, 5, -1, 1, 4) - .condition((user, target, move) => { - const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; - if (!turnCommand || !turnCommand.move) { - return false; - } - return (turnCommand.command === Command.FIGHT && !target.turnData.acted && allMoves[turnCommand.move.move].category !== MoveCategory.STATUS); - }), + .condition(failIfTargetNotAttackingCondition, 3), new StatusMove(MoveId.TOXIC_SPIKES, PokemonType.POISON, -1, 20, -1, 0, 4) .attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES) .target(MoveTarget.ENEMY_SIDE) @@ -9802,7 +9886,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5) - .condition(user => [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))) + .condition(user => [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag)), 3) .affectedByGravity(), new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4) .attr(RecoilAttr, false, 0.33) @@ -10012,7 +10096,7 @@ export function initMoves() { new StatusMove(MoveId.WIDE_GUARD, PokemonType.ROCK, -1, 10, -1, 3, 5) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.WIDE_GUARD, 1, true, true) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.GUARD_SPLIT, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) // TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight // .condition(failAgainstFinalBossCondition, 2) @@ -10042,6 +10126,7 @@ export function initMoves() { .condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING))) .attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3) + .affectedByGravity() .reflectable(), new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) .ignoresProtect() @@ -10121,7 +10206,7 @@ export function initMoves() { new StatusMove(MoveId.QUICK_GUARD, PokemonType.FIGHTING, -1, 15, -1, 3, 5) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.QUICK_GUARD, 1, true, true) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new SelfStatusMove(MoveId.ALLY_SWITCH, PokemonType.PSYCHIC, -1, 15, -1, 2, 5) .ignoresProtect() .unimplemented(), @@ -10336,8 +10421,8 @@ export function initMoves() { new StatusMove(MoveId.MAT_BLOCK, PokemonType.FIGHTING, -1, 10, -1, 0, 6) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.MAT_BLOCK, 1, true, true) - .condition(new FirstMoveCondition()) - .condition(failIfLastCondition), + .condition(new FirstMoveCondition(), 3) + .condition(failIfLastCondition, 3), new AttackMove(MoveId.BELCH, PokemonType.POISON, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 6) .restriction(user => !user.battleData.hasEatenBerry, "battle:moveDisabledBelch", true), new StatusMove(MoveId.ROTOTILLER, PokemonType.GROUND, -1, 10, -1, 0, 6) @@ -10399,7 +10484,7 @@ export function initMoves() { new StatusMove(MoveId.CRAFTY_SHIELD, PokemonType.FAIRY, -1, 10, -1, 3, 6) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.CRAFTY_SHIELD, 1, true, true) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.FLOWER_SHIELD, PokemonType.FAIRY, -1, 10, -1, 0, 6) .target(MoveTarget.ALL) .attr(StatStageChangeAttr, [ Stat.DEF ], 1, false, { condition: (user, target, move) => target.getTypes().includes(PokemonType.GRASS) && !target.getTag(SemiInvulnerableTag) }), @@ -10427,7 +10512,7 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.FAIRY_LOCK, 2, true), new SelfStatusMove(MoveId.KINGS_SHIELD, PokemonType.STEEL, -1, 10, -1, 4, 6) .attr(ProtectAttr, BattlerTagType.KINGS_SHIELD) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.PLAY_NICE, PokemonType.NORMAL, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.ATK ], -1) .ignoresSubstitute() @@ -10455,7 +10540,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPATK ], -1), new SelfStatusMove(MoveId.SPIKY_SHIELD, PokemonType.GRASS, -1, 10, -1, 4, 6) .attr(ProtectAttr, BattlerTagType.SPIKY_SHIELD) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.AROMATIC_MIST, PokemonType.FAIRY, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPDEF ], 1) .ignoresSubstitute() @@ -10623,10 +10708,10 @@ export function initMoves() { .attr(SandHealAttr) .triageMove(), new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7) - .condition(new FirstMoveCondition()), + .condition(new FirstMoveCondition(), 3), new SelfStatusMove(MoveId.BANEFUL_BUNKER, PokemonType.POISON, -1, 10, -1, 4, 7) .attr(ProtectAttr, BattlerTagType.BANEFUL_BUNKER) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.SPIRIT_SHACKLE, PokemonType.GHOST, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 7) .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true) .makesContact(false), @@ -10759,7 +10844,16 @@ export function initMoves() { new AttackMove(MoveId.BRUTAL_SWING, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 7) .target(MoveTarget.ALL_NEAR_OTHERS), new StatusMove(MoveId.AURORA_VEIL, PokemonType.ICE, -1, 20, -1, 0, 7) - .condition((user, target, move) => (globalScene.arena.weather?.weatherType === WeatherType.HAIL || globalScene.arena.weather?.weatherType === WeatherType.SNOW) && !globalScene.arena.weather?.isEffectSuppressed()) + .condition( + () => { + const weather = globalScene.arena.weather; + if (isNullOrUndefined(weather) || weather.isEffectSuppressed()) { + return false; + } + return weather.weatherType === WeatherType.HAIL || weather.weatherType === WeatherType.SNOW; + }, + 3 + ) .attr(AddArenaTagAttr, ArenaTagType.AURORA_VEIL, 5, true) .target(MoveTarget.USER_SIDE), /* Unused */ @@ -10796,7 +10890,10 @@ export function initMoves() { .attr(AddBattlerTagHeaderAttr, BattlerTagType.SHELL_TRAP) .target(MoveTarget.ALL_NEAR_ENEMIES) // Fails if the user was not hit by a physical attack during the turn - .condition((user, target, move) => user.getTag(ShellTrapTag)?.activated === true), + .condition( + user => user.getTag(ShellTrapTag)?.activated === true, + 3 + ), new AttackMove(MoveId.FLEUR_CANNON, PokemonType.FAIRY, MoveCategory.SPECIAL, 130, 90, 5, -1, 0, 7) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true), new AttackMove(MoveId.PSYCHIC_FANGS, PokemonType.PSYCHIC, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 7) @@ -10840,7 +10937,7 @@ export function initMoves() { .edgeCase(), // I assume it's because it needs thunderbolt and pikachu in a cap /* End Unused */ new AttackMove(MoveId.MIND_BLOWN, PokemonType.FIRE, MoveCategory.SPECIAL, 150, 100, 5, -1, 0, 7) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .attr(HalfSacrificialAttr) .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(MoveId.PLASMA_FISTS, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 100, 100, 15, -1, 0, 7) @@ -11031,7 +11128,8 @@ export function initMoves() { new SelfStatusMove(MoveId.CLANGOROUS_SOUL, PokemonType.DRAGON, 100, 5, -1, 0, 8) .attr(CutHpStatStageBoostAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, 3) .soundBased() - .danceMove(), + .danceMove() + .condition(new FailIfInsufficientHpCondition(3), 3), new AttackMove(MoveId.BODY_PRESS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 8) .attr(DefAtkAttr), new StatusMove(MoveId.DECORATE, PokemonType.FAIRY, -1, 15, -1, 0, 8) @@ -11078,7 +11176,7 @@ export function initMoves() { .triageMove(), new SelfStatusMove(MoveId.OBSTRUCT, PokemonType.DARK, 100, 10, -1, 4, 8) .attr(ProtectAttr, BattlerTagType.OBSTRUCT) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.FALSE_SURRENDER, PokemonType.DARK, MoveCategory.PHYSICAL, 80, -1, 10, -1, 0, 8), new AttackMove(MoveId.METEOR_ASSAULT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 5, -1, 0, 8) .attr(RechargeAttr) @@ -11092,7 +11190,7 @@ export function initMoves() { .attr(VariableTargetAttr, (user, target, move) => globalScene.arena.getTerrainType() === TerrainType.PSYCHIC && user.isGrounded() ? MoveTarget.ALL_NEAR_ENEMIES : MoveTarget.NEAR_OTHER), new AttackMove(MoveId.STEEL_ROLLER, PokemonType.STEEL, MoveCategory.PHYSICAL, 130, 100, 5, -1, 0, 8) .attr(ClearTerrainAttr) - .condition((user, target, move) => !!globalScene.arena.terrain), + .condition(() => !!globalScene.arena.terrain, 3), new AttackMove(MoveId.SCALE_SHOT, PokemonType.DRAGON, MoveCategory.PHYSICAL, 25, 90, 20, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.SPD ], 1, true, { lastHitOnly: true }) .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, { lastHitOnly: true }) @@ -11109,7 +11207,7 @@ export function initMoves() { .attr(SacrificialAttr) .target(MoveTarget.ALL_NEAR_OTHERS) .attr(MovePowerMultiplierAttr, (user, target, move) => globalScene.arena.getTerrainType() === TerrainType.MISTY && user.isGrounded() ? 1.5 : 1) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .makesContact(false), new AttackMove(MoveId.GRASSY_GLIDE, PokemonType.GRASS, MoveCategory.PHYSICAL, 55, 100, 20, -1, 0, 8) .attr(IncrementMovePriorityAttr, (user, target, move) => globalScene.arena.getTerrainType() === TerrainType.GRASSY && user.isGrounded()), @@ -11127,7 +11225,7 @@ export function initMoves() { new AttackMove(MoveId.LASH_OUT, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8) .attr(MovePowerMultiplierAttr, (user, _target, _move) => user.turnData.statStagesDecreased ? 2 : 1), new AttackMove(MoveId.POLTERGEIST, PokemonType.GHOST, MoveCategory.PHYSICAL, 110, 90, 5, -1, 0, 8) - .condition(failIfNoTargetHeldItemsCondition) + .condition(failIfNoTargetHeldItemsCondition, 3) .attr(PreMoveMessageAttr, attackedByItemMessageFunc) .makesContact(false), new StatusMove(MoveId.CORROSIVE_GAS, PokemonType.POISON, 100, 40, -1, 0, 8) @@ -11371,7 +11469,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, { condition: (user, target, move) => user.isTerastallized && user.isOfType(PokemonType.STELLAR) }), new SelfStatusMove(MoveId.SILK_TRAP, PokemonType.BUG, -1, 10, -1, 4, 9) .attr(ProtectAttr, BattlerTagType.SILK_TRAP) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.AXE_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 90, 10, 30, 0, 9) .attr(MissEffectAttr, crashDamageFunc) .attr(NoEffectAttr, crashDamageFunc) @@ -11400,10 +11498,7 @@ export function initMoves() { .attr(ClearTerrainAttr), new AttackMove(MoveId.GLAIVE_RUSH, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 9) .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_GET_HIT, true, false, 0, 0, true) - .attr(AddBattlerTagAttr, BattlerTagType.RECEIVE_DOUBLE_DAMAGE, true, false, 0, 0, true) - .condition((user, target, move) => { - return !(target.getTag(BattlerTagType.PROTECTED)?.tagType === "PROTECTED" || globalScene.arena.getTag(ArenaTagType.MAT_BLOCK)?.tagType === "MAT_BLOCK"); - }), + .attr(AddBattlerTagAttr, BattlerTagType.RECEIVE_DOUBLE_DAMAGE, true, false, 0, 0, true), new StatusMove(MoveId.REVIVAL_BLESSING, PokemonType.NORMAL, -1, 1, -1, 0, 9) .triageMove() .attr(RevivalBlessingAttr) @@ -11433,7 +11528,8 @@ export function initMoves() { new StatusMove(MoveId.DOODLE, PokemonType.NORMAL, 100, 10, -1, 0, 9) .attr(AbilityCopyAttr, true), new SelfStatusMove(MoveId.FILLET_AWAY, PokemonType.NORMAL, -1, 10, -1, 0, 9) - .attr(CutHpStatStageBoostAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], 2, 2), + .attr(CutHpStatStageBoostAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], 2, 2) + .condition(new FailIfInsufficientHpCondition(2), 3), new AttackMove(MoveId.KOWTOW_CLEAVE, PokemonType.DARK, MoveCategory.PHYSICAL, 85, -1, 10, -1, 0, 9) .slicingMove(), new AttackMove(MoveId.FLOWER_TRICK, PokemonType.GRASS, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 9) @@ -11522,8 +11618,8 @@ export function initMoves() { .makesContact(false) .restriction(ConsecutiveUseRestriction), new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9) - .attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5) - .redirectCounter() + .attr(CounterDamageAttr, 1.5) + .condition(counterAttackCondition_Both, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.AQUA_CUTTER, PokemonType.WATER, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 9) .attr(HighCritAttr) @@ -11575,15 +11671,9 @@ export function initMoves() { .edgeCase(), // Should not interact with Sheer Force new SelfStatusMove(MoveId.BURNING_BULWARK, PokemonType.FIRE, -1, 10, -1, 4, 9) .attr(ProtectAttr, BattlerTagType.BURNING_BULWARK) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.THUNDERCLAP, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 5, -1, 1, 9) - .condition((user, target, move) => { - const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; - if (!turnCommand || !turnCommand.move) { - return false; - } - return (turnCommand.command === Command.FIGHT && !target.turnData.acted && allMoves[turnCommand.move.move].category !== MoveCategory.STATUS); - }), + .condition(failIfTargetNotAttackingCondition, 3), new AttackMove(MoveId.MIGHTY_CLEAVE, PokemonType.ROCK, MoveCategory.PHYSICAL, 95, 100, 5, -1, 0, 9) .slicingMove() .ignoresProtect(), @@ -11611,7 +11701,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.HEAL_BLOCK, false, false, 2), new AttackMove(MoveId.UPPER_HAND, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 65, 100, 15, 100, 3, 9) .attr(FlinchAttr) - .condition(UpperHandCondition), + .condition(UpperHandCondition, 3), new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9) .attr(StatusEffectAttr, StatusEffect.TOXIC) ); diff --git a/src/enums/move-category.ts b/src/enums/move-category.ts index 0408655e6db..fd788055e02 100644 --- a/src/enums/move-category.ts +++ b/src/enums/move-category.ts @@ -3,3 +3,6 @@ export enum MoveCategory { SPECIAL, STATUS } + +/** Type of damage categories */ +export type MoveDamageCategory = Exclude; \ No newline at end of file diff --git a/src/enums/move-flags.ts b/src/enums/move-flags.ts index dc0964ad2a0..7504d3c1e95 100644 --- a/src/enums/move-flags.ts +++ b/src/enums/move-flags.ts @@ -47,10 +47,8 @@ export enum MoveFlags { CHECK_ALL_HITS = 1 << 16, /** Indicates a move is able to bypass its target's Substitute (if the target has one) */ IGNORE_SUBSTITUTE = 1 << 17, - /** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */ - REDIRECT_COUNTER = 1 << 18, /** Indicates a move is able to be reflected by {@linkcode AbilityId.MAGIC_BOUNCE} and {@linkcode MoveId.MAGIC_COAT} */ - REFLECTABLE = 1 << 19, + REFLECTABLE = 1 << 18, /** Indicates a move that fails when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */ - GRAVITY = 1 << 20, + GRAVITY = 1 << 19, } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index f7542b84034..c5e1b8d51ab 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -724,7 +724,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { abstract getFieldIndex(): number; - abstract getBattlerIndex(): BattlerIndex; + abstract getBattlerIndex(): Exclude; /** * Load all assets needed for this Pokemon's use in battle @@ -3305,7 +3305,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { */ public trySelectMove(moveIndex: number, ignorePp?: boolean): [boolean, string] { const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null; - return move?.isUsable(this, ignorePp) ?? [false, ""]; + return move?.isUsable(this, ignorePp, true) ?? [false, ""]; } showInfo(): void { @@ -5787,7 +5787,7 @@ export class PlayerPokemon extends Pokemon { return globalScene.getPlayerField().indexOf(this); } - getBattlerIndex(): BattlerIndex { + getBattlerIndex(): Exclude { return this.getFieldIndex(); } @@ -6903,7 +6903,7 @@ export class EnemyPokemon extends Pokemon { return globalScene.getEnemyField().indexOf(this); } - getBattlerIndex(): BattlerIndex { + getBattlerIndex(): Exclude { return BattlerIndex.ENEMY + this.getFieldIndex(); } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 6b51bb379a5..90da939bdd8 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -254,7 +254,7 @@ export class CommandPhase extends FieldPhase { // Ternary here ensures we don't compute struggle conditions unless necessary const useStruggle = canUse ? false - : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]); + : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon, ignorePP, true)[0]); if (!canUse && !useStruggle) { console.error("Cannot use move:", reason); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 115cbfb1ecc..22731977187 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -27,7 +27,7 @@ import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; import { BattlePhase } from "#phases/battle-phase"; // biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment -import type { PreUseInterruptAttr } from "#types/move-types"; +import type { Move, PreUseInterruptAttr } from "#types/move-types"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; @@ -201,6 +201,7 @@ export class MovePhase extends BattlePhase { * - Pollen puff used on an ally that is under effect of heal block * - Burn up / Double shock when the user does not have the required type * - No Retreat while already under its effects + * - Failure due to primal weather * - (on cart, not applicable to Pokerogue) Moves that fail if used ON a raid / special boss: selfdestruct/explosion/imprision/power split / guard split * - (on cart, not applicable to Pokerogue) Moves that fail during a "co-op" battle (like when Arven helps during raid boss): ally switch / teatime * @@ -209,17 +210,26 @@ export class MovePhase extends BattlePhase { protected secondFailureCheck(): boolean { const move = this.move.getMove(); const user = this.pokemon; + let failedText: string | undefined; + const arena = globalScene.arena; + if (!move.applyConditions(user, this.getActiveTargetPokemon()[0], 2)) { + // TODO: Make pollen puff failing from heal block use its own message this.failed = true; - // Note: If any of the moves have custom failure messages, this needs to be changed - // As of Gen 9, none do. (Except maybe pollen puff? Need to check) - return true; + } else if (arena.isMoveWeatherCancelled(user, move)) { + failedText = getWeatherBlockMessage(globalScene.arena.getWeatherType()); + this.failed = true; + } else { + // Powder *always* happens last + // Note: Powder's lapse method handles everything: messages, damage, animation, primal weather interaction, + // determining type of type changing moves, etc. + // It will set this phase's `failed` flag to true if it procs + user.lapseTag(BattlerTagType.POWDER, BattlerTagLapseType.PRE_MOVE); + return this.failed; + } + if (this.failed) { + this.showFailedText(failedText); } - // Powder *always* happens last - // Note: Powder's lapse method handles everything: messages, damage, animation, primal weather interaction, - // determining type of type changing moves, etc. - // It will set this phase's `failed` flag to true if it procs - user.lapseTag(BattlerTagType.POWDER, BattlerTagLapseType.PRE_MOVE); return this.failed; } @@ -229,10 +239,13 @@ export class MovePhase extends BattlePhase { * @returns Whether the move failed * * @remarks - * - Conditional attributes of the move + * - Anything in {@linkcode Move.conditionsSeq3} * - Weather blocking the move * - Terrain blocking the move * - Queenly Majesty / Dazzling + * - Damp (which is handled by move conditions in pokerogue rather than the ability, like queenly majesty / dazzling) + * + * The rest of the failure conditions are marked as sequence 4 and happen in the move effect phase. */ protected thirdFailureCheck(): boolean { /** @@ -244,9 +257,8 @@ export class MovePhase extends BattlePhase { const arena = globalScene.arena; const user = this.pokemon; const failsConditions = !move.applyConditions(user, targets[0]); - const failedDueToWeather = arena.isMoveWeatherCancelled(user, move); const failedDueToTerrain = arena.isMoveTerrainCancelled(user, this.targets, move); - let failed = failsConditions || failedDueToWeather || failedDueToTerrain; + let failed = failsConditions || failedDueToTerrain; // Apply queenly majesty / dazzling if (!failed) { @@ -264,7 +276,7 @@ export class MovePhase extends BattlePhase { } if (failed) { - this.failMove(true, failedDueToWeather, failedDueToTerrain); + this.failMove(failedDueToTerrain); return true; } @@ -308,17 +320,10 @@ export class MovePhase extends BattlePhase { return; } - // Now, issue the second failure checks - - // If the user was asleep but is using a move anyway, it should STILL display the "user is sleeping" message! - // At this point, cure the user's freeze - // At this point, called moves should be decided. - // For now, this is a placeholder until we rework how called moves are handled - // For correct alignment with mainline, this SHOULD go here, and it SHOULD rewrite its own move - // Though, this is not the case in pokerogue. + // For now, this comment works as a placeholder until we rework how called moves are handled + // For correct alignment with mainline, this SHOULD go here, and this phase SHOULD rewrite its own move - // At this point... // If the first failure check passes, then thaw the user if its move will thaw it. // The sleep message and animation are also played if the user is asleep but using a move anyway (snore, sleep talk, etc) this.post1stFailSleepOrThaw(); @@ -349,29 +354,36 @@ export class MovePhase extends BattlePhase { // Move is announced this.showMoveText(); - // Stance change happens const charging = this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING); - // Stance change happens now if the move is about to be executed + const move = this.move.getMove(); + + // Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer. + if (!move.hasAttr("CopyMoveAttr") && !isReflected(this.useMode)) { + globalScene.currentBattle.lastMove = move.id; + } + + // Stance change happens now if the move is about to be executed and is not a charging move if (!charging) { + this.usePP(); globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); } - if (this.secondFailureCheck()) { - this.showFailedText(); + // At this point, if the target index has not moved on from attacker, the move must fail + if (this.targets[0] === BattlerIndex.ATTACKER) { + this.fail(); + } + if (this.targets[0] === BattlerIndex.ATTACKER || this.secondFailureCheck()) { this.handlePreMoveFailures(); this.end(); return; } - if (!(this.failed || this.cancelled)) { - this.resolveFinalPreMoveCancellationChecks(); + if (this.resolveFinalPreMoveCancellationChecks()) { + this.end(); } - // Cancel, charge or use the move as applicable. - if (this.cancelled || this.failed) { - this.handlePreMoveFailures(); - } else if (charging) { + if (charging) { this.chargeMove(); } else { this.useMove(); @@ -380,20 +392,25 @@ export class MovePhase extends BattlePhase { this.end(); } - /** Check for cancellation edge cases - no targets remaining */ - protected resolveFinalPreMoveCancellationChecks(): void { + /** + * Check for cancellation edge cases - no targets remaining or the battler index being targeted is still the attacker + * @returns Whether the move fails + */ + protected resolveFinalPreMoveCancellationChecks(): boolean { const targets = this.getActiveTargetPokemon(); const moveQueue = this.pokemon.getMoveQueue(); if ( (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) || - (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) + (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) || + this.targets[0] === BattlerIndex.ATTACKER ) { this.showFailedText(); - this.cancel(); - } else { - this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + this.fail(); + return true; } + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + return false; } public getActiveTargetPokemon(): Pokemon[] { @@ -672,25 +689,23 @@ export class MovePhase extends BattlePhase { /** Execute the current move and apply its effects. */ private executeMove() { - const pokemon = this.pokemon; + const user = this.pokemon; const move = this.move.getMove(); const opponent = this.getActiveTargetPokemon()[0]; const targets = this.targets; // Trigger ability-based user type changes, display move text and then execute move effects. // TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter - applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon, move, opponent }); - this.showMoveText(); - globalScene.phaseManager.unshiftNew("MoveEffectPhase", pokemon.getBattlerIndex(), targets, move, this.useMode); + applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: user, move, opponent }); + globalScene.phaseManager.unshiftNew("MoveEffectPhase", user.getBattlerIndex(), targets, move, this.useMode); // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). // Note the MoveUseMode check here prevents an infinite Dancer loop. // TODO: This needs to go at the end of `MoveEffectPhase` to check move results const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const; if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) { - // biome-ignore lint/nursery/noShadow: We don't need to access `pokemon` from the outer scope globalScene.getField(true).forEach(pokemon => { - applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: pokemon, targets: targets }); + applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: user, targets: targets }); }); } } @@ -698,11 +713,9 @@ export class MovePhase extends BattlePhase { /** * Fail the move currently being used. * Handles failure messages, pushing to move history, etc. - * @param showText - Whether to show move text when failing the move. - * @param failedDueToWeather - Whether the move failed due to weather (default `false`) * @param failedDueToTerrain - Whether the move failed due to terrain (default `false`) */ - protected failMove(showText: boolean, failedDueToWeather = false, failedDueToTerrain = false) { + protected failMove(failedDueToTerrain = false) { const move = this.move.getMove(); const targets = this.getActiveTargetPokemon(); @@ -724,10 +737,6 @@ export class MovePhase extends BattlePhase { }); } - if (showText) { - this.showMoveText(); - } - this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, @@ -741,9 +750,7 @@ export class MovePhase extends BattlePhase { move.getFailedText(this.pokemon, targets[0], move) || (failedDueToTerrain ? getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType()) - : failedDueToWeather - ? getWeatherBlockMessage(globalScene.arena.getWeatherType()) - : i18next.t("battle:attackFailed")); + : i18next.t("battle:attackFailed")); this.showFailedText(failureMessage); @@ -760,7 +767,7 @@ export class MovePhase extends BattlePhase { const targets = this.getActiveTargetPokemon(); if (!move.applyConditions(this.pokemon, targets[0])) { - this.failMove(true); + this.failMove(); return; } @@ -888,34 +895,17 @@ export class MovePhase extends BattlePhase { * 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 - * move is marked for failure. - * @todo Make this a feature of the move rather than basing logic on {@linkcode BattlerIndex.ATTACKER} + * move is marked for failure */ protected resolveCounterAttackTarget(): void { if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) { return; } - // TODO: This should be covered in move conditions - if (this.pokemon.turnData.attacksReceived.length === 0) { - this.fail(); - this.showMoveText(); - this.showFailedText(); - return; - } + const targetHolder = new NumberHolder(BattlerIndex.ATTACKER); - this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex; - - // 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; - } + applyMoveAttrs("CounterRedirectAttr", this.pokemon, null, this.move.getMove(), targetHolder); + this.targets[0] = targetHolder.value; } /** @@ -937,10 +927,6 @@ export class MovePhase extends BattlePhase { return; } - if (this.failed) { - this.usePP(); - } - if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) { frenzyMissFunc(this.pokemon, this.move.getMove()); } diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index 8de0a3bfcf1..21c9531aadb 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene"; import { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters"; import { allSpecies } from "#data/data-lists"; import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species"; +import { BattlerIndex } from "#enums/battler-index"; import type { SpeciesId } from "#enums/species-id"; import { randSeedItem } from "./common"; @@ -123,3 +124,19 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po } return retSpecies; } + +/** + * Return whether two battler indices are allies + * @param a - First battler index + * @param b - Second battler index + * @returns Whether the two battler indices are allies. Always `false` if either index is `ATTACKER`. + */ +export function areAllies(a: BattlerIndex, b: BattlerIndex): boolean { + if (a === BattlerIndex.ATTACKER || b === BattlerIndex.ATTACKER) { + return false; + } + return ( + (a === BattlerIndex.PLAYER || a === BattlerIndex.PLAYER_2) === + (b === BattlerIndex.PLAYER || b === BattlerIndex.PLAYER_2) + ); +} diff --git a/test/moves/throat-chop.test.ts b/test/moves/throat-chop.test.ts index a4f090b2de1..335bfce2f40 100644 --- a/test/moves/throat-chop.test.ts +++ b/test/moves/throat-chop.test.ts @@ -35,6 +35,7 @@ describe("Moves - Throat Chop", () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const enemy = game.field.getEnemyPokemon(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.GROWL); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); @@ -46,6 +47,8 @@ describe("Moves - Throat Chop", () => { // Second turn, struggle if no valid moves await game.toNextTurn(); + expect(player.trySelectMove(MoveId.GROWL)[0]).toBe(false); + game.move.select(MoveId.GROWL); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);