diff --git a/src/data/move.ts b/src/data/move.ts index 2ac4d74b712..e1bc6e1c7c2 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 and should fail or not */ + private selectableConditions: MoveSelectCondition[] = []; /** The move's {@linkcode MoveFlags} */ private flags: number = 0; private nameAppend: string = ""; @@ -374,6 +376,20 @@ export default class Move implements Localizable { return this; } + /** + * Adds a {@linkcode MoveSelectCondition} to the move if it is only selectable/successful under certain conditions + * @param condition {@linkcode MoveSelectCondition} appends to conditions array a MoveSelectCondition {@linkcode selectableConditions} + * @returns the called object {@linkcode Move} + */ + selectableCondition(condition: MoveSelectCondition): this { + console.log(`GHnote condition: ${typeof condition}`); + 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 +683,21 @@ export default class Move implements Localizable { return true; } + /** + * Applies each {@linkcode MoveSelectCondition} function of this move to determine if the move can be used 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 @@ -7496,6 +7527,44 @@ 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 condition + * is met and move can be selected/ if move fails + */ +export class MoveSelectCondition { + protected func: UserMoveConditionFunc; + + constructor(func: UserMoveConditionFunc) { + this.func = func; + } + + /** + * {@linkcode func} is being called in order to check if the {@linkcode user} is able to + * select the {@linkcode move} and if the move should fail + * + * @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 { + return this.func(user, move); + } +} + +/** + * extends {@linkcode MoveSelectCondition} and contains the condition for {@link https://bulbapedia.bulbagarden.net/wiki/Stuff_Cheeks_(move) | Stuff Cheeks} + * success + */ +export class StuffCheeksCondition extends MoveSelectCondition { + /** + * contains function that checks if the {@linkcode user} is currently holding a berry or not + */ + constructor() { + super((user: Pokemon, move: Move) => user.scene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()).length > 0); + } +} + export class MoveCondition { protected func: MoveConditionFunc; @@ -9989,11 +10058,7 @@ 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()), 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/field/pokemon.ts b/src/field/pokemon.ts index f9e7d7d1cad..5b42981f7e4 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5355,6 +5355,10 @@ export class PokemonMove { return false; } + if (!this.isSelectable(pokemon)) { + return false; + } + if (this.getMove().name.endsWith(" (N)")) { return false; } @@ -5362,6 +5366,20 @@ export class PokemonMove { return (ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1); } + /** + * This function checks if the current move can be selected or not + * + * @param pokemon {@linkcode Pokemon} that selected this {@linkcode PokemonMove} + * @returns true if move can be selected, otherwise false + */ + isSelectable(pokemon: Pokemon): boolean { + const move = this.getMove(); + if (!move.applySelectableConditions(pokemon)) { + return false; + } + return true; + } + getMove(): Move { return allMoves[this.moveId]; } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index eab76282908..b4d67eae8b5 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -136,11 +136,12 @@ export class CommandPhase extends FieldPhase { const move = playerPokemon.getMoveset()[cursor]!; //TODO: is this bang correct? this.scene.ui.setMode(Mode.MESSAGE); - // Decides between a Disabled, Not Implemented, or No PP translation message + // Decides between a Disabled, not selectable, Not Implemented, or No PP translation message const errorMessage = playerPokemon.isMoveRestricted(move.moveId, playerPokemon) ? playerPokemon.getRestrictingTag(move.moveId, playerPokemon)!.selectionDeniedText(playerPokemon, move.moveId) - : move.getName().endsWith(" (N)") ? "battle:moveNotImplemented" : "battle:moveNoPP"; + : !move.isSelectable(playerPokemon) ? "battle:moveCannotBeSelected" + : move.getName().endsWith(" (N)") ? "battle:moveNotImplemented" : "battle:moveNoPP"; const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator this.scene.ui.showText(i18next.t(errorMessage, { moveName: moveName }), null, () => { diff --git a/src/test/moves/stuff_cheeks.test.ts b/src/test/moves/stuff_cheeks.test.ts new file mode 100644 index 00000000000..ce1d5f05d16 --- /dev/null +++ b/src/test/moves/stuff_cheeks.test.ts @@ -0,0 +1,184 @@ +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("Abilities - 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); + }); + + // Can be enabled when Knock off correctly knocks off the held berry + it.todo("should fail when used after berries getting knocked off", async () => { + game.override + .startingHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 1 }, + ]) + .enemyMoveset(Moves.KNOCK_OFF); + + 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(getHeldItemCount(player)).toBe(0); + expect(player.getStatStage(Stat.DEF)).toBe(0); + }); +});