diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 5d175ca22ec..675cd1c3a50 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1,5 +1,5 @@ import { CommonAnim, CommonBattleAnim } from "./battle-anims"; -import { CommonAnimPhase, MoveEffectPhase, MovePhase, PokemonHealPhase, ShowAbilityPhase, StatChangePhase } from "../phases"; +import { CommonAnimPhase, MoveEffectPhase, MovePhase, PokemonHealPhase, ShowAbilityPhase, StatChangeCallback, StatChangePhase } from "../phases"; import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { MoveResult, HitResult } from "../field/pokemon"; import { Stat, getStatName } from "./pokemon-stat"; @@ -1583,6 +1583,94 @@ export class IceFaceTag extends BattlerTag { } } +/** + * Battler tag enabling the Stockpile mechanic. This tag handles: + * - Stack tracking, including max limit enforcement (which is replicated in Stockpile for redundancy). + * + * - Stat changes on adding a stack. Adding a stockpile stack attempts to raise the pokemon's DEF and SPDEF by +1. + * + * - Stat changes on removal of (all) stacks. + * - Removing stacks decreases DEF and SPDEF, independently, by one stage for each stack that successfully changed + * the stat when added. + */ +export class StockpilingTag extends BattlerTag { + public stockpiledCount: number = 0; + public statChangeCounts: { [BattleStat.DEF]: number; [BattleStat.SPDEF]: number } = { + [BattleStat.DEF]: 0, + [BattleStat.SPDEF]: 0 + }; + + constructor(sourceMove: Moves = Moves.NONE) { + super(BattlerTagType.STOCKPILING, BattlerTagLapseType.CUSTOM, 1, sourceMove); + } + + private onStatsChanged: StatChangeCallback = (_, statsChanged, statChanges) => { + const defChange = statChanges[statsChanged.indexOf(BattleStat.DEF)] ?? 0; + const spDefChange = statChanges[statsChanged.indexOf(BattleStat.SPDEF)] ?? 0; + + if (defChange) { + this.statChangeCounts[BattleStat.DEF]++; + } + + if (spDefChange) { + this.statChangeCounts[BattleStat.SPDEF]++; + } + }; + + loadTag(source: BattlerTag | any): void { + super.loadTag(source); + this.stockpiledCount = source.stockpiledCount || 0; + this.statChangeCounts = { + [BattleStat.DEF]: source.statChangeCounts?.[BattleStat.DEF] || 0, + [BattleStat.SPDEF]: source.statChangeCounts?.[BattleStat.SPDEF] || 0, + }; + } + + /** + * Adds a stockpile stack to a pokemon, up to a maximum of 3 stacks. Note that onOverlap defers to this method. + * + * If a stack is added, a message is displayed and the pokemon's DEF and SPDEF are increased by 1. + * For each stat, an internal counter is incremented (by 1) if the stat was successfully changed. + */ + onAdd(pokemon: Pokemon): void { + if (this.stockpiledCount < 3) { + this.stockpiledCount++; + + pokemon.scene.queueMessage(i18next.t("battle:battlerTagsStockpilingOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + stockpiledCount: this.stockpiledCount + })); + + // Attempt to increase DEF and SPDEF by one stage, keeping track of successful changes. + pokemon.scene.unshiftPhase(new StatChangePhase( + pokemon.scene, pokemon.getBattlerIndex(), true, + [BattleStat.SPDEF, BattleStat.DEF], 1, true, false, true, this.onStatsChanged + )); + } + } + + onOverlap(pokemon: Pokemon): void { + this.onAdd(pokemon); + } + + /** + * Removing the tag removes all stacks, and the pokemon's DEF and SPDEF are decreased by + * one stage for each stack which had successfully changed that particular stat during onAdd. + */ + onRemove(pokemon: Pokemon): void { + const defChange = this.statChangeCounts[BattleStat.DEF]; + const spDefChange = this.statChangeCounts[BattleStat.SPDEF]; + + if (defChange) { + pokemon.scene.unshiftPhase(new StatChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [BattleStat.DEF], -defChange, true, false, true)); + } + + if (spDefChange) { + pokemon.scene.unshiftPhase(new StatChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [BattleStat.SPDEF], -spDefChange, true, false, true)); + } + } +} + export function getBattlerTag(tagType: BattlerTagType, turnCount: integer, sourceMove: Moves, sourceId: integer): BattlerTag { switch (tagType) { case BattlerTagType.RECHARGING: @@ -1704,6 +1792,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: integer, sourc return new DestinyBondTag(sourceMove, sourceId); case BattlerTagType.ICE_FACE: return new IceFaceTag(sourceMove); + case BattlerTagType.STOCKPILING: + return new StockpilingTag(sourceMove); case BattlerTagType.OCTOLOCK: return new OctolockTag(sourceId); case BattlerTagType.NONE: diff --git a/src/data/move.ts b/src/data/move.ts index 2efedd2bedf..6034f87f3a5 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1,7 +1,7 @@ import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims"; import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases"; import { BattleStat, getBattleStatName } from "./battle-stat"; -import { EncoreTag, HelpingHandTag, SemiInvulnerableTag, TypeBoostTag } from "./battler-tags"; +import { EncoreTag, HelpingHandTag, SemiInvulnerableTag, StockpilingTag, TypeBoostTag } from "./battler-tags"; import { getPokemonMessage, getPokemonNameWithAffix } from "../messages"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects} from "./status-effect"; @@ -3295,6 +3295,62 @@ export class WaterShurikenPowerAttr extends VariablePowerAttr { } } +/** + * Attribute used to calculate the power of attacks that scale with Stockpile stacks (i.e. Spit Up). + */ +export class SpitUpPowerAttr extends VariablePowerAttr { + private multiplier: number = 0; + + constructor(multiplier: number) { + super(); + this.multiplier = multiplier; + } + + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const stockpilingTag = user.getTag(StockpilingTag); + + if (stockpilingTag?.stockpiledCount > 0) { + const power = args[0] as Utils.IntegerHolder; + power.value = this.multiplier * stockpilingTag.stockpiledCount; + return true; + } + + return false; + } +} + +/** + * Attribute used to apply Swallow's healing, which scales with Stockpile stacks. + * Does NOT remove stockpiled stacks. + */ +export class SwallowHealAttr extends HealAttr { + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const stockpilingTag = user.getTag(StockpilingTag); + + if (stockpilingTag?.stockpiledCount > 0) { + const stockpiled = stockpilingTag.stockpiledCount; + let healRatio: number; + + if (stockpiled === 1) { + healRatio = 0.25; + } else if (stockpiled === 2) { + healRatio = 0.50; + } else { // stockpiled >= 3 + healRatio = 1.00; + } + + if (healRatio) { + this.addHealPhase(user, healRatio); + return true; + } + } + + return false; + } +} + +const hasStockpileStacksCondition: MoveConditionFunc = (user) => user.getTag(StockpilingTag)?.stockpiledCount > 0; + /** * Attribute used for multi-hit moves that increase power in increments of the * move's base power for each hit, namely Triple Kick and Triple Axel. @@ -6536,12 +6592,17 @@ export function initMoves() { .target(MoveTarget.RANDOM_NEAR_ENEMY) .partial(), new SelfStatusMove(Moves.STOCKPILE, Type.NORMAL, -1, 20, -1, 0, 3) - .unimplemented(), - new AttackMove(Moves.SPIT_UP, Type.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3) - .unimplemented(), + .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) + .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), + new AttackMove(Moves.SPIT_UP, Type.NORMAL, MoveCategory.SPECIAL, -1, -1, 10, -1, 0, 3) + .condition(hasStockpileStacksCondition) + .attr(SpitUpPowerAttr, 100) + .attr(RemoveBattlerTagAttr, [BattlerTagType.STOCKPILING], true), new SelfStatusMove(Moves.SWALLOW, Type.NORMAL, -1, 10, -1, 0, 3) - .triageMove() - .unimplemented(), + .condition(hasStockpileStacksCondition) + .attr(SwallowHealAttr) + .attr(RemoveBattlerTagAttr, [BattlerTagType.STOCKPILING], true) + .triageMove(), new AttackMove(Moves.HEAT_WAVE, Type.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) .attr(StatusEffectAttr, StatusEffect.BURN) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 786f67f5d33..52f6402861e 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -61,6 +61,7 @@ export enum BattlerTagType { DESTINY_BOND = "DESTINY_BOND", CENTER_OF_ATTENTION = "CENTER_OF_ATTENTION", ICE_FACE = "ICE_FACE", + STOCKPILING = "STOCKPILING", RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE", ALWAYS_GET_HIT = "ALWAYS_GET_HIT" } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 70b1e6929ae..15770e13b47 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2232,13 +2232,19 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return false; } + /** @overload */ + getTag(tagType: BattlerTagType): BattlerTag; + + /** @overload */ + getTag(tagType: Constructor): T; + getTag(tagType: BattlerTagType | Constructor): BattlerTag { if (!this.summonData) { return null; } - return typeof(tagType) === "string" - ? this.summonData.tags.find(t => t.tagType === tagType) - : this.summonData.tags.find(t => t instanceof tagType); + return tagType instanceof Function + ? this.summonData.tags.find(t => t instanceof tagType) + : this.summonData.tags.find(t => t.tagType === tagType); } findTag(tagFilter: ((tag: BattlerTag) => boolean)) { @@ -2248,15 +2254,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.summonData.tags.find(t => tagFilter(t)); } - getTags(tagType: BattlerTagType | Constructor): BattlerTag[] { - if (!this.summonData) { - return []; - } - return typeof(tagType) === "string" - ? this.summonData.tags.filter(t => t.tagType === tagType) - : this.summonData.tags.filter(t => t instanceof tagType); - } - findTags(tagFilter: ((tag: BattlerTag) => boolean)): BattlerTag[] { if (!this.summonData) { return []; diff --git a/src/locales/de/battle.ts b/src/locales/de/battle.ts index 74ddede5c89..a9686da7524 100644 --- a/src/locales/de/battle.ts +++ b/src/locales/de/battle.ts @@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} wurde eingepökelt!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} wurde durch {{moveName}} verletzt!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} nimmt einen Teil seiner KP und legt einen Fluch auf {{pokemonName}}!", - "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} wurde durch den Fluch verletzt!" + "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} wurde durch den Fluch verletzt!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/locales/en/battle.ts b/src/locales/en/battle.ts index d7a1f6ccf90..12a0f2c99c6 100644 --- a/src/locales/en/battle.ts +++ b/src/locales/en/battle.ts @@ -155,4 +155,5 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/locales/es/battle.ts b/src/locales/es/battle.ts index d0efa369b8d..7f29060c5d3 100644 --- a/src/locales/es/battle.ts +++ b/src/locales/es/battle.ts @@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} is being salt cured!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", - "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!" + "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/locales/fr/battle.ts b/src/locales/fr/battle.ts index 0ce9d73c70e..861dc6fd73c 100644 --- a/src/locales/fr/battle.ts +++ b/src/locales/fr/battle.ts @@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}}\nest couvert de sel !", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} sacrifie des PV\net lance une malédiction sur {{pokemonName}} !", - "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} est touché par la malédiction !" + "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} est touché par la malédiction !", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/locales/it/battle.ts b/src/locales/it/battle.ts index 7ae8ec9f397..d728e624ae3 100644 --- a/src/locales/it/battle.ts +++ b/src/locales/it/battle.ts @@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} è stato messo sotto sale!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} ha sacrificato metà dei suoi PS per\nlanciare una maledizione su {{pokemonName}}!", - "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} subisce la maledizione!" + "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} subisce la maledizione!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/locales/ko/battle.ts b/src/locales/ko/battle.ts index 60bbc067062..10f3e1b5853 100644 --- a/src/locales/ko/battle.ts +++ b/src/locales/ko/battle.ts @@ -155,4 +155,5 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}[[는]] 소금절이의\n데미지를 입고 있다.", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!", } as const; diff --git a/src/locales/pt_BR/battle.ts b/src/locales/pt_BR/battle.ts index 081e91d2306..5c194ae4583 100644 --- a/src/locales/pt_BR/battle.ts +++ b/src/locales/pt_BR/battle.ts @@ -155,4 +155,5 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} foi ferido pelo {{moveName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", } as const; diff --git a/src/locales/zh_CN/battle.ts b/src/locales/zh_CN/battle.ts index af9f7ac6cbb..c3c9077b7f1 100644 --- a/src/locales/zh_CN/battle.ts +++ b/src/locales/zh_CN/battle.ts @@ -146,5 +146,6 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}}\n陷入了盐腌状态!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}!", - "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒!" + "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/locales/zh_TW/battle.ts b/src/locales/zh_TW/battle.ts index 75457b34c07..4673474d313 100644 --- a/src/locales/zh_TW/battle.ts +++ b/src/locales/zh_TW/battle.ts @@ -144,4 +144,5 @@ export const battle: SimpleTranslationEntries = { "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!", + "battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" } as const; diff --git a/src/phases.ts b/src/phases.ts index 3b610f8b48c..8a1276c95a3 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -3224,6 +3224,8 @@ export class ShowAbilityPhase extends PokemonPhase { } } +export type StatChangeCallback = (target: Pokemon, changed: BattleStat[], relativeChanges: number[]) => void; + export class StatChangePhase extends PokemonPhase { private stats: BattleStat[]; private selfTarget: boolean; @@ -3231,8 +3233,10 @@ export class StatChangePhase extends PokemonPhase { private showMessage: boolean; private ignoreAbilities: boolean; private canBeCopied: boolean; + private onChange: StatChangeCallback; - constructor(scene: BattleScene, battlerIndex: BattlerIndex, selfTarget: boolean, stats: BattleStat[], levels: integer, showMessage: boolean = true, ignoreAbilities: boolean = false, canBeCopied: boolean = true) { + + constructor(scene: BattleScene, battlerIndex: BattlerIndex, selfTarget: boolean, stats: BattleStat[], levels: integer, showMessage: boolean = true, ignoreAbilities: boolean = false, canBeCopied: boolean = true, onChange: StatChangeCallback = null) { super(scene, battlerIndex); this.selfTarget = selfTarget; @@ -3241,6 +3245,7 @@ export class StatChangePhase extends PokemonPhase { this.showMessage = showMessage; this.ignoreAbilities = ignoreAbilities; this.canBeCopied = canBeCopied; + this.onChange = onChange; } start() { @@ -3282,6 +3287,8 @@ export class StatChangePhase extends PokemonPhase { const battleStats = this.getPokemon().summonData.battleStats; const relLevels = filteredStats.map(stat => (levels.value >= 1 ? Math.min(battleStats[stat] + levels.value, 6) : Math.max(battleStats[stat] + levels.value, -6)) - battleStats[stat]); + this.onChange?.(this.getPokemon(), filteredStats, relLevels); + const end = () => { if (this.showMessage) { const messages = this.getStatChangeMessages(filteredStats, levels.value, relLevels); diff --git a/src/test/battlerTags/stockpiling.test.ts b/src/test/battlerTags/stockpiling.test.ts new file mode 100644 index 00000000000..5cf07c47ce1 --- /dev/null +++ b/src/test/battlerTags/stockpiling.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Pokemon, { PokemonSummonData } from "#app/field/pokemon.js"; +import BattleScene from "#app/battle-scene.js"; +import { StockpilingTag } from "#app/data/battler-tags.js"; +import { StatChangePhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; +import * as messages from "#app/messages.js"; + +beforeEach(() => { + vi.spyOn(messages, "getPokemonNameWithAffix").mockImplementation(() => ""); +}); + +describe("BattlerTag - StockpilingTag", () => { + describe("onAdd", () => { + it("unshifts a StatChangePhase with expected stat changes on add", { timeout: 10000 }, async () => { + const mockPokemon = { + scene: vi.mocked(new BattleScene()) as BattleScene, + getBattlerIndex: () => 0, + } as Pokemon; + + vi.spyOn(mockPokemon.scene, "queueMessage").mockImplementation(() => {}); + + const subject = new StockpilingTag(1); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementation(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(1); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.DEF, BattleStat.SPDEF])); + + (phase as StatChangePhase)["onChange"](mockPokemon, [BattleStat.DEF, BattleStat.SPDEF], [1, 1]); + }); + + subject.onAdd(mockPokemon); + + expect(mockPokemon.scene.unshiftPhase).toBeCalledTimes(1); + }); + + it("unshifts a StatChangePhase with expected stat changes on add (one stat maxed)", { timeout: 10000 }, async () => { + const mockPokemon = { + scene: new BattleScene(), + summonData: new PokemonSummonData(), + getBattlerIndex: () => 0, + } as Pokemon; + + vi.spyOn(mockPokemon.scene, "queueMessage").mockImplementation(() => {}); + + mockPokemon.summonData.battleStats[BattleStat.DEF] = 6; + mockPokemon.summonData.battleStats[BattleStat.SPDEF] = 5; + + const subject = new StockpilingTag(1); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementation(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(1); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.DEF, BattleStat.SPDEF])); + + (phase as StatChangePhase)["onChange"](mockPokemon, [BattleStat.DEF, BattleStat.SPDEF], [1, 1]); + }); + + subject.onAdd(mockPokemon); + + expect(mockPokemon.scene.unshiftPhase).toBeCalledTimes(1); + }); + }); + + describe("onOverlap", () => { + it("unshifts a StatChangePhase with expected stat changes on overlap", { timeout: 10000 }, async () => { + const mockPokemon = { + scene: new BattleScene(), + getBattlerIndex: () => 0, + } as Pokemon; + + vi.spyOn(mockPokemon.scene, "queueMessage").mockImplementation(() => {}); + + const subject = new StockpilingTag(1); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementation(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(1); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.DEF, BattleStat.SPDEF])); + + (phase as StatChangePhase)["onChange"](mockPokemon, [BattleStat.DEF, BattleStat.SPDEF], [1, 1]); + }); + + subject.onOverlap(mockPokemon); + + expect(mockPokemon.scene.unshiftPhase).toBeCalledTimes(1); + }); + }); + + describe("stack limit, stat tracking, and removal", () => { + it("can be added up to three times, even when one stat does not change", { timeout: 10000 }, async () => { + const mockPokemon = { + scene: new BattleScene(), + summonData: new PokemonSummonData(), + getBattlerIndex: () => 0, + } as Pokemon; + + vi.spyOn(mockPokemon.scene, "queueMessage").mockImplementation(() => {}); + + mockPokemon.summonData.battleStats[BattleStat.DEF] = 5; + mockPokemon.summonData.battleStats[BattleStat.SPDEF] = 4; + + const subject = new StockpilingTag(1); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementationOnce(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(1); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.DEF, BattleStat.SPDEF])); + + // def doesn't change + (phase as StatChangePhase)["onChange"](mockPokemon, [BattleStat.SPDEF], [1]); + }); + + subject.onAdd(mockPokemon); + expect(subject.stockpiledCount).toBe(1); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementationOnce(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(1); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.DEF, BattleStat.SPDEF])); + + // def doesn't change + (phase as StatChangePhase)["onChange"](mockPokemon, [BattleStat.SPDEF], [1]); + }); + + subject.onOverlap(mockPokemon); + expect(subject.stockpiledCount).toBe(2); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementationOnce(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(1); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.DEF, BattleStat.SPDEF])); + + // neither stat changes, stack count should still increase + }); + + subject.onOverlap(mockPokemon); + expect(subject.stockpiledCount).toBe(3); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementationOnce(phase => { + throw new Error("Should not be called a fourth time"); + }); + + // fourth stack should not be applied + subject.onOverlap(mockPokemon); + expect(subject.stockpiledCount).toBe(3); + expect(subject.statChangeCounts).toMatchObject({ [BattleStat.DEF]: 0, [BattleStat.SPDEF]: 2 }); + + // removing tag should reverse stat changes + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementationOnce(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(-2); + expect((phase as StatChangePhase)["stats"]).toEqual(expect.arrayContaining([BattleStat.SPDEF])); + }); + + subject.onRemove(mockPokemon); + expect(mockPokemon.scene.unshiftPhase).toHaveBeenCalledOnce(); // note that re-spying each add/overlap has been refreshing call count + }); + }); +}); diff --git a/src/test/moves/spit_up.test.ts b/src/test/moves/spit_up.test.ts new file mode 100644 index 00000000000..6b24275f3e3 --- /dev/null +++ b/src/test/moves/spit_up.test.ts @@ -0,0 +1,201 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import overrides from "#app/overrides"; +import { MovePhase, TurnInitPhase } from "#app/phases"; +import { BattleStat } from "#app/data/battle-stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { StockpilingTag } from "#app/data/battler-tags.js"; +import { MoveResult, TurnMove } from "#app/field/pokemon.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; +import { allMoves } from "#app/data/move.js"; + +describe("Moves - Spit Up", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + vi.spyOn(overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(2000); + + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPIT_UP, Moves.SPIT_UP, Moves.SPIT_UP, Moves.SPIT_UP]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + }); + + describe("consumes all stockpile stacks to deal damage (scaling with stacks)", () => { + it("1 stack -> 100 power", { timeout: 10000 }, async () => { + const stacksToSetup = 1; + const expectedPower = 100; + + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); + + vi.spyOn(allMoves[Moves.SPIT_UP], "calculateBattlePower"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveBeenCalledOnce(); + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveReturnedWith(expectedPower); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("2 stacks -> 200 power", { timeout: 10000 }, async () => { + const stacksToSetup = 2; + const expectedPower = 200; + + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); + + vi.spyOn(allMoves[Moves.SPIT_UP], "calculateBattlePower"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveBeenCalledOnce(); + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveReturnedWith(expectedPower); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("3 stacks -> 300 power", { timeout: 10000 }, async () => { + const stacksToSetup = 3; + const expectedPower = 300; + + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + pokemon.addTag(BattlerTagType.STOCKPILING); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); + + vi.spyOn(allMoves[Moves.SPIT_UP], "calculateBattlePower"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveBeenCalledOnce(); + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveReturnedWith(expectedPower); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + }); + + it("fails without stacks", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeUndefined(); + + vi.spyOn(allMoves[Moves.SPIT_UP], "calculateBattlePower"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SPIT_UP, result: MoveResult.FAIL }); + + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).not.toHaveBeenCalled(); + }); + + describe("restores stat boosts granted by stacks", () => { + it("decreases stats based on stored values (both boosts equal)", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + + vi.spyOn(allMoves[Moves.SPIT_UP], "calculateBattlePower"); + + game.doAttack(0); + await game.phaseInterceptor.to(MovePhase); + + expect(pokemon.summonData.battleStats[BattleStat.DEF]).toBe(1); + expect(pokemon.summonData.battleStats[BattleStat.SPDEF]).toBe(1); + + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SPIT_UP, result: MoveResult.SUCCESS }); + + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveBeenCalledOnce(); + + expect(pokemon.summonData.battleStats[BattleStat.DEF]).toBe(0); + expect(pokemon.summonData.battleStats[BattleStat.SPDEF]).toBe(0); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("decreases stats based on stored values (different boosts)", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + + // for the sake of simplicity (and because other tests cover the setup), set boost amounts directly + stockpilingTag.statChangeCounts = { + [BattleStat.DEF]: -1, + [BattleStat.SPDEF]: 2, + }; + + expect(stockpilingTag.statChangeCounts).toMatchObject({ + [BattleStat.DEF]: -1, + [BattleStat.SPDEF]: 2, + }); + + vi.spyOn(allMoves[Moves.SPIT_UP], "calculateBattlePower"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SPIT_UP, result: MoveResult.SUCCESS }); + + expect(allMoves[Moves.SPIT_UP].calculateBattlePower).toHaveBeenCalledOnce(); + + expect(pokemon.summonData.battleStats[BattleStat.DEF]).toBe(1); + expect(pokemon.summonData.battleStats[BattleStat.SPDEF]).toBe(-2); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + }); +}); diff --git a/src/test/moves/stockpile.test.ts b/src/test/moves/stockpile.test.ts new file mode 100644 index 00000000000..4f9688e6dd9 --- /dev/null +++ b/src/test/moves/stockpile.test.ts @@ -0,0 +1,116 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import overrides from "#app/overrides"; +import { CommandPhase, TurnInitPhase } from "#app/phases"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import { BattleStat } from "#app/data/battle-stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { StockpilingTag } from "#app/data/battler-tags.js"; +import { MoveResult, TurnMove } from "#app/field/pokemon.js"; + +describe("Moves - Stockpile", () => { + describe("integration tests", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(2000); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.STOCKPILE, Moves.SPLASH]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + }); + + it("Gains a stockpile stack and increases DEF and SPDEF by 1 on each use, fails at max stacks (3)", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const user = game.scene.getPlayerPokemon(); + + // Unfortunately, Stockpile stacks are not directly queryable (i.e. there is no pokemon.getStockpileStacks()), + // we just have to know that they're implemented as a BattlerTag. + + expect(user.getTag(StockpilingTag)).toBeUndefined(); + expect(user.summonData.battleStats[BattleStat.DEF]).toBe(0); + expect(user.summonData.battleStats[BattleStat.SPDEF]).toBe(0); + + // use Stockpile four times + for (let i = 0; i < 4; i++) { + if (i !== 0) { + await game.phaseInterceptor.to(CommandPhase); + } + + game.doAttack(getMovePosition(game.scene, 0, Moves.STOCKPILE)); + await game.phaseInterceptor.to(TurnInitPhase); + + const stockpilingTag = user.getTag(StockpilingTag); + const def = user.summonData.battleStats[BattleStat.DEF]; + const spdef = user.summonData.battleStats[BattleStat.SPDEF]; + + if (i < 3) { // first three uses should behave normally + expect(def).toBe(i + 1); + expect(spdef).toBe(i + 1); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(i + 1); + + } else { // fourth should have failed + expect(def).toBe(3); + expect(spdef).toBe(3); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(3); + expect(user.getMoveHistory().at(-1)).toMatchObject({ result: MoveResult.FAIL, move: Moves.STOCKPILE }); + } + } + }); + + it("Gains a stockpile stack even if DEF and SPDEF are at +6", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const user = game.scene.getPlayerPokemon(); + + user.summonData.battleStats[BattleStat.DEF] = 6; + user.summonData.battleStats[BattleStat.SPDEF] = 6; + + expect(user.getTag(StockpilingTag)).toBeUndefined(); + expect(user.summonData.battleStats[BattleStat.DEF]).toBe(6); + expect(user.summonData.battleStats[BattleStat.SPDEF]).toBe(6); + + game.doAttack(getMovePosition(game.scene, 0, Moves.STOCKPILE)); + await game.phaseInterceptor.to(TurnInitPhase); + + const stockpilingTag = user.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(1); + expect(user.summonData.battleStats[BattleStat.DEF]).toBe(6); + expect(user.summonData.battleStats[BattleStat.SPDEF]).toBe(6); + + // do it again, just for good measure + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 0, Moves.STOCKPILE)); + await game.phaseInterceptor.to(TurnInitPhase); + + const stockpilingTagAgain = user.getTag(StockpilingTag); + expect(stockpilingTagAgain).toBeDefined(); + expect(stockpilingTagAgain.stockpiledCount).toBe(2); + expect(user.summonData.battleStats[BattleStat.DEF]).toBe(6); + expect(user.summonData.battleStats[BattleStat.SPDEF]).toBe(6); + }); + }); +}); diff --git a/src/test/moves/swallow.test.ts b/src/test/moves/swallow.test.ts new file mode 100644 index 00000000000..2d1ab55879a --- /dev/null +++ b/src/test/moves/swallow.test.ts @@ -0,0 +1,197 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import overrides from "#app/overrides"; +import { MovePhase, TurnInitPhase } from "#app/phases"; +import { BattleStat } from "#app/data/battle-stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { StockpilingTag } from "#app/data/battler-tags.js"; +import { MoveResult, TurnMove } from "#app/field/pokemon.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; + +describe("Moves - Swallow", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + vi.spyOn(overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(2000); + + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SWALLOW, Moves.SWALLOW, Moves.SWALLOW, Moves.SWALLOW]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + }); + + describe("consumes all stockpile stacks to heal (scaling with stacks)", () => { + it("1 stack -> 25% heal", { timeout: 10000 }, async () => { + const stacksToSetup = 1; + const expectedHeal = 25; + + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100); + pokemon["hp"] = 1; + + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); + + vi.spyOn(pokemon, "heal"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.heal).toHaveBeenCalledOnce(); + expect(pokemon.heal).toHaveReturnedWith(expectedHeal); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("2 stacks -> 50% heal", { timeout: 10000 }, async () => { + const stacksToSetup = 2; + const expectedHeal = 50; + + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100); + pokemon["hp"] = 1; + + pokemon.addTag(BattlerTagType.STOCKPILING); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); + + vi.spyOn(pokemon, "heal"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.heal).toHaveBeenCalledOnce(); + expect(pokemon.heal).toHaveReturnedWith(expectedHeal); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("3 stacks -> 100% heal", { timeout: 10000 }, async () => { + const stacksToSetup = 3; + const expectedHeal = 100; + + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100); + pokemon["hp"] = 0.0001; + + pokemon.addTag(BattlerTagType.STOCKPILING); + pokemon.addTag(BattlerTagType.STOCKPILING); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup); + + vi.spyOn(pokemon, "heal"); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.heal).toHaveBeenCalledOnce(); + expect(pokemon.heal).toHaveReturnedWith(expect.closeTo(expectedHeal)); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + }); + + it("fails without stacks", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeUndefined(); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SWALLOW, result: MoveResult.FAIL }); + }); + + describe("restores stat boosts granted by stacks", () => { + it("decreases stats based on stored values (both boosts equal)", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + + game.doAttack(0); + await game.phaseInterceptor.to(MovePhase); + + expect(pokemon.summonData.battleStats[BattleStat.DEF]).toBe(1); + expect(pokemon.summonData.battleStats[BattleStat.SPDEF]).toBe(1); + + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SWALLOW, result: MoveResult.SUCCESS }); + + expect(pokemon.summonData.battleStats[BattleStat.DEF]).toBe(0); + expect(pokemon.summonData.battleStats[BattleStat.SPDEF]).toBe(0); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + + it("decreases stats based on stored values (different boosts)", { timeout: 10000 }, async () => { + await game.startBattle([Species.ABOMASNOW]); + + const pokemon = game.scene.getPlayerPokemon(); + pokemon.addTag(BattlerTagType.STOCKPILING); + + const stockpilingTag = pokemon.getTag(StockpilingTag); + expect(stockpilingTag).toBeDefined(); + + // for the sake of simplicity (and because other tests cover the setup), set boost amounts directly + stockpilingTag.statChangeCounts = { + [BattleStat.DEF]: -1, + [BattleStat.SPDEF]: 2, + }; + + expect(stockpilingTag.statChangeCounts).toMatchObject({ + [BattleStat.DEF]: -1, + [BattleStat.SPDEF]: 2, + }); + + game.doAttack(0); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(pokemon.getMoveHistory().at(-1)).toMatchObject({ move: Moves.SWALLOW, result: MoveResult.SUCCESS }); + + expect(pokemon.summonData.battleStats[BattleStat.DEF]).toBe(1); + expect(pokemon.summonData.battleStats[BattleStat.SPDEF]).toBe(-2); + + expect(pokemon.getTag(StockpilingTag)).toBeUndefined(); + }); + }); +});