diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index bde5f2977d8..9dacd392c6c 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1,3 +1,4 @@ +import type { BattlerTag } from "#data/battler-tags"; import { AbAttrParamsWithCancel, PreAttackModifyPowerAbAttrParams } from "#abilities/ability"; import { applyAbAttrs @@ -1306,10 +1307,8 @@ export class MoveEffectAttr extends MoveAttr { * @param args Set of unique arguments needed by this attribute * @returns true if basic application of the ability attribute should be possible */ - canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) { - return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp) - && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || - move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })); + canApply(user: Pokemon, target: Pokemon, move: Move, _args?: any[]) { + return !(this.selfTarget ? user : target).isFainted(); } /** Applies move effects so long as they are able based on {@linkcode canApply} */ @@ -5598,16 +5597,15 @@ export class AddBattlerTagAttr extends MoveEffectAttr { public tagType: BattlerTagType; public turnCountMin: number; public turnCountMax: number; - protected cancelOnFail: boolean; private failOnOverlap: boolean; - constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false) { - super(selfTarget, { lastHitOnly: lastHitOnly }); + constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap = false, turnCountMin = 0, turnCountMax = turnCountMin, lastHitOnly = false) { + super(selfTarget, { lastHitOnly }); this.tagType = tagType; this.turnCountMin = turnCountMin; - this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin; - this.failOnOverlap = !!failOnOverlap; + this.turnCountMax = turnCountMax; + this.failOnOverlap = failOnOverlap; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -5615,9 +5613,10 @@ export class AddBattlerTagAttr extends MoveEffectAttr { return false; } + // TODO: Do any moves actually use chance-based battler tag adding? const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); if (moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) { - return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id); + return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id); } return false; @@ -5825,45 +5824,25 @@ export class CurseAttr extends MoveEffectAttr { } } -export class LapseBattlerTagAttr extends MoveEffectAttr { - public tagTypes: BattlerTagType[]; - - constructor(tagTypes: BattlerTagType[], selfTarget: boolean = false) { - super(selfTarget); - - this.tagTypes = tagTypes; - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args)) { - return false; - } - - for (const tagType of this.tagTypes) { - (this.selfTarget ? user : target).lapseTag(tagType); - } - - return true; - } -} - +/** + * Attribute to remove all {@linkcode BattlerTag}s matching one or more tag types. + */ export class RemoveBattlerTagAttr extends MoveEffectAttr { - public tagTypes: BattlerTagType[]; + /** An array of {@linkcode BattlerTagType}s to clear. */ + public tagTypes: [BattlerTagType, ...BattlerTagType[]]; - constructor(tagTypes: BattlerTagType[], selfTarget: boolean = false) { + constructor(tagTypes: [BattlerTagType, ...BattlerTagType[]], selfTarget = false) { super(selfTarget); this.tagTypes = tagTypes; } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args)) { + apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean { + if (!super.apply(user, target, move, _args)) { return false; } - for (const tagType of this.tagTypes) { - (this.selfTarget ? user : target).removeTag(tagType); - } + (this.selfTarget ? user : target).findAndRemoveTags(t => this.tagTypes.includes(t.tagType)); return true; } @@ -5930,6 +5909,8 @@ export class ProtectAttr extends AddBattlerTagAttr { } } +// TODO: Attributes should not exist solely to display a message + export class IgnoreAccuracyAttr extends AddBattlerTagAttr { constructor() { super(BattlerTagType.IGNORE_ACCURACY, true, false, 2); @@ -6001,14 +5982,13 @@ export class RemoveAllSubstitutesAttr extends MoveEffectAttr { export class HitsTagAttr extends MoveAttr { /** The {@linkcode BattlerTagType} this move hits */ public tagType: BattlerTagType; - /** Should this move deal double damage against {@linkcode HitsTagAttr.tagType}? */ + /** Should this move deal double damage against {@linkcode this.tagType}? */ public doubleDamage: boolean; - constructor(tagType: BattlerTagType, doubleDamage: boolean = false) { + constructor(tagType: BattlerTagType) { super(); this.tagType = tagType; - this.doubleDamage = !!doubleDamage; } getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { @@ -6023,7 +6003,8 @@ export class HitsTagAttr extends MoveAttr { */ export class HitsTagForDoubleDamageAttr extends HitsTagAttr { constructor(tagType: BattlerTagType) { - super(tagType, true); + super(tagType); + this.doubleDamage = true; } } @@ -6033,11 +6014,11 @@ export class AddArenaTagAttr extends MoveEffectAttr { private failOnOverlap: boolean; public selfSideTarget: boolean; - constructor(tagType: ArenaTagType, turnCount?: number | null, failOnOverlap: boolean = false, selfSideTarget: boolean = false) { + constructor(tagType: ArenaTagType, turnCount = 0, failOnOverlap = false, selfSideTarget: boolean = false) { super(true); this.tagType = tagType; - this.turnCount = turnCount!; // TODO: is the bang correct? + this.turnCount = turnCount; this.failOnOverlap = failOnOverlap; this.selfSideTarget = selfSideTarget; } @@ -6047,6 +6028,7 @@ export class AddArenaTagAttr extends MoveEffectAttr { return false; } + // TODO: Why does this check effect chance if nothing uses it? if ((move.chance < 0 || move.chance === 100 || user.randBattleSeedInt(100) < move.chance) && user.getLastXMoves(1)[0]?.result === MoveResult.SUCCESS) { const side = ((this.selfSideTarget ? user : target).isPlayer() !== (move.hasAttr("AddArenaTrapTagAttr") && target === user)) ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; globalScene.arena.addTag(this.tagType, this.turnCount, move.id, user.id, side); @@ -6058,25 +6040,28 @@ export class AddArenaTagAttr extends MoveEffectAttr { getCondition(): MoveConditionFunc | null { return this.failOnOverlap - ? (user, target, move) => !globalScene.arena.getTagOnSide(this.tagType, target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY) + ? (_user, target, _move) => !globalScene.arena.getTagOnSide(this.tagType, target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY) : null; } } /** - * Generic class for removing arena tags - * @param tagTypes: The types of tags that can be removed - * @param selfSideTarget: Is the user removing tags from its own side? + * Attribute to remove one or more arena tags from the field. */ export class RemoveArenaTagsAttr extends MoveEffectAttr { - public tagTypes: ArenaTagType[]; - public selfSideTarget: boolean; + /** An array containing the tags to be removed. */ + private readonly tagTypes: readonly [ArenaTagType, ...ArenaTagType[]]; + /** + * Whether to remove tags from both sides of the field (`true`) or + * the target's side of the field (`false`); default `false` + */ + private removeAllTags: boolean - constructor(tagTypes: ArenaTagType[], selfSideTarget: boolean) { - super(true); + constructor(tagTypes: readonly [ArenaTagType, ...ArenaTagType[]], removeAllTags = false, options?: MoveEffectAttrOptions) { + super(true, options); this.tagTypes = tagTypes; - this.selfSideTarget = selfSideTarget; + this.removeAllTags = removeAllTags; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -6084,11 +6069,9 @@ export class RemoveArenaTagsAttr extends MoveEffectAttr { return false; } - const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + const side = this.removeAllTags ? ArenaTagSide.BOTH : target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - for (const tagType of this.tagTypes) { - globalScene.arena.removeTagOnSide(tagType, side); - } + globalScene.arena.removeTagsOnSide(this.tagTypes, side); return true; } @@ -6113,6 +6096,7 @@ export class AddArenaTrapTagAttr extends AddArenaTagAttr { * @extends AddArenaTagAttr * @see {@linkcode apply} */ +// TODO: This has exactly 1 line of code difference from the base attribute wrt. effect chances... export class AddArenaTrapTagHitAttr extends AddArenaTagAttr { /** * @param user {@linkcode Pokemon} using this move @@ -6134,80 +6118,37 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr { } } -export class RemoveArenaTrapAttr extends MoveEffectAttr { +// TODO: Review if we can remove these attributes +const arenaTrapTags = [ + ArenaTagType.SPIKES, + ArenaTagType.TOXIC_SPIKES, + ArenaTagType.STEALTH_ROCK, + ArenaTagType.STICKY_WEB, +] as const; - private targetBothSides: boolean; - - constructor(targetBothSides: boolean = false) { - super(true, { trigger: MoveEffectTrigger.PRE_APPLY }); - this.targetBothSides = targetBothSides; - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - - if (!super.apply(user, target, move, args)) { - return false; - } - - if (this.targetBothSides) { - globalScene.arena.removeTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.STEALTH_ROCK, ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER); - - globalScene.arena.removeTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.STEALTH_ROCK, ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.ENEMY); - } else { - globalScene.arena.removeTagOnSide(ArenaTagType.SPIKES, target.isPlayer() ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.TOXIC_SPIKES, target.isPlayer() ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.STEALTH_ROCK, target.isPlayer() ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, target.isPlayer() ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER); - } - - return true; +export class RemoveArenaTrapAttr extends RemoveArenaTagsAttr { + constructor(targetBothSides = false) { + // TODO: This triggers at a different time than {@linkcode RemoveArenaTagsAbAttr}... + super(arenaTrapTags, targetBothSides, { trigger: MoveEffectTrigger.PRE_APPLY }); } } -export class RemoveScreensAttr extends MoveEffectAttr { - - private targetBothSides: boolean; - - constructor(targetBothSides: boolean = false) { - super(true, { trigger: MoveEffectTrigger.PRE_APPLY }); - this.targetBothSides = targetBothSides; - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - - if (!super.apply(user, target, move, args)) { - return false; - } - - if (this.targetBothSides) { - globalScene.arena.removeTagOnSide(ArenaTagType.REFLECT, ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.LIGHT_SCREEN, ArenaTagSide.PLAYER); - globalScene.arena.removeTagOnSide(ArenaTagType.AURORA_VEIL, ArenaTagSide.PLAYER); - - globalScene.arena.removeTagOnSide(ArenaTagType.REFLECT, ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.LIGHT_SCREEN, ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.AURORA_VEIL, ArenaTagSide.ENEMY); - } else { - globalScene.arena.removeTagOnSide(ArenaTagType.REFLECT, target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.LIGHT_SCREEN, target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY); - globalScene.arena.removeTagOnSide(ArenaTagType.AURORA_VEIL, target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY); - } - - return true; +const screenTags = [ + ArenaTagType.REFLECT, + ArenaTagType.LIGHT_SCREEN, + ArenaTagType.AURORA_VEIL +] as const; +export class RemoveScreensAttr extends RemoveArenaTagsAttr { + constructor(targetBothSides = false) { + // TODO: This triggers at a different time than {@linkcode RemoveArenaTagsAbAttr}... + super(arenaTrapTags, targetBothSides, { trigger: MoveEffectTrigger.PRE_APPLY }); } } -/*Swaps arena effects between the player and enemy side - * @extends MoveEffectAttr - * @see {@linkcode apply} -*/ +/** + * Swaps arena effects between the player and enemy side + */ export class SwapArenaTagsAttr extends MoveEffectAttr { public SwapTags: ArenaTagType[]; @@ -6602,6 +6543,7 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr { return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move); } } + export class RemoveTypeAttr extends MoveEffectAttr { private removedType: PokemonType; @@ -8429,7 +8371,6 @@ const MoveAttrs = Object.freeze({ GulpMissileTagAttr, JawLockAttr, CurseAttr, - LapseBattlerTagAttr, RemoveBattlerTagAttr, FlinchAttr, ConfuseAttr, @@ -8993,7 +8934,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1) .reflectable(), new StatusMove(MoveId.MIND_READER, PokemonType.NORMAL, -1, 5, -1, 0, 2) - .attr(IgnoreAccuracyAttr), + .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2), new StatusMove(MoveId.NIGHTMARE, PokemonType.GHOST, 100, 15, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.NIGHTMARE) .condition(targetSleptOrComatoseCondition), @@ -9097,7 +9038,7 @@ export function initMoves() { .attr(MultiHitAttr) .makesContact(false), new StatusMove(MoveId.LOCK_ON, PokemonType.NORMAL, -1, 5, -1, 0, 2) - .attr(IgnoreAccuracyAttr), + .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2), new AttackMove(MoveId.OUTRAGE, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 10, -1, 0, 2) .attr(FrenzyAttr) .attr(MissEffectAttr, frenzyMissFunc) @@ -10418,7 +10359,7 @@ export function initMoves() { .target(MoveTarget.USER_AND_ALLIES) .condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ AbilityId.PLUS, AbilityId.MINUS ].find(a => p?.hasAbility(a, false)))), new StatusMove(MoveId.HAPPY_HOUR, PokemonType.NORMAL, -1, 30, -1, 0, 6) // No animation - .attr(AddArenaTagAttr, ArenaTagType.HAPPY_HOUR, null, true) + .attr(AddArenaTagAttr, ArenaTagType.HAPPY_HOUR, 0, true) .target(MoveTarget.USER_SIDE), new StatusMove(MoveId.ELECTRIC_TERRAIN, PokemonType.ELECTRIC, -1, 10, -1, 0, 6) .attr(TerrainChangeAttr, TerrainType.ELECTRIC) @@ -10601,7 +10542,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPD ], -1) .reflectable(), new SelfStatusMove(MoveId.LASER_FOCUS, PokemonType.NORMAL, -1, 30, -1, 0, 7) - .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false), + .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false, 2), new StatusMove(MoveId.GEAR_UP, PokemonType.STEEL, -1, 20, -1, 0, 7) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => !![ AbilityId.PLUS, AbilityId.MINUS ].find(a => target.hasAbility(a, false)) }) .ignoresSubstitute() @@ -10690,7 +10631,7 @@ export function initMoves() { new AttackMove(MoveId.MALICIOUS_MOONSAULT, PokemonType.DARK, MoveCategory.PHYSICAL, 180, -1, 1, -1, 0, 7) .unimplemented() .attr(AlwaysHitMinimizeAttr) - .attr(HitsTagAttr, BattlerTagType.MINIMIZED, true) + .attr(HitsTagForDoubleDamageAttr, BattlerTagType.MINIMIZED) .edgeCase(), // I assume it's because it needs darkest lariat and incineroar new AttackMove(MoveId.OCEANIC_OPERETTA, PokemonType.WATER, MoveCategory.SPECIAL, 195, -1, 1, -1, 0, 7) .unimplemented() @@ -11318,6 +11259,7 @@ export function initMoves() { .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) => { + // TODO: This is really really really janky return !(target.getTag(BattlerTagType.PROTECTED)?.tagType === "PROTECTED" || globalScene.arena.getTag(ArenaTagType.MAT_BLOCK)?.tagType === "MAT_BLOCK"); }), new StatusMove(MoveId.REVIVAL_BLESSING, PokemonType.NORMAL, -1, 1, -1, 0, 9) @@ -11330,7 +11272,7 @@ export function initMoves() { new AttackMove(MoveId.TRIPLE_DIVE, PokemonType.WATER, MoveCategory.PHYSICAL, 30, 95, 10, -1, 0, 9) .attr(MultiHitAttr, MultiHitType._3), new AttackMove(MoveId.MORTAL_SPIN, PokemonType.POISON, MoveCategory.PHYSICAL, 30, 100, 15, 100, 0, 9) - .attr(LapseBattlerTagAttr, [ + .attr(RemoveBattlerTagAttr, [ BattlerTagType.BIND, BattlerTagType.WRAP, BattlerTagType.FIRE_SPIN, diff --git a/src/field/arena.ts b/src/field/arena.ts index 484450cc5df..2bdc01193fb 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -718,7 +718,7 @@ export class Arena { } // creates a new tag object - const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side); + const newTag = getArenaTag(tagType, turnCount, sourceMove, sourceId, side); if (newTag) { newTag.onAdd(this, quiet); this.tags.push(newTag); @@ -834,6 +834,36 @@ export class Arena { return !!tag; } + /** + * Find and remove all {@linkcode ArenaTag}s with the given tag types on the given side of the field. + * @param tagTypes - The {@linkcode ArenaTagType}s to remove + * @param side - The {@linkcode ArenaTagSide} to remove the tags from (for side-based tags), or {@linkcode ArenaTagSide.BOTH} + * to clear all tags on either side of the field + * @param quiet - Whether to suppress removal messages from currently-present tags; default `false` + * @todo Review the other tag manipulation functions to see if they can be migrated towards using this (more efficient) + */ + public removeTagsOnSide( + tagTypes: ArenaTagType[] | ReadonlyArray, + side: ArenaTagSide, + quiet = false, + ): void { + const leftoverTags: ArenaTag[] = []; + for (const tag of this.tags) { + // Skip tags of different types or on the wrong side of the field + if ( + !tagTypes.includes(tag.tagType) || + !(side === ArenaTagSide.BOTH || tag.side === ArenaTagSide.BOTH || tag.side === side) + ) { + leftoverTags.push(tag); + continue; + } + + tag.onRemove(this, quiet); + } + + this.tags = leftoverTags; + } + removeAllTags(): void { while (this.tags.length) { this.tags[0].onRemove(this); diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index c57e0f6cead..99dda6d4bec 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -745,7 +745,7 @@ export class MoveEffectPhase extends PokemonPhase { /** * Applies all move effects that trigger in the event of a successful hit: * - * - {@linkcode MoveEffectTrigger.PRE_APPLY | PRE_APPLY} effects` + * - {@linkcode MoveEffectTrigger.PRE_APPLY | PRE_APPLY} effects * - Applying damage to the target * - {@linkcode MoveEffectTrigger.POST_APPLY | POST_APPLY} effects * - Invoking {@linkcode applyOnTargetEffects} if the move does not hit a substitute diff --git a/test/moves/tidy-up.test.ts b/test/moves/tidy-up.test.ts index 8dd74e4ab78..56c0f338565 100644 --- a/test/moves/tidy-up.test.ts +++ b/test/moves/tidy-up.test.ts @@ -1,12 +1,13 @@ -import { SubstituteTag } from "#data/battler-tags"; import { AbilityId } from "#enums/ability-id"; +import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { MoveEndPhase } from "#phases/move-end-phase"; -import { TurnEndPhase } from "#phases/turn-end-phase"; import { GameManager } from "#test/test-utils/game-manager"; +import type { ArenaTrapTagType } from "#types/arena-tags"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -31,86 +32,65 @@ describe("Moves - Tidy Up", () => { .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.BALL_FETCH) .enemyMoveset(MoveId.SPLASH) - .starterSpecies(SpeciesId.FEEBAS) - .ability(AbilityId.BALL_FETCH) - .moveset([MoveId.TIDY_UP]) - .startingLevel(50); + .ability(AbilityId.BALL_FETCH); }); - it("spikes are cleared", async () => { - game.override.moveset([MoveId.SPIKES, MoveId.TIDY_UP]).enemyMoveset(MoveId.SPIKES); - await game.classicMode.startBattle(); + it.each<{ name: string; tagType: ArenaTrapTagType }>([ + { name: "Spikes", tagType: ArenaTagType.SPIKES }, + { name: "Toxic Spikes", tagType: ArenaTagType.TOXIC_SPIKES }, + { name: "Stealth Rock", tagType: ArenaTagType.STEALTH_ROCK }, + { name: "Sticky Web", tagType: ArenaTagType.STICKY_WEB }, + ])("should remove $name from both sides of the field", async ({ tagType }) => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.SPIKES); - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.TIDY_UP); - await game.phaseInterceptor.to(MoveEndPhase); - expect(game.scene.arena.getTag(ArenaTagType.SPIKES)).toBeUndefined(); + // Add tag to both sides of the field + game.scene.arena.addTag(tagType, 1, undefined, game.field.getPlayerPokemon().id, ArenaTagSide.PLAYER); + game.scene.arena.addTag(tagType, 1, undefined, game.field.getPlayerPokemon().id, ArenaTagSide.ENEMY); + + expect(game.scene.arena.getTag(tagType)).toBeDefined(); + + game.move.use(MoveId.TIDY_UP); + await game.toEndOfTurn(); + + expect(game.scene.arena.getTag(tagType)).toBeUndefined(); }); - it("stealth rocks are cleared", async () => { - game.override.moveset([MoveId.STEALTH_ROCK, MoveId.TIDY_UP]).enemyMoveset(MoveId.STEALTH_ROCK); - await game.classicMode.startBattle(); + it("should clear substitutes from all pokemon", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.CINCCINO, SpeciesId.FEEBAS]); - game.move.select(MoveId.STEALTH_ROCK); - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.TIDY_UP); - await game.phaseInterceptor.to(MoveEndPhase); - expect(game.scene.arena.getTag(ArenaTagType.STEALTH_ROCK)).toBeUndefined(); - }); + game.move.use(MoveId.SUBSTITUTE, BattlerIndex.PLAYER); + game.move.use(MoveId.SUBSTITUTE, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SUBSTITUTE); + await game.move.forceEnemyMove(MoveId.SUBSTITUTE); + await game.toNextTurn(); - it("toxic spikes are cleared", async () => { - game.override.moveset([MoveId.TOXIC_SPIKES, MoveId.TIDY_UP]).enemyMoveset(MoveId.TOXIC_SPIKES); - await game.classicMode.startBattle(); + game.scene.getField(true).forEach(p => { + expect(p).toHaveBattlerTag(BattlerTagType.SUBSTITUTE); + }); - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.TIDY_UP); - await game.phaseInterceptor.to(MoveEndPhase); - expect(game.scene.arena.getTag(ArenaTagType.TOXIC_SPIKES)).toBeUndefined(); - }); + game.move.use(MoveId.TIDY_UP, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); - it("sticky webs are cleared", async () => { - game.override.moveset([MoveId.STICKY_WEB, MoveId.TIDY_UP]).enemyMoveset(MoveId.STICKY_WEB); - - await game.classicMode.startBattle(); - - game.move.select(MoveId.STICKY_WEB); - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.TIDY_UP); - await game.phaseInterceptor.to(MoveEndPhase); - expect(game.scene.arena.getTag(ArenaTagType.STICKY_WEB)).toBeUndefined(); - }); - - it("substitutes are cleared", async () => { - game.override.moveset([MoveId.SUBSTITUTE, MoveId.TIDY_UP]).enemyMoveset(MoveId.SUBSTITUTE); - - await game.classicMode.startBattle(); - - game.move.select(MoveId.SUBSTITUTE); - await game.phaseInterceptor.to(TurnEndPhase); - game.move.select(MoveId.TIDY_UP); - await game.phaseInterceptor.to(MoveEndPhase); - - const pokemon = [game.scene.getPlayerPokemon()!, game.scene.getEnemyPokemon()!]; - pokemon.forEach(p => { - expect(p).toBeDefined(); - expect(p!.getTag(SubstituteTag)).toBeUndefined(); + game.scene.getField(true).forEach(p => { + expect(p).not.toHaveBattlerTag(BattlerTagType.SUBSTITUTE); }); }); - it("user's stats are raised with no traps set", async () => { - await game.classicMode.startBattle(); + it("should raise the user's stats even if a tag cannot be removed", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const playerPokemon = game.scene.getPlayerPokemon()!; + const feebas = game.field.getPlayerPokemon(); + expect(feebas).toHaveStatStage(Stat.ATK, 0); + expect(feebas).toHaveStatStage(Stat.SPD, 0); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(0); - expect(playerPokemon.getStatStage(Stat.SPD)).toBe(0); + game.move.use(MoveId.TIDY_UP); + await game.toEndOfTurn(); - game.move.select(MoveId.TIDY_UP); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(1); - expect(playerPokemon.getStatStage(Stat.SPD)).toBe(1); + expect(feebas).toHaveStatStage(Stat.ATK, 1); + expect(feebas).toHaveStatStage(Stat.SPD, 1); }); });