diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 0c0b8e9e0349..b7c44fcdfa34 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2831,6 +2831,32 @@ export class ImprisonTag extends MoveRestrictionBattlerTag { } } +export class StuffCheeksTag extends MoveRestrictionBattlerTag { + // Stuff Cheeks + private moveId: Moves = Moves.STUFF_CHEEKS; + /** + * This Tag only lasts the turn the {@linkcode StuffCheeksTag} was added. + * @param move {@linkcode Moves} that is selected + */ + constructor() { + super(BattlerTagType.STUFF_CHEEKS, BattlerTagLapseType.TURN_END, 0, Moves.STUFF_CHEEKS); + } + + /** + * This function returns true if {@linkcode move} is {@linkcode Moves.STUFF_CHEEKS} + * @param move {@linkcode Moves} that is selected + * @returns true if the move matches the ID of Stuff Cheeks + */ + override isMoveRestricted(move: Moves): boolean { + return move === this.moveId; + } + + override selectionDeniedText(pokemon: Pokemon, move: Moves): string { + return i18next.t("battle:moveCannotBeSelected", { moveName: allMoves[move].name }); + } +} + + /** * Battler Tag that applies the effects of Syrup Bomb to the target Pokemon. * For three turns, starting from the turn of hit, at the end of each turn, the target Pokemon's speed will decrease by 1. @@ -3174,6 +3200,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new GrudgeTag(); case BattlerTagType.PSYCHO_SHIFT: return new PsychoShiftTag(); + case BattlerTagType.STUFF_CHEEKS: + return new StuffCheeksTag(); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index 7a6f08a53725..5aebbee3b5c0 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -142,6 +142,8 @@ export default class Move implements Localizable { public generation: number; public attrs: MoveAttr[] = []; private conditions: MoveCondition[] = []; + /** contains conditions if move is selectable or not */ + private selectableConditions: MoveSelectCondition[] = []; /** The move's {@linkcode MoveFlags} */ private flags: number = 0; private nameAppend: string = ""; @@ -374,6 +376,19 @@ export default class Move implements Localizable { return this; } + /** + * Adds a {@linkcode MoveSelectCondition} to the move. It contains the condition if the move should be selectable in the move menu or not + * @param condition {@linkcode MoveSelectCondition} gets pushed into {@linkcode selectableConditions} + * @returns this move {@linkcode Move} + */ + selectableCondition(condition: MoveSelectCondition): this { + if (condition) { + this.selectableConditions.push(condition); + } + + return this; + } + /** * Internal dev flag for documenting edge cases. When using this, please document the known edge case. * @returns the called object {@linkcode Move} @@ -667,6 +682,21 @@ export default class Move implements Localizable { return true; } + /** + * Applies each {@linkcode MoveSelectCondition} function of this move to determine if the move can be selected during {@linkcode CommandPhase} + * @param user {@linkcode Pokemon} to apply conditions to + * @returns boolean: false if any of the apply()'s return false, else true + */ + applySelectableConditions(user: Pokemon): boolean { + for (const condition of this.selectableConditions) { + if (!condition.apply(user, this)) { + return false; + } + } + + return true; + } + /** * Sees if a move has a custom failure text (by looking at each {@linkcode MoveAttr} of this move) * @param user {@linkcode Pokemon} using the move @@ -7722,6 +7752,64 @@ export function applyMoveChargeAttrs(attrType: Constructor, user: Poke return applyMoveChargeAttrsInternal((attr: MoveAttr) => attr instanceof attrType, user, target, move, args); } +/** + * Base class defining all {@linkcode selectableConditions} + * Is used to add {@linkcode UserMoveConditionFunc} in order to check if move can be selected + */ +export class MoveSelectCondition { + protected func: UserMoveConditionFunc; + + constructor(func: UserMoveConditionFunc) { + this.func = func; + } + + /** + * Checks condition and adds appropriate MoveRestrictionTag accordingly + * @param user {@linkcode Pokemon} that uses the move + * @param move {@linkcode Move} that is used + * @returns true if Tag was added successfully + */ + apply(user: Pokemon, move: Move): boolean { + return true; + } +} + +/** + * extends {@linkcode MoveSelectCondition} and contains the condition + * for {@link https://bulbapedia.bulbagarden.net/wiki/Stuff_Cheeks_(move) | Stuff Cheeks}s success + */ +export class StuffCheeksCondition extends MoveSelectCondition { + constructor() { + super((user, move) => this.selectableCondition(user)); + } + + /** + * Checks if the user is holding a berry + * @param user {@linkcode Pokemon} whose berries to check + * @returns true if the user is holding a berry, otherwise false + */ + private selectableCondition(user: Pokemon): boolean { + return user.getHeldItems().filter(m => m instanceof BerryModifier).length > 0; + } + + /** + * {@linkcode func} is being called in order to check if the {@linkcode user} is able to + * select the {@linkcode move} and adds {@linkcode StuffCheeksTag} if condition fails + * + * @param user {@linkcode Pokemon} that want to use this {@linkcode move} + * @param move {@linkcode Move} being selected + * @returns true if the move can be selected/doesn't fail, otherwise false + */ + apply(user: Pokemon, move: Move): boolean { + if (!this.selectableCondition(user)) { + user.addTag(BattlerTagType.STUFF_CHEEKS, 0, move.id); + } + return this.func(user, move); + } +} + +const hasBerryCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => user.getHeldItems().filter(m => m instanceof BerryModifier).length > 0; + export class MoveCondition { protected func: MoveConditionFunc; @@ -10215,11 +10303,8 @@ export function initMoves() { new SelfStatusMove(Moves.STUFF_CHEEKS, Type.NORMAL, -1, 10, -1, 0, 8) .attr(EatBerryAttr) .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true) - .condition((user) => { - const userBerries = user.scene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()); - return userBerries.length > 0; - }) - .edgeCase(), // Stuff Cheeks should not be selectable when the user does not have a berry, see wiki + .selectableCondition(new StuffCheeksCondition()) + .condition(hasBerryCondition), new SelfStatusMove(Moves.NO_RETREAT, Type.FIGHTING, -1, 5, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true) .attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, false) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index f28ac37ae279..7ea0954bb51f 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -93,5 +93,6 @@ export enum BattlerTagType { GRUDGE = "GRUDGE", PSYCHO_SHIFT = "PSYCHO_SHIFT", ENDURE_TOKEN = "ENDURE_TOKEN", + STUFF_CHEEKS = "STUFF_CHEEKS", POWDER = "POWDER", } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index fa85f2427e51..6a6107a91f27 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -81,6 +81,19 @@ export class CommandPhase extends FieldPhase { const moveQueue = playerPokemon.getMoveQueue(); + /** + * Checks if the playerPokemon has a move that might be unselectable + */ + const moveset = playerPokemon.getMoveset(); + const conditionalMove = moveset.find(m => { + const move = m?.getMove(); + return move && move.selectableCondition && move.selectableCondition.length > 0; + }); + + if (conditionalMove) { + conditionalMove.getMove().applySelectableConditions(playerPokemon); + } + while (moveQueue.length && moveQueue[0] && moveQueue[0].move && (!playerPokemon.getMoveset().find(m => m?.moveId === moveQueue[0].move) || !playerPokemon.getMoveset()[playerPokemon.getMoveset().findIndex(m => m?.moveId === moveQueue[0].move)]!.isUsable(playerPokemon, moveQueue[0].ignorePP))) { // TODO: is the bang correct? diff --git a/src/test/moves/stuff_cheeks.test.ts b/src/test/moves/stuff_cheeks.test.ts new file mode 100644 index 000000000000..4639bda8f8b2 --- /dev/null +++ b/src/test/moves/stuff_cheeks.test.ts @@ -0,0 +1,186 @@ +import { Stat } from "#enums/stat"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import Pokemon from "#app/field/pokemon"; +import { allMoves, RandomMoveAttr } from "#app/data/move"; +import { BerryType } from "#enums/berry-type"; +import { BattlerIndex } from "#app/battle"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Stuff Cheeks", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + /** + * Count the number of held items a Pokemon has, accounting for stacks of multiple items. + */ + function getHeldItemCount(pokemon: Pokemon): number { + const stackCounts = pokemon.getHeldItems().map(m => m.getStackCount()); + if (stackCounts.length) { + return stackCounts.reduce((a, b) => a + b); + } else { + return 0; + } + } + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .disableCrits() + .moveset(Moves.STUFF_CHEEKS) + .ability(Abilities.BALL_FETCH) + .enemySpecies(Species.RATTATA) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should succeed if berries are held", async () => { + game.override + .startingHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 1 }, + ]); + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const player = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.STUFF_CHEEKS); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.DEF)).toBe(2); + expect(getHeldItemCount(player)).toBe(0); + }); + + it("should fail if no berries are held", async () => { + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const player = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.STUFF_CHEEKS); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.DEF)).toBe(0); + expect(getHeldItemCount(player)).toBe(0); + }); + + it("should succeed when called in the presence of unnerved", async () => { + game.override + .startingHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 2 }, + { name: "BERRY", type: BerryType.LUM, count: 2 }, + ]) + .enemyAbility(Abilities.UNNERVE); + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const player = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.STUFF_CHEEKS); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.DEF)).toBe(2); + expect(getHeldItemCount(player)).toBe(3); + }); + + it("should succeed when called in the presence of magic room", async () => { + game.override + .startingHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 2 }, + { name: "BERRY", type: BerryType.LUM, count: 2 }, + ]) + .enemyMoveset(Moves.MAGIC_ROOM); + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const player = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.STUFF_CHEEKS); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.DEF)).toBe(2); + expect(getHeldItemCount(player)).toBe(3); + }); + + it("should failed when called by another move (metronome) while holding no berries", async () => { + game.override.moveset(Moves.METRONOME); + + const randomMoveAttr = allMoves[Moves.METRONOME].findAttr(attr => attr instanceof RandomMoveAttr) as RandomMoveAttr; + vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.STUFF_CHEEKS); + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const player = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.METRONOME); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.DEF)).toBe(0); + }); + + it("should succeed when called by another move (metronome) while holding berries", async () => { + game.override + .moveset(Moves.METRONOME) + .startingHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 2 }, + { name: "BERRY", type: BerryType.LUM, count: 2 }, + ]); + + const randomMoveAttr = allMoves[Moves.METRONOME].findAttr(attr => attr instanceof RandomMoveAttr) as RandomMoveAttr; + vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.STUFF_CHEEKS); + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const player = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.METRONOME); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.DEF)).toBe(2); + expect(getHeldItemCount(player)).toBe(3); + }); + + it("from enemy should fail when player knocks off enemy berry before", async () => { + game.override + .enemyHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 1 }, + ]) + .enemyMoveset(Moves.STUFF_CHEEKS) + .moveset(Moves.KNOCK_OFF) + /** This is set so the enemy does not get oneshot by Knock Off */ + .enemyLevel(100); + + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.KNOCK_OFF); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.toNextTurn(); + + expect(getHeldItemCount(enemy)).toBe(0); + expect(enemy.getStatStage(Stat.DEF)).toBe(0); + }); +});