This commit is contained in:
Bertie690 2025-09-22 21:44:46 -04:00 committed by GitHub
commit 1ac4433deb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 766 additions and 711 deletions

View File

@ -11,8 +11,8 @@ import type { AbAttr } from "#abilities/ability";
* *
* Should never be used with `extends`, as this will nullify the exactness of the type. * Should never be used with `extends`, as this will nullify the exactness of the type.
* *
* As an example, used to ensure that the parameters of {@linkcode AbAttr.canApply} and {@linkcode AbAttr.getTriggerMessage} are compatible with * As an example, used to ensure that the parameters of {@linkcode AbAttr.canApply} and {@linkcode AbAttr.getTriggerMessage}
* the type of its {@linkcode AbAttr.apply | apply} method. * are compatible with the type of its {@linkcode AbAttr.apply | apply} method.
* *
* @typeParam T - The type to match exactly * @typeParam T - The type to match exactly
*/ */
@ -27,16 +27,17 @@ export type Exact<T> = {
export type Closed<X> = X; export type Closed<X> = X;
/** /**
* Remove `readonly` from all properties of the provided type. * Helper type to strip `readonly` from all properties of the provided type.
* @typeParam T - The type to make mutable. * Inverse of {@linkcode Readonly}
* @typeParam T - The type to make mutable
*/ */
export type Mutable<T> = { export type Mutable<T> = {
-readonly [P in keyof T]: T[P]; -readonly [P in keyof T]: T[P];
}; };
/** /**
* Type helper to obtain the keys associated with a given value inside an object. * Type helper to obtain the keys associated with a given value inside an object. \
* Acts similar to {@linkcode Pick}, except checking the object's values instead of its keys. * Functions similarly to `Pick`, but checking assignability of values instead of keys.
* @typeParam O - The type of the object * @typeParam O - The type of the object
* @typeParam V - The type of one of O's values. * @typeParam V - The type of one of O's values.
*/ */
@ -49,6 +50,7 @@ export type InferKeys<O extends object, V> = V extends ObjectValues<O>
/** /**
* Utility type to obtain a union of the values of a given object. \ * Utility type to obtain a union of the values of a given object. \
* Functions similar to `keyof E`, except producing the values instead of the keys. * Functions similar to `keyof E`, except producing the values instead of the keys.
* @typeParam E - The type of the object
* @remarks * @remarks
* This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members. * This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members.
*/ */
@ -85,6 +87,7 @@ export type NonFunctionPropertiesRecursive<Class> = {
: Class[K]; : Class[K];
}; };
/** Utility type for an abstract constructor. */
export type AbstractConstructor<T> = abstract new (...args: any[]) => T; export type AbstractConstructor<T> = abstract new (...args: any[]) => T;
/** /**
@ -103,11 +106,12 @@ export type CoerceNullPropertiesToUndefined<T extends object> = {
* of its properties be present. * of its properties be present.
* *
* Distinct from {@linkcode Partial} as this requires at least 1 property to _not_ be undefined. * Distinct from {@linkcode Partial} as this requires at least 1 property to _not_ be undefined.
* @typeParam T - The type to render partial * @typeParam T - The object type to render partial
*/ */
export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>; export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>;
/** Type helper that adds a brand to a type, used for nominal typing. /**
* Type helper that adds a brand to a type, used for nominal typing.
* *
* @remarks * @remarks
* Brands should be either a string or unique symbol. This prevents overlap with other types. * Brands should be either a string or unique symbol. This prevents overlap with other types.

View File

@ -1120,10 +1120,7 @@ export class PostDefendApplyArenaTrapTagAbAttr extends PostDefendAbAttr {
override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean { override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean {
const tag = globalScene.arena.getTag(this.arenaTagType) as EntryHazardTag; const tag = globalScene.arena.getTag(this.arenaTagType) as EntryHazardTag;
return ( return this.condition(pokemon, attacker, move) && (!tag || tag.canAdd());
this.condition(pokemon, attacker, move)
&& (!globalScene.arena.getTag(this.arenaTagType) || tag.layers < tag.maxLayers)
);
} }
override apply({ simulated, pokemon }: PostMoveInteractionAbAttrParams): void { override apply({ simulated, pokemon }: PostMoveInteractionAbAttrParams): void {

File diff suppressed because it is too large Load Diff

View File

@ -871,7 +871,7 @@ export abstract class Move implements Localizable {
if (!this.hasAttr("TypelessAttr")) { if (!this.hasAttr("TypelessAttr")) {
globalScene.arena.applyTags(WeakenMoveTypeTag, simulated, typeChangeHolder.value, power); globalScene.arena.applyTags(WeakenMoveTypeTag, typeChangeHolder.value, power);
globalScene.applyModifiers(AttackTypeBoosterModifier, source.isPlayer(), source, typeChangeHolder.value, power); globalScene.applyModifiers(AttackTypeBoosterModifier, source.isPlayer(), source, typeChangeHolder.value, power);
} }
@ -1343,7 +1343,7 @@ export class MoveEffectAttr extends MoveAttr {
if ((!move.hasAttr("FlinchAttr") || moveChance.value <= move.chance) && !move.hasAttr("SecretPowerAttr")) { if ((!move.hasAttr("FlinchAttr") || moveChance.value <= move.chance) && !move.hasAttr("SecretPowerAttr")) {
const userSide = user.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; const userSide = user.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
globalScene.arena.applyTagsForSide(ArenaTagType.WATER_FIRE_PLEDGE, userSide, false, moveChance); globalScene.arena.applyTagsForSide(ArenaTagType.WATER_FIRE_PLEDGE, userSide, moveChance);
} }
if (!selfEffect) { if (!selfEffect) {
@ -6063,7 +6063,7 @@ export class AddArenaTrapTagAttr extends AddArenaTagAttr {
if (!tag) { if (!tag) {
return true; return true;
} }
return tag.layers < tag.maxLayers; return tag.canAdd();
}; };
} }
} }
@ -6087,7 +6087,7 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr {
if (!tag) { if (!tag) {
return true; return true;
} }
return tag.layers < tag.maxLayers; return tag.canAdd();
} }
return false; return false;
} }

View File

@ -1,3 +1,6 @@
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import type { NonSerializableArenaTagType, SerializableArenaTagType } from "#types/arena-tags";
/** /**
* Enum representing all different types of {@linkcode ArenaTag}s. * Enum representing all different types of {@linkcode ArenaTag}s.
* @privateRemarks * @privateRemarks

View File

@ -7,7 +7,7 @@ import { globalScene } from "#app/global-scene";
import Overrides from "#app/overrides"; import Overrides from "#app/overrides";
import type { BiomeTierTrainerPools, PokemonPools } from "#balance/biomes"; import type { BiomeTierTrainerPools, PokemonPools } from "#balance/biomes";
import { BiomePoolTier, biomePokemonPools, biomeTrainerPools } from "#balance/biomes"; import { BiomePoolTier, biomePokemonPools, biomeTrainerPools } from "#balance/biomes";
import type { ArenaTag } from "#data/arena-tag"; import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import { EntryHazardTag, getArenaTag } from "#data/arena-tag"; import { EntryHazardTag, getArenaTag } from "#data/arena-tag";
import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
import type { PokemonSpecies } from "#data/pokemon-species"; import type { PokemonSpecies } from "#data/pokemon-species";
@ -651,16 +651,33 @@ export class Arena {
/** /**
* Applies each `ArenaTag` in this Arena, based on which side (self, enemy, or both) is passed in as a parameter * Applies each `ArenaTag` in this Arena, based on which side (self, enemy, or both) is passed in as a parameter
* @param tagType Either an {@linkcode ArenaTagType} string, or an actual {@linkcode ArenaTag} class to filter which ones to apply * @param tagType - A constructor of an ArenaTag to filter tags by
* @param side {@linkcode ArenaTagSide} which side's arena tags to apply * @param side - The {@linkcode ArenaTagSide} dictating which side's arena tags to apply
* @param simulated if `true`, this applies arena tags without changing game state * @param args - Parameters for the tag
* @param args array of parameters that the called upon tags may need * @privateRemarks
* If you get errors mentioning incompatibility with overload signatures, review the arguments being passed
* to ensure they are correct for the tag being used.
*/ */
applyTagsForSide( applyTagsForSide<T extends ArenaTag>(
tagType: ArenaTagType | Constructor<ArenaTag> | AbstractConstructor<ArenaTag>, tagType: Constructor<T> | AbstractConstructor<T>,
side: ArenaTagSide, side: ArenaTagSide,
simulated: boolean, ...args: Parameters<T["apply"]>
...args: unknown[] ): void;
/**
* Applies each `ArenaTag` in this Arena, based on which side (self, enemy, or both) is passed in as a parameter
* @param tagType - The {@linkcode ArenaTagType} of the desired tag
* @param side - The {@linkcode ArenaTagSide} dictating which side's arena tags to apply
* @param args - Parameters for the tag
*/
applyTagsForSide<T extends ArenaTagType>(
tagType: T,
side: ArenaTagSide,
...args: Parameters<ArenaTagTypeMap[T]["apply"]>
): void;
applyTagsForSide<T extends ArenaTag>(
tagType: T["tagType"] | Constructor<T> | AbstractConstructor<ArenaTag>,
side: ArenaTagSide,
...args: Parameters<T["apply"]>
): void { ): void {
let tags = let tags =
typeof tagType === "string" typeof tagType === "string"
@ -669,22 +686,32 @@ export class Arena {
if (side !== ArenaTagSide.BOTH) { if (side !== ArenaTagSide.BOTH) {
tags = tags.filter(t => t.side === side); tags = tags.filter(t => t.side === side);
} }
tags.forEach(t => t.apply(this, simulated, ...args)); tags.forEach(t => t.apply(...args));
} }
/** /**
* Applies the specified tag to both sides (ie: both user and trainer's tag that match the Tag specified) * Applies the specified tag to both sides (ie: both user and trainer's tag that match the Tag specified)
* by calling {@linkcode applyTagsForSide()} * by calling {@linkcode applyTagsForSide()}
* @param tagType Either an {@linkcode ArenaTagType} string, or an actual {@linkcode ArenaTag} class to filter which ones to apply * @param tagType - The {@linkcode ArenaTagType} of the desired tag
* @param simulated if `true`, this applies arena tags without changing game state * @param args - Parameters for the tag
* @param args array of parameters that the called upon tags may need
*/ */
applyTags( applyTags<T extends ArenaTagType>(tagType: T, ...args: Parameters<ArenaTagTypeMap[T]["apply"]>): void;
tagType: ArenaTagType | Constructor<ArenaTag> | AbstractConstructor<ArenaTag>, /**
simulated: boolean, * Applies the specified tag to both sides (ie: both user and trainer's tag that match the Tag specified)
...args: unknown[] * by calling {@linkcode applyTagsForSide()}
): void { * @param tagType - A constructor of an ArenaTag to filter tags by
this.applyTagsForSide(tagType, ArenaTagSide.BOTH, simulated, ...args); * @param args - Parameters for the tag
*/
applyTags<T extends ArenaTag>(
tagType: Constructor<T> | AbstractConstructor<T>,
...args: Parameters<T["apply"]>
): void;
applyTags<T extends ArenaTag>(
tagType: T["tagType"] | Constructor<T> | AbstractConstructor<ArenaTag>,
...args: Parameters<T["apply"]>
) {
// @ts-expect-error - Overload resolution
this.applyTagsForSide(tagType, ArenaTagSide.BOTH, ...args);
} }
/** /**
@ -708,7 +735,7 @@ export class Arena {
): boolean { ): boolean {
const existingTag = this.getTagOnSide(tagType, side); const existingTag = this.getTagOnSide(tagType, side);
if (existingTag) { if (existingTag) {
existingTag.onOverlap(this, globalScene.getPokemonById(sourceId)); existingTag.onOverlap(globalScene.getPokemonById(sourceId));
if (existingTag instanceof EntryHazardTag) { if (existingTag instanceof EntryHazardTag) {
const { tagType, side, turnCount, maxDuration, layers, maxLayers } = existingTag as EntryHazardTag; const { tagType, side, turnCount, maxDuration, layers, maxLayers } = existingTag as EntryHazardTag;
@ -721,7 +748,7 @@ export class Arena {
// creates a new tag object // creates a new tag object
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side); const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side);
if (newTag) { if (newTag) {
newTag.onAdd(this, quiet); newTag.onAdd(quiet);
this.tags.push(newTag); this.tags.push(newTag);
const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {}; const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {};
@ -801,9 +828,9 @@ export class Arena {
lapseTags(): void { lapseTags(): void {
this.tags this.tags
.filter(t => !t.lapse(this)) .filter(t => !t.lapse())
.forEach(t => { .forEach(t => {
t.onRemove(this); t.onRemove();
this.tags.splice(this.tags.indexOf(t), 1); this.tags.splice(this.tags.indexOf(t), 1);
this.eventTarget.dispatchEvent(new TagRemovedEvent(t.tagType, t.side, t.turnCount)); this.eventTarget.dispatchEvent(new TagRemovedEvent(t.tagType, t.side, t.turnCount));
@ -814,7 +841,7 @@ export class Arena {
const tags = this.tags; const tags = this.tags;
const tag = tags.find(t => t.tagType === tagType); const tag = tags.find(t => t.tagType === tagType);
if (tag) { if (tag) {
tag.onRemove(this); tag.onRemove();
tags.splice(tags.indexOf(tag), 1); tags.splice(tags.indexOf(tag), 1);
this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount));
@ -825,7 +852,7 @@ export class Arena {
removeTagOnSide(tagType: ArenaTagType, side: ArenaTagSide, quiet = false): boolean { removeTagOnSide(tagType: ArenaTagType, side: ArenaTagSide, quiet = false): boolean {
const tag = this.getTagOnSide(tagType, side); const tag = this.getTagOnSide(tagType, side);
if (tag) { if (tag) {
tag.onRemove(this, quiet); tag.onRemove(quiet);
this.tags.splice(this.tags.indexOf(tag), 1); this.tags.splice(this.tags.indexOf(tag), 1);
this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount));
@ -835,7 +862,7 @@ export class Arena {
removeAllTags(): void { removeAllTags(): void {
while (this.tags.length > 0) { while (this.tags.length > 0) {
this.tags[0].onRemove(this); this.tags[0].onRemove();
this.eventTarget.dispatchEvent( this.eventTarget.dispatchEvent(
new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount), new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount),
); );

View File

@ -2397,7 +2397,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return moveTypeHolder.value as PokemonType; return moveTypeHolder.value as PokemonType;
} }
globalScene.arena.applyTags(ArenaTagType.ION_DELUGE, simulated, moveTypeHolder); globalScene.arena.applyTags(ArenaTagType.ION_DELUGE, moveTypeHolder);
if (this.getTag(BattlerTagType.ELECTRIFIED)) { if (this.getTag(BattlerTagType.ELECTRIFIED)) {
moveTypeHolder.value = PokemonType.ELECTRIC; moveTypeHolder.value = PokemonType.ELECTRIC;
} }
@ -3704,14 +3704,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// Critical hits should bypass screens // Critical hits should bypass screens
if (!isCritical) { if (!isCritical) {
globalScene.arena.applyTagsForSide( globalScene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, source, moveCategory, screenMultiplier);
WeakenMoveScreenTag,
defendingSide,
simulated,
source,
moveCategory,
screenMultiplier,
);
} }
/** /**
@ -3849,11 +3842,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// apply crit block effects from lucky chant & co., overriding previous effects // apply crit block effects from lucky chant & co., overriding previous effects
const blockCrit = new BooleanHolder(false); const blockCrit = new BooleanHolder(false);
applyAbAttrs("BlockCritAbAttr", { pokemon: this, blockCrit }); applyAbAttrs("BlockCritAbAttr", { pokemon: this, blockCrit });
const blockCritTag = globalScene.arena.getTagOnSide( globalScene.arena.applyTagsForSide(
NoCritTag, NoCritTag,
this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY, this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY,
blockCrit,
); );
isCritical &&= !blockCritTag && !blockCrit.value; // need to roll a crit and not be blocked by either crit prevention effect isCritical &&= !blockCrit.value; // need to roll a crit and not be blocked by either crit prevention effect
return isCritical; return isCritical;
} }

View File

@ -5,9 +5,10 @@ import i18next from "i18next";
/** /**
* Retrieves the Pokemon's name, potentially with an affix indicating its role (wild or foe) in the current battle context, translated * Retrieves the Pokemon's name, potentially with an affix indicating its role (wild or foe) in the current battle context, translated
* @param pokemon {@linkcode Pokemon} name and battle context will be retrieved from this instance * @param pokemon - The {@linkcode Pokemon} to retrieve the name of. Will return 'Missingno' as a fallback if null/undefined
* @param useIllusion - Whether we want the name of the illusion or not. Default value : true * @param useIllusion - Whether we want the name of the illusion or not; default `true`
* @returns ex: "Wild Gengar", "Ectoplasma sauvage" * @returns The localized name of `pokemon` complete with affix. Ex: "Wild Gengar", "Ectoplasma sauvage"
* @todo Remove this and switch to using i18n context selectors based on pokemon trainer class - this causes incorrect locales
*/ */
export function getPokemonNameWithAffix(pokemon: Pokemon | undefined, useIllusion = true): string { export function getPokemonNameWithAffix(pokemon: Pokemon | undefined, useIllusion = true): string {
if (!pokemon) { if (!pokemon) {

View File

@ -18,7 +18,7 @@ export class TurnStartPhase extends FieldPhase {
* Returns an ordering of the current field based on command priority * Returns an ordering of the current field based on command priority
* @returns The sequence of commands for this turn * @returns The sequence of commands for this turn
*/ */
getCommandOrder(): BattlerIndex[] { private getCommandOrder(): BattlerIndex[] {
const playerField = globalScene.getPlayerField(true).map(p => p.getBattlerIndex()); const playerField = globalScene.getPlayerField(true).map(p => p.getBattlerIndex());
const enemyField = globalScene.getEnemyField(true).map(p => p.getBattlerIndex()); const enemyField = globalScene.getEnemyField(true).map(p => p.getBattlerIndex());
const orderedTargets: BattlerIndex[] = playerField.concat(enemyField); const orderedTargets: BattlerIndex[] = playerField.concat(enemyField);

View File

@ -49,8 +49,7 @@ function sortBySpeed<T extends Pokemon | hasPokemon>(pokemonList: T[]): void {
/** 'true' if Trick Room is on the field. */ /** 'true' if Trick Room is on the field. */
const speedReversed = new BooleanHolder(false); const speedReversed = new BooleanHolder(false);
globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, false, speedReversed); globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, speedReversed);
if (speedReversed.value) { if (speedReversed.value) {
pokemonList.reverse(); pokemonList.reverse();
} }

View File

@ -307,7 +307,7 @@ describe("Abilities - Magic Bounce", () => {
expect( expect(
game.scene.arena game.scene.arena
.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER) .getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)
?.getSourcePokemon() ?.["getSourcePokemon"]()
?.getBattlerIndex(), ?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY); ).toBe(BattlerIndex.ENEMY);
game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true); game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true);
@ -319,7 +319,7 @@ describe("Abilities - Magic Bounce", () => {
expect( expect(
game.scene.arena game.scene.arena
.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER) .getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)
?.getSourcePokemon() ?.["getSourcePokemon"]()
?.getBattlerIndex(), ?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY); ).toBe(BattlerIndex.ENEMY);
}); });

View File

@ -0,0 +1,106 @@
import { getPokemonNameWithAffix } from "#app/messages";
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 { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import { getEnumStr } from "#test/test-utils/string-utils";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Arena Tags", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
let playerId: number;
afterAll(() => {
game.phaseInterceptor.restoreOg();
});
beforeAll(async () => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
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);
await game.classicMode.startBattle([SpeciesId.MORELULL]);
playerId = game.field.getPlayerPokemon().id;
});
beforeEach(() => {
// Mock the message queue function to not unshift phases and just spit the text out directly
vi.spyOn(game.scene.phaseManager, "queueMessage").mockImplementation((text, callbackDelay, prompt, promptDelay) =>
game.scene.ui.showText(text, null, null, callbackDelay, prompt, promptDelay),
);
game.textInterceptor.clearLogs();
});
// These tags are either ineligible or just jaaaaaaaaaaank
const FORBIDDEN_TAGS = [ArenaTagType.NONE, ArenaTagType.NEUTRALIZING_GAS] as const;
const sides = getEnumValues(ArenaTagSide);
const arenaTags = Object.values(ArenaTagType)
.filter(t => !(FORBIDDEN_TAGS as readonly ArenaTagType[]).includes(t))
.flatMap(t =>
sides.map(side => ({
tagType: t,
name: toTitleCase(t),
side,
sideName: getEnumStr(ArenaTagSide, side),
})),
);
it.each(arenaTags)(
"$name should display a message on addition, and a separate one on removal - $sideName",
({ tagType, side }) => {
game.scene.arena.addTag(tagType, 0, undefined, playerId, side);
expect(game).toHaveArenaTag(tagType, side);
const tag = game.scene.arena.getTagOnSide(tagType, side)!;
if (tag["onAddMessageKey"]) {
expect(game.textInterceptor.logs).toContain(
i18next.t(tag["onAddMessageKey"], {
pokemonNameWithAffix: getPokemonNameWithAffix(tag["getSourcePokemon"]()),
moveName: tag["getMoveName"](),
}),
);
} else {
expect(game.textInterceptor.logs).toHaveLength(0);
}
game.textInterceptor.clearLogs();
game.scene.arena.removeTagOnSide(tagType, side, false);
if (tag["onRemoveMessageKey"]) {
// TODO: Convert to `game.toHaveShownMessage`
expect(game.textInterceptor.logs).toContain(
i18next.t(tag["onRemoveMessageKey"], {
pokemonNameWithAffix: getPokemonNameWithAffix(tag["getSourcePokemon"]()),
moveName: tag["getMoveName"](),
}),
);
} else {
expect(game.textInterceptor.logs).toHaveLength(0);
}
expect(game).not.toHaveArenaTag(tagType, side);
},
);
});

