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] Full Stuff Cheeks Implementation #4941

Open
wants to merge 17 commits into
base: beta
Choose a base branch
from
Open
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
28 changes: 28 additions & 0 deletions src/data/battler-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
95 changes: 90 additions & 5 deletions src/data/move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -7705,6 +7735,64 @@ export function applyMoveChargeAttrs(attrType: Constructor<MoveAttr>, 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, user.isPlayer()).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, user.isPlayer()).length > 0;

export class MoveCondition {
protected func: MoveConditionFunc;

Expand Down Expand Up @@ -10199,11 +10287,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)
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 @@ -93,5 +93,6 @@ export enum BattlerTagType {
GRUDGE = "GRUDGE",
PSYCHO_SHIFT = "PSYCHO_SHIFT",
ENDURE_TOKEN = "ENDURE_TOKEN",
STUFF_CHEEKS = "STUFF_CHEEKS",
POWDER = "POWDER",
}
13 changes: 13 additions & 0 deletions src/phases/command-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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?
Expand Down
186 changes: 186 additions & 0 deletions src/test/moves/stuff_cheeks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading