diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index a5efe4988dd..b122769967d 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6788,6 +6788,89 @@ export abstract class CallMoveAttr extends OverrideMoveEffectAttr { } } +/** + * Attribute to call a random move among moves not in a banlist. + * Used for {@linkcode MoveId.METRONOME}. + */ +export class RandomMoveAttr extends CallMoveAttr { + constructor( + /** + * A {@linkcode ReadonlySet} containing all moves that this MoveAttr cannot copy, + * in addition to unimplemented moves and `MoveId.NONE`. + * The move will fail if the chosen move is inside this banlist (if it exists). + */ + protected readonly invalidMoves: ReadonlySet, + ) { + super(true); + } + + /** + * Pick a random move to execute, barring unimplemented moves and ones + * in this move's {@linkcode invalidMetronomeMoves | exclusion list}. + * Overridden as public to allow tests to override move choice using mocks. + * + * @param user - The {@linkcode Pokemon} using the move + * @returns The {@linkcode MoveId} that will be called. + */ + public override getMove(user: Pokemon): MoveId { + const moveIds = getEnumValues(MoveId).filter(m => m !== MoveId.NONE && !this.invalidMoves.has(m) && !allMoves[m].name.endsWith(" (N)")); + return moveIds[user.randBattleSeedInt(moveIds.length)]; + } +} + +/** + * Attribute used to call a random move in the user or party's moveset. + * Used for {@linkcode MoveId.ASSIST} and {@linkcode MoveId.SLEEP_TALK} + * + * Fails if the user has no callable moves. + */ +export class RandomMovesetMoveAttr extends RandomMoveAttr { + /** + * The previously-selected MoveId for this attribute. + * Reset to `MoveId.NONE` after successful use. + */ + private selectedMove: MoveId = MoveId.NONE + constructor(invalidMoves: ReadonlySet, + /** + * Whether to consider all moves from the user's party (`true`) or the user's own moveset (`false`); + * default `false`. + */ + private includeParty = false + ) { + super(invalidMoves); + } + + /** + * Select a random move from either the user's or its party members' movesets, + * or return an already-selected one if one exists. + * + * @param user - The {@linkcode Pokemon} using the move. + * @returns The {@linkcode MoveId} that will be called. + */ + override getMove(user: Pokemon): MoveId { + if (this.selectedMove) { + const m = this.selectedMove; + this.selectedMove = MoveId.NONE; + return m; + } + + // includeParty will be true for Assist, false for Sleep Talk + const allies: Pokemon[] = this.includeParty + ? (user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty()).filter(p => p !== user) + : [ user ]; + + // Assist & Sleep Talk consider duplicate moves for their selection (hence why we use an array instead of a set) + const moveset = allies.flatMap(p => p.moveset); + const eligibleMoves = moveset.filter(m => m.moveId !== MoveId.NONE && !this.invalidMoves.has(m.moveId) && !m.getMove().name.endsWith(" (N)")); + this.selectedMove = eligibleMoves[user.randBattleSeedInt(eligibleMoves.length)]?.moveId ?? MoveId.NONE; // will fail if 0 length array + return this.selectedMove; + } + + override getCondition(): MoveConditionFunc { + return (user) => this.getMove(user) !== MoveId.NONE; + } +} + /** * Attribute to call a different move based on the current terrain and biome. * Used by {@linkcode MoveId.NATURE_POWER} @@ -6935,88 +7018,8 @@ export class CopyMoveAttr extends CallMoveAttr { }; } } -/** - * Attribute to call a random move among moves not in a banlist. - * Used for {@linkcode MoveId.METRONOME}. - */ -export class RandomMoveAttr extends CallMoveAttr { - constructor( - /** - * A {@linkcode ReadonlySet} containing all moves that this MoveAttr cannot copy, - * in addition to unimplemented moves and `MoveId.NONE`. - * The move will fail if the chosen move is inside this banlist (if it exists). - */ - protected readonly invalidMoves: ReadonlySet, - ) { - super(true); - } - /** - * Pick a random move to execute, barring unimplemented moves and ones - * in this move's {@linkcode invalidMetronomeMoves | exclusion list}. - * Overridden as public to allow tests to override move choice using mocks. - * - * @param user - The {@linkcode Pokemon} using the move - * @returns The {@linkcode MoveId} that will be called. - */ - public override getMove(user: Pokemon): MoveId { - const moveIds = getEnumValues(MoveId).filter(m => m !== MoveId.NONE && !this.invalidMoves.has(m) && !allMoves[m].name.endsWith(" (N)")); - return moveIds[user.randBattleSeedInt(moveIds.length)]; - } -} -/** - * Attribute used to call a random move in the user or party's moveset. - * Used for {@linkcode MoveId.ASSIST} and {@linkcode MoveId.SLEEP_TALK} - * - * Fails if the user has no callable moves. - */ -export class RandomMovesetMoveAttr extends RandomMoveAttr { - /** - * The previously-selected MoveId for this attribute. - * Reset to `MoveId.NONE` after successful use. - */ - private selectedMove: MoveId = MoveId.NONE - constructor(invalidMoves: ReadonlySet, - /** - * Whether to consider all moves from the user's party (`true`) or the user's own moveset (`false`); - * default `false`. - */ - private includeParty = false - ) { - super(invalidMoves); - } - - /** - * Select a random move from either the user's or its party members' movesets, - * or return an already-selected one if one exists. - * - * @param user - The {@linkcode Pokemon} using the move. - * @returns The {@linkcode MoveId} that will be called. - */ - override getMove(user: Pokemon): MoveId { - if (this.selectedMove) { - const m = this.selectedMove; - this.selectedMove = MoveId.NONE; - return m; - } - - // includeParty will be true for Assist, false for Sleep Talk - const allies: Pokemon[] = this.includeParty - ? (user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty()).filter(p => p !== user) - : [ user ]; - - // Assist & Sleep Talk consider duplicate moves for their selection (hence why we use an array instead of a set) - const moveset = allies.flatMap(p => p.moveset); - const eligibleMoves = moveset.filter(m => m.moveId !== MoveId.NONE && !this.invalidMoves.has(m.moveId) && !m.getMove().name.endsWith(" (N)")); - this.selectedMove = eligibleMoves[user.randBattleSeedInt(eligibleMoves.length)]?.moveId ?? MoveId.NONE; // will fail if 0 length array - return this.selectedMove; - } - - override getCondition(): MoveConditionFunc { - return (user) => this.getMove(user) !== MoveId.NONE; - } -} /** * Attribute used for moves that causes the target to repeat their last used move. diff --git a/test/abilities/sap_sipper.test.ts b/test/abilities/sap_sipper.test.ts index 16559fb563f..0a2b54c63a7 100644 --- a/test/abilities/sap_sipper.test.ts +++ b/test/abilities/sap_sipper.test.ts @@ -1,7 +1,5 @@ import { Stat } from "#enums/stat"; import { TerrainType } from "#app/data/terrain"; -import { MoveEndPhase } from "#app/phases/move-end-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { AbilityId } from "#enums/ability-id"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; @@ -10,7 +8,6 @@ import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { RandomMoveAttr } from "#app/data/moves/move"; -import { allMoves } from "#app/data/data-lists"; // See also: TypeImmunityAbAttr describe("Abilities - Sap Sipper", () => { @@ -38,131 +35,98 @@ describe("Abilities - Sap Sipper", () => { .enemyMoveset(MoveId.SPLASH); }); - it("raises ATK stat stage by 1 and block effects when activated against a grass attack", async () => { - const moveToUse = MoveId.LEAFAGE; - - game.override.moveset(moveToUse); - + it("should nullify all effects of Grass-type attacks and raise ATK by 1 stage", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - const enemyPokemon = game.scene.getEnemyPokemon()!; - const initialEnemyHp = enemyPokemon.hp; + game.move.use(MoveId.LEAFAGE); + await game.toNextTurn(); - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(initialEnemyHp - enemyPokemon.hp).toBe(0); + const enemyPokemon = game.field.getEnemyPokemon(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); - it("raises ATK stat stage by 1 and block effects when activated against a grass status move", async () => { - const moveToUse = MoveId.SPORE; - - game.override.moveset(moveToUse); - + it("should work on grass status moves", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const enemyPokemon = game.scene.getEnemyPokemon()!; - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); + game.move.use(MoveId.SPORE); + await game.toNextTurn(); expect(enemyPokemon.status).toBeUndefined(); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); - it("do not activate against status moves that target the field", async () => { - const moveToUse = MoveId.GRASSY_TERRAIN; - - game.override.moveset(moveToUse); - + it("should not activate on non Grass-type moves", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - game.move.select(moveToUse); + game.move.use(MoveId.TACKLE); + await game.toEndOfTurn(); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(game.scene.arena.terrain).toBeDefined(); - expect(game.scene.arena.terrain!.terrainType).toBe(TerrainType.GRASSY); - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(0); - }); - - it("activate once against multi-hit grass attacks", async () => { - const moveToUse = MoveId.BULLET_SEED; - - game.override.moveset(moveToUse); - - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - const initialEnemyHp = enemyPokemon.hp; - - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(initialEnemyHp - enemyPokemon.hp).toBe(0); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); - }); - - it("do not activate against status moves that target the user", async () => { - const moveToUse = MoveId.SPIKY_SHIELD; - - game.override.moveset(moveToUse); - - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const playerPokemon = game.scene.getPlayerPokemon()!; - - game.move.select(moveToUse); - - await game.phaseInterceptor.to(MoveEndPhase); - - expect(playerPokemon.getTag(BattlerTagType.SPIKY_SHIELD)).toBeDefined(); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(0); + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(enemy.getStatStage(Stat.ATK)).toBe(0); expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); }); - it("activate once against multi-hit grass attacks (metronome)", async () => { - const moveToUse = MoveId.METRONOME; - - const randomMoveAttr = allMoves[MoveId.METRONOME].findAttr( - attr => attr instanceof RandomMoveAttr, - ) as RandomMoveAttr; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.BULLET_SEED); - - game.override.moveset(moveToUse); - + it("should not activate against field-targeted moves", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - const enemyPokemon = game.scene.getEnemyPokemon()!; - const initialEnemyHp = enemyPokemon.hp; + game.move.use(MoveId.GRASSY_TERRAIN); + await game.toNextTurn(); - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(initialEnemyHp - enemyPokemon.hp).toBe(0); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); + expect(game.scene.arena.terrain).toBeDefined(); + expect(game.scene.arena.terrain!.terrainType).toBe(TerrainType.GRASSY); + expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(0); }); - it("still activates regardless of accuracy check", async () => { - game.override.moveset(MoveId.LEAF_BLADE); - + it("should trigger and cancel multi-hit moves, including ones called indirectly", async () => { + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.BULLET_SEED); await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - const enemyPokemon = game.scene.getEnemyPokemon()!; + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); - game.move.select(MoveId.LEAF_BLADE); - await game.phaseInterceptor.to("MoveEffectPhase"); + game.move.use(MoveId.BULLET_SEED); + await game.toNextTurn(); + expect(enemy.hp).toBe(enemy.getMaxHp()); + expect(enemy.getStatStage(Stat.ATK)).toBe(1); + expect(player.turnData.hitCount).toBe(1); + + game.move.use(MoveId.METRONOME); + await game.toEndOfTurn(); + + expect(enemy.hp).toBe(enemy.getMaxHp()); + expect(enemy.getStatStage(Stat.ATK)).toBe(1); + expect(player.turnData.hitCount).toBe(1); + }); + + it("should not activate on self-targeted status moves", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.SPIKY_SHIELD); + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(player.getTag(BattlerTagType.SPIKY_SHIELD)).toBeDefined(); + + await game.toEndOfTurn(); + + expect(player.getStatStage(Stat.ATK)).toBe(0); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + }); + + it("should activate even on missed moves", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR]); + + game.move.use(MoveId.LEAF_BLADE); await game.move.forceMiss(); - await game.phaseInterceptor.to("BerryPhase", false); + await game.toEndOfTurn(); + + const enemyPokemon = game.field.getEnemyPokemon(); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); });