Added full implementation of Stuff Cheeks

This commit is contained in:
geeil-han 2024-11-26 07:47:10 +01:00
parent ae8efeedf8
commit e86bdf9eb8
4 changed files with 275 additions and 7 deletions

View File

@ -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<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 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)

View File

@ -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];
}

View File

@ -136,10 +136,11 @@ 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.isSelectable(playerPokemon) ? "battle:moveCannotBeSelected"
: move.getName().endsWith(" (N)") ? "battle:moveNotImplemented" : "battle:moveNoPP";
const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator

View File

@ -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);
});
});