[Move] Implement Stockpile, Spit Up, Swallow (#2960)

* feat: Implement Stockpile, Spit Up, Swallow

* chore: Minor, likely unnecessary null checks

* feat: Localization

* Undo non-English localizations (unsure if they went through proper channels)

* ko localization

from @EnochG1

Co-authored-by: Enoch <enoch.jwsong@gmail.com>

* linting fix

* add tests, tiny (non-functional) tweaks

* Remove unnecessary cast

* Update src/data/move.ts

(oops)

* remove some unnecessary comments, rename something for clarity

---------

Co-authored-by: Enoch <enoch.jwsong@gmail.com>
This commit is contained in:
mcmontag 2024-07-23 17:30:42 -04:00 committed by GitHub
parent ef5e0d4c24
commit 0e5fd80431
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 865 additions and 25 deletions

View File

@ -1,5 +1,5 @@
import { CommonAnim, CommonBattleAnim } from "./battle-anims"; 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 { getPokemonNameWithAffix } from "../messages";
import Pokemon, { MoveResult, HitResult } from "../field/pokemon"; import Pokemon, { MoveResult, HitResult } from "../field/pokemon";
import { Stat, getStatName } from "./pokemon-stat"; 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 { export function getBattlerTag(tagType: BattlerTagType, turnCount: integer, sourceMove: Moves, sourceId: integer): BattlerTag {
switch (tagType) { switch (tagType) {
case BattlerTagType.RECHARGING: case BattlerTagType.RECHARGING:
@ -1704,6 +1792,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: integer, sourc
return new DestinyBondTag(sourceMove, sourceId); return new DestinyBondTag(sourceMove, sourceId);
case BattlerTagType.ICE_FACE: case BattlerTagType.ICE_FACE:
return new IceFaceTag(sourceMove); return new IceFaceTag(sourceMove);
case BattlerTagType.STOCKPILING:
return new StockpilingTag(sourceMove);
case BattlerTagType.OCTOLOCK: case BattlerTagType.OCTOLOCK:
return new OctolockTag(sourceId); return new OctolockTag(sourceId);
case BattlerTagType.NONE: case BattlerTagType.NONE:

View File

@ -1,7 +1,7 @@
import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims"; import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims";
import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases"; import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases";
import { BattleStat, getBattleStatName } from "./battle-stat"; 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 { getPokemonMessage, getPokemonNameWithAffix } from "../messages";
import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon";
import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects} from "./status-effect"; 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 * 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. * 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) .target(MoveTarget.RANDOM_NEAR_ENEMY)
.partial(), .partial(),
new SelfStatusMove(Moves.STOCKPILE, Type.NORMAL, -1, 20, -1, 0, 3) new SelfStatusMove(Moves.STOCKPILE, Type.NORMAL, -1, 20, -1, 0, 3)
.unimplemented(), .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
new AttackMove(Moves.SPIT_UP, Type.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3) .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
.unimplemented(), 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) new SelfStatusMove(Moves.SWALLOW, Type.NORMAL, -1, 10, -1, 0, 3)
.triageMove() .condition(hasStockpileStacksCondition)
.unimplemented(), .attr(SwallowHealAttr)
.attr(RemoveBattlerTagAttr, [BattlerTagType.STOCKPILING], true)
.triageMove(),
new AttackMove(Moves.HEAT_WAVE, Type.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3) new AttackMove(Moves.HEAT_WAVE, Type.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
.attr(StatusEffectAttr, StatusEffect.BURN) .attr(StatusEffectAttr, StatusEffect.BURN)

View File

@ -61,6 +61,7 @@ export enum BattlerTagType {
DESTINY_BOND = "DESTINY_BOND", DESTINY_BOND = "DESTINY_BOND",
CENTER_OF_ATTENTION = "CENTER_OF_ATTENTION", CENTER_OF_ATTENTION = "CENTER_OF_ATTENTION",
ICE_FACE = "ICE_FACE", ICE_FACE = "ICE_FACE",
STOCKPILING = "STOCKPILING",
RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE", RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE",
ALWAYS_GET_HIT = "ALWAYS_GET_HIT" ALWAYS_GET_HIT = "ALWAYS_GET_HIT"
} }

View File

@ -2232,13 +2232,19 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return false; return false;
} }
/** @overload */
getTag(tagType: BattlerTagType): BattlerTag;
/** @overload */
getTag<T extends BattlerTag>(tagType: Constructor<T>): T;
getTag(tagType: BattlerTagType | Constructor<BattlerTag>): BattlerTag { getTag(tagType: BattlerTagType | Constructor<BattlerTag>): BattlerTag {
if (!this.summonData) { if (!this.summonData) {
return null; return null;
} }
return typeof(tagType) === "string" return tagType instanceof Function
? this.summonData.tags.find(t => t.tagType === tagType) ? this.summonData.tags.find(t => t instanceof tagType)
: this.summonData.tags.find(t => t instanceof tagType); : this.summonData.tags.find(t => t.tagType === tagType);
} }
findTag(tagFilter: ((tag: BattlerTag) => boolean)) { 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)); return this.summonData.tags.find(t => tagFilter(t));
} }
getTags(tagType: BattlerTagType | Constructor<BattlerTag>): 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[] { findTags(tagFilter: ((tag: BattlerTag) => boolean)): BattlerTag[] {
if (!this.summonData) { if (!this.summonData) {
return []; return [];

View File

@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} wurde eingepökelt!", "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} wurde eingepökelt!",
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} wurde durch {{moveName}} verletzt!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} wurde durch {{moveName}} verletzt!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} nimmt einen Teil seiner KP und legt einen Fluch auf {{pokemonName}}!", "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; } as const;

