From a28dfa0315a9a84965c2b558f0dd18f40fbf0f6b Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 19 Aug 2025 22:56:03 -0400 Subject: [PATCH 01/17] Fixed up arena tags `apply` with type safety; removed unused parameters from tags --- src/@types/helpers/tuple-helpers.ts | 27 ++++ src/@types/helpers/type-helpers.ts | 8 +- src/data/arena-tag.ts | 224 ++++++++++++---------------- src/data/moves/move.ts | 4 +- src/data/phase-priority-queue.ts | 2 +- src/field/arena.ts | 79 ++++++---- src/field/pokemon.ts | 11 +- src/phases/turn-start-phase.ts | 2 +- test/moves/aurora-veil.test.ts | 9 +- test/moves/light-screen.test.ts | 9 +- test/moves/reflect.test.ts | 2 +- 11 files changed, 193 insertions(+), 184 deletions(-) create mode 100644 src/@types/helpers/tuple-helpers.ts diff --git a/src/@types/helpers/tuple-helpers.ts b/src/@types/helpers/tuple-helpers.ts new file mode 100644 index 00000000000..e8a893d872c --- /dev/null +++ b/src/@types/helpers/tuple-helpers.ts @@ -0,0 +1,27 @@ +/** + * Remove the first N entries from a tuple. + * @typeParam T - The array type to remove elements from. + * @typeParam N - The number of elements to remove. + * @typeParam Count - The current count of removed elements, used for recursion. + */ +export type RemoveFirst< + T extends readonly any[], + N extends number, + Count extends any[] = [], +> = Count["length"] extends N + ? T + : T extends readonly [any, ...infer Rest] + ? RemoveFirst + : []; + +/** + * Remove the last N entries from a tuple. + * @typeParam T - The array type to remove elements from. + * @typeParam N - The number of elements to remove. + * @typeParam Count - The current count of removed elements, used for recursion. + */ +export type RemoveLast = Count["length"] extends N + ? T + : T extends readonly [...infer Rest, any] + ? RemoveLast + : []; diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index 0be391aa3c4..d56ebb731bf 100644 --- a/src/@types/helpers/type-helpers.ts +++ b/src/@types/helpers/type-helpers.ts @@ -39,9 +39,11 @@ export type Mutable = { * @typeParam O - The type of the object * @typeParam V - The type of one of O's values */ -export type InferKeys> = { - [K in keyof O]: O[K] extends V ? K : never; -}[keyof O]; +export type InferKeys = V extends ObjectValues + ? { + [K in keyof O]: O[K] extends V ? K : never; + }[keyof O] + : never; /** * Utility type to obtain the values of a given object. \ diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 15c2cde1d58..6d624af8db4 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -120,13 +120,14 @@ export abstract class ArenaTag implements BaseArenaTag { this.side = side; } - apply(_arena: Arena, _simulated: boolean, ..._args: unknown[]): boolean { + apply(..._args: unknown[]): boolean { return true; } - onAdd(_arena: Arena, _quiet = false): void {} + // TODO: Make this show messages by default with an overriddable getter + onAdd(_quiet = false): void {} - onRemove(_arena: Arena, quiet = false): void { + onRemove(quiet = false): void { if (!quiet) { globalScene.phaseManager.queueMessage( i18next.t( @@ -137,16 +138,15 @@ export abstract class ArenaTag implements BaseArenaTag { } } - onOverlap(_arena: Arena, _source: Pokemon | null): void {} + onOverlap(_source: Pokemon | null): void {} /** * Trigger this {@linkcode ArenaTag}'s effect, reducing its duration as applicable. * 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; @@ -211,8 +211,8 @@ export class MistTag extends SerializableArenaTag { super(turnCount, MoveId.MIST, sourceId, side); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + onAdd(quiet = false): void { + super.onAdd(); // We assume `quiet=true` means "just add the bloody tag no questions asked" if (quiet) { @@ -234,14 +234,13 @@ export class MistTag extends SerializableArenaTag { /** * 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) { @@ -273,31 +272,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; - } - damageMultiplier.value = globalScene.currentBattle.double ? 2732 / 4096 : 0.5; - 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 during Double Battles + damageMultiplier.value = globalScene.currentBattle.double ? 2 / 3 : 1 / 2; + return true; } } @@ -315,7 +306,7 @@ class ReflectTag extends WeakenMoveScreenTag { super(turnCount, MoveId.REFLECT, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { + onAdd(quiet = false): void { if (!quiet) { globalScene.phaseManager.queueMessage( i18next.t( @@ -339,7 +330,7 @@ class LightScreenTag extends WeakenMoveScreenTag { super(turnCount, MoveId.LIGHT_SCREEN, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { + onAdd(quiet = false): void { if (!quiet) { globalScene.phaseManager.queueMessage( i18next.t( @@ -364,7 +355,7 @@ class AuroraVeilTag extends WeakenMoveScreenTag { super(turnCount, MoveId.AURORA_VEIL, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { + onAdd(quiet = false): void { if (!quiet) { globalScene.phaseManager.queueMessage( i18next.t( @@ -375,7 +366,7 @@ class AuroraVeilTag extends WeakenMoveScreenTag { } } -type ProtectConditionFunc = (arena: Arena, moveId: MoveId) => boolean; +type ProtectConditionFunc = (moveId: MoveId) => boolean; /** * Class to implement conditional team protection @@ -403,7 +394,7 @@ export abstract class ConditionalProtectTag extends ArenaTag { this.ignoresBypass = ignoresBypass; } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage( i18next.t( `arenaTag:conditionalProtectOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, @@ -413,12 +404,11 @@ export abstract class ConditionalProtectTag extends ArenaTag { } // Removes default message for effect removal - onRemove(_arena: Arena): void {} + onRemove(): void {} /** * 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} @@ -428,7 +418,7 @@ export abstract class ConditionalProtectTag extends ArenaTag { * @returns `true` if this tag protected against the attack; `false` otherwise */ override apply( - arena: Arena, + // TODO: WHy do we have `_attacker` here? simulated: boolean, isProtected: BooleanHolder, _attacker: Pokemon, @@ -436,7 +426,7 @@ export abstract class ConditionalProtectTag extends ArenaTag { moveId: MoveId, ignoresProtectBypass: BooleanHolder, ): boolean { - if ((this.side === ArenaTagSide.PLAYER) === defender.isPlayer() && this.protectConditionFunc(arena, moveId)) { + if ((this.side === ArenaTagSide.PLAYER) === defender.isPlayer() && this.protectConditionFunc(moveId)) { if (!isProtected.value) { isProtected.value = true; if (!simulated) { @@ -460,12 +450,11 @@ export abstract class ConditionalProtectTag extends ArenaTag { /** * 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(); @@ -492,11 +481,10 @@ class QuickGuardTag extends ConditionalProtectTag { /** * 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 WideGuardConditionFunc: ProtectConditionFunc = (moveId): boolean => { const move = allMoves[moveId]; switch (move.moveTarget) { @@ -524,11 +512,10 @@ class WideGuardTag extends ConditionalProtectTag { /** * 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 MatBlockConditionFunc: ProtectConditionFunc = (moveId): boolean => { const move = allMoves[moveId]; return move.category !== MoveCategory.STATUS; }; @@ -543,14 +530,14 @@ class MatBlockTag extends ConditionalProtectTag { super(MoveId.MAT_BLOCK, sourceId, side, MatBlockConditionFunc); } - onAdd(_arena: Arena) { + onAdd() { const source = this.getSourcePokemon(); if (!source) { console.warn(`Failed to get source Pokemon for Mat Block message; id: ${this.sourceId}`); return; } - super.onAdd(_arena); + super.onAdd(); globalScene.phaseManager.queueMessage( i18next.t("arenaTag:matBlockOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(source), @@ -562,12 +549,11 @@ class MatBlockTag extends ConditionalProtectTag { /** * 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 && @@ -597,7 +583,7 @@ 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 { + onAdd(): void { globalScene.phaseManager.queueMessage( i18next.t(`arenaTag:noCritOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : "Enemy"}`, { moveName: this.getMoveName(), @@ -606,7 +592,7 @@ export class NoCritTag extends SerializableArenaTag { } /** Queues a message upon removing this effect from the field */ - onRemove(_arena: Arena): void { + onRemove(): void { const source = this.getSourcePokemon(); if (!source) { console.warn(`Failed to get source Pokemon for NoCritTag on remove message; id: ${this.sourceId}`); @@ -631,13 +617,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; @@ -659,11 +643,11 @@ class MudSportTag extends WeakenMoveTypeTag { super(turnCount, MoveId.MUD_SPORT, sourceId); } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:mudSportOnAdd")); } - onRemove(_arena: Arena): void { + onRemove(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:mudSportOnRemove")); } } @@ -681,11 +665,11 @@ class WaterSportTag extends WeakenMoveTypeTag { super(turnCount, MoveId.WATER_SPORT, sourceId); } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:waterSportOnAdd")); } - onRemove(_arena: Arena): void { + onRemove(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:waterSportOnRemove")); } } @@ -702,20 +686,18 @@ export class IonDelugeTag extends ArenaTag { } /** Queues an on-add message */ - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:plasmaFistsOnAdd")); } - onRemove(_arena: Arena): void {} // Removes default on-remove message + onRemove(): void {} // Removes default on-remove message /** * 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; @@ -748,22 +730,21 @@ export abstract class ArenaTrapTag extends SerializableArenaTag { this.maxLayers = maxLayers; } - onOverlap(arena: Arena, _source: Pokemon | null): void { + onOverlap(_source: Pokemon | null): void { if (this.layers < this.maxLayers) { this.layers++; - this.onAdd(arena); + this.onAdd(); } } /** * Activates the hazard effect onto a Pokemon when it enters the field - * @param _arena the {@linkcode Arena} containing this tag * @param simulated if `true`, only checks if the hazard would activate. * @param pokemon the {@linkcode Pokemon} triggering this hazard * @returns `true` if this hazard affects the given Pokemon; `false` otherwise. */ - override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean { + override apply(simulated: boolean, pokemon: Pokemon): boolean { if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) { return false; } @@ -799,8 +780,8 @@ class SpikesTag extends ArenaTrapTag { super(MoveId.SPIKES, sourceId, side, 3); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + onAdd(quiet = false): void { + super.onAdd(); // We assume `quiet=true` means "just add the bloody tag no questions asked" if (quiet) { @@ -861,8 +842,8 @@ class ToxicSpikesTag extends ArenaTrapTag { this.#neutralized = false; } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + onAdd(quiet = false): void { + super.onAdd(); if (quiet) { // We assume `quiet=true` means "just add the bloody tag no questions asked" @@ -883,9 +864,9 @@ class ToxicSpikesTag extends ArenaTrapTag { ); } - onRemove(arena: Arena): void { + onRemove(): void { if (!this.#neutralized) { - super.onRemove(arena); + super.onRemove(); } } @@ -940,8 +921,8 @@ class StealthRockTag extends ArenaTrapTag { super(MoveId.STEALTH_ROCK, sourceId, side, 1); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + onAdd(quiet = false): void { + super.onAdd(); if (quiet) { return; @@ -1030,8 +1011,8 @@ class StickyWebTag extends ArenaTrapTag { super(MoveId.STICKY_WEB, sourceId, side, 1); } - onAdd(arena: Arena, quiet = false): void { - super.onAdd(arena); + onAdd(quiet = false): void { + super.onAdd(); // We assume `quiet=true` means "just add the bloody tag no questions asked" if (quiet) { @@ -1107,19 +1088,17 @@ export class TrickRoomTag extends SerializableArenaTag { /** * 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): boolean { speedReversed.value = !speedReversed.value; return true; } - onAdd(_arena: Arena): void { - super.onAdd(_arena); + onAdd(): void { + super.onAdd(); const source = this.getSourcePokemon(); if (!source) { @@ -1134,7 +1113,7 @@ export class TrickRoomTag extends SerializableArenaTag { ); } - onRemove(_arena: Arena): void { + onRemove(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:trickRoomOnRemove")); } } @@ -1150,7 +1129,7 @@ export class GravityTag extends SerializableArenaTag { super(turnCount, MoveId.GRAVITY, sourceId); } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd")); globalScene.getField(true).forEach(pokemon => { if (pokemon !== null) { @@ -1163,7 +1142,7 @@ export class GravityTag extends SerializableArenaTag { }); } - onRemove(_arena: Arena): void { + onRemove(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnRemove")); } } @@ -1179,13 +1158,13 @@ class TailwindTag extends SerializableArenaTag { super(turnCount, MoveId.TAILWIND, sourceId, side); } - onAdd(_arena: Arena, quiet = false): void { + onAdd(quiet = false): void { const source = this.getSourcePokemon(); if (!source) { return; } - super.onAdd(_arena, quiet); + super.onAdd(quiet); if (!quiet) { globalScene.phaseManager.queueMessage( @@ -1227,7 +1206,7 @@ class TailwindTag extends SerializableArenaTag { } } - onRemove(_arena: Arena, quiet = false): void { + onRemove(quiet = false): void { if (!quiet) { globalScene.phaseManager.queueMessage( i18next.t( @@ -1248,11 +1227,11 @@ class HappyHourTag extends SerializableArenaTag { super(turnCount, MoveId.HAPPY_HOUR, sourceId, side); } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:happyHourOnAdd")); } - onRemove(_arena: Arena): void { + onRemove(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:happyHourOnRemove")); } } @@ -1263,7 +1242,7 @@ class SafeguardTag extends ArenaTag { super(turnCount, MoveId.SAFEGUARD, sourceId, side); } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage( i18next.t( `arenaTag:safeguardOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, @@ -1271,7 +1250,7 @@ class SafeguardTag extends ArenaTag { ); } - onRemove(_arena: Arena): void { + onRemove(): void { globalScene.phaseManager.queueMessage( i18next.t( `arenaTag:safeguardOnRemove${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, @@ -1323,7 +1302,6 @@ class ImprisonTag extends ArenaTrapTag { /** * Checks if the source Pokemon is still active on the field - * @param _arena * @returns `true` if the source of the tag is still active on the field | `false` if not */ override lapse(): boolean { @@ -1346,7 +1324,6 @@ class ImprisonTag extends ArenaTrapTag { /** * When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon - * @param arena */ override onRemove(): void { const party = this.getAffectedPokemon(); @@ -1369,7 +1346,7 @@ class FireGrassPledgeTag extends SerializableArenaTag { super(4, MoveId.FIRE_PLEDGE, sourceId, side); } - override onAdd(_arena: Arena): void { + override onAdd(): void { // "A sea of fire enveloped your/the opposing team!" globalScene.phaseManager.queueMessage( i18next.t( @@ -1378,30 +1355,29 @@ class FireGrassPledgeTag extends SerializableArenaTag { ); } - override lapse(arena: Arena): boolean { - const field: Pokemon[] = - this.side === ArenaTagSide.PLAYER ? globalScene.getPlayerField() : globalScene.getEnemyField(); + override lapse(): boolean { + const field = this.getAffectedPokemon().filter( + pokemon => !pokemon.isOfType(PokemonType.FIRE, true, true) && !pokemon.switchOutStatus, + ); - 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 }); - }); + 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(arena); + return super.lapse(); } } @@ -1418,7 +1394,7 @@ class WaterFirePledgeTag extends SerializableArenaTag { super(4, MoveId.WATER_PLEDGE, sourceId, side); } - override onAdd(_arena: Arena): void { + override onAdd(): void { // "A rainbow appeared in the sky on your/the opposing team's side!" globalScene.phaseManager.queueMessage( i18next.t( @@ -1429,13 +1405,11 @@ class WaterFirePledgeTag extends SerializableArenaTag { /** * 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): boolean { moveChance.value *= 2; return true; } @@ -1453,7 +1427,7 @@ class GrassWaterPledgeTag extends SerializableArenaTag { super(4, MoveId.GRASS_PLEDGE, sourceId, side); } - override onAdd(_arena: Arena): void { + override onAdd(): void { // "A swamp enveloped your/the opposing team!" globalScene.phaseManager.queueMessage( i18next.t( @@ -1476,7 +1450,7 @@ export class FairyLockTag extends SerializableArenaTag { super(turnCount, MoveId.FAIRY_LOCK, sourceId); } - onAdd(_arena: Arena): void { + onAdd(): void { globalScene.phaseManager.queueMessage(i18next.t("arenaTag:fairyLockOnAdd")); } } @@ -1512,7 +1486,7 @@ 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); @@ -1529,7 +1503,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { } } - public override onOverlap(_arena: Arena, source: Pokemon | null): void { + public override onOverlap(source: Pokemon | null): void { (this as Mutable).sourceCount++; this.playActivationMessage(source); } @@ -1552,7 +1526,7 @@ 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")); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index ffcd844224c..bc357570852 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -870,7 +870,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); } @@ -1339,7 +1339,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) { diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts index 88361b0f4fa..2bd2ae7c375 100644 --- a/src/data/phase-priority-queue.ts +++ b/src/data/phase-priority-queue.ts @@ -92,6 +92,6 @@ export class PostSummonPhasePriorityQueue extends PhasePriorityQueue { function isTrickRoom(): boolean { const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); + globalScene.arena.applyTags(TrickRoomTag, speedReversed); return speedReversed.value; } diff --git a/src/field/arena.ts b/src/field/arena.ts index 1b6b165b8a3..78bc6a160ca 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 { ArenaTrapTag, getArenaTag } from "#data/arena-tag"; import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import type { PokemonSpecies } from "#data/pokemon-species"; @@ -650,16 +650,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" @@ -668,22 +685,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); } /** @@ -707,7 +734,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 ArenaTrapTag) { const { tagType, side, turnCount, layers, maxLayers } = existingTag as ArenaTrapTag; @@ -720,7 +747,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 ArenaTrapTag ? newTag : {}; @@ -802,9 +829,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)); @@ -815,7 +842,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)); @@ -826,7 +853,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)); @@ -836,7 +863,7 @@ export class Arena { removeAllTags(): void { while (this.tags.length) { - 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 e12485a7272..5b80c42d5ea 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2361,7 +2361,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; } @@ -3889,14 +3889,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); } /** diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 9c53a333ed0..8c457b378cb 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -37,7 +37,7 @@ export class TurnStartPhase extends FieldPhase { // Next, a check for Trick Room is applied to determine sort order. const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); + globalScene.arena.applyTags(TrickRoomTag, speedReversed); // Adjust the sort function based on whether Trick Room is active. orderedTargets.sort((a: Pokemon, b: Pokemon) => { diff --git a/test/moves/aurora-veil.test.ts b/test/moves/aurora-veil.test.ts index 3c7c86c7fdf..ca52a51ce81 100644 --- a/test/moves/aurora-veil.test.ts +++ b/test/moves/aurora-veil.test.ts @@ -141,14 +141,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = if (globalScene.arena.getTagOnSide(ArenaTagType.AURORA_VEIL, side)) { if (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); } } diff --git a/test/moves/light-screen.test.ts b/test/moves/light-screen.test.ts index c8282037f20..0dcd8bbc0ef 100644 --- a/test/moves/light-screen.test.ts +++ b/test/moves/light-screen.test.ts @@ -129,14 +129,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = if (globalScene.arena.getTagOnSide(ArenaTagType.LIGHT_SCREEN, side)) { if (move.getAttrs("CritOnlyAttr").length === 0) { - globalScene.arena.applyTagsForSide( - ArenaTagType.LIGHT_SCREEN, - side, - false, - attacker, - move.category, - multiplierHolder, - ); + globalScene.arena.applyTagsForSide(ArenaTagType.LIGHT_SCREEN, side, attacker, move.category, multiplierHolder); } } diff --git a/test/moves/reflect.test.ts b/test/moves/reflect.test.ts index b8fa2b1ce80..618d5065c40 100644 --- a/test/moves/reflect.test.ts +++ b/test/moves/reflect.test.ts @@ -145,7 +145,7 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = if (globalScene.arena.getTagOnSide(ArenaTagType.REFLECT, side)) { if (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); } } From 193897447856827726d45784e1d79279ae4efce0 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 10:57:57 -0400 Subject: [PATCH 02/17] ddd --- src/data/arena-tag.ts | 399 +++++++++++++++++++----------------------- src/field/pokemon.ts | 5 +- 2 files changed, 183 insertions(+), 221 deletions(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 6d624af8db4..8c99c32ed5d 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -108,11 +108,42 @@ interface BaseArenaTag { export abstract class ArenaTag implements BaseArenaTag { /** The type of the arena tag */ public abstract readonly tagType: ArenaTagType; + // Intentionally left undocumented to inherit comments from interface public turnCount: 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. + * @defaultValue "" + * @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. + * @defaultValue "arenaTag:arenaOnRemove", followed by the side of the field for one-sided tags (player/enemy) + * @remarks + * If this evaluates to an empty string, no message will be displayed. + */ + protected get onRemoveMessageKey(): string { + return "arenaTag:arenaOnRemove" + this.sideString; + } + + /** + * @returns A suffix corresponding to this tag's current {@linkcode side}. + * @sealed + */ + protected get sideString(): 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.sourceMove = sourceMove; @@ -120,30 +151,54 @@ export abstract class ArenaTag implements BaseArenaTag { this.side = side; } - apply(..._args: unknown[]): boolean { - return true; + /** + * Apply this tag's effects during a turn. + * @param _args - Arguments used by subclasses. + * @todo Remove all boolean return values + */ + public apply(..._args: unknown[]): void {} + + /** + * 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` + */ + onAdd(quiet = false): void { + if (quiet || !this.onAddMessageKey) { + return; + } + + globalScene.phaseManager.queueMessage( + i18next.t(this.onAddMessageKey, { + pokemonNameWithAffix: getPokemonNameWithAffix(this.getSourcePokemon()), + moveName: this.getMoveName(), + }), + ); } - // TODO: Make this show messages by default with an overriddable getter - onAdd(_quiet = false): void {} - + /** + * 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` + */ onRemove(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() }, - ), - ); + if (quiet || !this.onRemoveMessageKey) { + return; } + + globalScene.phaseManager.queueMessage( + i18next.t(this.onRemoveMessageKey, { + pokemonNameWithAffix: getPokemonNameWithAffix(this.getSourcePokemon()), + moveName: this.getMoveName(), + }), + ); } onOverlap(_source: Pokemon | null): void {} /** - * Trigger this {@linkcode ArenaTag}'s effect, reducing its duration as applicable. + * Reduce this {@linkcode ArenaTag}'s duration and apply any end-of-turn effects * Will ignore durations of all tags with durations `<=0`. - * 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(): boolean { @@ -171,10 +226,10 @@ export abstract class ArenaTag implements BaseArenaTag { /** * Helper function that retrieves the source Pokemon * @returns - The source {@linkcode Pokemon} for this tag. - * Returns `null` if `this.sourceId` is `undefined` + * Returns `undefined` if `this.sourceId` is `undefined` */ - public getSourcePokemon(): Pokemon | null { - return globalScene.getPokemonById(this.sourceId); + public getSourcePokemon(): Pokemon | undefined { + return globalScene.getPokemonById(this.sourceId) ?? undefined; } /** @@ -211,25 +266,8 @@ export class MistTag extends SerializableArenaTag { super(turnCount, MoveId.MIST, sourceId, side); } - onAdd(quiet = false): void { - super.onAdd(); - - // 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 onAddMessageKey(): string { + return "arenaTag:mistOnAdd" + this.sideString; } /** @@ -298,7 +336,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]; } @@ -306,14 +344,8 @@ class ReflectTag extends WeakenMoveScreenTag { super(turnCount, MoveId.REFLECT, sourceId, side); } - onAdd(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.sideString; } } @@ -323,21 +355,15 @@ 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(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.sideString; } } @@ -347,7 +373,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]; } @@ -355,14 +381,8 @@ class AuroraVeilTag extends WeakenMoveScreenTag { super(turnCount, MoveId.AURORA_VEIL, sourceId, side); } - onAdd(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.sideString; } } @@ -394,17 +414,13 @@ export abstract class ConditionalProtectTag extends ArenaTag { this.ignoresBypass = ignoresBypass; } - onAdd(): 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.sideString; } - // Removes default message for effect removal - onRemove(): void {} + protected override get onRemoveMessageKey(): string { + return ""; + } /** * Checks incoming moves against the condition function @@ -530,20 +546,11 @@ class MatBlockTag extends ConditionalProtectTag { super(MoveId.MAT_BLOCK, sourceId, side, MatBlockConditionFunc); } - onAdd() { - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for Mat Block message; id: ${this.sourceId}`); - return; - } - - super.onAdd(); - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:matBlockOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); + protected override get onAddMessageKey(): string { + return "arenaTag:matBlockOnAdd"; } + + // TODO: This is using incorrect locales for protection } /** @@ -581,30 +588,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(): 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.sideString; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:noCritOnRemove"; } - /** Queues a message upon removing this effect from the field */ - onRemove(): 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 ?? undefined), - moveName: this.getMoveName(), - }), - ); + public override apply(blockCrit: BooleanHolder): void { + blockCrit.value = true; } } @@ -643,12 +635,12 @@ class MudSportTag extends WeakenMoveTypeTag { super(turnCount, MoveId.MUD_SPORT, sourceId); } - onAdd(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:mudSportOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:mudSportOnAdd"; } - onRemove(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:mudSportOnRemove")); + protected override get onRemoveMessageKey(): string { + return "arenaTag:mudSportOnRemove"; } } @@ -665,12 +657,12 @@ class WaterSportTag extends WeakenMoveTypeTag { super(turnCount, MoveId.WATER_SPORT, sourceId); } - onAdd(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:waterSportOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:waterSportOnAdd"; } - onRemove(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:waterSportOnRemove")); + protected override get onRemoveMessageKey(): string { + return "arenaTag:waterSportOnRemove"; } } @@ -685,12 +677,13 @@ export class IonDelugeTag extends ArenaTag { super(1, sourceMove); } - /** Queues an on-add message */ - onAdd(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:plasmaFistsOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:plasmaFistsOnAdd"; } - onRemove(): void {} // Removes default on-remove message + protected override get onRemoveMessageKey(): string { + return ""; + } /** * Converts Normal-type moves to Electric type @@ -1086,35 +1079,22 @@ export class TrickRoomTag extends SerializableArenaTag { 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 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(speedReversed: BooleanHolder): boolean { + override apply(speedReversed: BooleanHolder): void { speedReversed.value = !speedReversed.value; - return true; - } - - onAdd(): void { - super.onAdd(); - - 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", { - moveName: this.getMoveName(), - }), - ); - } - - onRemove(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:trickRoomOnRemove")); } } @@ -1129,8 +1109,16 @@ export class GravityTag extends SerializableArenaTag { super(turnCount, MoveId.GRAVITY, sourceId); } - onAdd(): 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); @@ -1141,10 +1129,6 @@ export class GravityTag extends SerializableArenaTag { } }); } - - onRemove(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnRemove")); - } } /** @@ -1158,22 +1142,22 @@ class TailwindTag extends SerializableArenaTag { super(turnCount, MoveId.TAILWIND, sourceId, side); } + protected override get onAddMessageKey(): string { + return "arenaTag:tailwindOnAdd" + this.sideString; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:tailwindOnRemove" + this.sideString; + } + onAdd(quiet = false): void { + super.onAdd(quiet); + const source = this.getSourcePokemon(); if (!source) { return; } - super.onAdd(quiet); - - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:tailwindOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } - const field = source.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); for (const pokemon of field) { @@ -1205,16 +1189,6 @@ class TailwindTag extends SerializableArenaTag { } } } - - onRemove(quiet = false): void { - if (!quiet) { - globalScene.phaseManager.queueMessage( - i18next.t( - `arenaTag:tailwindOnRemove${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`, - ), - ); - } - } } /** @@ -1227,12 +1201,13 @@ class HappyHourTag extends SerializableArenaTag { super(turnCount, MoveId.HAPPY_HOUR, sourceId, side); } - onAdd(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:happyHourOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:happyHourOnAdd"; } - onRemove(): 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" + this.sideString; } } @@ -1242,20 +1217,12 @@ class SafeguardTag extends ArenaTag { super(turnCount, MoveId.SAFEGUARD, sourceId, side); } - onAdd(): 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.sideString; } - onRemove(): 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.sideString; } } @@ -1277,10 +1244,16 @@ class ImprisonTag extends ArenaTrapTag { super(MoveId.IMPRISON, sourceId, side, 1); } + override get onAddMessageKey(): string { + return "battlerTags:imprisonOnAdd"; + } + /** * Apply the effects of Imprison to all opposing on-field Pokemon. */ - override onAdd() { + override onAdd(quiet = false) { + super.onAdd(quiet); + const source = this.getSourcePokemon(); if (!source) { return; @@ -1292,12 +1265,6 @@ class ImprisonTag extends ArenaTrapTag { p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId); } }); - - globalScene.phaseManager.queueMessage( - i18next.t("battlerTags:imprisonOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); } /** @@ -1346,13 +1313,12 @@ class FireGrassPledgeTag extends SerializableArenaTag { super(4, MoveId.FIRE_PLEDGE, sourceId, side); } - override onAdd(): 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.sideString; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:fireGrassPledgeOnRemove" + this.sideString; } override lapse(): boolean { @@ -1393,25 +1359,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.sideString; + } - override onAdd(): 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.sideString; } /** * Doubles the chance for the given move's secondary effect(s) to trigger * @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(moveChance: NumberHolder): boolean { + override apply(moveChance: NumberHolder): void { moveChance.value *= 2; - return true; } } @@ -1427,14 +1389,15 @@ class GrassWaterPledgeTag extends SerializableArenaTag { super(4, MoveId.GRASS_PLEDGE, sourceId, side); } - override onAdd(): 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.sideString; } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:grassWaterPledgeOnAdd" + this.sideString; + } + + // TODO: Move speed drops into this class's `apply` method instead of an explicit check for it } /** @@ -1450,8 +1413,8 @@ export class FairyLockTag extends SerializableArenaTag { super(turnCount, MoveId.FAIRY_LOCK, sourceId); } - onAdd(): void { - globalScene.phaseManager.queueMessage(i18next.t("arenaTag:fairyLockOnAdd")); + protected override get onAddMessageKey(): string { + return "arenaTag:fairyLockOnAdd" + this.sideString; } } @@ -1528,9 +1491,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { 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 diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 5b80c42d5ea..0c6df140a32 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4027,11 +4027,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; } From 17c96a5260e2925415a9fd3ca3033930673ab0cd Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 13:10:24 -0400 Subject: [PATCH 03/17] Enforced member visibility on a few methods --- src/battle-scene.ts | 2 +- src/data/arena-tag.ts | 400 ++++++++++++++-------------- src/messages.ts | 6 +- test/abilities/magic-bounce.test.ts | 4 +- 4 files changed, 211 insertions(+), 201 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 4d3f190c02a..1349010a6cc 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -804,7 +804,7 @@ export class BattleScene extends SceneBase { * @returns An array of {@linkcode Pokemon}, as described above. */ public getField(activeOnly = false): Pokemon[] { - const ret = new Array(4).fill(null); + const ret: Pokemon[] = new Array(4).fill(null); const playerField = this.getPlayerField(); const enemyField = this.getEnemyField(); ret.splice(0, playerField.length, ...playerField); diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index f44766cb23a..51b8b6c416b 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -115,32 +115,28 @@ export abstract class ArenaTag implements BaseArenaTag { public side: ArenaTagSide; /** - * Return the i18n locales key that will be shown when this tag is added. + * 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. - * @defaultValue "" * @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. + * 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. - * @defaultValue "arenaTag:arenaOnRemove", followed by the side of the field for one-sided tags (player/enemy) * @remarks * If this evaluates to an empty string, no message will be displayed. */ - protected get onRemoveMessageKey(): string { - return "arenaTag:arenaOnRemove" + this.sideString; - } + protected abstract get onRemoveMessageKey(): string; /** * @returns A suffix corresponding to this tag's current {@linkcode side}. * @sealed */ - protected get sideString(): string { + protected get i18nSideKey(): string { return this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""; } @@ -154,7 +150,9 @@ export abstract class ArenaTag implements BaseArenaTag { /** * Apply this tag's effects during a turn. * @param _args - Arguments used by subclasses. - * @todo Remove all boolean return values + * @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 {} @@ -163,7 +161,7 @@ export abstract class ArenaTag implements BaseArenaTag { * 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` */ - onAdd(quiet = false): void { + public onAdd(quiet = false): void { if (quiet || !this.onAddMessageKey) { return; } @@ -181,7 +179,7 @@ export abstract class ArenaTag implements BaseArenaTag { * 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` */ - onRemove(quiet = false): void { + public onRemove(quiet = false): void { if (quiet || !this.onRemoveMessageKey) { return; } @@ -194,7 +192,11 @@ export abstract class ArenaTag implements BaseArenaTag { ); } - onOverlap(_source: Pokemon | null): void {} + /** + * 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 `null` if no pokemon did + */ + public onOverlap(_source: Pokemon | null): void {} /** * Reduce this {@linkcode ArenaTag}'s duration and apply any end-of-turn effects @@ -207,7 +209,7 @@ export abstract class ArenaTag implements BaseArenaTag { return this.turnCount < 1 || --this.turnCount > 0; } - getMoveName(): string | null { + protected getMoveName(): string | null { return this.sourceMove ? allMoves[this.sourceMove].name : null; } @@ -228,7 +230,7 @@ export abstract class ArenaTag implements BaseArenaTag { * @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) ?? undefined; } @@ -236,17 +238,20 @@ export abstract class ArenaTag implements BaseArenaTag { * Helper function that retrieves the Pokemon affected * @returns list of PlayerPokemon or EnemyPokemon on the field */ - 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); } } + + protected canAffect(pokemon: Pokemon) { + return this.getAffectedPokemon().includes(pokemon); + } } /** @@ -267,7 +272,11 @@ export class MistTag extends SerializableArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:mistOnAdd" + this.sideString; + return "arenaTag:mistOnAdd" + this.i18nSideKey; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:mistOnRemove" + this.i18nSideKey; } /** @@ -345,7 +354,11 @@ class ReflectTag extends WeakenMoveScreenTag { } protected override get onAddMessageKey(): string { - return "arenaTag:reflectOnAdd" + this.sideString; + return "arenaTag:reflectOnAdd" + this.i18nSideKey; + } + + protected override get onRemoveMessageKey(): string { + return "arenaTag:reflectOnRemove" + this.i18nSideKey; } } @@ -363,7 +376,10 @@ class LightScreenTag extends WeakenMoveScreenTag { } protected override get onAddMessageKey(): string { - return "arenaTag:lightScreenOnAdd" + this.sideString; + return "arenaTag:lightScreenOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:lightScreenOnRemove" + this.i18nSideKey; } } @@ -382,7 +398,10 @@ class AuroraVeilTag extends WeakenMoveScreenTag { } protected override get onAddMessageKey(): string { - return "arenaTag:auroraVeilOnAdd" + this.sideString; + return "arenaTag:auroraVeilOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return "arenaTag:auroraVeilOnRemove" + this.i18nSideKey; } } @@ -415,26 +434,36 @@ export abstract class ConditionalProtectTag extends ArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:conditionalProtectOnAdd" + this.sideString; + return "arenaTag:conditionalProtectOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { return ""; } + /** + * Return the message key that will be used when protecting an allied target. + * + * @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 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( - // TODO: WHy do we have `_attacker` here? + // TODO: `_attacker` is unused by all classes + // TODO: Simulated is only ever passed as `false` here... simulated: boolean, isProtected: BooleanHolder, _attacker: Pokemon, @@ -442,24 +471,33 @@ export abstract class ConditionalProtectTag extends ArenaTag { moveId: MoveId, ignoresProtectBypass: BooleanHolder, ): boolean { - if ((this.side === ArenaTagSide.PLAYER) === defender.isPlayer() && this.protectConditionFunc(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.protectConditionFunc(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(), + attackingMove: allMoves[moveId].name, + }), + ); + } + + ignoresProtectBypass.value ||= this.ignoresBypass; + return true; } } @@ -589,7 +627,7 @@ class CraftyShieldTag extends ConditionalProtectTag { export class NoCritTag extends SerializableArenaTag { public readonly tagType = ArenaTagType.NO_CRIT; protected override get onAddMessageKey(): string { - return "arenaTag:noCritOnAdd" + this.sideString; + return "arenaTag:noCritOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { return "arenaTag:noCritOnRemove"; @@ -724,59 +762,26 @@ export abstract class EntryHazardTag extends SerializableArenaTag { // TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts - /** - * Display text when this tag is added to the field. - * @param _arena - The {@linkcode Arena} at the time of adding this tag - * @param quiet - Whether to suppress messages during tag creation; default `false` - */ - override onAdd(_arena: Arena, quiet = false): void { - // Here, `quiet=true` means "just add the tag, no questions asked" - if (quiet) { - return; - } - - const source = this.getSourcePokemon(); - if (!source) { - console.warn( - "Failed to get source Pokemon for AernaTrapTag on add message!" + - `\nTag type: ${this.tagType}` + - `\nPID: ${this.sourceId}`, - ); - return; - } - - globalScene.phaseManager.queueMessage(this.getAddMessage(source)); - } - - /** - * Return the text to be displayed upon adding a new layer to this trap. - * @param source - The {@linkcode Pokemon} having created this tag - * @returns The localized message to be displayed on screen. - */ - protected abstract getAddMessage(source: Pokemon): string; - /** * Add a new layer to this tag upon overlap, triggering the tag's normal {@linkcode onAdd} effects upon doing so. - * @param arena - The {@linkcode arena} at the time of adding the tag */ - override onOverlap(arena: Arena): void { - if (this.layers >= this.maxLayers) { + override onOverlap(): void { + if (!this.canAdd()) { return; } this.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. */ override apply(simulated: boolean, pokemon: Pokemon): boolean { - if ((this.side === ArenaTagSide.PLAYER) !== pokemon.isPlayer()) { + if (!this.canAffect(pokemon)) { return false; } @@ -784,17 +789,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() @@ -813,7 +818,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 }); @@ -829,18 +841,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. @@ -865,17 +881,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 { @@ -902,16 +917,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", { - pokemonName: 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 { @@ -932,11 +947,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; @@ -946,36 +956,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(); - } + 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; } @@ -1009,14 +1005,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", { @@ -1036,7 +1033,7 @@ class StickyWebTag extends EntryHazardTag { globalScene.phaseManager.queueMessage( i18next.t("arenaTag:stickyWebActivateTrap", { - pokemonName: pokemon.getNameToRender(), + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), ); @@ -1072,11 +1069,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 => { @@ -1086,16 +1090,8 @@ 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 + * Checks if the source Pokemon is still active on the field * @returns `true` if the source of the tag is still active on the field | `false` if not */ override lapse(): boolean { const source = this.getSourcePokemon(); @@ -1104,10 +1100,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} 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); @@ -1117,9 +1113,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); @@ -1202,22 +1198,17 @@ class TailwindTag extends SerializableArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:tailwindOnAdd" + this.sideString; + return "arenaTag:tailwindOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { - return "arenaTag:tailwindOnRemove" + this.sideString; + return "arenaTag:tailwindOnRemove" + this.i18nSideKey; } onAdd(quiet = false): void { super.onAdd(quiet); - const source = this.getSourcePokemon(); - if (!source) { - return; - } - - 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 @@ -1248,6 +1239,8 @@ class TailwindTag extends SerializableArenaTag { } } } + + // TODO: Have the `apply` method double speed } /** @@ -1266,7 +1259,7 @@ class HappyHourTag extends SerializableArenaTag { // 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" + this.sideString; + return "arenaTag:happyHourOnRemove"; } } @@ -1277,11 +1270,11 @@ class SafeguardTag extends ArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:safeguardOnAdd" + this.sideString; + return "arenaTag:safeguardOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { - return "arenaTag:safeguardOnRemove" + this.sideString; + return "arenaTag:safeguardOnRemove" + this.i18nSideKey; } } @@ -1290,6 +1283,14 @@ class NoneTag extends ArenaTag { constructor() { super(0); } + + protected override get onAddMessageKey(): string { + return ""; + } + + protected override get onRemoveMessageKey(): string { + return ""; + } } /** @@ -1306,11 +1307,11 @@ class FireGrassPledgeTag extends SerializableArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:fireGrassPledgeOnAdd" + this.sideString; + return "arenaTag:fireGrassPledgeOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { - return "arenaTag:fireGrassPledgeOnRemove" + this.sideString; + return "arenaTag:fireGrassPledgeOnRemove" + this.i18nSideKey; } override lapse(): boolean { @@ -1352,11 +1353,11 @@ class WaterFirePledgeTag extends SerializableArenaTag { super(4, MoveId.WATER_PLEDGE, sourceId, side); } protected override get onAddMessageKey(): string { - return "arenaTag:waterFirePledgeOnAdd" + this.sideString; + return "arenaTag:waterFirePledgeOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { - return "arenaTag:waterFirePledgeOnRemove" + this.sideString; + return "arenaTag:waterFirePledgeOnRemove" + this.i18nSideKey; } /** @@ -1382,11 +1383,11 @@ class GrassWaterPledgeTag extends SerializableArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:grassWaterPledgeOnAdd" + this.sideString; + return "arenaTag:grassWaterPledgeOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { - return "arenaTag:grassWaterPledgeOnAdd" + this.sideString; + return "arenaTag:grassWaterPledgeOnAdd" + this.i18nSideKey; } // TODO: Move speed drops into this class's `apply` method instead of an explicit check for it @@ -1406,7 +1407,10 @@ export class FairyLockTag extends SerializableArenaTag { } protected override get onAddMessageKey(): string { - return "arenaTag:fairyLockOnAdd" + this.sideString; + return "arenaTag:fairyLockOnAdd" + this.i18nSideKey; + } + protected override get onRemoveMessageKey(): string { + return ""; } } @@ -1420,11 +1424,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; @@ -1432,8 +1436,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 | null) { + if (pokemon) { + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:neutralizingGasOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }), + ); + } } public override loadTag(source: BaseArenaTag & Pick): void { @@ -1447,7 +1467,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { 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 => { @@ -1472,8 +1492,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"), @@ -1487,7 +1507,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { 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 }); }); @@ -1498,16 +1518,6 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { public shouldApplyToSelf(): boolean { return this.sourceCount > 1; } - - private playActivationMessage(pokemon: Pokemon | null) { - if (pokemon) { - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:neutralizingGasOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - ); - } - } } // TODO: swap `sourceMove` and `sourceId` and make `sourceMove` an optional parameter diff --git a/src/messages.ts b/src/messages.ts index 177b4cc9b05..38ce3533c40 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -5,9 +5,9 @@ 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 {boolean} useIllusion - Whether we want the name of the illusion or not. Default value : true - * @returns {string} 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" */ export function getPokemonNameWithAffix(pokemon: Pokemon | undefined, useIllusion = true): string { if (!pokemon) { diff --git a/test/abilities/magic-bounce.test.ts b/test/abilities/magic-bounce.test.ts index c15690c3f5d..ff4bd1e588d 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); }); From 0b4a6edc7c32805012bd843bd525b7d689ce385a Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 17:15:47 -0400 Subject: [PATCH 04/17] Made arena tag layers readonly; cleaned up callsites --- src/data/abilities/ability.ts | 5 +---- src/data/arena-tag.ts | 22 ++++++++++++++++------ src/data/moves/move.ts | 4 ++-- src/field/arena.ts | 2 +- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 21c4d45584e..1fb634f7694 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1114,10 +1114,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 51b8b6c416b..ba2f1451778 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -747,8 +747,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. + * Intended to be mutable within the class itself and readonly outside of it + * @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` */ @@ -762,6 +764,14 @@ export abstract class EntryHazardTag extends SerializableArenaTag { // TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts + /** + * Check if this tag can have more layers added to it. + * @returns Whether this tag can have another layer added to it. + */ + public canAdd(): boolean { + return this.layers < this.maxLayers; + } + /** * Add a new layer to this tag upon overlap, triggering the tag's normal {@linkcode onAdd} effects upon doing so. */ @@ -769,7 +779,7 @@ export abstract class EntryHazardTag extends SerializableArenaTag { if (!this.canAdd()) { return; } - this.layers++; + (this as Mutable).layers++; this.onAdd(); } @@ -809,7 +819,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; } } @@ -906,7 +916,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() { @@ -997,7 +1007,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; } @@ -1061,7 +1071,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; } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index cd98a3c3e9d..08d64f6e214 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6156,7 +6156,7 @@ export class AddArenaTrapTagAttr extends AddArenaTagAttr { if (!tag) { return true; } - return tag.layers < tag.maxLayers; + return tag.canAdd(); }; } } @@ -6182,7 +6182,7 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr { if (!tag) { return true; } - return tag.layers < tag.maxLayers; + return tag.canAdd(); } return false; } diff --git a/src/field/arena.ts b/src/field/arena.ts index 34ee7aa853c..7bfeb7e5625 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -737,7 +737,7 @@ export class Arena { existingTag.onOverlap(globalScene.getPokemonById(sourceId)); if (existingTag instanceof EntryHazardTag) { - const { tagType, side, turnCount, layers, maxLayers } = existingTag as EntryHazardTag; + const { tagType, side, turnCount, layers, maxLayers } = existingTag; this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, layers, maxLayers)); } From 1b07a1aa2293439cc0da0a2bd0dc50688b1a7dfb Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 17:16:39 -0400 Subject: [PATCH 05/17] Added tests for stone axe --- test/moves/ceaseless-edge-stone-axe.test.ts | 95 +++++++++++++++++ test/moves/ceaseless-edge.test.ts | 108 -------------------- test/moves/destiny-bond.test.ts | 4 +- 3 files changed, 96 insertions(+), 111 deletions(-) create mode 100644 test/moves/ceaseless-edge-stone-axe.test.ts delete mode 100644 test/moves/ceaseless-edge.test.ts 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 118a45e7682..49093363325 100644 --- a/test/moves/destiny-bond.test.ts +++ b/test/moves/destiny-bond.test.ts @@ -195,9 +195,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 () => { From 3e290b7ce5a9ff591c43e7698fbcba5e89c74dc3 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 18:06:00 -0400 Subject: [PATCH 06/17] Fixed mat block + removed unused file --- src/@types/helpers/tuple-helpers.ts | 27 --------- src/data/arena-tag.ts | 92 +++++++++++++---------------- 2 files changed, 42 insertions(+), 77 deletions(-) delete mode 100644 src/@types/helpers/tuple-helpers.ts diff --git a/src/@types/helpers/tuple-helpers.ts b/src/@types/helpers/tuple-helpers.ts deleted file mode 100644 index e8a893d872c..00000000000 --- a/src/@types/helpers/tuple-helpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Remove the first N entries from a tuple. - * @typeParam T - The array type to remove elements from. - * @typeParam N - The number of elements to remove. - * @typeParam Count - The current count of removed elements, used for recursion. - */ -export type RemoveFirst< - T extends readonly any[], - N extends number, - Count extends any[] = [], -> = Count["length"] extends N - ? T - : T extends readonly [any, ...infer Rest] - ? RemoveFirst - : []; - -/** - * Remove the last N entries from a tuple. - * @typeParam T - The array type to remove elements from. - * @typeParam N - The number of elements to remove. - * @typeParam Count - The current count of removed elements, used for recursion. - */ -export type RemoveLast = Count["length"] extends N - ? T - : T extends readonly [...infer Rest, any] - ? RemoveLast - : []; diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index ba2f1451778..422a3f4dba6 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -21,6 +21,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, ArenaTagTypeData, @@ -249,6 +250,11 @@ export abstract class ArenaTag implements BaseArenaTag { } } + /** + * 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); } @@ -412,24 +418,15 @@ type ProtectConditionFunc = (moveId: MoveId) => boolean; * 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; } @@ -441,9 +438,17 @@ export abstract class ConditionalProtectTag extends ArenaTag { 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 { @@ -475,7 +480,7 @@ export abstract class ConditionalProtectTag extends ArenaTag { return false; } - if (!this.protectConditionFunc(moveId)) { + if (!this.condition(moveId)) { return false; } @@ -491,7 +496,7 @@ export abstract class ConditionalProtectTag extends ArenaTag { i18next.t(this.onProtectMessageKey, { pokemonNameWithAffix: getPokemonNameWithAffix(defender), moveName: this.getMoveName(), - attackingMove: allMoves[moveId].name, + attackName: allMoves[moveId].name, }), ); } @@ -528,29 +533,14 @@ const QuickGuardConditionFunc: ProtectConditionFunc = 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 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 = (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 @@ -559,21 +549,14 @@ const WideGuardConditionFunc: ProtectConditionFunc = (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 moveId {@linkcode MoveId} The move to check against this condition. - * @returns `true` if the incoming move is not a Status move. - */ -const MatBlockConditionFunc: ProtectConditionFunc = (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. @@ -581,14 +564,20 @@ const MatBlockConditionFunc: ProtectConditionFunc = (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); } protected override get onAddMessageKey(): string { return "arenaTag:matBlockOnAdd"; } - // TODO: This is using incorrect locales for protection + protected override get onProtectMessageKey(): string { + return "arenaTag:matBlockApply"; + } + + protected override get condition(): ProtectConditionFunc { + return m => allMoves[m].category !== MoveCategory.STATUS; + } } /** @@ -616,7 +605,11 @@ const CraftyShieldConditionFunc: ProtectConditionFunc = 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; } } @@ -626,6 +619,7 @@ class CraftyShieldTag extends ConditionalProtectTag { */ export class NoCritTag extends SerializableArenaTag { public readonly tagType = ArenaTagType.NO_CRIT; + protected override get onAddMessageKey(): string { return "arenaTag:noCritOnAdd" + this.i18nSideKey; } @@ -762,8 +756,6 @@ 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 - /** * Check if this tag can have more layers added to it. * @returns Whether this tag can have another layer added to it. From 1a99b08a9def06b55d4036d8718861ed5577a56a Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 19:25:16 -0400 Subject: [PATCH 07/17] Fixed up the tests for locale messages + fixed lucky chant --- src/data/arena-tag.ts | 2 +- test/arena/arena-tags.test.ts | 97 ++++++++++++++++++++++++++++++++ test/moves/entry-hazards.test.ts | 24 ++------ test/moves/mat-block.test.ts | 7 +++ 4 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 test/arena/arena-tags.test.ts diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 422a3f4dba6..3d8d6cce8ec 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -624,7 +624,7 @@ export class NoCritTag extends SerializableArenaTag { return "arenaTag:noCritOnAdd" + this.i18nSideKey; } protected override get onRemoveMessageKey(): string { - return "arenaTag:noCritOnRemove"; + return "arenaTag:noCritOnRemove" + this.i18nSideKey; } public override apply(blockCrit: BooleanHolder): void { diff --git a/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts new file mode 100644 index 00000000000..9b92b209ef3 --- /dev/null +++ b/test/arena/arena-tags.test.ts @@ -0,0 +1,97 @@ +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 { 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.logs = []; + }); + + const arenaTags = Object.values(ArenaTagType) + .filter(t => t !== ArenaTagType.NONE) + .map(t => ({ + tagType: t, + name: toTitleCase(t), + })); + describe.each(arenaTags)("$name", ({ tagType }) => { + it.each(getEnumValues(ArenaTagSide))( + "should display a message on addition, and a separate one on removal", + 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.logs = []; + + game.scene.arena.removeTagOnSide(tagType, side, false); + if (tag["onRemoveMessageKey"]) { + 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/entry-hazards.test.ts b/test/moves/entry-hazards.test.ts index c4dead1bb67..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); }); @@ -200,7 +188,7 @@ describe("Moves - Entry Hazards", () => { expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi); expect(game.textInterceptor.logs).toContain( i18next.t("arenaTag:stealthRockActivateTrap", { - pokemonName: getPokemonNameWithAffix(enemy), + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), }), ); }); @@ -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/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 () => { From 3b518b0d94bc0b1d0b63f6d446fe5ca1f2c59e98 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 21:09:06 -0400 Subject: [PATCH 08/17] Reverted change to light screen DR% --- src/data/arena-tag.ts | 3 +-- test/moves/light-screen.test.ts | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 3d8d6cce8ec..fd4b57ecaf0 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -339,8 +339,7 @@ export abstract class WeakenMoveScreenTag extends SerializableArenaTag { if (bypassed.value) { return false; } - // Screens are less effective during Double Battles - damageMultiplier.value = globalScene.currentBattle.double ? 2 / 3 : 1 / 2; + damageMultiplier.value = globalScene.currentBattle.double ? 2732 / 4096 : 0.5; return true; } } diff --git a/test/moves/light-screen.test.ts b/test/moves/light-screen.test.ts index 0dcd8bbc0ef..72028459bac 100644 --- a/test/moves/light-screen.test.ts +++ b/test/moves/light-screen.test.ts @@ -127,10 +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)) { - if (move.getAttrs("CritOnlyAttr").length === 0) { - globalScene.arena.applyTagsForSide(ArenaTagType.LIGHT_SCREEN, side, 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; From 7f41be4513bc31ed50ad0797bddee1f8ea39e0e9 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 21:51:46 -0400 Subject: [PATCH 09/17] Fixed tests to not check neutralizing gas msgs --- test/arena/arena-tags.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts index 9b92b209ef3..6ef6f00342f 100644 --- a/test/arena/arena-tags.test.ts +++ b/test/arena/arena-tags.test.ts @@ -50,8 +50,11 @@ describe("Arena Tags", () => { game.textInterceptor.logs = []; }); + // These tags are either ineligible or just jaaaaaaaaaaank + const FORBIDDEN_TAGS = [ArenaTagType.NONE, ArenaTagType.NEUTRALIZING_GAS] as const; + const arenaTags = Object.values(ArenaTagType) - .filter(t => t !== ArenaTagType.NONE) + .filter(t => (FORBIDDEN_TAGS as readonly ArenaTagType[]).includes(t)) .map(t => ({ tagType: t, name: toTitleCase(t), From 25ac2f3a744e8ddc89d5678018a23ea599546181 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:10:09 -0400 Subject: [PATCH 10/17] Fixed inverted conditional in test file Smh my head --- test/arena/arena-tags.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts index 6ef6f00342f..00a513ef480 100644 --- a/test/arena/arena-tags.test.ts +++ b/test/arena/arena-tags.test.ts @@ -54,7 +54,7 @@ describe("Arena Tags", () => { const FORBIDDEN_TAGS = [ArenaTagType.NONE, ArenaTagType.NEUTRALIZING_GAS] as const; const arenaTags = Object.values(ArenaTagType) - .filter(t => (FORBIDDEN_TAGS as readonly ArenaTagType[]).includes(t)) + .filter(t => !(FORBIDDEN_TAGS as readonly ArenaTagType[]).includes(t)) .map(t => ({ tagType: t, name: toTitleCase(t), From 74b5ad4336c185ce40179dba974d207ab8bdf229 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:22:02 -0700 Subject: [PATCH 11/17] Update doc comments for type-helpers.ts --- src/@types/helpers/type-helpers.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index d56ebb731bf..908ed15fc9a 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,17 +27,19 @@ 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. + * 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 + * @typeParam V - The type of one of O's values. */ export type InferKeys = V extends ObjectValues ? { @@ -48,6 +50,7 @@ export type InferKeys = V extends ObjectValues /** * Utility type to obtain 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. */ @@ -84,6 +87,7 @@ export type NonFunctionPropertiesRecursive = { : Class[K]; }; +/** Utility type for an abstract constructor. */ export type AbstractConstructor = abstract new (...args: any[]) => T; /** @@ -102,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. From 2d9f04e89130e2a4fe71a7b68344bc4d5f61b709 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:54:41 +0000 Subject: [PATCH 12/17] ran biome --- src/@types/helpers/type-helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index 908ed15fc9a..4a2a3a11e88 100644 --- a/src/@types/helpers/type-helpers.ts +++ b/src/@types/helpers/type-helpers.ts @@ -11,7 +11,7 @@ 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} + * 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 @@ -110,7 +110,7 @@ export type CoerceNullPropertiesToUndefined = { */ export type AtLeastOne = Partial & ObjectValues<{ [K in keyof T]: Pick, K> }>; -/** +/** * Type helper that adds a brand to a type, used for nominal typing. * * @remarks From b0a46efb68dbd98dd7d2a92c0f32c1f1db6dd7ba Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 9 Sep 2025 12:21:36 -0400 Subject: [PATCH 13/17] Fixed more conflicts --- src/field/arena.ts | 2 +- test/moves/aurora-veil.test.ts | 6 ++---- test/moves/reflect.test.ts | 6 ++---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/field/arena.ts b/src/field/arena.ts index 4785fca8090..695e14243e6 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -857,7 +857,7 @@ export class Arena { } removeAllTags(): void { - while (this.tags.length) { + while (this.tags.length > 0) { this.tags[0].onRemove(); this.eventTarget.dispatchEvent( new TagRemovedEvent(this.tags[0].tagType, this.tags[0].side, this.tags[0].turnCount), diff --git a/test/moves/aurora-veil.test.ts b/test/moves/aurora-veil.test.ts index 4c5ab928df7..ebcb9ffb12f 100644 --- a/test/moves/aurora-veil.test.ts +++ b/test/moves/aurora-veil.test.ts @@ -139,10 +139,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.AURORA_VEIL, side)) { - if (move.getAttrs("CritOnlyAttr").length === 0) { - globalScene.arena.applyTagsForSide(ArenaTagType.AURORA_VEIL, side, attacker, move.category, multiplierHolder); - } + if (globalScene.arena.getTagOnSide(ArenaTagType.AURORA_VEIL, side) && move.getAttrs("CritOnlyAttr").length === 0) { + globalScene.arena.applyTagsForSide(ArenaTagType.AURORA_VEIL, side, attacker, move.category, multiplierHolder); } return move.power * multiplierHolder.value; diff --git a/test/moves/reflect.test.ts b/test/moves/reflect.test.ts index 36b545aae14..39f74f5189c 100644 --- a/test/moves/reflect.test.ts +++ b/test/moves/reflect.test.ts @@ -143,10 +143,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.REFLECT, side)) { - if (move.getAttrs("CritOnlyAttr").length === 0) { - globalScene.arena.applyTagsForSide(ArenaTagType.REFLECT, side, attacker, move.category, multiplierHolder); - } + if (globalScene.arena.getTagOnSide(ArenaTagType.REFLECT, side) && move.getAttrs("CritOnlyAttr").length === 0) { + globalScene.arena.applyTagsForSide(ArenaTagType.REFLECT, side, attacker, move.category, multiplierHolder); } return move.power * multiplierHolder.value; From f2198e60bfb149cb6d480e941a26d75828ae2735 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 9 Sep 2025 19:04:36 -0400 Subject: [PATCH 14/17] Added util to make `it.each` test cases from a bunch of enums --- test/arena/arena-tags.test.ts | 12 +++++----- test/test-utils/string-utils.ts | 39 +++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts index 00a513ef480..b78e5ac8bba 100644 --- a/test/arena/arena-tags.test.ts +++ b/test/arena/arena-tags.test.ts @@ -6,7 +6,7 @@ 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 { getEnumValues } from "#utils/enums"; +import { getEnumTestCases } from "#test/test-utils/string-utils"; import { toTitleCase } from "#utils/strings"; import i18next from "i18next"; import Phaser from "phaser"; @@ -47,7 +47,7 @@ describe("Arena Tags", () => { vi.spyOn(game.scene.phaseManager, "queueMessage").mockImplementation((text, callbackDelay, prompt, promptDelay) => game.scene.ui.showText(text, null, null, callbackDelay, prompt, promptDelay), ); - game.textInterceptor.logs = []; + game.textInterceptor.clearLogs(); }); // These tags are either ineligible or just jaaaaaaaaaaank @@ -60,9 +60,9 @@ describe("Arena Tags", () => { name: toTitleCase(t), })); describe.each(arenaTags)("$name", ({ tagType }) => { - it.each(getEnumValues(ArenaTagSide))( - "should display a message on addition, and a separate one on removal", - side => { + it.each(getEnumTestCases(ArenaTagSide))( + "should display a message on addition, and a separate one on removal - ArenaTagSide.$name", + ({ value: side }) => { game.scene.arena.addTag(tagType, 0, undefined, playerId, side); expect(game).toHaveArenaTag(tagType, side); @@ -79,7 +79,7 @@ describe("Arena Tags", () => { expect(game.textInterceptor.logs).toHaveLength(0); } - game.textInterceptor.logs = []; + game.textInterceptor.clearLogs(); game.scene.arena.removeTagOnSide(tagType, side, false); if (tag["onRemoveMessageKey"]) { diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index e19224f4571..8b953ac4742 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -1,10 +1,12 @@ import { getStatKey, type Stat } from "#enums/stat"; import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types"; import type { ObjectValues } from "#types/type-helpers"; -import { enumValueToKey } from "#utils/enums"; +import { enumValueToKey, getEnumValues } 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"; @@ -87,7 +89,7 @@ export function getEnumStr( * ``` */ export function stringifyEnumArray(obj: E, enums: E[keyof E][]): string { - if (obj.length === 0) { + if (enums.length === 0) { return "[]"; } @@ -185,3 +187,36 @@ export function getOnelineDiffStr(this: MatcherState, obj: unknown): string { .replace(/\n/g, " ") // Replace newlines with spaces .replace(/,(\s*)\}$/g, "$1}"); // Trim trailing commas } + +/** + * Convert an enum or `const object` into an array of object literals + * suitable for use inside {@linkcode describe.each} or {@linkcode it.each}. + * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from + * @param values - An array containing one or more of `obj`'s values to convert. + * Defaults to all the enum's values. + * @param options - Options to pass to {@linkcode getEnumStr} + * @returns An array of objects containing the enum's name and value. + * @example + * ```ts + * enum fakeEnum { + * ONE: 1, + * TWO: 2, + * THREE: 3, + * } + * describe.each(getEnumTestCases(fakeEnum))("should do XYZ - $name", ({value}) => {}); + * ``` + */ +export function getEnumTestCases( + obj: E, + values: E[keyof E][] = isTSNumericEnum(obj) ? getEnumValues(obj) : (Object.values(obj) as E[keyof E][]), + options: getEnumStrOptions = {}, +): { value: E[keyof E]; name: string }[] { + return values.map(e => ({ + value: e, + name: getEnumStr(obj, e, options), + })); +} + +function isTSNumericEnum(obj: E): obj is TSNumericEnum { + return Object.keys(obj).some(k => typeof k === "number"); +} From 3780841e0d7bda919ad068fb4a0e21452e385a6e Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 9 Sep 2025 19:07:44 -0400 Subject: [PATCH 15/17] Fixed up tsdocs --- src/data/arena-tag.ts | 2 +- src/enums/arena-tag-type.ts | 3 +++ test/arena/arena-tags.test.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 4e951ee7395..09a123d4b8c 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -108,7 +108,7 @@ 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; diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 30f053b98bd..33b734e7196 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/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts index b78e5ac8bba..f4086cdd01d 100644 --- a/test/arena/arena-tags.test.ts +++ b/test/arena/arena-tags.test.ts @@ -83,6 +83,7 @@ describe("Arena Tags", () => { 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"]()), From 38cafe7b2729942de6ab3b28033fd8975da021cc Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 10 Sep 2025 13:48:50 -0400 Subject: [PATCH 16/17] Fixed type error + removed broken util --- test/arena/arena-tags.test.ts | 91 +++++++++++++++++---------------- test/test-utils/string-utils.ts | 51 ++++-------------- 2 files changed, 59 insertions(+), 83 deletions(-) diff --git a/test/arena/arena-tags.test.ts b/test/arena/arena-tags.test.ts index f4086cdd01d..9e1eb4d7fb8 100644 --- a/test/arena/arena-tags.test.ts +++ b/test/arena/arena-tags.test.ts @@ -6,7 +6,8 @@ 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 { getEnumTestCases } from "#test/test-utils/string-utils"; +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"; @@ -53,49 +54,53 @@ describe("Arena Tags", () => { // 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)) - .map(t => ({ - tagType: t, - name: toTitleCase(t), - })); - describe.each(arenaTags)("$name", ({ tagType }) => { - it.each(getEnumTestCases(ArenaTagSide))( - "should display a message on addition, and a separate one on removal - ArenaTagSide.$name", - ({ value: 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); - }, + .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/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index 8b953ac4742..281a8ce0a41 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -1,7 +1,7 @@ import { getStatKey, type Stat } from "#enums/stat"; import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types"; import type { ObjectValues } from "#types/type-helpers"; -import { enumValueToKey, getEnumValues } from "#utils/enums"; +import { enumValueToKey } from "#utils/enums"; import { toTitleCase } from "#utils/strings"; import type { MatcherState } from "@vitest/expect"; import i18next from "i18next"; @@ -24,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 @@ -48,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": @@ -70,7 +74,7 @@ export function getEnumStr( stringPart = casingFunc(stringPart); } - return `${prefix}${stringPart}${suffix} (=${val})`; + return `${prefix}${stringPart}${suffix}${omitValue ? ` (=${val})` : ""}`; } /** @@ -187,36 +191,3 @@ export function getOnelineDiffStr(this: MatcherState, obj: unknown): string { .replace(/\n/g, " ") // Replace newlines with spaces .replace(/,(\s*)\}$/g, "$1}"); // Trim trailing commas } - -/** - * Convert an enum or `const object` into an array of object literals - * suitable for use inside {@linkcode describe.each} or {@linkcode it.each}. - * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from - * @param values - An array containing one or more of `obj`'s values to convert. - * Defaults to all the enum's values. - * @param options - Options to pass to {@linkcode getEnumStr} - * @returns An array of objects containing the enum's name and value. - * @example - * ```ts - * enum fakeEnum { - * ONE: 1, - * TWO: 2, - * THREE: 3, - * } - * describe.each(getEnumTestCases(fakeEnum))("should do XYZ - $name", ({value}) => {}); - * ``` - */ -export function getEnumTestCases( - obj: E, - values: E[keyof E][] = isTSNumericEnum(obj) ? getEnumValues(obj) : (Object.values(obj) as E[keyof E][]), - options: getEnumStrOptions = {}, -): { value: E[keyof E]; name: string }[] { - return values.map(e => ({ - value: e, - name: getEnumStr(obj, e, options), - })); -} - -function isTSNumericEnum(obj: E): obj is TSNumericEnum { - return Object.keys(obj).some(k => typeof k === "number"); -} From 69e78f1b407c5bbceb245e53ae1d86a064babc81 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 21 Sep 2025 13:26:00 -0400 Subject: [PATCH 17/17] Fixed TR signature --- src/utils/speed-order.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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(); }