View File

@ -140,14 +140,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) =
const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
if (globalScene.arena.getTagOnSide(ArenaTagType.AURORA_VEIL, side) && move.getAttrs("CritOnlyAttr").length === 0) { if (globalScene.arena.getTagOnSide(ArenaTagType.AURORA_VEIL, side) && move.getAttrs("CritOnlyAttr").length === 0) {
globalScene.arena.applyTagsForSide( globalScene.arena.applyTagsForSide(ArenaTagType.AURORA_VEIL, side, attacker, move.category, multiplierHolder);
ArenaTagType.AURORA_VEIL,
side,
false,
attacker,
move.category,
multiplierHolder,
);
} }
return move.power * multiplierHolder.value; return move.power * multiplierHolder.value;

View File

@ -0,0 +1,95 @@
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 { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import type { EntryHazardTagType } from "#types/arena-tags";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe.each<{ name: string; move: MoveId; hazard: EntryHazardTagType; hazardName: string }>([
{ name: "Ceaseless Edge", move: MoveId.CEASELESS_EDGE, hazard: ArenaTagType.SPIKES, hazardName: "spikes" },
{ name: "Stone Axe", move: MoveId.STONE_AXE, hazard: ArenaTagType.STEALTH_ROCK, hazardName: "stealth rock" },
])("Move - $name", ({ move, hazard, hazardName }) => {
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.RATTATA)
.ability(AbilityId.NO_GUARD)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.startingLevel(100)
.enemyLevel(100);
});
it(`should hit and apply ${hazardName}`, async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.use(move);
await game.phaseInterceptor.to("MoveEffectPhase", false);
// Spikes should not have any layers before move effect is applied
expect(game).not.toHaveArenaTag(hazard, ArenaTagSide.ENEMY);
await game.toEndOfTurn();
expect(game).toHaveArenaTag({ tagType: hazard, side: ArenaTagSide.ENEMY, layers: 1 });
expect(game.field.getEnemyPokemon()).not.toHaveFullHp();
});
const maxLayers = hazard === ArenaTagType.SPIKES ? 3 : 1;
it(`should not fail if ${hazardName} already has ${maxLayers} layer${maxLayers === 1 ? "" : "s"}`, async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
for (let i = 0; i < maxLayers; i++) {
game.scene.arena.addTag(hazard, 0, undefined, 0, ArenaTagSide.ENEMY);
}
expect(game).toHaveArenaTag({ tagType: hazard, side: ArenaTagSide.ENEMY, layers: maxLayers });
game.move.use(move);
await game.toEndOfTurn();
// Should not have increased due to already being at max layers
expect(game).toHaveArenaTag({ tagType: hazard, side: ArenaTagSide.ENEMY, layers: maxLayers });
const illumise = game.field.getPlayerPokemon();
expect(illumise).toHaveUsedMove({ move, result: MoveResult.SUCCESS });
expect(game.field.getEnemyPokemon()).not.toHaveFullHp();
});
it.runIf(move === MoveId.CEASELESS_EDGE)(
"should apply 1 layer of spikes per hit when given multiple hits",
async () => {
game.override.startingHeldItems([{ name: "MULTI_LENS" }]);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.use(MoveId.CEASELESS_EDGE);
await game.phaseInterceptor.to("MoveEffectPhase");
// Hit 1
expect(game).toHaveArenaTag({ tagType: ArenaTagType.SPIKES, side: ArenaTagSide.ENEMY, layers: 1 });
await game.toEndOfTurn();
// Hit 2
expect(game).toHaveArenaTag({ tagType: ArenaTagType.SPIKES, side: ArenaTagSide.ENEMY, layers: 2 });
expect(game.field.getEnemyPokemon()).not.toHaveFullHp();
},
);
});

