Compare commits

...

5 Commits

Author SHA1 Message Date
Bertie690
47794d4e40
Merge 687a28e85f into 5bfcb1d379 2025-08-04 19:44:11 -04:00
Amani H.
5bfcb1d379
[Bug] Release Fainted Pokémon in Switch Menu (#6215)
* [Bug] Release Fainted Pokémon in Switch Menu

* Add Kev's Suggestions
2025-08-04 15:55:36 -07:00
Acelynn Zhang
40443d2afa
[Bug] Fix override move animations not loading for enemy Pokemon
https://github.com/pagefaultgames/pokerogue/pull/6214
2025-08-04 14:33:01 -07:00
Bertie690
687a28e85f Cleaned up entry hazard arena tags; merged tests into 1 file 2025-08-03 14:50:46 -04:00
Bertie690
ba48f16500 Grabbed matchers from other branch 2025-08-03 13:48:26 -04:00
10 changed files with 678 additions and 592 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,83 +725,36 @@ export class IonDelugeTag extends ArenaTag {
}
/**
* Abstract class to implement arena traps.
* Abstract class to implement [arena traps (AKA entry hazards)](https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards).
* These persistent tags remain on-field across turns and apply effects to any {@linkcode Pokemon} switching in. \
* Uniquely, adding a tag multiple times will stack multiple "layers" of the effect, increasing its severity.
*/
export abstract class ArenaTrapTag extends SerializableArenaTag {
abstract readonly tagType: ArenaTrapTagType;
public layers: number;
public maxLayers: number;
/**
* Creates a new instance of the ArenaTrapTag class.
*
* @param tagType - The type of the arena tag.
* @param sourceMove - The move that created the tag.
* @param sourceId - The ID of the source of the tag.
* @param side - The side (player or enemy) the tag affects.
* @param maxLayers - The maximum amount of layers this tag can have.
* The current number of layers this tag has.
* Starts at 1 and increases each time the trap is laid.
*/
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide, maxLayers: number) {
public layers = 1;
/** The maximum number of layers this tag can have. */
public abstract get maxLayers(): number;
/** Whether this tag should only affect grounded targets; default `true` */
protected get groundedOnly(): boolean {
return true;
}
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide) {
super(0, sourceMove, sourceId, side);
this.layers = 1;
this.maxLayers = maxLayers;
}
onOverlap(arena: Arena, _source: Pokemon | null): void {
if (this.layers < this.maxLayers) {
this.layers++;
this.onAdd(arena);
}
}
// TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts
/**
* Activates the hazard effect onto a Pokemon when it enters the field
* @param _arena the {@linkcode Arena} containing this tag
* @param simulated if `true`, only checks if the hazard would activate.
* @param pokemon the {@linkcode Pokemon} triggering this hazard
* @returns `true` if this hazard affects the given Pokemon; `false` otherwise.
* Display text when this tag is added to the field.
* @param _arena - The {@linkcode Arena} at the time of adding this tag
* @param quiet - Whether to suppress messages during tag creation; default `false`
*/
override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean {
if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) {
return false;
}
return this.activateTrap(pokemon, simulated);
}
activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean {
return false;
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
return pokemon.isGrounded()
? 1
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
}
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
super.loadTag(source);
this.layers = source.layers;
this.maxLayers = source.maxLayers;
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}.
* Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP
* in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap.
*/
class SpikesTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.SPIKES;
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.SPIKES, sourceId, side, 3);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
override onAdd(_arena: Arena, quiet = false): void {
// We assume `quiet=true` means "just add the bloody tag no questions asked"
if (quiet) {
return;
@ -809,113 +762,251 @@ class SpikesTag extends ArenaTrapTag {
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`);
console.warn(
"Failed to get source Pokemon for AernaTrapTag on add message!" +
`\nTag type: ${this.tagType}` +
`\nPID: ${this.sourceId}`,
);
return;
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:spikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
globalScene.phaseManager.queueMessage(this.getAddMessage(source));
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (!pokemon.isGrounded()) {
/**
* Return the text to be displayed upon adding a new layer to this trap.
* @param source - The {@linkcode Pokemon} having created this tag
* @returns The localized message to be displayed on screen.
*/
protected abstract getAddMessage(source: Pokemon): string;
/**
* Add a new layer to this tag upon overlap, triggering the tag's normal {@linkcode onAdd} effects upon doing so.
* @param arena - The {@linkcode arena} at the time of adding the tag
*/
override onOverlap(arena: Arena): void {
if (this.layers >= this.maxLayers) {
return;
}
this.layers++;
this.onAdd(arena);
}
/**
* Activate the hazard effect onto a Pokemon when it enters the field.
* @param _arena - The {@linkcode Arena} at the time of tag activation
* @param simulated - Whether to suppress activation effects during execution
* @param pokemon - The {@linkcode Pokemon} triggering this hazard
* @returns `true` if this hazard affects the given Pokemon; `false` otherwise.
*/
override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean {
if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) {
return false;
}
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (simulated || cancelled.value) {
return !cancelled.value;
if (this.groundedOnly && !pokemon.isGrounded()) {
return false;
}
const damageHpRatio = 1 / (10 - 2 * this.layers);
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
return this.activateTrap(pokemon, simulated);
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:spikesActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}),
);
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
pokemon.turnData.damageTaken += damage;
return true;
/**
* Activate this trap's effects when a Pokemon switches into it.
* @param _pokemon - The {@linkcode Pokemon}
* @param _simulated - Whether the activation is simulated
* @returns Whether the trap activation succeeded
* @todo Do we need the return value? nothing uses it
*/
protected abstract activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean;
getMatchupScoreMultiplier(pokemon: Pokemon): number {
return pokemon.isGrounded()
? 1
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
}
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers">): void {
super.loadTag(source);
this.layers = source.layers;
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) Toxic Spikes}.
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon who is
* summoned into this trap if 1 or 2 layers of Toxic Spikes respectively are up. Poison-type
* Pokémon summoned into this trap remove it entirely.
* Abstract class to implement damaging entry hazards.
* Currently used for {@linkcode SpikesTag} and {@linkcode StealthRockTag}.
*/
class ToxicSpikesTag extends ArenaTrapTag {
#neutralized: boolean;
public readonly tagType = ArenaTagType.TOXIC_SPIKES;
abstract class DamagingTrapTag extends ArenaTrapTag {
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
// Check for magic guard immunity
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (cancelled.value) {
return false;
}
if (simulated) {
return true;
}
// Damage the target and trigger a message
const damageHpRatio = this.getDamageHpRatio(pokemon);
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.phaseManager.queueMessage(this.getTriggerMessage(pokemon));
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
pokemon.turnData.damageTaken += damage;
return true;
}
/**
* Return the text to be displayed when this tag deals damage.
* @param _pokemon - The {@linkcode Pokemon} switching in
* @returns The localized trigger message to be displayed on-screen.
*/
protected abstract getTriggerMessage(_pokemon: Pokemon): string;
/**
* Return the amount of damage this tag should deal to the given Pokemon, relative to its maximum HP.
* @param _pokemon - The {@linkcode Pokemon} switching in
* @returns The percentage of max HP to deal upon activation.
*/
protected abstract getDamageHpRatio(_pokemon: Pokemon): number;
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}.
* Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP
* in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap.
*/
class SpikesTag extends DamagingTrapTag {
public readonly tagType = ArenaTagType.SPIKES;
override get maxLayers() {
return 3 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.TOXIC_SPIKES, sourceId, side, 2);
this.#neutralized = false;
super(MoveId.SPIKES, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
if (quiet) {
// We assume `quiet=true` means "just add the bloody tag no questions asked"
return;
}
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for ToxicSpikesTag on add message; id: ${this.sourceId}`);
return;
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:spikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
});
}
onRemove(arena: Arena): void {
protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:spikesActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
});
}
protected override getDamageHpRatio(_pokemon: Pokemon): number {
// 1/8 for 1 layer, 1/6 for 2, 1/4 for 3
return 1 / (10 - 2 * this.layers);
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) | Stealth Rock}.
* Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon
* who is summoned into the trap based on the Rock type's type effectiveness.
*/
class StealthRockTag extends DamagingTrapTag {
public readonly tagType = ArenaTagType.STEALTH_ROCK;
public override get maxLayers() {
return 1 as const;
}
protected override get groundedOnly() {
return false;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.STEALTH_ROCK, sourceId, side);
}
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:stealthRockOnAdd", {
opponentDesc: source.getOpponentDescriptor(),
});
}
protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonName: getPokemonNameWithAffix(pokemon),
});
}
protected override getDamageHpRatio(pokemon: Pokemon): number {
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true);
return 0.125 * effectiveness;
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
const damageHpRatio = this.getDamageHpRatio(pokemon);
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) | Toxic Spikes}.
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon switched in
* based on the current layer count. \
* Poison-type Pokémon will remove it entirely upon switch-in.
*/
class ToxicSpikesTag extends ArenaTrapTag {
/**
* Whether the tag is currently in the process of being neutralized by a Poison-type.
* @defaultValue `false`
*/
#neutralized = false;
public readonly tagType = ArenaTagType.TOXIC_SPIKES;
override get maxLayers() {
return 2 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.TOXIC_SPIKES, sourceId, side);
}
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:toxicSpikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
});
}
// Override remove function to only display text when not neutralized
override onRemove(arena: Arena): void {
if (!this.#neutralized) {
super.onRemove(arena);
}
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
if (simulated) {
return true;
}
if (pokemon.isOfType(PokemonType.POISON)) {
this.#neutralized = true;
if (globalScene.arena.removeTag(this.tagType)) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
} else if (!pokemon.status) {
const toxic = this.layers > 1;
if (
pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName())
) {
return true;
}
}
if (simulated) {
return true;
}
return false;
if (pokemon.isOfType(PokemonType.POISON)) {
// Neutralize the tag and remove it from the field.
// Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon
this.#neutralized = true;
globalScene.arena.removeTagOnSide(this.tagType, this.side);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
// Attempt to poison the target, suppressing any status effect messages
const effect = this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC;
return pokemon.trySetStatus(effect, true, null, 0, this.getMoveName(), false, true);
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
@ -930,71 +1021,37 @@ class ToxicSpikesTag extends ArenaTrapTag {
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) Stealth Rock}.
* Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon
* who is summoned into the trap, based on the Rock type's type effectiveness.
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) | Sticky Web}.
* Applies a single-layer trap that lowers the Speed of all grounded Pokémon switching in.
*/
class StealthRockTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.STEALTH_ROCK;
class StickyWebTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.STICKY_WEB;
public override get maxLayers() {
return 1 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.STEALTH_ROCK, sourceId, side, 1);
super(MoveId.STICKY_WEB, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
if (quiet) {
return;
}
const source = this.getSourcePokemon();
if (!quiet && source) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stealthRockOnAdd", {
opponentDesc: source.getOpponentDescriptor(),
}),
);
}
}
getDamageHpRatio(pokemon: Pokemon): number {
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true);
let damageHpRatio = 0;
switch (effectiveness) {
case 0:
damageHpRatio = 0;
break;
case 0.25:
damageHpRatio = 0.03125;
break;
case 0.5:
damageHpRatio = 0.0625;
break;
case 1:
damageHpRatio = 0.125;
break;
case 2:
damageHpRatio = 0.25;
break;
case 4:
damageHpRatio = 0.5;
break;
}
return damageHpRatio;
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:stickyWebOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
});
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (cancelled.value) {
return false;
}
// TODO: Does this need to pass `simulated` as a parameter?
applyAbAttrs("ProtectStatAbAttr", {
pokemon,
cancelled,
stat: Stat.SPD,
stages: -1,
});
const damageHpRatio = this.getDamageHpRatio(pokemon);
if (!damageHpRatio) {
if (cancelled.value) {
return false;
}
@ -1002,95 +1059,96 @@ class StealthRockTag extends ArenaTrapTag {
return true;
}
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
i18next.t("arenaTag:stickyWebActivateTrap", {
pokemonName: pokemon.getNameToRender(),
}),
);
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
pokemon.turnData.damageTaken += damage;
return true;
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
const damageHpRatio = this.getDamageHpRatio(pokemon);
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),
false,
[Stat.SPD],
-1,
true,
false,
true,
null,
false,
true,
);
return true;
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) Sticky Web}.
* Applies up to 1 layer of Sticky Web, which lowers the Speed by one stage
* to any Pokémon who is summoned into this trap.
* This arena tag facilitates the application of the move Imprison
* Imprison remains in effect as long as the source Pokemon is active and present on the field.
* Imprison will apply to any opposing Pokemon that switch onto the field as well.
*/
class StickyWebTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.STICKY_WEB;
class ImprisonTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.IMPRISON;
public override get maxLayers() {
return 1 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.STICKY_WEB, sourceId, side, 1);
super(MoveId.IMPRISON, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
/**
* Apply the effects of Imprison to all opposing on-field Pokemon.
*/
override onAdd(_arena: Arena, quiet = false) {
super.onAdd(_arena, quiet);
// We assume `quiet=true` means "just add the bloody tag no questions asked"
if (quiet) {
return;
}
const party = this.getAffectedPokemon();
party.forEach(p => {
if (p.isAllowedInBattle()) {
p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
});
}
protected override getAddMessage(source: Pokemon): string {
return i18next.t("battlerTags:imprisonOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(source),
});
}
/**
* Checks if the source Pokemon is still active on the field
* @param _arena
* @returns `true` if the source of the tag is still active on the field | `false` if not
*/
override lapse(): boolean {
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`);
return;
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stickyWebOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
return !!source?.isActive(true);
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
const cancelled = new BooleanHolder(false);
applyAbAttrs("ProtectStatAbAttr", {
pokemon,
cancelled,
stat: Stat.SPD,
stages: -1,
});
if (simulated) {
return !cancelled.value;
}
if (!cancelled.value) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stickyWebActivateTrap", {
pokemonName: pokemon.getNameToRender(),
}),
);
const stages = new NumberHolder(-1);
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),
false,
[Stat.SPD],
stages.value,
true,
false,
true,
null,
false,
true,
);
return true;
}
/**
* This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active
* @param {Pokemon} pokemon the Pokemon Imprison is applied to
* @returns `true`
*/
override activateTrap(pokemon: Pokemon): boolean {
const source = this.getSourcePokemon();
if (source?.isActive(true) && pokemon.isAllowedInBattle()) {
pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
return true;
}
return false;
/**
* When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon
* @param arena
*/
override onRemove(): void {
const party = this.getAffectedPokemon();
party.forEach(p => {
p.removeTag(BattlerTagType.IMPRISON);
});
}
}
@ -1287,75 +1345,6 @@ class NoneTag extends ArenaTag {
}
}
/**
* This arena tag facilitates the application of the move Imprison
* Imprison remains in effect as long as the source Pokemon is active and present on the field.
* Imprison will apply to any opposing Pokemon that switch onto the field as well.
*/
class ImprisonTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.IMPRISON;
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.IMPRISON, sourceId, side, 1);
}
/**
* Apply the effects of Imprison to all opposing on-field Pokemon.
*/
override onAdd() {
const source = this.getSourcePokemon();
if (!source) {
return;
}
const party = this.getAffectedPokemon();
party.forEach(p => {
if (p.isAllowedInBattle()) {
p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
});
globalScene.phaseManager.queueMessage(
i18next.t("battlerTags:imprisonOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(source),
}),
);
}
/**
* Checks if the source Pokemon is still active on the field
* @param _arena
* @returns `true` if the source of the tag is still active on the field | `false` if not
*/
override lapse(): boolean {
const source = this.getSourcePokemon();
return !!source?.isActive(true);
}
/**
* This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active
* @param {Pokemon} pokemon the Pokemon Imprison is applied to
* @returns `true`
*/
override activateTrap(pokemon: Pokemon): boolean {
const source = this.getSourcePokemon();
if (source?.isActive(true) && pokemon.isAllowedInBattle()) {
pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
return true;
}
/**
* When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon
* @param arena
*/
override onRemove(): void {
const party = this.getAffectedPokemon();
party.forEach(p => {
p.removeTag(BattlerTagType.IMPRISON);
});
}
}
/**
* Arena Tag implementing the "sea of fire" effect from the combination
* of {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge}

View File

@ -1821,8 +1821,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @returns An array of {@linkcode PokemonMove}, as described above.
*/
getMoveset(ignoreOverride = false): PokemonMove[] {
const ret = !ignoreOverride && this.summonData.moveset ? this.summonData.moveset : this.moveset;
// Overrides moveset based on arrays specified in overrides.ts
let overrideArray: MoveId | Array<MoveId> = this.isPlayer()
? Overrides.MOVESET_OVERRIDE
@ -1838,7 +1836,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
});
}
return ret;
return !ignoreOverride && this.summonData.moveset ? this.summonData.moveset : this.moveset;
}
/**

View File

@ -827,6 +827,11 @@ export class PartyUiHandler extends MessageUiHandler {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeItemTrigger, false, true);
}
// This is processed before the filter result since releasing does not depend on status.
if (option === PartyOption.RELEASE) {
return this.processReleaseOption(pokemon);
}
// If the pokemon is filtered out for this option, we cannot continue
const filterResult = this.getFilterResult(option, pokemon);
if (filterResult) {
@ -850,10 +855,6 @@ export class PartyUiHandler extends MessageUiHandler {
// PartyUiMode.POST_BATTLE_SWITCH (SEND_OUT)
// These are the options that need a callback
if (option === PartyOption.RELEASE) {
return this.processReleaseOption(pokemon);
}
if (this.partyUiMode === PartyUiMode.SPLICE) {
if (option === PartyOption.SPLICE) {
(this.selectCallback as PartyModifierSpliceSelectCallback)(this.transferCursor, this.cursor);

View File

@ -1,20 +1,27 @@
import type { TerrainType } from "#app/data/terrain";
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import type { AbilityId } from "#enums/ability-id";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id";
import type { PokemonType } from "#enums/pokemon-type";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type";
import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon";
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect";
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers";
import type { toDmgValue } from "utils/common";
import type { expect } from "vitest";
import "vitest";
import type Overrides from "#app/overrides";
import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { PokemonMove } from "#moves/pokemon-move";
import type { OneOther } from "#test/@types/test-helpers";
declare module "vitest" {
interface Assertion {
@ -35,6 +42,7 @@ declare module "vitest" {
* @param expected - The expected types (in any order)
* @param options - The options passed to the matcher
*/
toHaveTypes(expected: PokemonType[], options?: toHaveTypesOptions): void;
toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void;
/**
@ -79,6 +87,24 @@ declare module "vitest" {
*/
toHaveTerrain(expectedTerrainType: TerrainType): void;
/**
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
*
* @param expectedType - A partially-filled {@linkcode ArenaTag} containing the desired properties
*/
toHaveArenaTag<T extends ArenaTagType>(
expectedType: OneOther<ArenaTagTypeMap[T], "tagType" | "side"> & { tagType: T }, // intersection required bc this doesn't preserve T
): void;
/**
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
*
* @param expectedType - The {@linkcode ArenaTagType} of the desired tag
* @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
* {@linkcode ArenaTagSide.BOTH} to check both sides;
* default `ArenaTagSide.BOTH`
*/
toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void;
/**
* Check whether a {@linkcode Pokemon} is at full HP.
*/

View File

@ -1,5 +1,6 @@
import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted";
import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied";
import { toHaveArenaTag } from "#test/test-utils/matchers/to-have-arena-tag";
import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag";
import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat";
import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted";
@ -28,6 +29,7 @@ expect.extend({
toHaveTakenDamage,
toHaveWeather,
toHaveTerrain,
toHaveArenaTag,
toHaveFullHp,
toHaveStatusEffect,
toHaveStatStage,

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

View File

@ -0,0 +1,80 @@
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import type { OneOther } from "#test/@types/test-helpers";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { GameManager } from "#test/test-utils/game-manager";
import { getEnumStr, getOnelineDiffStr, stringifyEnumArray } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import type { NonFunctionPropertiesRecursive } from "#types/type-helpers";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTypeMap[T], "tagType">;
/**
* Matcher to check if the {@linkcode Arena} has a given {@linkcode ArenaTag} active.
* @param received - The object to check. Should be the current {@linkcode GameManager}.
* @param expectedType - The {@linkcode ArenaTagType} of the desired tag, or a partially-filled object
* containing the desired properties
* @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
* {@linkcode ArenaTagSide.BOTH} to check both sides
* @returns The result of the matching
*/
export function toHaveArenaTag<T extends ArenaTagType>(
this: MatcherState,
received: unknown,
// simplified types used for brevity; full overloads are in `vitest.d.ts`
expectedType: T | (Partial<NonFunctionPropertiesRecursive<ArenaTag>> & { tagType: T; side: ArenaTagSide }),
side?: ArenaTagSide,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to recieve a GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena) {
return {
pass: false,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
};
}
if (typeof expectedType === "string") {
// Coerce lone `tagType`s into objects
// Bangs are ok as we enforce safety via overloads
expectedType = { tagType: expectedType, side: side! };
}
// 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 === expectedType.tagType, expectedType.side);
if (!tags.length) {
const expectedStr = getEnumStr(ArenaTagType, expectedType.tagType);
return {
pass: false,
message: () => `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: getEnumStr(ArenaTagType, expectedType.tagType),
actual: stringifyEnumArray(
ArenaTagType,
received.scene.arena.tags.map(t => t.tagType),
),
};
}
// Pass if any of the matching tags meet our criteria
const pass = tags.some(tag =>
this.equals(tag, expectedType, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
);
const expectedStr = getOnelineDiffStr.call(this, expectedType);
return {
pass,
message: () =>
pass
? `Expected the arena to NOT have a tag matching ${expectedStr}, but it did!`
: `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: expectedType,
actual: tags,
};
}

View File

@ -37,10 +37,8 @@ export function toHaveStatusEffect(
const actualEffect = received.status?.effect ?? StatusEffect.NONE;
// Check exclusively effect equality first, coercing non-matching status effects to numbers.
if (actualEffect !== (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>)?.effect) {
// This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed,
// which will never match actualEffect by definition
expectedStatus = (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>).effect;
if (typeof expectedStatus === "object" && actualEffect !== expectedStatus.effect) {
expectedStatus = expectedStatus.effect;
}
if (typeof expectedStatus === "number") {