diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 0ed70a17b..937ea697f 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -41,6 +41,7 @@ import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { AbilityApplyMode } from "#enums/ability-apply-mode"; import { GulpMissileBattlerTagTypes, + MoveLockTagTypes, RemoveTypeBattlerTagTypes, SemiInvulnerableBattlerTagTypes, TrappedBattlerTagTypes, @@ -1182,6 +1183,78 @@ export class FrenzyTag extends MoveLockTag { } } +/** + * Puts the source {@linkcode Pokemon} into an uproar, locking them into using + * Uproar for 2 turns after the initial usage and preventing all + * Pokemon on the field from sleeping. All Pokemon on the field also + * wake up when this tag is added. + * @extends MoveLockTag + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Uproar_(move) Uproar} + */ +export class UproarTag extends MoveLockTag { + constructor() { + super(BattlerTagType.UPROAR, 3, MoveId.UPROAR); + } + + /** + * Plays a "started an uproar" message, then wakes up all active Pokemon + * @param pokemon the {@linkcode Pokemon} with this tag + */ + override onAdd(pokemon: Pokemon): void { + // "{pokemonNameWithAffix} caused an uproar!" + globalScene.queueMessage( + i18next.t("battlerTags:uproarOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), + ); + + // Wake up all sleeping Pokemon on the field + globalScene.getField(true).forEach((p) => { + if (p.hasStatusEffect(StatusEffect.SLEEP, false, true)) { + p.resetStatus(); + // "The uproar woke {pokemonNameWithAffix}!" + globalScene.queueMessage( + i18next.t("battlerTags:uproarOnCureSleep", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), + ); + } + }); + } + + override onRemove(pokemon: Pokemon): void { + // "{pokemonNameWithAffix} calmed down." + globalScene.queueMessage( + i18next.t("battlerTags:uproarOnRemove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), + ); + + super.onRemove(pokemon); + } + + /** + * Prevents Pokemon on the field from falling asleep + * @param pokemon the {@linkcode Pokemon} with this tag + * @param simulated if `true`, suppresses changes to game state + * @param affectedPokemon the {@linkcode Pokemon} to be afflicted with sleep + * @param preventSleep a {@linkcode BooleanHolder} which, if `true`, cancels attempts to afflict sleep + * @returns `true` + */ + override apply( + _pokemon: Pokemon, + simulated: boolean, + affectedPokemon: Pokemon, + preventSleep: BooleanHolder, + ): boolean { + if (!simulated) { + // "But the uproar kept {pokemonNameWithAffix} awake!" + globalScene.queueMessage( + i18next.t("battlerTags:uproarOnPreventSleep", { + pokemonNameWithAffix: getPokemonNameWithAffix(affectedPokemon), + }), + ); + } + + preventSleep.value = true; + return true; + } +} + /** * Applies the effects of the move Encore onto the target Pokemon * Encore forces the target Pokemon to use its most-recent move for 3 turns @@ -3194,10 +3267,13 @@ export class TormentTag extends MoveRestrictionBattlerTag { if (!lastMoveTurn) { return false; } - // This checks for locking / momentum moves like Rollout and Hydro Cannon + if the user is under the influence of BattlerTagType.FRENZY - // Because Uproar's unique behavior is not implemented, it does not check for Uproar. Torment has been marked as partial in moves.ts + const moveObj = allMoves[lastMoveTurn.move.id]; - const isUnaffected = moveObj.hasAttr(ConsecutiveUseDoublePowerAttr) || user.getTag(BattlerTagType.FRENZY); + /** + * Consecutively-executed moves are not interrupted by Torment + * @todo remove the additional attribute check once Rollout/Ice Ball are reimplemented + */ + const isUnaffected = moveObj.hasAttr(ConsecutiveUseDoublePowerAttr) || user.getTag(...MoveLockTagTypes); const validLastMoveResult = lastMoveTurn.result === MoveResult.SUCCESS || lastMoveTurn.result === MoveResult.MISS; if ( lastMoveTurn.move.id === moveId @@ -3534,6 +3610,8 @@ export function getBattlerTag( return new NightmareTag(); case BattlerTagType.FRENZY: return new FrenzyTag(turnCount, sourceMoveId); + case BattlerTagType.UPROAR: + return new UproarTag(); case BattlerTagType.CHARGING: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, 1, sourceMoveId, sourceId); case BattlerTagType.ENCORE: diff --git a/src/data/init-moves.ts b/src/data/init-moves.ts index c7133f352..66edd38cd 100644 --- a/src/data/init-moves.ts +++ b/src/data/init-moves.ts @@ -1123,9 +1123,15 @@ export function initMoves() { .attr(FlinchAttr) .condition(new FirstMoveCondition()), new AttackMove(MoveId.UPROAR, ElementalType.NORMAL, MoveCategory.SPECIAL, 90, 100, 10, -1, 0, 3) + .attr(AddBattlerTagAttr, BattlerTagType.UPROAR, true) + .attr(MessageHeaderAttr, (user, _move) => + !!user.getTag(BattlerTagType.UPROAR) + ? // "{pokemonNameWithAffix} is making an uproar!" + i18next.t("moveTriggers:isMakingAnUproar", { pokemonNameWithAffix: getPokemonNameWithAffix(user) }) + : undefined, + ) .soundMove() - .target(MoveTarget.RANDOM_NEAR_ENEMY) - .partial(), // Does not lock the user, does not stop Pokemon from sleeping + .target(MoveTarget.RANDOM_NEAR_ENEMY), new SelfStatusMove(MoveId.STOCKPILE, ElementalType.NORMAL, -1, 20, -1, 0, 3) .condition((user) => (user.getTag(BattlerTagType.STOCKPILING)?.stockpiledCount ?? 0) < 3) .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), @@ -1148,7 +1154,6 @@ export function initMoves() { .target(MoveTarget.BOTH_SIDES), new StatusMove(MoveId.TORMENT, ElementalType.DARK, 100, 15, -1, 0, 3) .ignoresSubstitute() - .edgeCase() // Incomplete implementation because of Uproar's partial implementation .attr(AddBattlerTagAttr, BattlerTagType.TORMENT, false, { failOnOverlap: true }), new StatusMove(MoveId.FLATTER, ElementalType.DARK, 100, 15, -1, 0, 3) .attr(StatStageChangeAttr, [Stat.SPATK], 1) diff --git a/src/data/move-attrs/add-battler-tag-attr.ts b/src/data/move-attrs/add-battler-tag-attr.ts index 3797e1ded..78a7fbe78 100644 --- a/src/data/move-attrs/add-battler-tag-attr.ts +++ b/src/data/move-attrs/add-battler-tag-attr.ts @@ -89,6 +89,7 @@ export class AddBattlerTagAttr extends ChanceBasedMoveEffectAttr { case BattlerTagType.SALT_CURED: case BattlerTagType.CURSED: case BattlerTagType.FRENZY: + case BattlerTagType.UPROAR: case BattlerTagType.TRAPPED: case BattlerTagType.OCTOLOCK: case BattlerTagType.NO_RETREAT: diff --git a/src/data/move-attrs/message-header-attr.ts b/src/data/move-attrs/message-header-attr.ts index 07d013f3e..d41eae53f 100644 --- a/src/data/move-attrs/message-header-attr.ts +++ b/src/data/move-attrs/message-header-attr.ts @@ -8,9 +8,9 @@ import { MoveHeaderAttr } from "#app/data/move-attrs/move-header-attr"; * @extends MoveHeaderAttr */ export class MessageHeaderAttr extends MoveHeaderAttr { - private message: string | ((user: Pokemon, move: Move) => string); + private message: string | ((user: Pokemon, move: Move) => string | undefined); - constructor(message: string | ((user: Pokemon, move: Move) => string)) { + constructor(message: string | ((user: Pokemon, move: Move) => string | undefined)) { super(); this.message = message; } diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 94b75f8bb..ed0cf53ed 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -109,4 +109,5 @@ export enum BattlerTagType { SKY_DROP, CRIT_BOOST_STACKABLE, RAGE, + UPROAR, } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 9db95192c..9c38221e3 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -47,6 +47,7 @@ import { getBattlerTag, type AutotomizedTag, type CritBoostStackableTag, + type UproarTag, type EncoreTag, type SubstituteTag, } from "#app/data/battler-tags"; @@ -3610,7 +3611,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } break; case StatusEffect.SLEEP: - if (this.isGrounded() && globalScene.arena.hasTerrain(TerrainType.ELECTRIC)) { + const preventSleep = new BooleanHolder(false); + globalScene + .getField(true) + .forEach((p) => p.getTag(BattlerTagType.UPROAR)?.apply(p, quiet, this, preventSleep)); + + if (preventSleep.value || (this.isGrounded() && globalScene.arena.hasTerrain(TerrainType.ELECTRIC))) { return false; } break; @@ -3653,7 +3659,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { turnsRemaining: number = 0, sourceText: string | null = null, ): boolean { - if (!this.canSetStatus(effect, asPhase, false, sourcePokemon)) { + if (!this.canSetStatus(effect, !asPhase, false, sourcePokemon)) { return false; } if (this.isFainted()) { diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index 26fb72a7a..bd18fb469 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -11,6 +11,7 @@ import { SwitchType } from "#enums/switch-type"; import { settings } from "#app/system/settings/settings-manager"; import i18next from "i18next"; import { PhaseId } from "#enums/phase-id"; +import { MoveLockTagTypes } from "#app/utils/battler-tag-type-utils"; /** * Handles the prompt to switch pokemon at the start of a battle when the player is playing in Switch mode @@ -60,7 +61,7 @@ export class CheckSwitchPhase extends BattlePhase { // ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching if ( - pokemon.getTag(BattlerTagType.FRENZY) + pokemon.getTag(...MoveLockTagTypes) || pokemon.isTrapped() || globalScene.getPlayerField().some((p) => p.getTag(BattlerTagType.COMMANDED)) ) { diff --git a/src/utils/battler-tag-type-utils.ts b/src/utils/battler-tag-type-utils.ts index b4d62dd17..adfbdd7f2 100644 --- a/src/utils/battler-tag-type-utils.ts +++ b/src/utils/battler-tag-type-utils.ts @@ -7,7 +7,7 @@ export const SemiInvulnerableBattlerTagTypes = Object.freeze([ BattlerTagType.HIDDEN, ]); -export const MoveLockTagTypes = Object.freeze([BattlerTagType.FRENZY]); +export const MoveLockTagTypes = Object.freeze([BattlerTagType.FRENZY, BattlerTagType.UPROAR]); export const CritBoostBattlerTagTypes = Object.freeze([BattlerTagType.CRIT_BOOST, BattlerTagType.DRAGON_CHEER]); diff --git a/test/moves/uproar.test.ts b/test/moves/uproar.test.ts new file mode 100644 index 000000000..69229da79 --- /dev/null +++ b/test/moves/uproar.test.ts @@ -0,0 +1,145 @@ +import { Abilities } from "#enums/abilities"; +import { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { Species } from "#enums/species"; +import { StatusEffect } from "#enums/status-effect"; +import { GameManager } from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Uproar", () => { + 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 + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.BLISSEY) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it("should lock the user into using Uproar for the following 2 turns", async () => { + await game.classicMode.startBattle([Species.FEEBAS]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.UPROAR); + await game.toNextTurn(); + + expect(player.getTag(BattlerTagType.UPROAR)?.turnCount).toBe(2); + expect(player.getMoveQueue()[0]).toMatchObject({ + move: expect.objectContaining({ id: MoveId.UPROAR }), + ignorePP: true, + }); + + const playerUproar = player.getMoveset().find((mv) => mv.moveId === MoveId.UPROAR); + expect(playerUproar?.ppUsed).toBe(1); + + await game.toNextTurn(); + await game.toNextTurn(); + + expect(player.getTag(BattlerTagType.UPROAR)).toBeUndefined(); + expect(player.getMoveQueue()).toHaveLength(0); + expect(playerUproar?.ppUsed).toBe(1); + }); + + it("should stop execution after using Uproar has no effect", async () => { + await game.classicMode.startBattle([Species.FEEBAS]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.UPROAR); + await game.toNextTurn(); + + expect(player.getTag(BattlerTagType.UPROAR)?.turnCount).toBe(2); + expect(player.getMoveQueue()[0]).toMatchObject({ + move: expect.objectContaining({ id: MoveId.UPROAR }), + ignorePP: true, + }); + + game.override.enemyAbility(Abilities.SOUNDPROOF); + + await game.toNextTurn(); + expect(player.getTag(BattlerTagType.UPROAR)).toBeUndefined(); + expect(player.getMoveQueue()).toHaveLength(0); + }); + + it("should wake up all active Pokemon on its initial use", async () => { + game.override.enemyStatusEffect(StatusEffect.SLEEP).battleType("double"); + + await game.classicMode.startBattle([Species.FEEBAS]); + + const enemyPokemon = game.scene.getEnemyField(); + + game.move.use(MoveId.UPROAR, 0); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + + await game.phaseInterceptor.to("MoveEndPhase"); + enemyPokemon.forEach((p) => expect(p.getStatusEffect()).toBe(StatusEffect.NONE)); + }); + + it("should prevent active Pokemon from falling asleep during its execution", async () => { + game.override.battleType("double"); + await game.classicMode.startBattle([Species.FEEBAS, Species.MAGIKARP]); + + const enemyPokemon = game.scene.getEnemyField(); + + game.move.use(MoveId.UPROAR, 0); + game.move.use(MoveId.SPORE, 1, BattlerIndex.ENEMY_2); + await game.move.forceEnemyMove(MoveId.REST); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2]); + + await game.phaseInterceptor.to("BerryPhase", false); + enemyPokemon.forEach((p) => expect(p.getStatusEffect()).toBe(StatusEffect.NONE)); + }); + + it("should not have its execution interrupted by Torment", async () => { + await game.classicMode.startBattle([Species.FEEBAS]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.UPROAR); + await game.move.forceEnemyMove(MoveId.TORMENT); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + + await game.toNextTurn(); + + expect(player.getTag(BattlerTagType.UPROAR)?.turnCount).toBe(2); + expect(player.getMoveQueue()[0]).toMatchObject({ + move: expect.objectContaining({ id: MoveId.UPROAR }), + ignorePP: true, + }); + + await game.toNextTurn(); + await game.toNextTurn(); + + expect(player.getTag(BattlerTagType.UPROAR)).toBeUndefined(); + expect(player.getMoveQueue()).toHaveLength(0); + expect(player.getMoveHistory()).toHaveLength(3); + player.getMoveHistory().forEach((turnMove) => + expect(turnMove).toMatchObject({ + move: expect.objectContaining({ id: MoveId.UPROAR }), + result: MoveResult.SUCCESS, + }), + ); + }); +});