View File

@ -155,4 +155,5 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "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; } as const;

View File

@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} is being salt cured!", "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} is being salt cured!",
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "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; } as const;

View File

@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}}\nest couvert de sel !", "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}}\nest couvert de sel !",
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} sacrifie des PV\net lance une malédiction sur {{pokemonName}} !", "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; } as const;

View File

@ -154,5 +154,6 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} è stato messo sotto sale!", "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}} è stato messo sotto sale!",
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} ha sacrificato metà dei suoi PS per\nlanciare una maledizione su {{pokemonName}}!", "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; } as const;

View File

@ -155,4 +155,5 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}[[는]] 소금절이의\n데미지를 입고 있다.", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}[[는]] 소금절이의\n데미지를 입고 있다.",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!",
} as const; } as const;

View File

@ -155,4 +155,5 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} foi ferido pelo {{moveName}}!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} foi ferido pelo {{moveName}}!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!",
} as const; } as const;

View File

@ -146,5 +146,6 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}}\n陷入了盐腌状态", "battlerTagsSaltCuredOnAdd": "{{pokemonNameWithAffix}}\n陷入了盐腌状态",
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒" "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!"
} as const; } as const;

View File

@ -144,4 +144,5 @@ export const battle: SimpleTranslationEntries = {
"battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!", "battlerTagsSaltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!",
"battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}", "battlerTagsCursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}",
"battlerTagsCursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!", "battlerTagsCursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!",
"battlerTagsStockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!"
} as const; } as const;

View File

@ -3224,6 +3224,8 @@ export class ShowAbilityPhase extends PokemonPhase {
} }
} }
export type StatChangeCallback = (target: Pokemon, changed: BattleStat[], relativeChanges: number[]) => void;
export class StatChangePhase extends PokemonPhase { export class StatChangePhase extends PokemonPhase {
private stats: BattleStat[]; private stats: BattleStat[];
private selfTarget: boolean; private selfTarget: boolean;
@ -3231,8 +3233,10 @@ export class StatChangePhase extends PokemonPhase {
private showMessage: boolean; private showMessage: boolean;
private ignoreAbilities: boolean; private ignoreAbilities: boolean;
private canBeCopied: 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); super(scene, battlerIndex);
this.selfTarget = selfTarget; this.selfTarget = selfTarget;
@ -3241,6 +3245,7 @@ export class StatChangePhase extends PokemonPhase {
this.showMessage = showMessage; this.showMessage = showMessage;
this.ignoreAbilities = ignoreAbilities; this.ignoreAbilities = ignoreAbilities;
this.canBeCopied = canBeCopied; this.canBeCopied = canBeCopied;
this.onChange = onChange;
} }
start() { start() {
@ -3282,6 +3287,8 @@ export class StatChangePhase extends PokemonPhase {
const battleStats = this.getPokemon().summonData.battleStats; 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]); 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 = () => { const end = () => {
if (this.showMessage) { if (this.showMessage) {
const messages = this.getStatChangeMessages(filteredStats, levels.value, relLevels); const messages = this.getStatChangeMessages(filteredStats, levels.value, relLevels);

View File

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

View File

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

View File

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

View File

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