Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Move] Fully Implement Uproar #699

Draft
wants to merge 9 commits into
base: beta
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions src/data/battler-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions src/data/init-moves.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this gives pokemonNameWithAffix but the text expects just pokemonName
image

: 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<StockpilingTag>(BattlerTagType.STOCKPILING)?.stockpiledCount ?? 0) < 3)
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/data/move-attrs/add-battler-tag-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/data/move-attrs/message-header-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/enums/battler-tag-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,5 @@ export enum BattlerTagType {
SKY_DROP,
CRIT_BOOST_STACKABLE,
RAGE,
UPROAR,
}
10 changes: 8 additions & 2 deletions src/field/pokemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
getBattlerTag,
type AutotomizedTag,
type CritBoostStackableTag,
type UproarTag,
type EncoreTag,
type SubstituteTag,
} from "#app/data/battler-tags";
Expand Down Expand Up @@ -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<UproarTag>(BattlerTagType.UPROAR)?.apply(p, quiet, this, preventSleep));

if (preventSleep.value || (this.isGrounded() && globalScene.arena.hasTerrain(TerrainType.ELECTRIC))) {
return false;
}
break;
Expand Down Expand Up @@ -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()) {
Expand Down
3 changes: 2 additions & 1 deletion src/phases/check-switch-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
) {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/battler-tag-type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
145 changes: 145 additions & 0 deletions test/moves/uproar.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
});
});