diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index 048a86ab489..f4fb53e351f 100644 --- a/src/@types/helpers/type-helpers.ts +++ b/src/@types/helpers/type-helpers.ts @@ -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. * - * As an example, used to ensure that the parameters of {@linkcode AbAttr.canApply} and {@linkcode AbAttr.getTriggerMessage} are compatible with - * the type of its {@linkcode AbAttr.apply | apply} method. + * As an example, used to ensure that the parameters of {@linkcode AbAttr.canApply} and {@linkcode AbAttr.getTriggerMessage} + * are compatible with the type of its {@linkcode AbAttr.apply | apply} method. * * @typeParam T - The type to match exactly */ @@ -27,16 +27,17 @@ export type Exact = { export type Closed = X; /** - * Remove `readonly` from all properties of the provided type. - * @typeParam T - The type to make mutable. + * Helper type to strip `readonly` from all properties of the provided type. + * Inverse of {@linkcode Readonly} + * @typeParam T - The type to make mutable */ export type Mutable = { -readonly [P in keyof T]: T[P]; }; /** - * 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. + * Type helper to obtain the keys associated with a given value inside an object. \ + * Functions similarly to `Pick`, but checking assignability of values instead of keys. * @typeParam O - The type of the object * @typeParam V - The type of one of O's values. */ @@ -49,6 +50,7 @@ export type InferKeys = V extends ObjectValues /** * 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. + * @typeParam E - The type of the object * @remarks * 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[K]; }; +/** Utility type for an abstract constructor. */ export type AbstractConstructor = abstract new (...args: any[]) => T; /** @@ -103,11 +106,12 @@ export type CoerceNullPropertiesToUndefined = { * of its properties be present. * * 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 = Partial & ObjectValues<{ [K in keyof T]: Pick, 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 * Brands should be either a string or unique symbol. This prevents overlap with other types. diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index f6494548b99..7ee8d206528 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1120,10 +1120,7 @@ export class PostDefendApplyArenaTrapTagAbAttr extends PostDefendAbAttr { override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean { const tag = globalScene.arena.getTag(this.arenaTagType) as EntryHazardTag; - return ( - this.condition(pokemon, attacker, move) - && (!globalScene.arena.getTag(this.arenaTagType) || tag.layers < tag.maxLayers) - ); + return this.condition(pokemon, attacker, move) && (!tag || tag.canAdd()); } override apply({ simulated, pokemon }: PostMoveInteractionAbAttrParams): void { diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index fd64e271758..a3831e73702 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -44,9 +44,9 @@ * @module */ -// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */ import type { BattlerTag } from "#app/data/battler-tags"; -// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports +/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */ import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; @@ -68,6 +68,7 @@ import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; import type { Arena } from "#field/arena"; import type { Pokemon } from "#field/pokemon"; +import { isSpreadMove } from "#moves/move-utils"; import type { ArenaScreenTagType, ArenaTagData, @@ -112,14 +113,42 @@ interface BaseArenaTag { * the Pokemon currently on-field, only cleared on arena reset or through their respective {@linkcode ArenaTag.lapse | lapse} methods. */ export abstract class ArenaTag implements BaseArenaTag { - /** The type of the arena tag */ + /** The type of the arena tag. */ public abstract readonly tagType: ArenaTagType; + // Intentionally left undocumented to inherit comments from interface public turnCount: number; public maxDuration: number; public sourceMove?: MoveId; public sourceId: number | undefined; public side: ArenaTagSide; + /** + * Return the i18n locales key that will be shown when this tag is added. \ + * Within the text, `{{pokemonNameWithAffix}}` and `{{moveName}}` will be populated with + * the name of the Pokemon that added the tag and the name of the move that created the tag, respectively. + * @remarks + * If this evaluates to an empty string, no message will be displayed. + */ + protected abstract get onAddMessageKey(): string; + + /** + * Return the i18n locales key that will be shown when this tag is removed. \ + * Within the text, `{{pokemonNameWithAffix}}` and `{{moveName}}` will be populated with + * the name of the Pokemon that added the tag and the name of the move that created the tag, respectively. + * @remarks + * If this evaluates to an empty string, no message will be displayed. + */ + protected abstract get onRemoveMessageKey(): string; + + /** + * @returns A suffix corresponding to this tag's current side. + * @sealed + * @todo Make this an i18n context + */ + protected get i18nSideKey(): string { + return this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""; + } + constructor(turnCount: number, sourceMove?: MoveId, sourceId?: number, side: ArenaTagSide = ArenaTagSide.BOTH) { this.turnCount = turnCount; this.maxDuration = turnCount; @@ -128,39 +157,70 @@ export abstract class ArenaTag implements BaseArenaTag { this.side = side; } - apply(_arena: Arena, _simulated: boolean, ..._args: unknown[]): boolean { - return true; - } - - onAdd(_arena: Arena, _quiet = false): void {} - - onRemove(_arena: Arena, quiet = false): void { - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:arenaOnRemove${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - { moveName: this.getMoveName() }, - ), - ); - } - } - - onOverlap(_arena: Arena, _source: Pokemon | undefined): void {} + /** + * Apply this tag's effects during a turn. + * @param _args - Arguments used by subclasses. + * @todo Remove all boolean return values from subclasses + * @todo Move all classes with `apply` triggers into a unique sub-class to prevent + * applying effects of tags that lack effect application + */ + public apply(..._args: unknown[]): void {} /** - * Trigger this {@linkcode ArenaTag}'s effect, reducing its duration as applicable. + * Trigger effects when this tag is added to the Arena. + * By default, will queue a message with the contents of {@linkcode getOnAddMessage}. + * @param quiet - Whether to suppress any messages created during tag addition; default `false` + */ + public onAdd(quiet = false): void { + if (quiet || !this.onAddMessageKey) { + return; + } + + globalScene.phaseManager.queueMessage( + i18next.t(this.onAddMessageKey, { + pokemonNameWithAffix: getPokemonNameWithAffix(this.getSourcePokemon()), + moveName: this.getMoveName(), + }), + ); + } + + /** + * Trigger effects when this tag is removed from the Arena. + * By default, will queue a message with the contents of {@linkcode getOnRemoveMessage}. + * @param quiet - Whether to suppress any messages created during tag addition; default `false` + */ + public onRemove(quiet = false): void { + if (quiet || !this.onRemoveMessageKey) { + return; + } + + globalScene.phaseManager.queueMessage( + i18next.t(this.onRemoveMessageKey, { + pokemonNameWithAffix: getPokemonNameWithAffix(this.getSourcePokemon()), + moveName: this.getMoveName(), + }), + ); + } + + /** + * Apply effects when this Tag overlaps by creating a new instance while one is already present. + * @param _source - The {@linkcode Pokemon} having added the tag, or `undefined` if no pokemon did + * @todo Rather than passing this `undefined`, maybe just... don't pass the tags? + */ + public onOverlap(_source: Pokemon | undefined): void {} + + /** + * Reduce this {@linkcode ArenaTag}'s duration and apply any end-of-turn effects * Will ignore durations of all tags with durations `<=0`. - * @param _arena - The {@linkcode Arena} at the moment the tag is being lapsed. - * Unused by default but can be used by sub-classes. * @returns `true` if this tag should be kept; `false` if it should be removed. */ - lapse(_arena: Arena): boolean { + lapse(): boolean { // TODO: Rather than treating negative duration tags as being indefinite, // make all duration based classes inherit from their own sub-class return this.turnCount < 1 || --this.turnCount > 0; } - getMoveName(): string | null { + protected getMoveName(): string | null { return this.sourceMove ? allMoves[this.sourceMove].name : null; } @@ -178,28 +238,37 @@ export abstract class ArenaTag implements BaseArenaTag { } /** - * Helper function that retrieves the source Pokemon + * Helper function that retrieves the source Pokemon. * @returns - The source {@linkcode Pokemon} for this tag. + * Returns `undefined` if `this.sourceId` is `undefined` */ - public getSourcePokemon(): Pokemon | undefined { + protected getSourcePokemon(): Pokemon | undefined { return globalScene.getPokemonById(this.sourceId); } /** - * Helper function that retrieves the Pokemon affected - * @returns list of PlayerPokemon or EnemyPokemon on the field + * Helper function that retrieves the Pokemon affected. + * @returns An array containing all {@linkcode Pokemon} affected by this Tag. */ - public getAffectedPokemon(): Pokemon[] { + protected getAffectedPokemon(): Pokemon[] { switch (this.side) { case ArenaTagSide.PLAYER: - return globalScene.getPlayerField() ?? []; + return globalScene.getPlayerField(); case ArenaTagSide.ENEMY: - return globalScene.getEnemyField() ?? []; + return globalScene.getEnemyField(); case ArenaTagSide.BOTH: - default: - return globalScene.getField(true) ?? []; + return globalScene.getField(true); } } + + /** + * Return whether this Tag can affect the given Pokemon, based on this tag's {@linkcode side}. + * @param pokemon - The {@linkcode Pokemon} to check + * @returns Whether this tag can affect `pokemon`. + */ + protected canAffect(pokemon: Pokemon) { + return this.getAffectedPokemon().includes(pokemon); + } } /** @@ -219,37 +288,23 @@ export class MistTag extends SerializableArenaTag { super(turnCount, MoveId.MIST, sourceId, side); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + protected override get onAddMessageKey(): string { + return "arenaTag:mistOnAdd" + this.i18nSideKey; + } - // We assume `quiet=true` means "just add the bloody tag no questions asked" - if (quiet) { - return; - } - - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for MistTag on add message; id: ${this.sourceId}`); - return; - } - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:mistOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); + protected override get onRemoveMessageKey(): string { + return "arenaTag:mistOnRemove" + this.i18nSideKey; } /** * Cancels the lowering of stats - * @param _arena the {@linkcode Arena} containing this effect * @param simulated `true` if the effect should be applied quietly * @param attacker the {@linkcode Pokemon} using a move into this effect. * @param cancelled a {@linkcode BooleanHolder} whose value is set to `true` * to flag the stat reduction as cancelled * @returns `true` if a stat reduction was cancelled; `false` otherwise */ - override apply(_arena: Arena, simulated: boolean, attacker: Pokemon, cancelled: BooleanHolder): boolean { + override apply(simulated: boolean, attacker: Pokemon | null, cancelled: BooleanHolder): boolean { // `StatStageChangePhase` currently doesn't have a reference to the source of stat drops, // so this code currently has no effect on gameplay. if (attacker) { @@ -281,32 +336,23 @@ export abstract class WeakenMoveScreenTag extends SerializableArenaTag { /** * Applies the weakening effect to the move. - * - * @param _arena the {@linkcode Arena} where the move is applied. - * @param _simulated n/a * @param attacker the attacking {@linkcode Pokemon} * @param moveCategory the attacking move's {@linkcode MoveCategory}. * @param damageMultiplier A {@linkcode NumberHolder} containing the damage multiplier * @returns `true` if the attacking move was weakened; `false` otherwise. */ - override apply( - _arena: Arena, - _simulated: boolean, - attacker: Pokemon, - moveCategory: MoveCategory, - damageMultiplier: NumberHolder, - ): boolean { - if (this.weakenedCategories.includes(moveCategory)) { - const bypassed = new BooleanHolder(false); - applyAbAttrs("InfiltratorAbAttr", { pokemon: attacker, bypassed }); - if (bypassed.value) { - return false; - } - // Screens are less effective in Double Battles - damageMultiplier.value = globalScene.currentBattle.double ? 2 / 3 : 1 / 2; - return true; + override apply(attacker: Pokemon, moveCategory: MoveCategory, damageMultiplier: NumberHolder): boolean { + if (!this.weakenedCategories.includes(moveCategory)) { + return false; } - return false; + const bypassed = new BooleanHolder(false); + applyAbAttrs("InfiltratorAbAttr", { pokemon: attacker, bypassed }); + if (bypassed.value) { + return false; + } + // Screens are less effective in Double Battles + damageMultiplier.value = globalScene.currentBattle.double ? 2 / 3 : 1 / 2; + return true; } } @@ -316,7 +362,7 @@ export abstract class WeakenMoveScreenTag extends SerializableArenaTag { */ class ReflectTag extends WeakenMoveScreenTag { public readonly tagType = ArenaTagType.REFLECT; - protected get weakenedCategories(): [MoveCategory.PHYSICAL] { + protected override get weakenedCategories(): [MoveCategory.PHYSICAL] { return [MoveCategory.PHYSICAL]; } @@ -324,14 +370,12 @@ class ReflectTag extends WeakenMoveScreenTag { super(turnCount, MoveId.REFLECT, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:reflectOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } + protected override get onAddMessageKey(): string { + return "arenaTag:reflectOnAdd" + this.i18nSideKey; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:reflectOnRemove" + this.i18nSideKey; } } @@ -341,21 +385,18 @@ class ReflectTag extends WeakenMoveScreenTag { */ class LightScreenTag extends WeakenMoveScreenTag { public readonly tagType = ArenaTagType.LIGHT_SCREEN; - protected get weakenedCategories(): [MoveCategory.SPECIAL] { + protected override get weakenedCategories(): [MoveCategory.SPECIAL] { return [MoveCategory.SPECIAL]; } constructor(turnCount: number, sourceId: number | undefined, side: ArenaTagSide) { super(turnCount, MoveId.LIGHT_SCREEN, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:lightScreenOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } + protected override get onAddMessageKey(): string { + return "arenaTag:lightScreenOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:lightScreenOnRemove" + this.i18nSideKey; } } @@ -365,7 +406,7 @@ class LightScreenTag extends WeakenMoveScreenTag { */ class AuroraVeilTag extends WeakenMoveScreenTag { public readonly tagType = ArenaTagType.AURORA_VEIL; - protected get weakenedCategories(): [MoveCategory.PHYSICAL, MoveCategory.SPECIAL] { + protected override get weakenedCategories(): [MoveCategory.PHYSICAL, MoveCategory.SPECIAL] { return [MoveCategory.PHYSICAL, MoveCategory.SPECIAL]; } @@ -373,71 +414,72 @@ class AuroraVeilTag extends WeakenMoveScreenTag { super(turnCount, MoveId.AURORA_VEIL, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:auroraVeilOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } + protected override get onAddMessageKey(): string { + return "arenaTag:auroraVeilOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:auroraVeilOnRemove" + this.i18nSideKey; } } -type ProtectConditionFunc = (arena: Arena, moveId: MoveId) => boolean; +type ProtectConditionFunc = (moveId: MoveId) => boolean; /** * Class to implement conditional team protection * applies protection based on the attributes of incoming moves */ export abstract class ConditionalProtectTag extends ArenaTag { - /** The condition function to determine which moves are negated */ - protected protectConditionFunc: ProtectConditionFunc; /** * Whether this protection effect should apply to _all_ moves, including ones that ignore other forms of protection. * @defaultValue `false` */ protected ignoresBypass: boolean; - constructor( - sourceMove: MoveId, - sourceId: number | undefined, - side: ArenaTagSide, - condition: ProtectConditionFunc, - ignoresBypass = false, - ) { + constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide, ignoresBypass = false) { super(1, sourceMove, sourceId, side); - this.protectConditionFunc = condition; this.ignoresBypass = ignoresBypass; } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:conditionalProtectOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - { moveName: super.getMoveName() }, - ), - ); + protected override get onAddMessageKey(): string { + return "arenaTag:conditionalProtectOnAdd" + this.i18nSideKey; } - // Removes default message for effect removal - onRemove(_arena: Arena): void {} + protected override get onRemoveMessageKey(): string { + return ""; + } + + /** + * The condition function to determine which moves are negated. + */ + protected abstract get condition(): ProtectConditionFunc; + + /** + * Return the message key that will be used when protecting an allied target. + * Within the text, the following variables will be populated: + * - `{{pokemonNameWithAffix}}`: The name of the Pokemon protected by the attack + * - `{{moveName}}`: The name of the move that created the tag + * - `{{attackName}}`: The name of the move that _triggered_ the protection effect. + * @defaultValue `arenaTag:conditionalProtectApply` + */ + protected get onProtectMessageKey(): string { + return "arenaTag:conditionalProtectApply"; + } /** * Checks incoming moves against the condition function * and protects the target if conditions are met - * @param arena the {@linkcode Arena} containing this tag - * @param simulated `true` if the tag is applied quietly; `false` otherwise. - * @param isProtected a {@linkcode BooleanHolder} used to flag if the move is protected against - * @param _attacker the attacking {@linkcode Pokemon} - * @param defender the defending {@linkcode Pokemon} - * @param moveId the {@linkcode MoveId | identifier} for the move being used - * @param ignoresProtectBypass a {@linkcode BooleanHolder} used to flag if a protection effect supercedes effects that ignore protection + * @param simulated - `true` if the tag is applied quietly; `false` otherwise. + * @param isProtected - A {@linkcode BooleanHolder} used to flag if the move is protected against + * @param _attacker - The attacking {@linkcode Pokemon} + * @param defender - The defending {@linkcode Pokemon} + * @param moveId - The {@linkcode MoveId} of the move being used + * @param ignoresProtectBypass - A {@linkcode BooleanHolder} used to flag if a protection effect superceded effects that ignore protection * @returns `true` if this tag protected against the attack; `false` otherwise */ override apply( - arena: Arena, + // TODO: `_attacker` is unused by all classes + // TODO: Simulated is only ever passed as `false` here... simulated: boolean, isProtected: BooleanHolder, _attacker: Pokemon, @@ -445,36 +487,44 @@ export abstract class ConditionalProtectTag extends ArenaTag { moveId: MoveId, ignoresProtectBypass: BooleanHolder, ): boolean { - if ((this.side === ArenaTagSide.PLAYER) === defender.isPlayer() && this.protectConditionFunc(arena, moveId)) { - if (!isProtected.value) { - isProtected.value = true; - if (!simulated) { - new CommonBattleAnim(CommonAnim.PROTECT, defender).play(); - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:conditionalProtectApply", { - moveName: super.getMoveName(), - pokemonNameWithAffix: getPokemonNameWithAffix(defender), - }), - ); - } - } - - ignoresProtectBypass.value = ignoresProtectBypass.value || this.ignoresBypass; - return true; + if (!this.canAffect(defender)) { + return false; } - return false; + + if (!this.condition(moveId)) { + return false; + } + + if (isProtected.value) { + return false; + } + + isProtected.value = true; + if (!simulated) { + // TODO: This is a floating animation promise + new CommonBattleAnim(CommonAnim.PROTECT, defender).play(); + globalScene.phaseManager.queueMessage( + i18next.t(this.onProtectMessageKey, { + pokemonNameWithAffix: getPokemonNameWithAffix(defender), + moveName: this.getMoveName(), + attackName: allMoves[moveId].name, + }), + ); + } + + ignoresProtectBypass.value ||= this.ignoresBypass; + return true; } } /** * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Guard_(move) Quick Guard's} * protection effect. - * @param _arena {@linkcode Arena} The arena containing the protection effect * @param moveId {@linkcode MoveId} The move to check against this condition * @returns `true` if the incoming move's priority is greater than 0. * This includes moves with modified priorities from abilities (e.g. Prankster) */ -const QuickGuardConditionFunc: ProtectConditionFunc = (_arena, moveId) => { +const QuickGuardConditionFunc: ProtectConditionFunc = moveId => { const move = allMoves[moveId]; const effectPhase = globalScene.phaseManager.getCurrentPhase(); @@ -494,30 +544,14 @@ const QuickGuardConditionFunc: ProtectConditionFunc = (_arena, moveId) => { class QuickGuardTag extends ConditionalProtectTag { public readonly tagType = ArenaTagType.QUICK_GUARD; constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.QUICK_GUARD, sourceId, side, QuickGuardConditionFunc); + super(MoveId.QUICK_GUARD, sourceId, side); + } + + override get condition(): ProtectConditionFunc { + return QuickGuardConditionFunc; } } -/** - * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Wide_Guard_(move) Wide Guard's} - * protection effect. - * @param _arena {@linkcode Arena} The arena containing the protection effect - * @param moveId {@linkcode MoveId} The move to check against this condition - * @returns `true` if the incoming move is multi-targeted (even if it's only used against one Pokemon). - */ -const WideGuardConditionFunc: ProtectConditionFunc = (_arena, moveId): boolean => { - const move = allMoves[moveId]; - - switch (move.moveTarget) { - case MoveTarget.ALL_ENEMIES: - case MoveTarget.ALL_NEAR_ENEMIES: - case MoveTarget.ALL_OTHERS: - case MoveTarget.ALL_NEAR_OTHERS: - return true; - } - return false; -}; - /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wide_Guard_(move) Wide Guard} * Condition: The incoming move can target multiple Pokemon. The move's source @@ -526,22 +560,14 @@ const WideGuardConditionFunc: ProtectConditionFunc = (_arena, moveId): boolean = class WideGuardTag extends ConditionalProtectTag { public readonly tagType = ArenaTagType.WIDE_GUARD; constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.WIDE_GUARD, sourceId, side, WideGuardConditionFunc); + super(MoveId.WIDE_GUARD, sourceId, side); + } + + override get condition(): ProtectConditionFunc { + return m => isSpreadMove(allMoves[m]); } } -/** - * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Mat_Block_(move) Mat Block's} - * protection effect. - * @param _arena {@linkcode Arena} The arena containing the protection effect. - * @param moveId {@linkcode MoveId} The move to check against this condition. - * @returns `true` if the incoming move is not a Status move. - */ -const MatBlockConditionFunc: ProtectConditionFunc = (_arena, moveId): boolean => { - const move = allMoves[moveId]; - return move.category !== MoveCategory.STATUS; -}; - /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Mat_Block_(move) Mat Block} * Condition: The incoming move is a Physical or Special attack move. @@ -549,34 +575,30 @@ const MatBlockConditionFunc: ProtectConditionFunc = (_arena, moveId): boolean => class MatBlockTag extends ConditionalProtectTag { public readonly tagType = ArenaTagType.MAT_BLOCK; constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.MAT_BLOCK, sourceId, side, MatBlockConditionFunc); + super(MoveId.MAT_BLOCK, sourceId, side); } - onAdd(_arena: Arena) { - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for Mat Block message; id: ${this.sourceId}`); - return; - } + protected override get onAddMessageKey(): string { + return "arenaTag:matBlockOnAdd"; + } - super.onAdd(_arena); - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:matBlockOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); + protected override get onProtectMessageKey(): string { + return "arenaTag:matBlockApply"; + } + + protected override get condition(): ProtectConditionFunc { + return m => allMoves[m].category !== MoveCategory.STATUS; } } /** * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Crafty_Shield_(move) Crafty Shield's} * protection effect. - * @param _arena {@linkcode Arena} The arena containing the protection effect * @param moveId {@linkcode MoveId} The move to check against this condition * @returns `true` if the incoming move is a Status move, is not a hazard, and does not target all * Pokemon or sides of the field. */ -const CraftyShieldConditionFunc: ProtectConditionFunc = (_arena, moveId) => { +const CraftyShieldConditionFunc: ProtectConditionFunc = moveId => { const move = allMoves[moveId]; return ( move.category === MoveCategory.STATUS @@ -594,7 +616,11 @@ const CraftyShieldConditionFunc: ProtectConditionFunc = (_arena, moveId) => { class CraftyShieldTag extends ConditionalProtectTag { public readonly tagType = ArenaTagType.CRAFTY_SHIELD; constructor(sourceId: number | undefined, side: ArenaTagSide) { - super(MoveId.CRAFTY_SHIELD, sourceId, side, CraftyShieldConditionFunc, true); + super(MoveId.CRAFTY_SHIELD, sourceId, side, true); + } + + protected override get condition(): ProtectConditionFunc { + return CraftyShieldConditionFunc; } } @@ -605,29 +631,15 @@ class CraftyShieldTag extends ConditionalProtectTag { export class NoCritTag extends SerializableArenaTag { public readonly tagType = ArenaTagType.NO_CRIT; - /** Queues a message upon adding this effect to the field */ - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage( - i18next.t(`arenaTag:noCritOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : "Enemy"}`, { - moveName: this.getMoveName(), - }), - ); + protected override get onAddMessageKey(): string { + return "arenaTag:noCritOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:noCritOnRemove" + this.i18nSideKey; } - /** Queues a message upon removing this effect from the field */ - onRemove(_arena: Arena): void { - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for NoCritTag on remove message; id: ${this.sourceId}`); - return; - } - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:noCritOnRemove", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - moveName: this.getMoveName(), - }), - ); + public override apply(blockCrit: BooleanHolder): void { + blockCrit.value = true; } } @@ -640,13 +652,11 @@ export abstract class WeakenMoveTypeTag extends SerializableArenaTag { /** * Reduces an attack's power by 0.33x if it matches this tag's weakened type. - * @param _arena n/a - * @param _simulated n/a * @param type the attack's {@linkcode PokemonType} * @param power a {@linkcode NumberHolder} containing the attack's power * @returns `true` if the attack's power was reduced; `false` otherwise. */ - override apply(_arena: Arena, _simulated: boolean, type: PokemonType, power: NumberHolder): boolean { + override apply(type: PokemonType, power: NumberHolder): boolean { if (type === this.weakenedType) { power.value *= 0.33; return true; @@ -668,12 +678,12 @@ class MudSportTag extends WeakenMoveTypeTag { super(turnCount, MoveId.MUD_SPORT, sourceId); } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:mudSportOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:mudSportOnAdd"; } - onRemove(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:mudSportOnRemove")); + protected override get onRemoveMessageKey(): string { + return "arenaTag:mudSportOnRemove"; } } @@ -690,12 +700,12 @@ class WaterSportTag extends WeakenMoveTypeTag { super(turnCount, MoveId.WATER_SPORT, sourceId); } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:waterSportOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:waterSportOnAdd"; } - onRemove(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:waterSportOnRemove")); + protected override get onRemoveMessageKey(): string { + return "arenaTag:waterSportOnRemove"; } } @@ -710,21 +720,20 @@ export class IonDelugeTag extends ArenaTag { super(1, sourceMove); } - /** Queues an on-add message */ - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:plasmaFistsOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:plasmaFistsOnAdd"; } - onRemove(_arena: Arena): void {} // Removes default on-remove message + protected override get onRemoveMessageKey(): string { + return ""; + } /** * Converts Normal-type moves to Electric type - * @param _arena n/a - * @param _simulated n/a * @param moveType a {@linkcode NumberHolder} containing a move's {@linkcode PokemonType} * @returns `true` if the given move type changed; `false` otherwise. */ - override apply(_arena: Arena, _simulated: boolean, moveType: NumberHolder): boolean { + override apply(moveType: NumberHolder): boolean { if (moveType.value === PokemonType.NORMAL) { moveType.value = PokemonType.ELECTRIC; return true; @@ -743,8 +752,10 @@ export abstract class EntryHazardTag extends SerializableArenaTag { /** * The current number of layers this tag has. * Starts at 1 and increases each time the trap is laid. + * Should not be accessed by anything other than this class (with exceptions for arena flyout-related code). + * @protected */ - public layers = 1; + public readonly layers: number = 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` */ @@ -756,62 +767,35 @@ export abstract class EntryHazardTag extends SerializableArenaTag { super(0, sourceMove, sourceId, side); } - // TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts - /** - * Display text when this tag is added to the field. - * @param _arena - The {@linkcode Arena} at the time of adding this tag - * @param quiet - Whether to suppress messages during tag creation; default `false` + * Check if this tag can have more layers added to it. + * @returns Whether this tag can have another layer added to it. */ - override onAdd(_arena: Arena, quiet = false): void { - // Here, `quiet=true` means "just add the tag, no questions asked" - if (quiet) { - return; - } - - const source = this.getSourcePokemon(); - if (!source) { - console.warn( - // biome-ignore lint/complexity/noUselessStringConcat: Rule bugs out with operator linebreaks set to `before` - "Failed to get source Pokemon for AernaTrapTag on add message!" - + `\nTag type: ${this.tagType}` - + `\nPID: ${this.sourceId}`, - ); - return; - } - - globalScene.phaseManager.queueMessage(this.getAddMessage(source)); + public canAdd(): boolean { + return this.layers < this.maxLayers; } - /** - * 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) { + override onOverlap(): void { + if (!this.canAdd()) { return; } - this.layers++; + (this as Mutable).layers++; - this.onAdd(arena); + this.onAdd(); } /** * 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. + * @todo Do we need the return value? nothing uses it */ - override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean { - if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) { + override apply(simulated: boolean, pokemon: Pokemon): boolean { + if (!this.canAffect(pokemon)) { return false; } @@ -819,17 +803,17 @@ export abstract class EntryHazardTag extends SerializableArenaTag { return false; } - return this.activateTrap(pokemon, simulated); + return this.activateTrap(simulated, pokemon); } /** * Activate this trap's effects when a Pokemon switches into it. - * @param _pokemon - The {@linkcode Pokemon} - * @param _simulated - Whether the activation is simulated + * @param simulated - Whether the activation is simulated + * @param pokemon - The {@linkcode Pokemon} switching in * @returns Whether the trap activation succeeded * @todo Do we need the return value? nothing uses it */ - protected abstract activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean; + protected abstract activateTrap(simulated: boolean, pokemon: Pokemon): boolean; getMatchupScoreMultiplier(pokemon: Pokemon): number { return pokemon.isGrounded() @@ -839,7 +823,7 @@ export abstract class EntryHazardTag extends SerializableArenaTag { public loadTag(source: BaseArenaTag & Pick): void { super.loadTag(source); - this.layers = source.layers; + (this as Mutable).layers = source.layers; } } @@ -848,7 +832,14 @@ export abstract class EntryHazardTag extends SerializableArenaTag { * Currently used for {@linkcode SpikesTag} and {@linkcode StealthRockTag}. */ abstract class DamagingTrapTag extends EntryHazardTag { - override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { + /** + * Damage a target that switches into this Tag while active. + * @param simulated - Whether the activation is simulated + * @param pokemon - The {@linkcode Pokemon} switching in + * @returns Whether the trap activation succeeded + * @sealed + */ + override activateTrap(simulated: boolean, pokemon: Pokemon): boolean { // Check for magic guard immunity const cancelled = new BooleanHolder(false); applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled }); @@ -864,18 +855,22 @@ abstract class DamagingTrapTag extends EntryHazardTag { const damageHpRatio = this.getDamageHpRatio(pokemon); const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); - globalScene.phaseManager.queueMessage(this.getTriggerMessage(pokemon)); + globalScene.phaseManager.queueMessage( + i18next.t(this.triggerMessageKey, { + pokemonNameWithAffix: getPokemonNameWithAffix(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. + * Return the i18n key of the text to be displayed when this tag deals damage. + * Within the text, `{{pokemonNameWithAffix}}` will be populated with the victim's name. + * @returns The locales key for the trigger message to be displayed on-screen. */ - protected abstract getTriggerMessage(_pokemon: Pokemon): string; + protected abstract get triggerMessageKey(): string; /** * Return the amount of damage this tag should deal to the given Pokemon, relative to its maximum HP. @@ -900,17 +895,16 @@ class SpikesTag extends DamagingTrapTag { super(MoveId.SPIKES, sourceId, side); } - protected override getAddMessage(source: Pokemon): string { - return i18next.t("arenaTag:spikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }); + protected override get onAddMessageKey(): string { + return "arenaTag:spikesOnAdd" + this.i18nSideKey; } - protected override getTriggerMessage(pokemon: Pokemon): string { - return i18next.t("arenaTag:spikesActivateTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }); + protected override get onRemoveMessageKey(): string { + return "arenaTag:spikesOnAdd" + this.i18nSideKey; + } + + protected override get triggerMessageKey(): string { + return "arenaTag:spikesActivateTrap"; } protected override getDamageHpRatio(_pokemon: Pokemon): number { @@ -926,7 +920,7 @@ class SpikesTag extends DamagingTrapTag { */ class StealthRockTag extends DamagingTrapTag { public readonly tagType = ArenaTagType.STEALTH_ROCK; - public override get maxLayers() { + override get maxLayers() { return 1 as const; } protected override get groundedOnly() { @@ -937,16 +931,16 @@ class StealthRockTag extends DamagingTrapTag { super(MoveId.STEALTH_ROCK, sourceId, side); } - protected override getAddMessage(source: Pokemon): string { - return i18next.t("arenaTag:stealthRockOnAdd", { - opponentDesc: source.getOpponentDescriptor(), - }); + protected override get onAddMessageKey(): string { + return "arenaTag:stealthRockOnAdd" + this.i18nSideKey; } - protected override getTriggerMessage(pokemon: Pokemon): string { - return i18next.t("arenaTag:stealthRockActivateTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }); + protected override get onRemoveMessageKey(): string { + return "arenaTag:stealthRockOnRemove" + this.i18nSideKey; + } + + protected override get triggerMessageKey(): string { + return "arenaTag:stealthRockActivateTrap"; } protected override getDamageHpRatio(pokemon: Pokemon): number { @@ -967,11 +961,6 @@ class StealthRockTag extends DamagingTrapTag { * Poison-type Pokémon will remove it entirely upon switch-in. */ class ToxicSpikesTag extends EntryHazardTag { - /** - * Whether the tag is currently in the process of being neutralized by a Poison-type. - * @defaultValue `false` - */ - #neutralized = false; public readonly tagType = ArenaTagType.TOXIC_SPIKES; override get maxLayers() { return 2 as const; @@ -981,36 +970,22 @@ class ToxicSpikesTag extends EntryHazardTag { super(MoveId.TOXIC_SPIKES, sourceId, side); } - protected override getAddMessage(source: Pokemon): string { - return i18next.t("arenaTag:toxicSpikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }); + protected override get onAddMessageKey(): string { + return "arenaTag:toxicSpikesOnAdd" + this.i18nSideKey; } - // Override remove function to only display text when not neutralized - override onRemove(arena: Arena): void { - if (!this.#neutralized) { - super.onRemove(arena); - } + protected override get onRemoveMessageKey(): string { + return "arenaTag:toxicSpikesOnRemove" + this.i18nSideKey; } - override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { + override activateTrap(simulated: boolean, pokemon: Pokemon): boolean { if (simulated) { return true; } if (pokemon.isOfType(PokemonType.POISON)) { // Neutralize the tag and remove it from the field. - // Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon - this.#neutralized = true; globalScene.arena.removeTagOnSide(this.tagType, this.side); - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:toxicSpikesActivateTrapPoison", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - moveName: this.getMoveName(), - }), - ); return true; } @@ -1036,7 +1011,7 @@ class ToxicSpikesTag extends EntryHazardTag { */ class StickyWebTag extends EntryHazardTag { public readonly tagType = ArenaTagType.STICKY_WEB; - public override get maxLayers() { + override get maxLayers() { return 1 as const; } @@ -1044,14 +1019,15 @@ class StickyWebTag extends EntryHazardTag { super(MoveId.STICKY_WEB, sourceId, side); } - protected override getAddMessage(source: Pokemon): string { - return i18next.t("arenaTag:stickyWebOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }); + protected override get onAddMessageKey(): string { + return "arenaTag:stickyWebOnAdd" + this.i18nSideKey; } - override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { + protected override get onRemoveMessageKey(): string { + return "arenaTag:stickyWebOnRemove" + this.i18nSideKey; + } + + override activateTrap(simulated: boolean, pokemon: Pokemon): boolean { const cancelled = new BooleanHolder(false); // TODO: Does this need to pass `simulated` as a parameter? applyAbAttrs("ProtectStatAbAttr", { @@ -1071,7 +1047,7 @@ class StickyWebTag extends EntryHazardTag { globalScene.phaseManager.queueMessage( i18next.t("arenaTag:stickyWebActivateTrap", { - pokemonName: pokemon.getNameToRender(), + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), ); @@ -1099,7 +1075,7 @@ class StickyWebTag extends EntryHazardTag { */ class ImprisonTag extends EntryHazardTag { public readonly tagType = ArenaTagType.IMPRISON; - public override get maxLayers() { + override get maxLayers() { return 1 as const; } @@ -1107,11 +1083,18 @@ class ImprisonTag extends EntryHazardTag { super(MoveId.IMPRISON, sourceId, side); } + protected override get onAddMessageKey(): string { + return "battlerTags:imprisonOnAdd"; + } + protected override get onRemoveMessageKey(): string { + return ""; + } + /** * Apply the effects of Imprison to all opposing on-field Pokemon. */ - override onAdd(_arena: Arena, quiet = false) { - super.onAdd(_arena, quiet); + override onAdd(quiet = false): void { + super.onAdd(quiet); const party = this.getAffectedPokemon(); party.forEach(p => { @@ -1121,16 +1104,9 @@ class ImprisonTag extends EntryHazardTag { }); } - 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 + * @returns `true` if the source of the tag is still active on the field */ override lapse(): boolean { const source = this.getSourcePokemon(); @@ -1139,10 +1115,10 @@ class ImprisonTag extends EntryHazardTag { /** * This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active - * @param pokemon the Pokemon Imprison is applied to + * @param pokemon - The Pokemon Imprison is applied to * @returns `true` */ - override activateTrap(pokemon: Pokemon): boolean { + override activateTrap(_simulated: boolean, pokemon: Pokemon): boolean { const source = this.getSourcePokemon(); if (source?.isActive(true) && pokemon.isAllowedInBattle()) { pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId); @@ -1152,9 +1128,9 @@ class ImprisonTag extends EntryHazardTag { /** * 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 { + override onRemove(quiet = false): void { + super.onRemove(quiet); const party = this.getAffectedPokemon(); party.forEach(p => { p.removeTag(BattlerTagType.IMPRISON); @@ -1189,37 +1165,21 @@ export class TrickRoomTag extends RoomArenaTag { super(turnCount, MoveId.TRICK_ROOM, sourceId); } + protected override get onAddMessageKey(): string { + return "arenaTag:trickRoomOnAdd"; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:trickRoomOnRemove"; + } + /** * Reverses Speed-based turn order for all Pokemon on the field - * @param _arena n/a - * @param _simulated n/a * @param speedReversed a {@linkcode BooleanHolder} used to flag if Speed-based * turn order should be reversed. - * @returns `true` if turn order is successfully reversed; `false` otherwise */ - override apply(_arena: Arena, _simulated: boolean, speedReversed: BooleanHolder): boolean { + override apply(speedReversed: BooleanHolder): void { speedReversed.value = !speedReversed.value; - return true; - } - - onAdd(_arena: Arena): void { - super.onAdd(_arena); - - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for TrickRoomTag on add message; id: ${this.sourceId}`); - return; - } - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:trickRoomOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); - } - - onRemove(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:trickRoomOnRemove")); } } @@ -1234,8 +1194,16 @@ export class GravityTag extends SerializableArenaTag { super(turnCount, MoveId.GRAVITY, sourceId); } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:gravityOnAdd"; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:gravityOnRemove"; + } + + onAdd(quiet = false): void { + super.onAdd(quiet); globalScene.getField(true).forEach(pokemon => { if (pokemon !== null) { pokemon.removeTag(BattlerTagType.FLOATING); @@ -1246,10 +1214,6 @@ export class GravityTag extends SerializableArenaTag { } }); } - - onRemove(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnRemove")); - } } /** @@ -1263,23 +1227,18 @@ class TailwindTag extends SerializableArenaTag { super(turnCount, MoveId.TAILWIND, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { - const source = this.getSourcePokemon(); - if (!source) { - return; - } + protected override get onAddMessageKey(): string { + return "arenaTag:tailwindOnAdd" + this.i18nSideKey; + } - super.onAdd(_arena, quiet); + protected override get onRemoveMessageKey(): string { + return "arenaTag:tailwindOnRemove" + this.i18nSideKey; + } - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:tailwindOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } + onAdd(quiet = false): void { + super.onAdd(quiet); - const field = source.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); + const field = this.getAffectedPokemon(); for (const pokemon of field) { // Apply the CHARGED tag to party members with the WIND_POWER ability @@ -1311,15 +1270,7 @@ class TailwindTag extends SerializableArenaTag { } } - onRemove(_arena: Arena, quiet = false): void { - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:tailwindOnRemove${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } - } + // TODO: Have the `apply` method double speed } /** @@ -1332,12 +1283,13 @@ class HappyHourTag extends SerializableArenaTag { super(turnCount, MoveId.HAPPY_HOUR, sourceId, side); } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:happyHourOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:happyHourOnAdd"; } - onRemove(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:happyHourOnRemove")); + // Mainline technically doesn't have a "happy hour removal message", but we keep it in as nice QoL + protected override get onRemoveMessageKey(): string { + return "arenaTag:happyHourOnRemove"; } } @@ -1347,20 +1299,12 @@ class SafeguardTag extends ArenaTag { super(turnCount, MoveId.SAFEGUARD, sourceId, side); } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:safeguardOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); + protected override get onAddMessageKey(): string { + return "arenaTag:safeguardOnAdd" + this.i18nSideKey; } - onRemove(_arena: Arena): void { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:safeguardOnRemove${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); + protected override get onRemoveMessageKey(): string { + return "arenaTag:safeguardOnRemove" + this.i18nSideKey; } } @@ -1369,6 +1313,14 @@ class NoneTag extends ArenaTag { constructor() { super(0); } + + protected override get onAddMessageKey(): string { + return ""; + } + + protected override get onRemoveMessageKey(): string { + return ""; + } } /** @@ -1384,39 +1336,37 @@ class FireGrassPledgeTag extends SerializableArenaTag { super(4, MoveId.FIRE_PLEDGE, sourceId, side); } - override onAdd(_arena: Arena): void { - // "A sea of fire enveloped your/the opposing team!" - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:fireGrassPledgeOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); + protected override get onAddMessageKey(): string { + return "arenaTag:fireGrassPledgeOnAdd" + this.i18nSideKey; } - override lapse(arena: Arena): boolean { - const field: Pokemon[] = - this.side === ArenaTagSide.PLAYER ? globalScene.getPlayerField() : globalScene.getEnemyField(); + protected override get onRemoveMessageKey(): string { + return "arenaTag:fireGrassPledgeOnRemove" + this.i18nSideKey; + } - field - .filter(pokemon => !pokemon.isOfType(PokemonType.FIRE) && !pokemon.switchOutStatus) - .forEach(pokemon => { - // "{pokemonNameWithAffix} was hurt by the sea of fire!" - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:fireGrassPledgeLapse", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - ); - // TODO: Replace this with a proper animation - globalScene.phaseManager.unshiftNew( - "CommonAnimPhase", - pokemon.getBattlerIndex(), - pokemon.getBattlerIndex(), - CommonAnim.MAGMA_STORM, - ); - pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT }); - }); + override lapse(): boolean { + const field = this.getAffectedPokemon().filter( + pokemon => !pokemon.isOfType(PokemonType.FIRE, true, true) && !pokemon.switchOutStatus, + ); - return super.lapse(arena); + field.forEach(pokemon => { + // "{pokemonNameWithAffix} was hurt by the sea of fire!" + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:fireGrassPledgeLapse", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }), + ); + // TODO: Replace this with a proper animation + globalScene.phaseManager.unshiftNew( + "CommonAnimPhase", + pokemon.getBattlerIndex(), + pokemon.getBattlerIndex(), + CommonAnim.MAGMA_STORM, + ); + pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT }); + }); + + return super.lapse(); } } @@ -1432,27 +1382,21 @@ class WaterFirePledgeTag extends SerializableArenaTag { constructor(sourceId: number | undefined, side: ArenaTagSide) { super(4, MoveId.WATER_PLEDGE, sourceId, side); } + protected override get onAddMessageKey(): string { + return "arenaTag:waterFirePledgeOnAdd" + this.i18nSideKey; + } - override onAdd(_arena: Arena): void { - // "A rainbow appeared in the sky on your/the opposing team's side!" - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:waterFirePledgeOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); + protected override get onRemoveMessageKey(): string { + return "arenaTag:waterFirePledgeOnRemove" + this.i18nSideKey; } /** * Doubles the chance for the given move's secondary effect(s) to trigger - * @param _arena the {@linkcode Arena} containing this tag - * @param _simulated n/a * @param moveChance a {@linkcode NumberHolder} containing * the move's current effect chance - * @returns `true` if the move's effect chance was doubled (currently always `true`) */ - override apply(_arena: Arena, _simulated: boolean, moveChance: NumberHolder): boolean { + override apply(moveChance: NumberHolder): void { moveChance.value *= 2; - return true; } } @@ -1468,14 +1412,15 @@ class GrassWaterPledgeTag extends SerializableArenaTag { super(4, MoveId.GRASS_PLEDGE, sourceId, side); } - override onAdd(_arena: Arena): void { - // "A swamp enveloped your/the opposing team!" - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:grassWaterPledgeOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); + protected override get onAddMessageKey(): string { + return "arenaTag:grassWaterPledgeOnAdd" + this.i18nSideKey; } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:grassWaterPledgeOnAdd" + this.i18nSideKey; + } + + // TODO: Move speed drops into this class's `apply` method instead of an explicit check for it } /** @@ -1491,8 +1436,11 @@ export class FairyLockTag extends SerializableArenaTag { super(turnCount, MoveId.FAIRY_LOCK, sourceId); } - onAdd(_arena: Arena): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:fairyLockOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:fairyLockOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return ""; } } @@ -1506,11 +1454,11 @@ export class FairyLockTag extends SerializableArenaTag { */ export class SuppressAbilitiesTag extends SerializableArenaTag { // Source count is allowed to be inwardly mutable, but outwardly immutable - public readonly sourceCount: number; + public readonly sourceCount = 1; public readonly tagType = ArenaTagType.NEUTRALIZING_GAS; // Private field prevents field from appearing during serialization /** Whether the tag is in the process of being removed */ - #beingRemoved: boolean; + #beingRemoved = false; /** Whether the tag is in the process of being removed */ public get beingRemoved(): boolean { return this.#beingRemoved; @@ -1518,8 +1466,24 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { constructor(sourceId?: number) { super(0, undefined, sourceId); - this.sourceCount = 1; - this.#beingRemoved = false; + } + + // Disable on add message since we have to handle it ourself + protected override get onAddMessageKey(): string { + return ""; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:neutralizingGasOnRemove"; + } + + private playActivationMessage(pokemon: Pokemon | undefined): void { + if (pokemon) { + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:neutralizingGasOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }), + ); + } } public override loadTag(source: BaseArenaTag & Pick): void { @@ -1527,13 +1491,13 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { (this as Mutable).sourceCount = source.sourceCount; } - public override onAdd(_arena: Arena): void { + public override onAdd(): void { const pokemon = this.getSourcePokemon(); if (pokemon) { this.playActivationMessage(pokemon); for (const fieldPokemon of globalScene.getField(true)) { - if (fieldPokemon && fieldPokemon.id !== pokemon.id) { + if (fieldPokemon.id !== pokemon.id) { // TODO: investigate whether we can just remove the foreach and call `applyAbAttrs` directly, providing // the appropriate attributes (preLEaveField and IllusionBreak) [true, false].forEach(passive => { @@ -1544,7 +1508,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { } } - public override onOverlap(_arena: Arena, source: Pokemon | undefined): void { + public override onOverlap(source: Pokemon | undefined): void { (this as Mutable).sourceCount++; this.playActivationMessage(source); } @@ -1558,8 +1522,8 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { // This may be confusing for players but would be the most accurate gameplay-wise // Could have a custom message that plays when a specific pokemon's NG ends? This entire thing exists due to passives after all const setter = globalScene - .getField() - .filter(p => p?.hasAbilityWithAttr("PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr", false))[0]; + .getField(true) + .filter(p => p.hasAbilityWithAttr("PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr", false))[0]; applyOnGainAbAttrs({ pokemon: setter, passive: setter.getAbility().hasAttr("PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr"), @@ -1567,15 +1531,13 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { } } - public override onRemove(_arena: Arena, quiet = false) { + public override onRemove(quiet = false) { this.#beingRemoved = true; - if (!quiet) { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:neutralizingGasOnRemove")); - } + super.onRemove(quiet); for (const pokemon of globalScene.getField(true)) { // There is only one pokemon with this attr on the field on removal, so its abilities are already active - if (pokemon && !pokemon.hasAbilityWithAttr("PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr", false)) { + if (!pokemon.hasAbilityWithAttr("PreLeaveFieldRemoveSuppressAbilitiesSourceAbAttr", false)) { [true, false].forEach(passive => { applyOnGainAbAttrs({ pokemon, passive }); }); @@ -1586,16 +1548,6 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { public shouldApplyToSelf(): boolean { return this.sourceCount > 1; } - - private playActivationMessage(pokemon: Pokemon | undefined) { - if (pokemon) { - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:neutralizingGasOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - ); - } - } } /** diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 075876d8ddd..fc8628ff9f4 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -871,7 +871,7 @@ export abstract class Move implements Localizable { 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); } @@ -1343,7 +1343,7 @@ export class MoveEffectAttr extends MoveAttr { if ((!move.hasAttr("FlinchAttr") || moveChance.value <= move.chance) && !move.hasAttr("SecretPowerAttr")) { 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) { @@ -6063,7 +6063,7 @@ export class AddArenaTrapTagAttr extends AddArenaTagAttr { if (!tag) { return true; } - return tag.layers < tag.maxLayers; + return tag.canAdd(); }; } } @@ -6087,7 +6087,7 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr { if (!tag) { return true; } - return tag.layers < tag.maxLayers; + return tag.canAdd(); } return false; } diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 717845cf2d9..abcf8842d53 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -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. * @privateRemarks diff --git a/src/field/arena.ts b/src/field/arena.ts index 3e214ff1ea7..ab19d009c8f 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -7,7 +7,7 @@ import { globalScene } from "#app/global-scene"; import Overrides from "#app/overrides"; import type { BiomeTierTrainerPools, PokemonPools } from "#balance/biomes"; import { BiomePoolTier, biomePokemonPools, biomeTrainerPools } from "#balance/biomes"; -import type { ArenaTag } from "#data/arena-tag"; +import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; import { EntryHazardTag, getArenaTag } from "#data/arena-tag"; import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import type { PokemonSpecies } from "#data/pokemon-species"; @@ -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 - * @param tagType Either an {@linkcode ArenaTagType} string, or an actual {@linkcode ArenaTag} class to filter which ones to apply - * @param side {@linkcode ArenaTagSide} which side's arena tags to apply - * @param simulated if `true`, this applies arena tags without changing game state - * @param args array of parameters that the called upon tags may need + * @param tagType - A constructor of an ArenaTag to filter tags by + * @param side - The {@linkcode ArenaTagSide} dictating which side's arena tags to apply + * @param args - Parameters for the tag + * @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( - tagType: ArenaTagType | Constructor | AbstractConstructor, + applyTagsForSide( + tagType: Constructor | AbstractConstructor, side: ArenaTagSide, - simulated: boolean, - ...args: unknown[] + ...args: Parameters + ): 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( + tagType: T, + side: ArenaTagSide, + ...args: Parameters + ): void; + applyTagsForSide( + tagType: T["tagType"] | Constructor | AbstractConstructor, + side: ArenaTagSide, + ...args: Parameters ): void { let tags = typeof tagType === "string" @@ -669,22 +686,32 @@ export class Arena { if (side !== ArenaTagSide.BOTH) { 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) * by calling {@linkcode applyTagsForSide()} - * @param tagType Either an {@linkcode ArenaTagType} string, or an actual {@linkcode ArenaTag} class to filter which ones to apply - * @param simulated if `true`, this applies arena tags without changing game state - * @param args array of parameters that the called upon tags may need + * @param tagType - The {@linkcode ArenaTagType} of the desired tag + * @param args - Parameters for the tag */ - applyTags( - tagType: ArenaTagType | Constructor | AbstractConstructor, - simulated: boolean, - ...args: unknown[] - ): void { - this.applyTagsForSide(tagType, ArenaTagSide.BOTH, simulated, ...args); + applyTags(tagType: T, ...args: Parameters): void; + /** + * Applies the specified tag to both sides (ie: both user and trainer's tag that match the Tag specified) + * by calling {@linkcode applyTagsForSide()} + * @param tagType - A constructor of an ArenaTag to filter tags by + * @param args - Parameters for the tag + */ + applyTags( + tagType: Constructor | AbstractConstructor, + ...args: Parameters + ): void; + applyTags( + tagType: T["tagType"] | Constructor | AbstractConstructor, + ...args: Parameters + ) { + // @ts-expect-error - Overload resolution + this.applyTagsForSide(tagType, ArenaTagSide.BOTH, ...args); } /** @@ -708,7 +735,7 @@ export class Arena { ): boolean { const existingTag = this.getTagOnSide(tagType, side); if (existingTag) { - existingTag.onOverlap(this, globalScene.getPokemonById(sourceId)); + existingTag.onOverlap(globalScene.getPokemonById(sourceId)); if (existingTag instanceof EntryHazardTag) { const { tagType, side, turnCount, maxDuration, layers, maxLayers } = existingTag as EntryHazardTag; @@ -721,7 +748,7 @@ export class Arena { // creates a new tag object const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side); if (newTag) { - newTag.onAdd(this, quiet); + newTag.onAdd(quiet); this.tags.push(newTag); const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {}; @@ -801,9 +828,9 @@ export class Arena { lapseTags(): void { this.tags - .filter(t => !t.lapse(this)) + .filter(t => !t.lapse()) .forEach(t => { - t.onRemove(this); + t.onRemove(); this.tags.splice(this.tags.indexOf(t), 1); this.eventTarget.dispatchEvent(new TagRemovedEvent(t.tagType, t.side, t.turnCount)); @@ -814,7 +841,7 @@ export class Arena { const tags = this.tags; const tag = tags.find(t => t.tagType === tagType); if (tag) { - tag.onRemove(this); + tag.onRemove(); tags.splice(tags.indexOf(tag), 1); 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 { const tag = this.getTagOnSide(tagType, side); if (tag) { - tag.onRemove(this, quiet); + tag.onRemove(quiet); this.tags.splice(this.tags.indexOf(tag), 1); this.eventTarget.dispatchEvent(new TagRemovedEvent(tag.tagType, tag.side, tag.turnCount)); @@ -835,7 +862,7 @@ export class Arena { removeAllTags(): void { while (this.tags.length > 0) { - this.tags[0].onRemove(this); + this.tags[0].onRemove(); this.eventTarget.dispatchEvent( new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount), ); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index cbad6caaafa..a29b29078d0 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2397,7 +2397,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { 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)) { moveTypeHolder.value = PokemonType.ELECTRIC; } @@ -3704,14 +3704,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { // Critical hits should bypass screens if (!isCritical) { - globalScene.arena.applyTagsForSide( - WeakenMoveScreenTag, - defendingSide, - simulated, - source, - moveCategory, - screenMultiplier, - ); + globalScene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, 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 const blockCrit = new BooleanHolder(false); applyAbAttrs("BlockCritAbAttr", { pokemon: this, blockCrit }); - const blockCritTag = globalScene.arena.getTagOnSide( + globalScene.arena.applyTagsForSide( NoCritTag, 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; } diff --git a/src/messages.ts b/src/messages.ts index c9673345110..c54d5603d5c 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -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 - * @param pokemon {@linkcode Pokemon} name and battle context will be retrieved from this instance - * @param useIllusion - Whether we want the name of the illusion or not. Default value : true - * @returns ex: "Wild Gengar", "Ectoplasma sauvage" + * @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 `true` + * @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 { if (!pokemon) { diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index cd45a73c813..1f1b78af0ad 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -18,7 +18,7 @@ export class TurnStartPhase extends FieldPhase { * Returns an ordering of the current field based on command priority * @returns The sequence of commands for this turn */ - getCommandOrder(): BattlerIndex[] { + private getCommandOrder(): BattlerIndex[] { const playerField = globalScene.getPlayerField(true).map(p => p.getBattlerIndex()); const enemyField = globalScene.getEnemyField(true).map(p => p.getBattlerIndex()); const orderedTargets: BattlerIndex[] = playerField.concat(enemyField); diff --git a/src/utils/speed-order.ts b/src/utils/speed-order.ts index 1d894369bb3..736f8e7e2a4 100644 --- a/src/utils/speed-order.ts +++ b/src/utils/speed-order.ts @@ -49,8 +49,7 @@ function sortBySpeed(pokemonList: T[]): void { /** 'true' if Trick Room is on the field. */ const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, false, speedReversed); - + globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, speedReversed); if (speedReversed.value) { pokemonList.reverse(); } diff --git a/test/abilities/magic-bounce.test.ts b/test/abilities/magic-bounce.test.ts index 6b7bc7453ed..89d982eb26e 100644 --- a/test/abilities/magic-bounce.test.ts +++ b/test/abilities/magic-bounce.test.ts @@ -307,7 +307,7 @@ describe("Abilities - Magic Bounce", () => { expect( game.scene.arena .getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER) - ?.getSourcePokemon() + ?.["getSourcePokemon"]() ?.getBattlerIndex(), ).toBe(BattlerIndex.ENEMY); game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true); @@ -319,7 +319,7 @@ describe("Abilities - Magic Bounce", () => { expect( game.scene.arena .getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER) - ?.getSourcePokemon() + ?.["getSourcePokemon"]() ?.getBattlerIndex(), ).toBe(BattlerIndex.ENEMY); }); diff --git a/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts new file mode 100644 index 00000000000..9e1eb4d7fb8 --- /dev/null +++ b/test/arena/arena-tags.test.ts @@ -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); + }, + ); +}); diff --git a/test/moves/aurora-veil.test.ts b/test/moves/aurora-veil.test.ts index 15aa72d039a..ebcb9ffb12f 100644 --- a/test/moves/aurora-veil.test.ts +++ b/test/moves/aurora-veil.test.ts @@ -140,14 +140,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; if (globalScene.arena.getTagOnSide(ArenaTagType.AURORA_VEIL, side) && move.getAttrs("CritOnlyAttr").length === 0) { - globalScene.arena.applyTagsForSide( - ArenaTagType.AURORA_VEIL, - side, - false, - attacker, - move.category, - multiplierHolder, - ); + globalScene.arena.applyTagsForSide(ArenaTagType.AURORA_VEIL, side, attacker, move.category, multiplierHolder); } return move.power * multiplierHolder.value; diff --git a/test/moves/ceaseless-edge-stone-axe.test.ts b/test/moves/ceaseless-edge-stone-axe.test.ts new file mode 100644 index 00000000000..6355ab4813a --- /dev/null +++ b/test/moves/ceaseless-edge-stone-axe.test.ts @@ -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(); + }, + ); +}); diff --git a/test/moves/ceaseless-edge.test.ts b/test/moves/ceaseless-edge.test.ts deleted file mode 100644 index b06ea84308c..00000000000 --- a/test/moves/ceaseless-edge.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/moves/destiny-bond.test.ts b/test/moves/destiny-bond.test.ts index a5020b83944..a71db6fbcda 100644 --- a/test/moves/destiny-bond.test.ts +++ b/test/moves/destiny-bond.test.ts @@ -191,9 +191,7 @@ describe("Moves - Destiny Bond", () => { expect(playerPokemon.isFainted()).toBe(true); // Ceaseless Edge spikes effect should still activate - const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as EntryHazardTag; - expect(tagAfter.tagType).toBe(ArenaTagType.SPIKES); - expect(tagAfter.layers).toBe(1); + expect(game).toHaveArenaTag({ tagType: ArenaTagType.SPIKES, side: ArenaTagSide.ENEMY, layers: 1 }); }); it("should not cause a crash if the user is KO'd by Pledge moves", async () => { diff --git a/test/moves/entry-hazards.test.ts b/test/moves/entry-hazards.test.ts index af8145183a3..06392894c59 100644 --- a/test/moves/entry-hazards.test.ts +++ b/test/moves/entry-hazards.test.ts @@ -1,5 +1,4 @@ import { getPokemonNameWithAffix } from "#app/messages"; -import { allMoves } from "#data/data-lists"; import type { TypeDamageMultiplier } from "#data/type"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; @@ -147,7 +146,7 @@ describe("Moves - Entry Hazards", () => { 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 }) => { + ])("should apply $name at $layers", async ({ layers, status }) => { for (let i = 0; i < layers; i++) { 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(); 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]); - 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); 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(game.textInterceptor.logs).toContain(i18next.t("arenaTag:toxicSpikesOnRemovePlayer")); expect(ekans).not.toHaveStatusEffect(StatusEffect.POISON); }); @@ -225,7 +213,7 @@ describe("Moves - Entry Hazards", () => { expect(enemy).toHaveStatStage(Stat.SPD, -1); expect(game.textInterceptor.logs).toContain( i18next.t("arenaTag:stickyWebActivateTrap", { - pokemonName: enemy.getNameToRender(), + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), }), ); }); diff --git a/test/moves/light-screen.test.ts b/test/moves/light-screen.test.ts index c715229d4a6..e0985bbe9e6 100644 --- a/test/moves/light-screen.test.ts +++ b/test/moves/light-screen.test.ts @@ -127,15 +127,8 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = const multiplierHolder = new NumberHolder(1); const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - if (globalScene.arena.getTagOnSide(ArenaTagType.LIGHT_SCREEN, side) && move.getAttrs("CritOnlyAttr").length === 0) { - globalScene.arena.applyTagsForSide( - ArenaTagType.LIGHT_SCREEN, - side, - false, - attacker, - move.category, - multiplierHolder, - ); + if (globalScene.arena.getTagOnSide(ArenaTagType.LIGHT_SCREEN, side) && !move.hasAttr("CritOnlyAttr")) { + globalScene.arena.applyTagsForSide(ArenaTagType.LIGHT_SCREEN, side, attacker, move.category, multiplierHolder); } return move.power * multiplierHolder.value; diff --git a/test/moves/mat-block.test.ts b/test/moves/mat-block.test.ts index 0870a38f062..51fb8b55348 100644 --- a/test/moves/mat-block.test.ts +++ b/test/moves/mat-block.test.ts @@ -1,3 +1,4 @@ +import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; @@ -6,6 +7,7 @@ import { BerryPhase } from "#phases/berry-phase"; import { CommandPhase } from "#phases/command-phase"; import { TurnEndPhase } from "#phases/turn-end-phase"; import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; @@ -47,6 +49,11 @@ describe("Moves - Mat Block", () => { await game.phaseInterceptor.to(BerryPhase, false); 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 () => { diff --git a/test/moves/reflect.test.ts b/test/moves/reflect.test.ts index 2d1593fb935..39f74f5189c 100644 --- a/test/moves/reflect.test.ts +++ b/test/moves/reflect.test.ts @@ -144,7 +144,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; 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; diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index e19224f4571..281a8ce0a41 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -5,6 +5,8 @@ import { enumValueToKey } from "#utils/enums"; import { toTitleCase } from "#utils/strings"; import type { MatcherState } from "@vitest/expect"; import i18next from "i18next"; +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { describe, it } from "vitest"; type Casing = "Preserve" | "Title"; @@ -22,15 +24,18 @@ interface getEnumStrOptions { * If present, will be added to the end of the enum 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. * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from - * @param enums - One of {@linkcode obj}'s values - * @param casing - A string denoting the casing method to use; default `Preserve` - * @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 + * @param val - One of {@linkcode obj}'s values + * @param options - Options modifying the stringification process. * @returns The stringified representation of `val` as dictated by the options. * @example * ```ts @@ -46,8 +51,9 @@ interface getEnumStrOptions { export function getEnumStr( obj: E, val: ObjectValues, - { casing = "Preserve", prefix = "", suffix = "" }: getEnumStrOptions = {}, + options: getEnumStrOptions = {}, ): string { + const { casing = "Preserve", prefix = "", suffix = "", omitValue = typeof val === "number" } = options; let casingFunc: ((s: string) => string) | undefined; switch (casing) { case "Preserve": @@ -68,7 +74,7 @@ export function getEnumStr( stringPart = casingFunc(stringPart); } - return `${prefix}${stringPart}${suffix} (=${val})`; + return `${prefix}${stringPart}${suffix}${omitValue ? ` (=${val})` : ""}`; } /** @@ -87,7 +93,7 @@ export function getEnumStr( * ``` */ export function stringifyEnumArray(obj: E, enums: E[keyof E][]): string { - if (obj.length === 0) { + if (enums.length === 0) { return "[]"; }