View File

@ -1,108 +0,0 @@
import { EntryHazardTag } from "#data/arena-tag";
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { MoveEffectPhase } from "#phases/move-effect-phase";
import { TurnEndPhase } from "#phases/turn-end-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
describe("Moves - Ceaseless Edge", () => {
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.RATTATA)
.enemyAbility(AbilityId.RUN_AWAY)
.enemyPassiveAbility(AbilityId.RUN_AWAY)
.startingLevel(100)
.enemyLevel(100)
.moveset([MoveId.CEASELESS_EDGE, MoveId.SPLASH, MoveId.ROAR])
.enemyMoveset(MoveId.SPLASH);
vi.spyOn(allMoves[MoveId.CEASELESS_EDGE], "accuracy", "get").mockReturnValue(100);
});
test("move should hit and apply spikes", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
const enemyStartingHp = enemyPokemon.hp;
game.move.select(MoveId.CEASELESS_EDGE);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Spikes should not have any layers before move effect is applied
const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag;
expect(tagBefore instanceof EntryHazardTag).toBeFalsy();
await game.phaseInterceptor.to(TurnEndPhase);
const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag;
expect(tagAfter instanceof EntryHazardTag).toBeTruthy();
expect(tagAfter.layers).toBe(1);
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp);
});
test("move should hit twice with multi lens and apply two layers of spikes", async () => {
game.override.startingHeldItems([{ name: "MULTI_LENS" }]);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
const enemyStartingHp = enemyPokemon.hp;
game.move.select(MoveId.CEASELESS_EDGE);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Spikes should not have any layers before move effect is applied
const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag;
expect(tagBefore instanceof EntryHazardTag).toBeFalsy();
await game.phaseInterceptor.to(TurnEndPhase);
const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag;
expect(tagAfter instanceof EntryHazardTag).toBeTruthy();
expect(tagAfter.layers).toBe(2);
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp);
});
test("trainer - move should hit twice, apply two layers of spikes, force switch opponent - opponent takes damage", async () => {
game.override.startingHeldItems([{ name: "MULTI_LENS" }]).startingWave(25);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.select(MoveId.CEASELESS_EDGE);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Spikes should not have any layers before move effect is applied
const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag;
expect(tagBefore instanceof EntryHazardTag).toBeFalsy();
await game.toNextTurn();
const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag;
expect(tagAfter instanceof EntryHazardTag).toBeTruthy();
expect(tagAfter.layers).toBe(2);
const hpBeforeSpikes = game.scene.currentBattle.enemyParty[1].hp;
// Check HP of pokemon that WILL BE switched in (index 1)
game.forceEnemyToSwitch();
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase, false);
expect(game.scene.currentBattle.enemyParty[0].hp).toBeLessThan(hpBeforeSpikes);
});
});

