diff --git a/src/data/ability.ts b/src/data/ability.ts index 07fd48e2f91..14f1eb2481b 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1663,6 +1663,7 @@ export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { const stolenItem = heldItems[pokemon.randSeedInt(heldItems.length)]; pokemon.scene.tryTransferHeldItemModifier(stolenItem, pokemon, false).then(success => { if (success) { + defender.turnData.itemsLost += 1; pokemon.scene.queueMessage(i18next.t("abilityTriggers:postAttackStealHeldItem", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), defenderName: defender.name, stolenItemType: stolenItem.type.name })); } resolve(success); @@ -1756,6 +1757,7 @@ export class PostDefendStealHeldItemAbAttr extends PostDefendAbAttr { const stolenItem = heldItems[pokemon.randSeedInt(heldItems.length)]; pokemon.scene.tryTransferHeldItemModifier(stolenItem, pokemon, false).then(success => { if (success) { + attacker.turnData.itemsLost += 1; pokemon.scene.queueMessage(i18next.t("abilityTriggers:postDefendStealHeldItem", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), attackerName: attacker.name, stolenItemType: stolenItem.type.name })); } resolve(success); @@ -3806,6 +3808,117 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { } } +/** + * Ability attribute for Unburden, triggers when a Pokemon consumes a berry they are holding + * @extends PostTurnAbAttr + * @see {@linkcode applyPostTurn} + * @see {@linkcode getCondition} + */ +export class UnburdenBerryRemovedAbAttr extends PostTurnAbAttr { + + /** + * + * @param {Pokemon} pokemon the {@linkcode Pokemon} with this ability + * @param passive n/a + * @param simulated whether the ability is being simulated + * @param args n/a + * @returns `true` if the ability is applied + */ + applyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean { + if (simulated) { + return simulated; + } + + if (pokemon.getTag(BattlerTagType.UNBURDEN)) { + return false; + } + + pokemon.addTag(BattlerTagType.UNBURDEN); + return true; + } + + getCondition(): AbAttrCondition { + return (pokemon: Pokemon) => pokemon.battleData.berriesEaten.length !== 0; + } + +} + +/** + * Ability attribute for Unburden, triggers upon an item being lost while defending (Knock Off, Thief, Pluck) + * @extends PostDefendAbAttr + * @see {@linkcode applyPostDefend} + * @see {@linkcode getCondition} + */ +export class UnburdenDefendingItemRemovedAbAttr extends PostDefendAbAttr { + + /** + * + * @param {Pokemon} pokemon the {@linkcode Pokemon} with this ability + * @param passive n/a + * @param simulated whether the ability is being simulated + * @param attacker n/a + * @param move n/a + * @param hitResult n/a + * @param args n/a + * @returns `true` if the ability is applied + */ + applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { + if (simulated) { + return simulated; + } + + if (pokemon.getTag(BattlerTagType.UNBURDEN)) { + return false; + } + + pokemon.addTag(BattlerTagType.UNBURDEN); + return true; + } + + getCondition(): AbAttrCondition { + return (pokemon: Pokemon) => pokemon.turnData.itemsLost > 0; + } + +} + +/** + * Ability attribute for Unburden, triggers upon an item being lost while attacking (Pickpocket) + * @extends PostAttackAbAttr + * @see {@linkcode applyPostAttackAfterMoveTypeCheck} + * @see {@linkcode getCondition} + */ +export class UnburdenAttackingItemRemovedAbAttr extends PostAttackAbAttr { + + /** + * + * @param {Pokemon} pokemon the {@linkcode Pokemon} with this ability + * @param passive n/a + * @param simulated whether the ability is being simulated + * @param defender n/a + * @param move n/a + * @param hitResult n/a + * @param args n/a + * @returns `true` if the ability is applied + */ + applyPostAttackAfterMoveTypeCheck(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { + if (simulated) { + return simulated; + } + + if (pokemon.getTag(BattlerTagType.UNBURDEN)) { + return false; + } + + pokemon.addTag(BattlerTagType.UNBURDEN); + return true; + } + + getCondition(): AbAttrCondition { + return (pokemon: Pokemon) => pokemon.turnData.itemsLost > 0; + } + +} + export class StatStageChangeMultiplierAbAttr extends AbAttr { private multiplier: integer; @@ -5117,7 +5230,9 @@ export function initAbilities() { new Ability(Abilities.ANGER_POINT, 4) .attr(PostDefendCritStatStageChangeAbAttr, Stat.ATK, 6), new Ability(Abilities.UNBURDEN, 4) - .unimplemented(), + .attr(UnburdenBerryRemovedAbAttr) + .attr(UnburdenAttackingItemRemovedAbAttr) + .attr(UnburdenDefendingItemRemovedAbAttr), new Ability(Abilities.HEATPROOF, 4) .attr(ReceivedTypeDamageMultiplierAbAttr, Type.FIRE, 0.5) .attr(ReduceBurnDamageAbAttr, 0.5) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index a5016746013..97f79d3978d 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1558,6 +1558,22 @@ export class AbilityBattlerTag extends BattlerTag { } } +/** + * Tag used by Unburden to double speed + * @extends AbilityBattlerTag + */ +export class UnburdenTag extends AbilityBattlerTag { + constructor() { + super(BattlerTagType.UNBURDEN, Abilities.UNBURDEN, BattlerTagLapseType.CUSTOM, 1); + } + onAdd(pokemon: Pokemon): void { + super.onAdd(pokemon); + } + onRemove(pokemon: Pokemon): void { + super.onRemove(pokemon); + } +} + export class TruantTag extends AbilityBattlerTag { constructor() { super(BattlerTagType.TRUANT, Abilities.TRUANT, BattlerTagLapseType.MOVE, 1); @@ -2881,6 +2897,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new ThroatChoppedTag(); case BattlerTagType.GORILLA_TACTICS: return new GorillaTacticsTag(); + case BattlerTagType.UNBURDEN: + return new UnburdenTag(); case BattlerTagType.SUBSTITUTE: return new SubstituteTag(sourceMove, sourceId); case BattlerTagType.AUTOTOMIZED: diff --git a/src/data/move.ts b/src/data/move.ts index a77e8096672..69997ccf32f 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -2175,6 +2175,7 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { const stolenItem = tierHeldItems[user.randSeedInt(tierHeldItems.length)]; user.scene.tryTransferHeldItemModifier(stolenItem, user, false).then(success => { if (success) { + target.turnData.itemsLost += 1; user.scene.queueMessage(i18next.t("moveTriggers:stoleItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: stolenItem.type.name })); } resolve(success); @@ -2256,6 +2257,7 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { // Decrease item amount and update icon !--removedItem.stackCount; target.scene.updateModifiers(target.isPlayer()); + target.turnData.itemsLost += 1; if (this.berriesOnly) { user.scene.queueMessage(i18next.t("moveTriggers:incineratedItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); @@ -2370,6 +2372,7 @@ export class StealEatBerryAttr extends EatBerryAttr { } // if the target has berries, pick a random berry and steal it this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)]; + target.turnData.itemsLost += 1; const message = i18next.t("battle:stealEatBerry", { pokemonName: user.name, targetName: target.name, berryName: this.chosenBerry.type.name }); user.scene.queueMessage(message); this.reduceBerryModifier(target); diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 2efae9ad359..5133399b479 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -74,6 +74,7 @@ export enum BattlerTagType { DRAGON_CHEER = "DRAGON_CHEER", NO_RETREAT = "NO_RETREAT", GORILLA_TACTICS = "GORILLA_TACTICS", + UNBURDEN = "UNBURDEN", THROAT_CHOPPED = "THROAT_CHOPPED", TAR_SHOT = "TAR_SHOT", BURNED_UP = "BURNED_UP", diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 9ae83753e62..5b9df94631d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -949,6 +949,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (this.status && this.status.effect === StatusEffect.PARALYSIS) { ret >>= 1; } + if (this.getTag(BattlerTagType.UNBURDEN)) { + ret *= 2; + } break; } @@ -5060,6 +5063,7 @@ export class PokemonTurnData { public statStagesIncreased: boolean = false; public statStagesDecreased: boolean = false; public moveEffectiveness: TypeDamageMultiplier | null = null; + public itemsLost: number = 0; public combiningPledge?: Moves; } diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index dd8c82357a7..f04146b4868 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -3125,6 +3125,7 @@ export abstract class HeldItemTransferModifier extends PokemonHeldItemModifier { const randItem = itemModifiers[randItemIndex]; heldItemTransferPromises.push(pokemon.scene.tryTransferHeldItemModifier(randItem, pokemon, false).then(success => { if (success) { + targetPokemon.turnData.itemsLost += 1; transferredModifierTypes.push(randItem.type); itemModifiers.splice(randItemIndex, 1); } diff --git a/src/test/abilities/unburden.test.ts b/src/test/abilities/unburden.test.ts new file mode 100644 index 00000000000..c467c2495fe --- /dev/null +++ b/src/test/abilities/unburden.test.ts @@ -0,0 +1,180 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { Stat } from "#enums/stat"; +import { BerryType } from "#app/enums/berry-type"; +import { allMoves, StealHeldItemChanceAttr } from "#app/data/move"; + + +describe("Abilities - Unburden", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .starterSpecies(Species.TREECKO) + .startingLevel(1) + .moveset([ Moves.POPULATION_BOMB, Moves.KNOCK_OFF, Moves.PLUCK, Moves.THIEF ]) + .ability(Abilities.UNBURDEN) + .startingHeldItems([ + { name: "BERRY", count: 1, type: BerryType.SITRUS }, + { name: "BERRY", count: 2, type: BerryType.APICOT }, + { name: "BERRY", count: 2, type: BerryType.LUM }, + ]) + .enemySpecies(Species.NINJASK) + .enemyLevel(100) + .enemyMoveset([ Moves.FALSE_SWIPE ]) + .enemyAbility(Abilities.UNBURDEN) + .enemyHeldItems([ + { name: "BERRY", type: BerryType.SITRUS, count: 1 }, + { name: "BERRY", type: BerryType.LUM, count: 1 }, + ]); + }); + + it("should activate when a berry is eaten", async () => { + await game.classicMode.startBattle(); + const playerPokemon = game.scene.getPlayerPokemon()!; + const playerHeldItems = playerPokemon.getHeldItems().length; + const initialPlayerSpeed = playerPokemon.getStat(Stat.SPD); + game.move.select(Moves.FALSE_SWIPE); + await game.toNextTurn(); + expect(playerPokemon.getHeldItems().length).toBeLessThan(playerHeldItems); + expect(playerPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialPlayerSpeed * 2); + + }); + it("should activate when a berry is stolen", async () => { + await game.classicMode.startBattle(); + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyHeldItemCt = enemyPokemon.getHeldItems().length; + const initialEnemySpeed = enemyPokemon.getStat(Stat.SPD); + game.move.select(Moves.PLUCK); + await game.toNextTurn(); + expect(enemyPokemon.getHeldItems().length).toBeLessThan(enemyHeldItemCt); + expect(enemyPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialEnemySpeed * 2); + }); + it("should activate when an item is knocked off", async () => { + await game.classicMode.startBattle(); + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyHeldItemCt = enemyPokemon.getHeldItems().length; + const initialEnemySpeed = enemyPokemon.getStat(Stat.SPD); + game.move.select(Moves.KNOCK_OFF); + await game.toNextTurn(); + expect(enemyPokemon.getHeldItems().length).toBeLessThan(enemyHeldItemCt); + expect(enemyPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialEnemySpeed * 2); + }); + it("should activate when an item is stolen via attacking ability", async () => { + game.override + .ability(Abilities.MAGICIAN) + .startingHeldItems([ + { name: "MULTI_LENS", count: 3 }, + ]); + await game.classicMode.startBattle(); + vi.spyOn(allMoves[Moves.POPULATION_BOMB], "accuracy", "get").mockReturnValue(100); + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyHeldItemCt = enemyPokemon.getHeldItems().length; + const initialEnemySpeed = enemyPokemon.getStat(Stat.SPD); + game.move.select(Moves.POPULATION_BOMB); + await game.toNextTurn(); + expect(enemyPokemon.getHeldItems().length).toBeLessThan(enemyHeldItemCt); + expect(enemyPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialEnemySpeed * 2); + }); + it("should activate when an item is stolen via defending ability", async () => { + game.override + .startingLevel(50) + .enemyAbility(Abilities.PICKPOCKET) + .startingHeldItems([ + { name: "MULTI_LENS", count: 3 }, + { name: "SOUL_DEW", count: 1 }, + { name: "LUCKY_EGG", count: 1 }, + ]); + await game.classicMode.startBattle(); + vi.spyOn(allMoves[Moves.POPULATION_BOMB], "accuracy", "get").mockReturnValue(100); + const playerPokemon = game.scene.getPlayerPokemon()!; + const playerHeldItems = playerPokemon.getHeldItems().length; + const initialPlayerSpeed = playerPokemon.getStat(Stat.SPD); + game.move.select(Moves.POPULATION_BOMB); + await game.toNextTurn(); + expect(playerPokemon.getHeldItems().length).toBeLessThan(playerHeldItems); + expect(playerPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialPlayerSpeed * 2); + }); + it("should activate when an item is stolen via move", async () => { + vi.spyOn(allMoves[Moves.THIEF], "attrs", "get").mockReturnValue([ new StealHeldItemChanceAttr(1.0) ]); // give Thief 100% steal rate + game.override.startingHeldItems([ + { name: "MULTI_LENS", count: 3 }, + ]); + await game.classicMode.startBattle(); + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyHeldItemCt = enemyPokemon.getHeldItems().length; + const initialEnemySpeed = enemyPokemon.getStat(Stat.SPD); + game.move.select(Moves.THIEF); + await game.toNextTurn(); + expect(enemyPokemon.getHeldItems().length).toBeLessThan(enemyHeldItemCt); + expect(enemyPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialEnemySpeed * 2); + }); + it("should activate when an item is stolen via grip claw", async () => { + game.override + .startingLevel(5) + .startingHeldItems([ + { name: "GRIP_CLAW", count: 5 }, + { name: "MULTI_LENS", count: 3 }, + ]) + .enemyHeldItems([ + { name: "SOUL_DEW", count: 1 }, + { name: "LUCKY_EGG", count: 1 }, + { name: "LEFTOVERS", count: 1 }, + { name: "GRIP_CLAW", count: 1 }, + { name: "MULTI_LENS", count: 1 }, + { name: "BERRY", type: BerryType.SITRUS, count: 1 }, + { name: "BERRY", type: BerryType.LUM, count: 1 }, + ]); + await game.classicMode.startBattle(); + vi.spyOn(allMoves[Moves.POPULATION_BOMB], "accuracy", "get").mockReturnValue(100); + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyHeldItemCt = enemyPokemon.getHeldItems().length; + const initialEnemySpeed = enemyPokemon.getStat(Stat.SPD); + while (enemyPokemon.getHeldItems().length === enemyHeldItemCt) { + game.move.select(Moves.POPULATION_BOMB); + await game.toNextTurn(); + } + expect(enemyPokemon.getHeldItems().length).toBeLessThan(enemyHeldItemCt); + expect(enemyPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialEnemySpeed * 2); + }); + + it("should not activate when a neutralizing ability is present", async () => { + game.override.enemyAbility(Abilities.NEUTRALIZING_GAS); + await game.classicMode.startBattle(); + const playerPokemon = game.scene.getPlayerPokemon()!; + const playerHeldItems = playerPokemon.getHeldItems().length; + const initialPlayerSpeed = playerPokemon.getStat(Stat.SPD); + game.move.select(Moves.FALSE_SWIPE); + await game.toNextTurn(); + expect(playerPokemon.getHeldItems().length).toBeLessThan(playerHeldItems); + expect(playerPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialPlayerSpeed); + }); + it("should activate when a move that consumes a berry is used", async () => { + game.override.enemyMoveset([ Moves.STUFF_CHEEKS ]); + await game.classicMode.startBattle(); + const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyHeldItemCt = enemyPokemon.getHeldItems().length; + const initialEnemySpeed = enemyPokemon.getStat(Stat.SPD); + game.move.select(Moves.STUFF_CHEEKS); + await game.toNextTurn(); + expect(enemyPokemon.getHeldItems().length).toBeLessThan(enemyHeldItemCt); + expect(enemyPokemon.getEffectiveStat(Stat.SPD)).toBeCloseTo(initialEnemySpeed * 2); + }); +});