Cleaned up entry hazard arena tags; merged tests into 1 file

This commit is contained in:
Bertie690 2025-08-03 13:51:49 -04:00
parent ba48f16500
commit 687a28e85f
4 changed files with 562 additions and 581 deletions

View File

@ -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,42 +725,79 @@ 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) {
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) {
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide) {
super(0, sourceMove, sourceId, side);
}
// 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 {
// 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 AernaTrapTag on add message!" +
`\nTag type: ${this.tagType}` +
`\nPID: ${this.sourceId}`,
);
return;
}
globalScene.phaseManager.queueMessage(this.getAddMessage(source));
}
/**
* 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);
}
}
/**
* 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
* 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,122 +827,174 @@ 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<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers">): 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 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 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", {
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;
}
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", {
protected override getTriggerMessage(pokemon: Pokemon): string {
return 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;
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:stealthRockOnAdd", {
opponentDesc: source.getOpponentDescriptor(),
});
}
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for ToxicSpikesTag on add message; id: ${this.sourceId}`);
return;
protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonName: getPokemonNameWithAffix(pokemon),
});
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesOnAdd", {
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(),
}),
);
});
}
onRemove(arena: Arena): void {
// 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)) {
// 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;
if (globalScene.arena.removeTag(this.tagType)) {
globalScene.arena.removeTagOnSide(this.tagType, this.side);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
@ -905,17 +1003,10 @@ class ToxicSpikesTag extends ArenaTrapTag {
);
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;
}
}
}
return false;
// 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,131 +1021,29 @@ 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.
*/
class StealthRockTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.STEALTH_ROCK;
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.STEALTH_ROCK, sourceId, side, 1);
}
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;
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (cancelled.value) {
return false;
}
const damageHpRatio = this.getDamageHpRatio(pokemon);
if (!damageHpRatio) {
return false;
}
if (simulated) {
return true;
}
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}),
);
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));
}
}
/**
* 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.
* 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 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.STICKY_WEB, sourceId, side, 1);
super(MoveId.STICKY_WEB, 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:stickyWebOnAdd", {
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:stickyWebOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
});
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
const cancelled = new BooleanHolder(false);
// TODO: Does this need to pass `simulated` as a parameter?
applyAbAttrs("ProtectStatAbAttr", {
pokemon,
cancelled,
@ -1062,23 +1051,26 @@ class StickyWebTag extends ArenaTrapTag {
stages: -1,
});
if (simulated) {
return !cancelled.value;
if (cancelled.value) {
return false;
}
if (simulated) {
return true;
}
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,
-1,
true,
false,
true,
@ -1090,7 +1082,73 @@ class StickyWebTag extends ArenaTrapTag {
}
}
return false;
/**
* 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;
public override get maxLayers() {
return 1 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.IMPRISON, sourceId, side);
}
/**
* Apply the effects of Imprison to all opposing on-field Pokemon.
*/
override onAdd(_arena: Arena, quiet = false) {
super.onAdd(_arena, quiet);
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();
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);
});
}
}
@ -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}

View File

@ -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(),
}),
);
});
});
});

View File

@ -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();
});
});

View File

@ -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");
});
});