mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-06 15:39:27 +02:00
Compare commits
5 Commits
cd683b9e31
...
47794d4e40
Author | SHA1 | Date | |
---|---|---|---|
|
47794d4e40 | ||
|
5bfcb1d379 | ||
|
40443d2afa | ||
|
687a28e85f | ||
|
ba48f16500 |
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
|
26
test/@types/vitest.d.ts
vendored
26
test/@types/vitest.d.ts
vendored
@ -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.
|
||||
*/
|
||||
|
@ -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,
|
||||
|
234
test/moves/entry-hazards.test.ts
Normal file
234
test/moves/entry-hazards.test.ts
Normal 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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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");
|
||||
});
|
||||
});
|
80
test/test-utils/matchers/to-have-arena-tag.ts
Normal file
80
test/test-utils/matchers/to-have-arena-tag.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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") {
|
||||
|
Loading…
Reference in New Issue
Block a user