diff --git a/src/@types/arena-tags.ts b/src/@types/arena-tags.ts index afcc8a0f924..bac9e815c31 100644 --- a/src/@types/arena-tags.ts +++ b/src/@types/arena-tags.ts @@ -2,7 +2,7 @@ import type { ArenaTagTypeMap } from "#data/arena-tag"; import type { ArenaTagType } from "#enums/arena-tag-type"; /** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */ -export type ArenaTrapTagType = +export type EntryHazardTagType = | ArenaTagType.STICKY_WEB | ArenaTagType.SPIKES | ArenaTagType.TOXIC_SPIKES diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 199e0af3efb..7a462da9628 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -6,7 +6,7 @@ import type { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-chang import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { ArenaTrapTag, SuppressAbilitiesTag } from "#data/arena-tag"; +import type { EntryHazardTag, SuppressAbilitiesTag } from "#data/arena-tag"; import type { BattlerTag } from "#data/battler-tags"; import { GroundedTag } from "#data/battler-tags"; import { getBerryEffectFunc } from "#data/berry"; @@ -1113,7 +1113,7 @@ export class PostDefendApplyArenaTrapTagAbAttr extends PostDefendAbAttr { } override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean { - const tag = globalScene.arena.getTag(this.arenaTagType) as ArenaTrapTag; + const tag = globalScene.arena.getTag(this.arenaTagType) as EntryHazardTag; return ( this.condition(pokemon, attacker, move) && (!globalScene.arena.getTag(this.arenaTagType) || tag.layers < tag.maxLayers) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 15c2cde1d58..ddaf265aee1 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -24,11 +24,11 @@ import type { Pokemon } from "#field/pokemon"; import type { ArenaScreenTagType, ArenaTagTypeData, - ArenaTrapTagType, + EntryHazardTagType, 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,42 +725,79 @@ export class IonDelugeTag extends ArenaTag { } /** - * Abstract class to implement arena traps. + * Abstract class to implement [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 may 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; - +export abstract class EntryHazardTag extends SerializableArenaTag { + public declare abstract readonly tagType: EntryHazardTagType; /** - * 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) { - super(0, sourceMove, sourceId, side); - - this.layers = 1; - this.maxLayers = maxLayers; + 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; } - onOverlap(arena: Arena, _source: Pokemon | null): void { - if (this.layers < this.maxLayers) { - this.layers++; + constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide) { + super(0, sourceMove, sourceId, side); + } - this.onAdd(arena); + // TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts + + /** + * 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 onAdd(_arena: Arena, quiet = false): void { + // Here, `quiet=true` means "just add the tag, no questions asked" + if (quiet) { + return; } + + const source = this.getSourcePokemon(); + if (!source) { + console.warn( + "Failed to get source Pokemon for AernaTrapTag on add message!" + + `\nTag type: ${this.tagType}` + + `\nPID: ${this.sourceId}`, + ); + return; + } + + globalScene.phaseManager.queueMessage(this.getAddMessage(source)); } /** - * 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 + * 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 { @@ -768,12 +805,21 @@ export abstract class ArenaTrapTag extends SerializableArenaTag { return false; } + if (this.groundedOnly && !pokemon.isGrounded()) { + return false; + } + return this.activateTrap(pokemon, simulated); } - activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean { - return false; - } + /** + * 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() @@ -781,141 +827,186 @@ export abstract class ArenaTrapTag extends SerializableArenaTag { : Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2); } - public loadTag(source: BaseArenaTag & Pick): void { + public loadTag(source: BaseArenaTag & Pick): void { super.loadTag(source); this.layers = source.layers; - this.maxLayers = source.maxLayers; } } +/** + * Abstract class to implement damaging entry hazards. + * Currently used for {@linkcode SpikesTag} and {@linkcode StealthRockTag}. + */ +abstract class DamagingTrapTag extends EntryHazardTag { + 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 ArenaTrapTag { +class SpikesTag extends DamagingTrapTag { public readonly tagType = ArenaTagType.SPIKES; + override get maxLayers() { + return 3 as const; + } + constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.SPIKES, sourceId, side, 3); + super(MoveId.SPIKES, sourceId, side); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); - - // We assume `quiet=true` means "just add the bloody tag no questions asked" - if (quiet) { - return; - } - - 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:spikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + protected override getAddMessage(source: Pokemon): string { + return i18next.t("arenaTag:spikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }); } - override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { - if (!pokemon.isGrounded()) { - return false; - } + protected override getTriggerMessage(pokemon: Pokemon): string { + return i18next.t("arenaTag:spikesActivateTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }); + } - const cancelled = new BooleanHolder(false); - applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled }); - if (simulated || cancelled.value) { - return !cancelled.value; - } - - const damageHpRatio = 1 / (10 - 2 * this.layers); - const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:spikesActivateTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - ); - pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT }); - pokemon.turnData.damageTaken += damage; - return true; + 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/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. + * 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 ToxicSpikesTag extends ArenaTrapTag { - #neutralized: boolean; - public readonly tagType = ArenaTagType.TOXIC_SPIKES; +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.TOXIC_SPIKES, sourceId, side, 2); - this.#neutralized = false; + super(MoveId.STEALTH_ROCK, 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:stealthRockOnAdd", { + opponentDesc: source.getOpponentDescriptor(), + }); } - onRemove(arena: Arena): void { + 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 EntryHazardTag { + /** + * 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 EntryHazardTag { + 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 EntryHazardTag { + 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/src/data/moves/move.ts b/src/data/moves/move.ts index ffcd844224c..5df4553b7c9 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6,7 +6,7 @@ import { loggedInUser } from "#app/account"; import type { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { ArenaTrapTag } from "#data/arena-tag"; +import type { EntryHazardTag } from "#data/arena-tag"; import { WeakenMoveTypeTag } from "#data/arena-tag"; import { MoveChargeAnim } from "#data/battle-anims"; import { @@ -6083,7 +6083,7 @@ export class AddArenaTrapTagAttr extends AddArenaTagAttr { getCondition(): MoveConditionFunc { return (user, target, move) => { const side = (this.selfSideTarget !== user.isPlayer()) ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER; - const tag = globalScene.arena.getTagOnSide(this.tagType, side) as ArenaTrapTag; + const tag = globalScene.arena.getTagOnSide(this.tagType, side) as EntryHazardTag; if (!tag) { return true; } @@ -6107,7 +6107,7 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - const tag = globalScene.arena.getTagOnSide(this.tagType, side) as ArenaTrapTag; + const tag = globalScene.arena.getTagOnSide(this.tagType, side) as EntryHazardTag; if ((moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) && user.getLastXMoves(1)[0]?.result === MoveResult.SUCCESS) { globalScene.arena.addTag(this.tagType, 0, move.id, user.id, side); if (!tag) { diff --git a/src/field/arena.ts b/src/field/arena.ts index 1b6b165b8a3..5ae092b562a 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -8,7 +8,7 @@ import Overrides from "#app/overrides"; import type { BiomeTierTrainerPools, PokemonPools } from "#balance/biomes"; import { BiomePoolTier, biomePokemonPools, biomeTrainerPools } from "#balance/biomes"; import type { ArenaTag } from "#data/arena-tag"; -import { ArenaTrapTag, getArenaTag } from "#data/arena-tag"; +import { EntryHazardTag, getArenaTag } from "#data/arena-tag"; import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import type { PokemonSpecies } from "#data/pokemon-species"; import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager"; @@ -709,8 +709,8 @@ export class Arena { if (existingTag) { existingTag.onOverlap(this, globalScene.getPokemonById(sourceId)); - if (existingTag instanceof ArenaTrapTag) { - const { tagType, side, turnCount, layers, maxLayers } = existingTag as ArenaTrapTag; + if (existingTag instanceof EntryHazardTag) { + const { tagType, side, turnCount, layers, maxLayers } = existingTag as EntryHazardTag; this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, layers, maxLayers)); } @@ -723,7 +723,7 @@ export class Arena { newTag.onAdd(this, quiet); this.tags.push(newTag); - const { layers = 0, maxLayers = 0 } = newTag instanceof ArenaTrapTag ? newTag : {}; + const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {}; this.eventTarget.dispatchEvent( new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, layers, maxLayers), diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 584c9310932..56b5c0660fa 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -1,7 +1,7 @@ import { globalScene } from "#app/global-scene"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { signatureSpecies } from "#balance/signature-species"; -import { ArenaTrapTag } from "#data/arena-tag"; +import { EntryHazardTag } from "#data/arena-tag"; import type { PokemonSpecies } from "#data/pokemon-species"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { PartyMemberStrength } from "#enums/party-member-strength"; @@ -584,8 +584,8 @@ export class Trainer extends Phaser.GameObjects.Container { score /= playerField.length; if (forSwitch && !p.isOnField()) { globalScene.arena - .findTagsOnSide(t => t instanceof ArenaTrapTag, ArenaTagSide.ENEMY) - .map(t => (score *= (t as ArenaTrapTag).getMatchupScoreMultiplier(p))); + .findTagsOnSide(t => t instanceof EntryHazardTag, ArenaTagSide.ENEMY) + .map(t => (score *= (t as EntryHazardTag).getMatchupScoreMultiplier(p))); } } diff --git a/src/phases/post-summon-phase.ts b/src/phases/post-summon-phase.ts index e0811d0ab93..913a29cded8 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -1,6 +1,6 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; -import { ArenaTrapTag } from "#data/arena-tag"; +import { EntryHazardTag } from "#data/arena-tag"; import { MysteryEncounterPostSummonTag } from "#data/battler-tags"; import { BattlerTagType } from "#enums/battler-tag-type"; import { StatusEffect } from "#enums/status-effect"; @@ -16,7 +16,7 @@ export class PostSummonPhase extends PokemonPhase { if (pokemon.status?.effect === StatusEffect.TOXIC) { pokemon.status.toxicTurnCount = 0; } - globalScene.arena.applyTags(ArenaTrapTag, false, pokemon); + globalScene.arena.applyTags(EntryHazardTag, false, pokemon); // If this is mystery encounter and has post summon phase tag, apply post summon effects if ( diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 90cbf6e18cc..14224751262 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -10,7 +10,7 @@ import { Tutorial } from "#app/tutorial"; import { speciesEggMoves } from "#balance/egg-moves"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { speciesStarterCosts } from "#balance/starters"; -import { ArenaTrapTag } from "#data/arena-tag"; +import { EntryHazardTag } from "#data/arena-tag"; import { allMoves, allSpecies } from "#data/data-lists"; import type { Egg } from "#data/egg"; import { pokemonFormChanges } from "#data/pokemon-forms"; @@ -1135,8 +1135,8 @@ export class GameData { globalScene.arena.tags = sessionData.arena.tags; if (globalScene.arena.tags) { for (const tag of globalScene.arena.tags) { - if (tag instanceof ArenaTrapTag) { - const { tagType, side, turnCount, layers, maxLayers } = tag as ArenaTrapTag; + if (tag instanceof EntryHazardTag) { + const { tagType, side, turnCount, layers, maxLayers } = tag as EntryHazardTag; globalScene.arena.eventTarget.dispatchEvent( new TagAddedEvent(tagType, side, turnCount, layers, maxLayers), ); diff --git a/src/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index e243bef342e..da062f5c96f 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -1,5 +1,5 @@ import { globalScene } from "#app/global-scene"; -import { ArenaTrapTag } from "#data/arena-tag"; +import { EntryHazardTag } from "#data/arena-tag"; import { TerrainType } from "#data/terrain"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; @@ -287,7 +287,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { switch (arenaEffectChangedEvent.constructor) { case TagAddedEvent: { const tagAddedEvent = arenaEffectChangedEvent as TagAddedEvent; - const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof ArenaTrapTag; + const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof EntryHazardTag; let arenaEffectType: ArenaEffectType; if (tagAddedEvent.arenaTagSide === ArenaTagSide.BOTH) { diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 21cf76ed352..2ed0512538a 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -1,3 +1,5 @@ +import "vitest"; + import type { TerrainType } from "#app/data/terrain"; import type Overrides from "#app/overrides"; import type { ArenaTag } from "#data/arena-tag"; diff --git a/test/moves/ceaseless-edge.test.ts b/test/moves/ceaseless-edge.test.ts index 64f4cf15511..b06ea84308c 100644 --- a/test/moves/ceaseless-edge.test.ts +++ b/test/moves/ceaseless-edge.test.ts @@ -1,4 +1,4 @@ -import { ArenaTrapTag } from "#data/arena-tag"; +import { EntryHazardTag } from "#data/arena-tag"; import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; @@ -50,12 +50,12 @@ describe("Moves - Ceaseless Edge", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); // Spikes should not have any layers before move effect is applied - const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(tagBefore instanceof ArenaTrapTag).toBeFalsy(); + const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; + expect(tagBefore instanceof EntryHazardTag).toBeFalsy(); await game.phaseInterceptor.to(TurnEndPhase); - const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(tagAfter instanceof ArenaTrapTag).toBeTruthy(); + const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; + expect(tagAfter instanceof EntryHazardTag).toBeTruthy(); expect(tagAfter.layers).toBe(1); expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); }); @@ -72,12 +72,12 @@ describe("Moves - Ceaseless Edge", () => { await game.phaseInterceptor.to(MoveEffectPhase, false); // Spikes should not have any layers before move effect is applied - const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(tagBefore instanceof ArenaTrapTag).toBeFalsy(); + const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; + expect(tagBefore instanceof EntryHazardTag).toBeFalsy(); await game.phaseInterceptor.to(TurnEndPhase); - const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(tagAfter instanceof ArenaTrapTag).toBeTruthy(); + const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; + expect(tagAfter instanceof EntryHazardTag).toBeTruthy(); expect(tagAfter.layers).toBe(2); expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); }); @@ -90,12 +90,12 @@ describe("Moves - Ceaseless Edge", () => { game.move.select(MoveId.CEASELESS_EDGE); await game.phaseInterceptor.to(MoveEffectPhase, false); // Spikes should not have any layers before move effect is applied - const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(tagBefore instanceof ArenaTrapTag).toBeFalsy(); + const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; + expect(tagBefore instanceof EntryHazardTag).toBeFalsy(); await game.toNextTurn(); - const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; - expect(tagAfter instanceof ArenaTrapTag).toBeTruthy(); + const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; + expect(tagAfter instanceof EntryHazardTag).toBeTruthy(); expect(tagAfter.layers).toBe(2); const hpBeforeSpikes = game.scene.currentBattle.enemyParty[1].hp; diff --git a/test/moves/destiny-bond.test.ts b/test/moves/destiny-bond.test.ts index 9c397717335..118a45e7682 100644 --- a/test/moves/destiny-bond.test.ts +++ b/test/moves/destiny-bond.test.ts @@ -1,4 +1,4 @@ -import type { ArenaTrapTag } from "#data/arena-tag"; +import type { EntryHazardTag } from "#data/arena-tag"; import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; @@ -195,7 +195,7 @@ describe("Moves - Destiny Bond", () => { expect(playerPokemon.isFainted()).toBe(true); // Ceaseless Edge spikes effect should still activate - const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; + const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; expect(tagAfter.tagType).toBe(ArenaTagType.SPIKES); expect(tagAfter.layers).toBe(1); }); @@ -220,7 +220,10 @@ describe("Moves - Destiny Bond", () => { expect(playerPokemon1?.isFainted()).toBe(true); // Pledge secondary effect should still activate - const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.GRASS_WATER_PLEDGE, ArenaTagSide.ENEMY) as ArenaTrapTag; + const tagAfter = game.scene.arena.getTagOnSide( + ArenaTagType.GRASS_WATER_PLEDGE, + ArenaTagSide.ENEMY, + ) as EntryHazardTag; expect(tagAfter.tagType).toBe(ArenaTagType.GRASS_WATER_PLEDGE); }); diff --git a/test/moves/entry-hazards.test.ts b/test/moves/entry-hazards.test.ts new file mode 100644 index 00000000000..c4dead1bb67 --- /dev/null +++ b/test/moves/entry-hazards.test.ts @@ -0,0 +1,233 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +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 { EntryHazardTagType } 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: EntryHazardTagType }>([ + { 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.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.RAYQUAZA, SpeciesId.SHUCKLE]); + + const [enemy1, enemy2] = game.scene.getEnemyField(); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(move, BattlerIndex.PLAYER_2); + await game.doKillOpponents(); + await game.toEndOfTurn(); + + expect(enemy1.isFainted()).toBe(true); + expect(enemy2.isFainted()).toBe(true); + expect(game).toHaveArenaTag(tagType, ArenaTagSide.ENEMY); + }); + + 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); + 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.DELIBIRD }, + ])("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", { + pokemonName: 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 0a0bf8baefc..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.field.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"); - }); -}); diff --git a/test/test-utils/matchers/to-have-arena-tag.ts b/test/test-utils/matchers/to-have-arena-tag.ts index dee7c133f25..e2a4a71ffd5 100644 --- a/test/test-utils/matchers/to-have-arena-tag.ts +++ b/test/test-utils/matchers/to-have-arena-tag.ts @@ -1,5 +1,5 @@ import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; -import type { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagSide } from "#enums/arena-tag-side"; import type { ArenaTagType } from "#enums/arena-tag-type"; import type { OneOther } from "#test/@types/test-helpers"; // biome-ignore lint/correctness/noUnusedImports: TSDoc @@ -26,7 +26,7 @@ export function toHaveArenaTag( this: MatcherState, received: unknown, expectedTag: T | toHaveArenaTagOptions, - side?: ArenaTagSide, + side: ArenaTagSide = ArenaTagSide.BOTH, ): SyncExpectationResult { if (!isGameManagerInstance(received)) { return { @@ -46,22 +46,27 @@ export function toHaveArenaTag( // Bangs are ok as we enforce safety via overloads // @ts-expect-error - Typescript is being stupid as tag type and side will always exist const etag: Partial & { tagType: T; side: ArenaTagSide } = - typeof expectedTag === "object" ? expectedTag : { tagType: expectedTag, side: side! }; + typeof expectedTag === "object" ? expectedTag : { tagType: expectedTag, side }; + // If checking only tag type/side OR no tags were found, break out early. // We need to get all tags for the case of checking properties of a tag present on both sides of the arena const tags = received.scene.arena.findTagsOnSide(t => t.tagType === etag.tagType, etag.side); - if (tags.length === 0) { + if (typeof expectedTag !== "object" || tags.length === 0) { + const pass = tags.length > 0; return { - pass: false, - message: () => `Expected the Arena to have a tag of type ${etag.tagType}, but it didn't!`, - expected: etag.tagType, - actual: received.scene.arena.tags.map(t => t.tagType), + pass, + message: () => + pass + ? `Expected the Arena to NOT have a tag of type ${etag.tagType}, but it did!` + : `Expected the Arena to have a tag of type ${etag.tagType}, but it didn't!`, + expected: etag, + actual: received.scene.arena.tags.map(t => ({ tagType: t.tagType, side: t.side })), }; } // Pass if any of the matching tags meet our criteria const pass = tags.some(tag => - this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + this.equals(tag, etag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), ); const expectedStr = getOnelineDiffStr.call(this, expectedTag);