View File

@ -191,9 +191,7 @@ describe("Moves - Destiny Bond", () => {
expect(playerPokemon.isFainted()).toBe(true); expect(playerPokemon.isFainted()).toBe(true);
// Ceaseless Edge spikes effect should still activate // Ceaseless Edge spikes effect should still activate
const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; expect(game).toHaveArenaTag({ tagType: ArenaTagType.SPIKES, side: ArenaTagSide.ENEMY, layers: 1 });
expect(tagAfter.tagType).toBe(ArenaTagType.SPIKES);
expect(tagAfter.layers).toBe(1);
}); });
it("should not cause a crash if the user is KO'd by Pledge moves", async () => { it("should not cause a crash if the user is KO'd by Pledge moves", async () => {

View File

@ -1,5 +1,4 @@
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import { allMoves } from "#data/data-lists";
import type { TypeDamageMultiplier } from "#data/type"; import type { TypeDamageMultiplier } from "#data/type";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagSide } from "#enums/arena-tag-side";
@ -147,7 +146,7 @@ describe("Moves - Entry Hazards", () => {
it.each<{ name: string; layers: number; status: StatusEffect }>([ it.each<{ name: string; layers: number; status: StatusEffect }>([
{ name: "Poison", layers: 1, status: StatusEffect.POISON }, { name: "Poison", layers: 1, status: StatusEffect.POISON },
{ name: "Toxic", layers: 2, status: StatusEffect.TOXIC }, { name: "Toxic", layers: 2, status: StatusEffect.TOXIC },
])("should apply $name at $layers without displaying neutralization msg", async ({ layers, status }) => { ])("should apply $name at $layers", async ({ layers, status }) => {
for (let i = 0; i < layers; i++) { for (let i = 0; i < layers; i++) {
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY); game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY);
} }
@ -155,31 +154,20 @@ describe("Moves - Entry Hazards", () => {
const enemy = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
expect(enemy).toHaveStatusEffect(status); expect(enemy).toHaveStatusEffect(status);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
moveName: allMoves[MoveId.TOXIC_SPIKES].name,
}),
);
}); });
}); });
it("should be removed without triggering upon a grounded Poison-type switching in", async () => { it("should be removed upon a grounded Poison-type switching in", async () => {
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]); await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]);
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY); game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.PLAYER);
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toNextTurn(); await game.toNextTurn();
const ekans = game.field.getPlayerPokemon(); const ekans = game.field.getPlayerPokemon();
expect(game).not.toHaveArenaTag(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER); expect(game).not.toHaveArenaTag(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER);
expect(game.textInterceptor.logs).not.toContain( expect(game.textInterceptor.logs).toContain(i18next.t("arenaTag:toxicSpikesOnRemovePlayer"));
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(ekans),
moveName: allMoves[MoveId.TOXIC_SPIKES].name,
}),
);
expect(ekans).not.toHaveStatusEffect(StatusEffect.POISON); expect(ekans).not.toHaveStatusEffect(StatusEffect.POISON);
}); });
@ -225,7 +213,7 @@ describe("Moves - Entry Hazards", () => {
expect(enemy).toHaveStatStage(Stat.SPD, -1); expect(enemy).toHaveStatStage(Stat.SPD, -1);
expect(game.textInterceptor.logs).toContain( expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:stickyWebActivateTrap", { i18next.t("arenaTag:stickyWebActivateTrap", {
pokemonName: enemy.getNameToRender(), pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
}), }),
); );
}); });

