From 687a28e85fa2381002a0d95715d12e65d4fb8a3a Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 3 Aug 2025 13:51:49 -0400 Subject: [PATCH] Cleaned up entry hazard arena tags; merged tests into 1 file --- src/data/arena-tag.ts | 667 +++++++++++++++---------------- test/moves/entry-hazards.test.ts | 234 +++++++++++ test/moves/spikes.test.ts | 99 ----- test/moves/toxic-spikes.test.ts | 143 ------- 4 files changed, 562 insertions(+), 581 deletions(-) create mode 100644 test/moves/entry-hazards.test.ts delete mode 100644 test/moves/spikes.test.ts delete mode 100644 test/moves/toxic-spikes.test.ts diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 15c2cde1d58..a2efc1ba067 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -28,7 +28,7 @@ import type { SerializableArenaTagType, } from "#types/arena-tags"; import type { Mutable } from "#types/type-helpers"; -import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common"; +import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common"; import i18next from "i18next"; /** @@ -725,83 +725,36 @@ export class IonDelugeTag extends ArenaTag { } /** - * Abstract class to implement arena traps. + * Abstract class to implement [arena traps (AKA entry hazards)](https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards). + * These persistent tags remain on-field across turns and apply effects to any {@linkcode Pokemon} switching in. \ + * Uniquely, adding a tag multiple times will stack multiple "layers" of the effect, increasing its severity. */ export abstract class ArenaTrapTag extends SerializableArenaTag { abstract readonly tagType: ArenaTrapTagType; - public layers: number; - public maxLayers: number; - /** - * Creates a new instance of the ArenaTrapTag class. - * - * @param tagType - The type of the arena tag. - * @param sourceMove - The move that created the tag. - * @param sourceId - The ID of the source of the tag. - * @param side - The side (player or enemy) the tag affects. - * @param maxLayers - The maximum amount of layers this tag can have. + * The current number of layers this tag has. + * Starts at 1 and increases each time the trap is laid. */ - constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide, maxLayers: number) { + public layers = 1; + /** The maximum number of layers this tag can have. */ + public abstract get maxLayers(): number; + /** Whether this tag should only affect grounded targets; default `true` */ + protected get groundedOnly(): boolean { + return true; + } + + constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide) { super(0, sourceMove, sourceId, side); - - this.layers = 1; - this.maxLayers = maxLayers; } - onOverlap(arena: Arena, _source: Pokemon | null): void { - if (this.layers < this.maxLayers) { - this.layers++; - - this.onAdd(arena); - } - } + // TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts /** - * Activates the hazard effect onto a Pokemon when it enters the field - * @param _arena the {@linkcode Arena} containing this tag - * @param simulated if `true`, only checks if the hazard would activate. - * @param pokemon the {@linkcode Pokemon} triggering this hazard - * @returns `true` if this hazard affects the given Pokemon; `false` otherwise. + * Display text when this tag is added to the field. + * @param _arena - The {@linkcode Arena} at the time of adding this tag + * @param quiet - Whether to suppress messages during tag creation; default `false` */ - override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean { - if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) { - return false; - } - - return this.activateTrap(pokemon, simulated); - } - - activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean { - return false; - } - - getMatchupScoreMultiplier(pokemon: Pokemon): number { - return pokemon.isGrounded() - ? 1 - : Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2); - } - - public loadTag(source: BaseArenaTag & Pick): void { - super.loadTag(source); - this.layers = source.layers; - this.maxLayers = source.maxLayers; - } -} - -/** - * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}. - * Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP - * in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap. - */ -class SpikesTag extends ArenaTrapTag { - public readonly tagType = ArenaTagType.SPIKES; - constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.SPIKES, sourceId, side, 3); - } - - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); - + override onAdd(_arena: Arena, quiet = false): void { // We assume `quiet=true` means "just add the bloody tag no questions asked" if (quiet) { return; @@ -809,113 +762,251 @@ class SpikesTag extends ArenaTrapTag { const source = this.getSourcePokemon(); if (!source) { - console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`); + console.warn( + "Failed to get source Pokemon for AernaTrapTag on add message!" + + `\nTag type: ${this.tagType}` + + `\nPID: ${this.sourceId}`, + ); return; } - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:spikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + globalScene.phaseManager.queueMessage(this.getAddMessage(source)); } - override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { - if (!pokemon.isGrounded()) { + /** + * Return the text to be displayed upon adding a new layer to this trap. + * @param source - The {@linkcode Pokemon} having created this tag + * @returns The localized message to be displayed on screen. + */ + protected abstract getAddMessage(source: Pokemon): string; + + /** + * Add a new layer to this tag upon overlap, triggering the tag's normal {@linkcode onAdd} effects upon doing so. + * @param arena - The {@linkcode arena} at the time of adding the tag + */ + override onOverlap(arena: Arena): void { + if (this.layers >= this.maxLayers) { + return; + } + this.layers++; + + this.onAdd(arena); + } + + /** + * Activate the hazard effect onto a Pokemon when it enters the field. + * @param _arena - The {@linkcode Arena} at the time of tag activation + * @param simulated - Whether to suppress activation effects during execution + * @param pokemon - The {@linkcode Pokemon} triggering this hazard + * @returns `true` if this hazard affects the given Pokemon; `false` otherwise. + */ + override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean { + if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) { return false; } - const cancelled = new BooleanHolder(false); - applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled }); - if (simulated || cancelled.value) { - return !cancelled.value; + if (this.groundedOnly && !pokemon.isGrounded()) { + return false; } - const damageHpRatio = 1 / (10 - 2 * this.layers); - const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); + return this.activateTrap(pokemon, simulated); + } - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:spikesActivateTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - ); - pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT }); - pokemon.turnData.damageTaken += damage; - return true; + /** + * Activate this trap's effects when a Pokemon switches into it. + * @param _pokemon - The {@linkcode Pokemon} + * @param _simulated - Whether the activation is simulated + * @returns Whether the trap activation succeeded + * @todo Do we need the return value? nothing uses it + */ + protected abstract activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean; + + getMatchupScoreMultiplier(pokemon: Pokemon): number { + return pokemon.isGrounded() + ? 1 + : Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2); + } + + public loadTag(source: BaseArenaTag & Pick): void { + super.loadTag(source); + this.layers = source.layers; } } /** - * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) Toxic Spikes}. - * Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon who is - * summoned into this trap if 1 or 2 layers of Toxic Spikes respectively are up. Poison-type - * Pokémon summoned into this trap remove it entirely. + * Abstract class to implement damaging entry hazards. + * Currently used for {@linkcode SpikesTag} and {@linkcode StealthRockTag}. */ -class ToxicSpikesTag extends ArenaTrapTag { - #neutralized: boolean; - public readonly tagType = ArenaTagType.TOXIC_SPIKES; +abstract class DamagingTrapTag extends ArenaTrapTag { + override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { + // Check for magic guard immunity + const cancelled = new BooleanHolder(false); + applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled }); + if (cancelled.value) { + return false; + } + + if (simulated) { + return true; + } + + // Damage the target and trigger a message + const damageHpRatio = this.getDamageHpRatio(pokemon); + const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); + + globalScene.phaseManager.queueMessage(this.getTriggerMessage(pokemon)); + pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT }); + pokemon.turnData.damageTaken += damage; + return true; + } + + /** + * Return the text to be displayed when this tag deals damage. + * @param _pokemon - The {@linkcode Pokemon} switching in + * @returns The localized trigger message to be displayed on-screen. + */ + protected abstract getTriggerMessage(_pokemon: Pokemon): string; + + /** + * Return the amount of damage this tag should deal to the given Pokemon, relative to its maximum HP. + * @param _pokemon - The {@linkcode Pokemon} switching in + * @returns The percentage of max HP to deal upon activation. + */ + protected abstract getDamageHpRatio(_pokemon: Pokemon): number; +} + +/** + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}. + * Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP + * in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap. + */ +class SpikesTag extends DamagingTrapTag { + public readonly tagType = ArenaTagType.SPIKES; + override get maxLayers() { + return 3 as const; + } constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.TOXIC_SPIKES, sourceId, side, 2); - this.#neutralized = false; + super(MoveId.SPIKES, sourceId, side); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); - - if (quiet) { - // We assume `quiet=true` means "just add the bloody tag no questions asked" - return; - } - - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for ToxicSpikesTag on add message; id: ${this.sourceId}`); - return; - } - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:toxicSpikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + protected override getAddMessage(source: Pokemon): string { + return i18next.t("arenaTag:spikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }); } - onRemove(arena: Arena): void { + protected override getTriggerMessage(pokemon: Pokemon): string { + return i18next.t("arenaTag:spikesActivateTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }); + } + + protected override getDamageHpRatio(_pokemon: Pokemon): number { + // 1/8 for 1 layer, 1/6 for 2, 1/4 for 3 + return 1 / (10 - 2 * this.layers); + } +} + +/** + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) | Stealth Rock}. + * Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon + * who is summoned into the trap based on the Rock type's type effectiveness. + */ +class StealthRockTag extends DamagingTrapTag { + public readonly tagType = ArenaTagType.STEALTH_ROCK; + public override get maxLayers() { + return 1 as const; + } + protected override get groundedOnly() { + return false; + } + + constructor(sourceId: number | undefined, side: ArenaTagSide) { + super(MoveId.STEALTH_ROCK, sourceId, side); + } + + protected override getAddMessage(source: Pokemon): string { + return i18next.t("arenaTag:stealthRockOnAdd", { + opponentDesc: source.getOpponentDescriptor(), + }); + } + + protected override getTriggerMessage(pokemon: Pokemon): string { + return i18next.t("arenaTag:stealthRockActivateTrap", { + pokemonName: getPokemonNameWithAffix(pokemon), + }); + } + + protected override getDamageHpRatio(pokemon: Pokemon): number { + const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true); + return 0.125 * effectiveness; + } + + getMatchupScoreMultiplier(pokemon: Pokemon): number { + const damageHpRatio = this.getDamageHpRatio(pokemon); + return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio)); + } +} + +/** + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) | Toxic Spikes}. + * Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon switched in + * based on the current layer count. \ + * Poison-type Pokémon will remove it entirely upon switch-in. + */ +class ToxicSpikesTag extends ArenaTrapTag { + /** + * Whether the tag is currently in the process of being neutralized by a Poison-type. + * @defaultValue `false` + */ + #neutralized = false; + public readonly tagType = ArenaTagType.TOXIC_SPIKES; + override get maxLayers() { + return 2 as const; + } + + constructor(sourceId: number | undefined, side: ArenaTagSide) { + super(MoveId.TOXIC_SPIKES, sourceId, side); + } + + protected override getAddMessage(source: Pokemon): string { + return i18next.t("arenaTag:toxicSpikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }); + } + + // Override remove function to only display text when not neutralized + override onRemove(arena: Arena): void { if (!this.#neutralized) { super.onRemove(arena); } } override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { - if (pokemon.isGrounded()) { - if (simulated) { - return true; - } - if (pokemon.isOfType(PokemonType.POISON)) { - this.#neutralized = true; - if (globalScene.arena.removeTag(this.tagType)) { - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - moveName: this.getMoveName(), - }), - ); - return true; - } - } else if (!pokemon.status) { - const toxic = this.layers > 1; - if ( - pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName()) - ) { - return true; - } - } + if (simulated) { + return true; } - return false; + if (pokemon.isOfType(PokemonType.POISON)) { + // Neutralize the tag and remove it from the field. + // Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon + this.#neutralized = true; + globalScene.arena.removeTagOnSide(this.tagType, this.side); + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + moveName: this.getMoveName(), + }), + ); + return true; + } + + // Attempt to poison the target, suppressing any status effect messages + const effect = this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC; + return pokemon.trySetStatus(effect, true, null, 0, this.getMoveName(), false, true); } getMatchupScoreMultiplier(pokemon: Pokemon): number { @@ -930,71 +1021,37 @@ class ToxicSpikesTag extends ArenaTrapTag { } /** - * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) Stealth Rock}. - * Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon - * who is summoned into the trap, based on the Rock type's type effectiveness. + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) | Sticky Web}. + * Applies a single-layer trap that lowers the Speed of all grounded Pokémon switching in. */ -class StealthRockTag extends ArenaTrapTag { - public readonly tagType = ArenaTagType.STEALTH_ROCK; +class StickyWebTag extends ArenaTrapTag { + public readonly tagType = ArenaTagType.STICKY_WEB; + public override get maxLayers() { + return 1 as const; + } + constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.STEALTH_ROCK, sourceId, side, 1); + super(MoveId.STICKY_WEB, sourceId, side); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); - - if (quiet) { - return; - } - - const source = this.getSourcePokemon(); - if (!quiet && source) { - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:stealthRockOnAdd", { - opponentDesc: source.getOpponentDescriptor(), - }), - ); - } - } - - getDamageHpRatio(pokemon: Pokemon): number { - const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true); - - let damageHpRatio = 0; - - switch (effectiveness) { - case 0: - damageHpRatio = 0; - break; - case 0.25: - damageHpRatio = 0.03125; - break; - case 0.5: - damageHpRatio = 0.0625; - break; - case 1: - damageHpRatio = 0.125; - break; - case 2: - damageHpRatio = 0.25; - break; - case 4: - damageHpRatio = 0.5; - break; - } - - return damageHpRatio; + protected override getAddMessage(source: Pokemon): string { + return i18next.t("arenaTag:stickyWebOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }); } override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { const cancelled = new BooleanHolder(false); - applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled }); - if (cancelled.value) { - return false; - } + // TODO: Does this need to pass `simulated` as a parameter? + applyAbAttrs("ProtectStatAbAttr", { + pokemon, + cancelled, + stat: Stat.SPD, + stages: -1, + }); - const damageHpRatio = this.getDamageHpRatio(pokemon); - if (!damageHpRatio) { + if (cancelled.value) { return false; } @@ -1002,95 +1059,96 @@ class StealthRockTag extends ArenaTrapTag { return true; } - const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:stealthRockActivateTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + i18next.t("arenaTag:stickyWebActivateTrap", { + pokemonName: pokemon.getNameToRender(), }), ); - pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT }); - pokemon.turnData.damageTaken += damage; - return true; - } - getMatchupScoreMultiplier(pokemon: Pokemon): number { - const damageHpRatio = this.getDamageHpRatio(pokemon); - return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio)); + globalScene.phaseManager.unshiftNew( + "StatStageChangePhase", + pokemon.getBattlerIndex(), + false, + [Stat.SPD], + -1, + true, + false, + true, + null, + false, + true, + ); + return true; } } /** - * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) Sticky Web}. - * Applies up to 1 layer of Sticky Web, which lowers the Speed by one stage - * to any Pokémon who is summoned into this trap. + * This arena tag facilitates the application of the move Imprison + * Imprison remains in effect as long as the source Pokemon is active and present on the field. + * Imprison will apply to any opposing Pokemon that switch onto the field as well. */ -class StickyWebTag extends ArenaTrapTag { - public readonly tagType = ArenaTagType.STICKY_WEB; +class ImprisonTag extends ArenaTrapTag { + public readonly tagType = ArenaTagType.IMPRISON; + public override get maxLayers() { + return 1 as const; + } + constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.STICKY_WEB, sourceId, side, 1); + super(MoveId.IMPRISON, sourceId, side); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + /** + * Apply the effects of Imprison to all opposing on-field Pokemon. + */ + override onAdd(_arena: Arena, quiet = false) { + super.onAdd(_arena, quiet); - // We assume `quiet=true` means "just add the bloody tag no questions asked" - if (quiet) { - return; - } + const party = this.getAffectedPokemon(); + party.forEach(p => { + if (p.isAllowedInBattle()) { + p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId); + } + }); + } + protected override getAddMessage(source: Pokemon): string { + return i18next.t("battlerTags:imprisonOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(source), + }); + } + + /** + * Checks if the source Pokemon is still active on the field + * @param _arena + * @returns `true` if the source of the tag is still active on the field | `false` if not + */ + override lapse(): boolean { const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`); - return; - } - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:stickyWebOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + return !!source?.isActive(true); } - override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { - if (pokemon.isGrounded()) { - const cancelled = new BooleanHolder(false); - applyAbAttrs("ProtectStatAbAttr", { - pokemon, - cancelled, - stat: Stat.SPD, - stages: -1, - }); - - if (simulated) { - return !cancelled.value; - } - - if (!cancelled.value) { - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:stickyWebActivateTrap", { - pokemonName: pokemon.getNameToRender(), - }), - ); - const stages = new NumberHolder(-1); - globalScene.phaseManager.unshiftNew( - "StatStageChangePhase", - pokemon.getBattlerIndex(), - false, - [Stat.SPD], - stages.value, - true, - false, - true, - null, - false, - true, - ); - return true; - } + /** + * This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active + * @param {Pokemon} pokemon the Pokemon Imprison is applied to + * @returns `true` + */ + override activateTrap(pokemon: Pokemon): boolean { + const source = this.getSourcePokemon(); + if (source?.isActive(true) && pokemon.isAllowedInBattle()) { + pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId); } + return true; + } - return false; + /** + * When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon + * @param arena + */ + override onRemove(): void { + const party = this.getAffectedPokemon(); + party.forEach(p => { + p.removeTag(BattlerTagType.IMPRISON); + }); } } @@ -1287,75 +1345,6 @@ class NoneTag extends ArenaTag { } } -/** - * This arena tag facilitates the application of the move Imprison - * Imprison remains in effect as long as the source Pokemon is active and present on the field. - * Imprison will apply to any opposing Pokemon that switch onto the field as well. - */ -class ImprisonTag extends ArenaTrapTag { - public readonly tagType = ArenaTagType.IMPRISON; - constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.IMPRISON, sourceId, side, 1); - } - - /** - * Apply the effects of Imprison to all opposing on-field Pokemon. - */ - override onAdd() { - const source = this.getSourcePokemon(); - if (!source) { - return; - } - - const party = this.getAffectedPokemon(); - party.forEach(p => { - if (p.isAllowedInBattle()) { - p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId); - } - }); - - globalScene.phaseManager.queueMessage( - i18next.t("battlerTags:imprisonOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); - } - - /** - * Checks if the source Pokemon is still active on the field - * @param _arena - * @returns `true` if the source of the tag is still active on the field | `false` if not - */ - override lapse(): boolean { - const source = this.getSourcePokemon(); - return !!source?.isActive(true); - } - - /** - * This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active - * @param {Pokemon} pokemon the Pokemon Imprison is applied to - * @returns `true` - */ - override activateTrap(pokemon: Pokemon): boolean { - const source = this.getSourcePokemon(); - if (source?.isActive(true) && pokemon.isAllowedInBattle()) { - pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId); - } - return true; - } - - /** - * When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon - * @param arena - */ - override onRemove(): void { - const party = this.getAffectedPokemon(); - party.forEach(p => { - p.removeTag(BattlerTagType.IMPRISON); - }); - } -} - /** * Arena Tag implementing the "sea of fire" effect from the combination * of {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge} diff --git a/test/moves/entry-hazards.test.ts b/test/moves/entry-hazards.test.ts new file mode 100644 index 00000000000..d546e6176d1 --- /dev/null +++ b/test/moves/entry-hazards.test.ts @@ -0,0 +1,234 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { ArenaTrapTag } from "#data/arena-tag"; +import { allMoves } from "#data/data-lists"; +import type { TypeDamageMultiplier } from "#data/type"; +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 { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { PokemonType } from "#enums/pokemon-type"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import { GameManager } from "#test/test-utils/game-manager"; +import type { ArenaTrapTagType } from "#types/arena-tags"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Entry Hazards", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.BLISSEY) + .startingLevel(100) + .enemyLevel(100) + .enemyAbility(AbilityId.BALL_FETCH) + .ability(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .battleType(BattleType.TRAINER); + }); + + describe.each<{ name: string; move: MoveId; tagType: ArenaTrapTagType }>([ + { name: "Spikes", move: MoveId.SPIKES, tagType: ArenaTagType.SPIKES }, + { + name: "Toxic Spikes", + move: MoveId.TOXIC_SPIKES, + tagType: ArenaTagType.TOXIC_SPIKES, + }, + { + name: "Stealth Rock", + move: MoveId.STEALTH_ROCK, + tagType: ArenaTagType.STEALTH_ROCK, + }, + { + name: "Sticky Web", + move: MoveId.STICKY_WEB, + tagType: ArenaTagType.STICKY_WEB, + }, + ])("General checks - $name", ({ move, tagType }) => { + it("should add a persistent tag to the opposing side of the field", async () => { + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + + expect(game).not.toHaveArenaTag(tagType); + + game.move.use(move); + await game.toNextTurn(); + + // Tag should've been added to the opposing side of the field + expect(game).not.toHaveArenaTag(tagType, ArenaTagSide.PLAYER); + expect(game).toHaveArenaTag(tagType, ArenaTagSide.ENEMY); + }); + + // TODO: re-enable after re-fixing hazards moves + it.todo("should work when all targets fainted", async () => { + game.override.enemySpecies(SpeciesId.DIGLETT).battleStyle("double").startingLevel(1000); + await game.classicMode.startBattle([SpeciesId.RAYQUAZA, SpeciesId.SHUCKLE]); + + const [enemy1, enemy2] = game.scene.getEnemyField(); + + game.move.use(MoveId.HYPER_VOICE, BattlerIndex.PLAYER); + game.move.use(MoveId.SPIKES, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + expect(enemy1.isFainted()).toBe(true); + expect(enemy2.isFainted()).toBe(true); + expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeDefined(); + }); + + const maxLayers = tagType === ArenaTagType.SPIKES ? 3 : tagType === ArenaTagType.TOXIC_SPIKES ? 2 : 1; + const msgText = + maxLayers === 1 + ? "should fail if added while already present" + : `can be added up to ${maxLayers} times in a row before failing`; + + it(msgText, async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + + // set up hazards until at max layers + for (let i = 0; i < maxLayers; i++) { + game.move.use(move); + await game.toNextTurn(); + + expect(feebas).toHaveUsedMove({ move, result: MoveResult.SUCCESS }); + expect(game).toHaveArenaTag({ tagType, side: ArenaTagSide.ENEMY, layers: i + 1 }); + } + + game.move.use(move); + await game.toNextTurn(); + + expect(feebas).toHaveUsedMove({ move, result: MoveResult.FAIL }); + expect(game).toHaveArenaTag({ tagType, side: ArenaTagSide.ENEMY, layers: maxLayers }); + }); + }); + + describe("Spikes", () => { + it.each<{ layers: number; damage: number }>([ + { layers: 1, damage: 12.5 }, + { layers: 2, damage: 100 / 6 }, + { layers: 3, damage: 25 }, + ])("should play message and deal $damage% of the target's max HP at $layers", async ({ layers, damage }) => { + for (let i = 0; i < layers; i++) { + game.scene.arena.addTag(ArenaTagType.SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY); + } + + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toHaveTakenDamage((enemy.getMaxHp() * damage) / 100); + expect(game.textInterceptor.logs).toContain( + i18next.t("arenaTag:spikesActivateTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + }), + ); + }); + }); + + describe("Toxic Spikes", () => { + it.each<{ name: string; layers: number; status: StatusEffect }>([ + { name: "Poison", layers: 1, status: StatusEffect.POISON }, + { name: "Toxic", layers: 2, status: StatusEffect.TOXIC }, + ])("should apply $name at $layers without displaying neutralization msg", async ({ layers, status }) => { + for (let i = 0; i < layers; i++) { + game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY); + } + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toHaveStatusEffect(status); + // shoudl + expect(game.textInterceptor.logs).not.toContain( + i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + moveName: allMoves[MoveId.TOXIC_SPIKES].name, + }), + ); + }); + }); + + it("should be removed without triggering upon a grounded Poison-type switching in", async () => { + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]); + + game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY); + + game.doSwitchPokemon(1); + await game.toNextTurn(); + + const ekans = game.field.getPlayerPokemon(); + expect(game).not.toHaveArenaTag(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER); + expect(game.textInterceptor.logs).not.toContain( + i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { + pokemonNameWithAffix: getPokemonNameWithAffix(ekans), + moveName: allMoves[MoveId.TOXIC_SPIKES].name, + }), + ); + expect(ekans).not.toHaveStatusEffect(StatusEffect.POISON); + }); + + describe("Stealth Rock", () => { + it.each<{ multi: TypeDamageMultiplier; species: SpeciesId }>([ + { multi: 0.25, species: SpeciesId.LUCARIO }, + { multi: 0.5, species: SpeciesId.DURALUDON }, + { multi: 1, species: SpeciesId.LICKILICKY }, + { multi: 2, species: SpeciesId.DARMANITAN }, + { multi: 4, species: SpeciesId.ARTICUNO }, + ])("should deal damage based on the target's weakness to Rock - $multi", async ({ multi, species }) => { + game.override.enemySpecies(species); + game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 0, undefined, 0, ArenaTagSide.ENEMY); + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true)).toBe(multi); + expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi); + expect(game.textInterceptor.logs).toContain( + i18next.t("arenaTag:stealthRockActivateTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + }), + ); + }); + + it("should ignore strong winds for type effectiveness", async () => { + game.override.enemyAbility(AbilityId.DELTA_STREAM).enemySpecies(SpeciesId.RAYQUAZA); + game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 0, undefined, 0, ArenaTagSide.ENEMY); + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]); + + const rayquaza = game.field.getEnemyPokemon(); + // took 25% damage despite strong winds halving effectiveness + expect(rayquaza).toHaveTakenDamage(rayquaza.getMaxHp() * 0.25); + }); + }); + + describe("Sticky Web", () => { + it("should lower the target's speed by 1 stage on entry", async () => { + game.scene.arena.addTag(ArenaTagType.STICKY_WEB, 0, undefined, 0, ArenaTagSide.ENEMY); + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toHaveStatStage(Stat.SPD, -1); + expect(game.textInterceptor.logs).toContain( + i18next.t("arenaTag:stickyWebActivateTrap", { + pokemonName: enemy.getNameToRender(), + }), + ); + }); + }); +}); diff --git a/test/moves/spikes.test.ts b/test/moves/spikes.test.ts deleted file mode 100644 index 0055945cef9..00000000000 --- a/test/moves/spikes.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { ArenaTrapTag } from "#data/arena-tag"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { BattlerIndex } from "#enums/battler-index"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Spikes", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - game = new GameManager(phaserGame); - game.override - .battleStyle("single") - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .ability(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH) - .moveset([MoveId.SPIKES, MoveId.SPLASH, MoveId.ROAR]); - }); - - it("should not damage the team that set them", async () => { - await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - - game.move.select(MoveId.SPIKES); - await game.toNextTurn(); - - game.move.select(MoveId.SPLASH); - await game.toNextTurn(); - - game.doSwitchPokemon(1); - await game.toNextTurn(); - - game.doSwitchPokemon(1); - await game.toNextTurn(); - - const player = game.scene.getPlayerParty()[0]; - expect(player.hp).toBe(player.getMaxHp()); - }); - - it("should damage opposing pokemon that are forced to switch in", async () => { - game.override.startingWave(5); - await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - - game.move.select(MoveId.SPIKES); - await game.toNextTurn(); - - game.move.select(MoveId.ROAR); - await game.toNextTurn(); - - const enemy = game.scene.getEnemyParty()[0]; - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); - }); - - it("should damage opposing pokemon that choose to switch in", async () => { - game.override.startingWave(5); - await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - - game.move.select(MoveId.SPIKES); - await game.toNextTurn(); - - game.move.select(MoveId.SPLASH); - game.forceEnemyToSwitch(); - await game.toNextTurn(); - - const enemy = game.scene.getEnemyParty()[0]; - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); - }); - - // TODO: re-enable after re-fixing hazards moves - it.todo("should work when all targets fainted", async () => { - game.override.enemySpecies(SpeciesId.DIGLETT).battleStyle("double").startingLevel(1000); - await game.classicMode.startBattle([SpeciesId.RAYQUAZA, SpeciesId.SHUCKLE]); - - const [enemy1, enemy2] = game.scene.getEnemyField(); - - game.move.use(MoveId.HYPER_VOICE, BattlerIndex.PLAYER); - game.move.use(MoveId.SPIKES, BattlerIndex.PLAYER_2); - await game.toEndOfTurn(); - - expect(enemy1.isFainted()).toBe(true); - expect(enemy2.isFainted()).toBe(true); - expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeDefined(); - }); -}); diff --git a/test/moves/toxic-spikes.test.ts b/test/moves/toxic-spikes.test.ts deleted file mode 100644 index 1315eaa31c3..00000000000 --- a/test/moves/toxic-spikes.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { ArenaTrapTag } from "#data/arena-tag"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import type { SessionSaveData } from "#system/game-data"; -import { GameData } from "#system/game-data"; -import { GameManager } from "#test/test-utils/game-manager"; -import { decrypt, encrypt } from "#utils/data"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Toxic Spikes", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - game = new GameManager(phaserGame); - game.override - .battleStyle("single") - .startingWave(5) - .enemySpecies(SpeciesId.RATTATA) - .enemyAbility(AbilityId.BALL_FETCH) - .ability(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH) - .moveset([MoveId.TOXIC_SPIKES, MoveId.SPLASH, MoveId.ROAR, MoveId.COURT_CHANGE]); - }); - - it("should not affect the opponent if they do not switch", async () => { - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - - const enemy = game.scene.getEnemyField()[0]; - - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); - game.doSwitchPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(enemy.hp).toBe(enemy.getMaxHp()); - expect(enemy.status?.effect).toBeUndefined(); - }); - - it("should poison the opponent if they switch into 1 layer", async () => { - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA]); - - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(MoveId.ROAR); - await game.phaseInterceptor.to("TurnEndPhase"); - - const enemy = game.scene.getEnemyField()[0]; - - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); - expect(enemy.status?.effect).toBe(StatusEffect.POISON); - }); - - it("should badly poison the opponent if they switch into 2 layers", async () => { - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA]); - - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(MoveId.ROAR); - await game.phaseInterceptor.to("TurnEndPhase"); - - const enemy = game.scene.getEnemyField()[0]; - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); - expect(enemy.status?.effect).toBe(StatusEffect.TOXIC); - }); - - it("should be removed if a grounded poison pokemon switches in", async () => { - await game.classicMode.runToSummon([SpeciesId.MUK, SpeciesId.PIDGEY]); - - const muk = game.scene.getPlayerPokemon()!; - - game.move.select(MoveId.TOXIC_SPIKES); - await game.toNextTurn(); - // also make sure the toxic spikes are removed even if the pokemon - // that set them up is the one switching in (https://github.com/pagefaultgames/pokerogue/issues/935) - game.move.select(MoveId.COURT_CHANGE); - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - game.move.select(MoveId.SPLASH); - await game.toNextTurn(); - - expect(muk.isFullHp()).toBe(true); - expect(muk.status?.effect).toBeUndefined(); - expect(game.scene.arena.tags.length).toBe(0); - }); - - it("shouldn't create multiple layers per use in doubles", async () => { - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to("TurnEndPhase"); - - const arenaTags = game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(arenaTags.tagType).toBe(ArenaTagType.TOXIC_SPIKES); - expect(arenaTags.layers).toBe(1); - }); - - it("should persist through reload", async () => { - game.override.startingWave(1); - const gameData = new GameData(); - - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA]); - - game.move.select(MoveId.TOXIC_SPIKES); - await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(MoveId.SPLASH); - await game.doKillOpponents(); - await game.phaseInterceptor.to("BattleEndPhase"); - await game.toNextWave(); - - const sessionData: SessionSaveData = gameData.getSessionSaveData(); - localStorage.setItem("sessionTestData", encrypt(JSON.stringify(sessionData), true)); - const recoveredData: SessionSaveData = gameData.parseSessionData( - decrypt(localStorage.getItem("sessionTestData")!, true), - ); - await gameData.loadSession(0, recoveredData); - - expect(sessionData.arena.tags).toEqual(recoveredData.arena.tags); - localStorage.removeItem("sessionTestData"); - }); -});