View File

@ -127,15 +127,8 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) =
const multiplierHolder = new NumberHolder(1); const multiplierHolder = new NumberHolder(1);
const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
if (globalScene.arena.getTagOnSide(ArenaTagType.LIGHT_SCREEN, side) && move.getAttrs("CritOnlyAttr").length === 0) { if (globalScene.arena.getTagOnSide(ArenaTagType.LIGHT_SCREEN, side) && !move.hasAttr("CritOnlyAttr")) {
globalScene.arena.applyTagsForSide( globalScene.arena.applyTagsForSide(ArenaTagType.LIGHT_SCREEN, side, attacker, move.category, multiplierHolder);
ArenaTagType.LIGHT_SCREEN,
side,
false,
attacker,
move.category,
multiplierHolder,
);
} }
return move.power * multiplierHolder.value; return move.power * multiplierHolder.value;

View File

@ -1,3 +1,4 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
@ -6,6 +7,7 @@ import { BerryPhase } from "#phases/berry-phase";
import { CommandPhase } from "#phases/command-phase"; import { CommandPhase } from "#phases/command-phase";
import { TurnEndPhase } from "#phases/turn-end-phase"; import { TurnEndPhase } from "#phases/turn-end-phase";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
@ -47,6 +49,11 @@ describe("Moves - Mat Block", () => {
await game.phaseInterceptor.to(BerryPhase, false); await game.phaseInterceptor.to(BerryPhase, false);
leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp()));
expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTags:matBlockApply", {
attackName: allMoves[MoveId.TACKLE].name,
}),
);
}); });
test("should not protect the user and allies from status moves", async () => { test("should not protect the user and allies from status moves", async () => {

View File

@ -144,7 +144,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) =
const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
if (globalScene.arena.getTagOnSide(ArenaTagType.REFLECT, side) && move.getAttrs("CritOnlyAttr").length === 0) { if (globalScene.arena.getTagOnSide(ArenaTagType.REFLECT, side) && move.getAttrs("CritOnlyAttr").length === 0) {
globalScene.arena.applyTagsForSide(ArenaTagType.REFLECT, side, false, attacker, move.category, multiplierHolder); globalScene.arena.applyTagsForSide(ArenaTagType.REFLECT, side, attacker, move.category, multiplierHolder);
} }
return move.power * multiplierHolder.value; return move.power * multiplierHolder.value;

View File

@ -5,6 +5,8 @@ import { enumValueToKey } from "#utils/enums";
import { toTitleCase } from "#utils/strings"; import { toTitleCase } from "#utils/strings";
import type { MatcherState } from "@vitest/expect"; import type { MatcherState } from "@vitest/expect";
import i18next from "i18next"; import i18next from "i18next";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { describe, it } from "vitest";
type Casing = "Preserve" | "Title"; type Casing = "Preserve" | "Title";
@ -22,15 +24,18 @@ interface getEnumStrOptions {
* If present, will be added to the end of the enum string. * If present, will be added to the end of the enum string.
*/ */
suffix?: string; suffix?: string;
/**
* Whether to omit the value from the text.
* @defaultValue Whether `E` is a non-string enum
*/
omitValue?: boolean;
} }
/** /**
* Return the name of an enum member or const object value, alongside its corresponding value. * Return the name of an enum member or const object value, alongside its corresponding value.
* @param obj - The {@linkcode EnumOrObject} to source reverse mappings from * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from
* @param enums - One of {@linkcode obj}'s values * @param val - One of {@linkcode obj}'s values
* @param casing - A string denoting the casing method to use; default `Preserve` * @param options - Options modifying the stringification process.
* @param prefix - An optional string to be prepended to the enum's string representation
* @param suffix - An optional string to be appended to the enum's string representation
* @returns The stringified representation of `val` as dictated by the options. * @returns The stringified representation of `val` as dictated by the options.
* @example * @example
* ```ts * ```ts
@ -46,8 +51,9 @@ interface getEnumStrOptions {
export function getEnumStr<E extends EnumOrObject>( export function getEnumStr<E extends EnumOrObject>(
obj: E, obj: E,
val: ObjectValues<E>, val: ObjectValues<E>,
{ casing = "Preserve", prefix = "", suffix = "" }: getEnumStrOptions = {}, options: getEnumStrOptions = {},
): string { ): string {
const { casing = "Preserve", prefix = "", suffix = "", omitValue = typeof val === "number" } = options;
let casingFunc: ((s: string) => string) | undefined; let casingFunc: ((s: string) => string) | undefined;
switch (casing) { switch (casing) {
case "Preserve": case "Preserve":
@ -68,7 +74,7 @@ export function getEnumStr<E extends EnumOrObject>(
stringPart = casingFunc(stringPart); stringPart = casingFunc(stringPart);
} }
return `${prefix}${stringPart}${suffix} (=${val})`; return `${prefix}${stringPart}${suffix}${omitValue ? ` (=${val})` : ""}`;
} }
/** /**
@ -87,7 +93,7 @@ export function getEnumStr<E extends EnumOrObject>(
* ``` * ```
*/ */
export function stringifyEnumArray<E extends EnumOrObject>(obj: E, enums: E[keyof E][]): string { export function stringifyEnumArray<E extends EnumOrObject>(obj: E, enums: E[keyof E][]): string {
if (obj.length === 0) { if (enums.length === 0) {
return "[]"; return "[]";
} }