From b31d5fd23e2f8b5263d797ad560eb8506c57dd4a Mon Sep 17 00:00:00 2001 From: geeilhan <107366005+geeilhan@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:26:01 +0100 Subject: [PATCH] [Move] Spectral Thief Full Implementation (#4891) * fully implemented spectral thief * Update to structure of implementation * line commented target.scene.queueMessage since message does not exist yet * changed documentation * added move-trigger.json key * removed line comment since key was added to english locales * removed console.log messages used for debugging * refactored move-trigger key to race with @muscode13 * added more automated tests * github tests failed * removed line comment since key was added to english locales * refactored move-trigger key to race with @muscode13 * added more automated tests * github tests failed * solved conflicts * Update src/data/move.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * removed .partial() * corrected spectral thief name * changed target.scene to globalScene * changed comments --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> --- src/data/move.ts | 67 +++++++- src/field/pokemon.ts | 41 ++++- src/test/moves/spectral_thief.test.ts | 224 ++++++++++++++++++++++++++ 3 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 src/test/moves/spectral_thief.test.ts diff --git a/src/data/move.ts b/src/data/move.ts index 75908f86a14..1c768f20bb0 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4380,6 +4380,69 @@ export class CueNextRoundAttr extends MoveEffectAttr { } } +/** + * Attribute that changes stat stages before the damage is calculated + */ +export class StatChangeBeforeDmgCalcAttr extends MoveAttr { + /** + * Applies Stat Changes before damage is calculated + * + * @param user {@linkcode Pokemon} that called {@linkcode move} + * @param target {@linkcode Pokemon} that is the target of {@linkcode move} + * @param move {@linkcode Move} called by {@linkcode user} + * @param args N/A + * + * @returns true if stat stages where correctly applied + */ + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + return false; + } +} + +/** + * Steals the postitive Stat stages of the target before damage calculation so stat changes + * apply to damage calculation (e.g. {@linkcode Moves.SPECTRAL_THIEF}) + * {@link https://bulbapedia.bulbagarden.net/wiki/Spectral_Thief_(move) | Spectral Thief} + */ +export class SpectralThiefAttr extends StatChangeBeforeDmgCalcAttr { + /** + * steals max amount of positive stats of the target while not exceeding the limit of max 6 stat stages + * + * @param user {@linkcode Pokemon} that called {@linkcode move} + * @param target {@linkcode Pokemon} that is the target of {@linkcode move} + * @param move {@linkcode Move} called by {@linkcode user} + * @param args N/A + * + * @returns true if stat stages where correctly stolen + */ + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + /** + * Copy all positive stat stages to user and reduce copied stat stages on target. + */ + for (const s of BATTLE_STATS) { + const statStageValueTarget = target.getStatStage(s); + const statStageValueUser = user.getStatStage(s); + + if (statStageValueTarget > 0) { + /** + * Only value of up to 6 can be stolen (stat stages don't exceed 6) + */ + const availableToSteal = Math.min(statStageValueTarget, 6 - statStageValueUser); + + globalScene.unshiftPhase(new StatStageChangePhase(user.getBattlerIndex(), this.selfTarget, [ s ], availableToSteal)); + target.setStatStage(s, statStageValueTarget - availableToSteal); + } + } + + target.updateInfo(); + user.updateInfo(); + globalScene.queueMessage(i18next.t("moveTriggers:stealPositiveStats", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })); + + return true; + } + +} + export class VariableAtkAttr extends MoveAttr { constructor() { super(); @@ -10467,8 +10530,8 @@ export function initMoves() { new AttackMove(Moves.PRISMATIC_LASER, Type.PSYCHIC, MoveCategory.SPECIAL, 160, 100, 10, -1, 0, 7) .attr(RechargeAttr), new AttackMove(Moves.SPECTRAL_THIEF, Type.GHOST, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 7) - .ignoresSubstitute() - .partial(), // Does not steal stats + .attr(SpectralThiefAttr) + .ignoresSubstitute(), new AttackMove(Moves.SUNSTEEL_STRIKE, Type.STEEL, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 7) .ignoresAbilities(), new AttackMove(Moves.MOONGEIST_BEAM, Type.GHOST, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 7) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 79d7192b4db..82674fb8b46 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -7,7 +7,40 @@ import { variantColorCache } from "#app/data/variant"; import { variantData } from "#app/data/variant"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "#app/ui/battle-info"; import type Move from "#app/data/move"; -import { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget, CombinedPledgeStabBoostAttr, VariableMoveTypeChartAttr, HpSplitAttr } from "#app/data/move"; +import { + HighCritAttr, + StatChangeBeforeDmgCalcAttr, + HitsTagAttr, + applyMoveAttrs, + FixedDamageAttr, + VariableAtkAttr, + allMoves, + MoveCategory, + TypelessAttr, + CritOnlyAttr, + getMoveTargets, + OneHitKOAttr, + VariableMoveTypeAttr, + VariableDefAttr, + AttackMove, + ModifiedDamageAttr, + VariableMoveTypeMultiplierAttr, + IgnoreOpponentStatStagesAttr, + SacrificialAttr, + VariableMoveCategoryAttr, + CounterDamageAttr, + StatStageChangeAttr, + RechargeAttr, + IgnoreWeatherTypeDebuffAttr, + BypassBurnDamageReductionAttr, + SacrificialAttrOnHit, + OneHitKOAccuracyAttr, + RespectAttackTypeImmunityAttr, + MoveTarget, + CombinedPledgeStabBoostAttr, + VariableMoveTypeChartAttr, + HpSplitAttr +} from "#app/data/move"; import type { PokemonSpeciesForm } from "#app/data/pokemon-species"; import { default as PokemonSpecies, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters"; @@ -2903,6 +2936,12 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { isCritical = false; } + /** + * Applies stat changes from {@linkcode move} and gives it to {@linkcode source} + * before damage calculation + */ + applyMoveAttrs(StatChangeBeforeDmgCalcAttr, source, this, move); + const { cancelled, result, damage: dmg } = this.getAttackDamage(source, move, false, false, isCritical, false); const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === source.getMoveType(move)) as TypeBoostTag; diff --git a/src/test/moves/spectral_thief.test.ts b/src/test/moves/spectral_thief.test.ts new file mode 100644 index 00000000000..8913b7f3683 --- /dev/null +++ b/src/test/moves/spectral_thief.test.ts @@ -0,0 +1,224 @@ +import { Abilities } from "#enums/abilities"; +import { BattlerIndex } from "#app/battle"; +import { Stat } from "#enums/stat"; +import { allMoves } from "#app/data/move"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Spectral Thief", () => { + 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 + .enemySpecies(Species.SHUCKLE) + .enemyLevel(100) + .enemyMoveset(Moves.SPLASH) + .enemyAbility(Abilities.BALL_FETCH) + .moveset([ Moves.SPECTRAL_THIEF, Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) + .disableCrits; + }); + + it("should steal max possible positive stat changes and ignore negative ones.", async () => { + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 6); + enemy.setStatStage(Stat.DEF, -6); + enemy.setStatStage(Stat.SPATK, 6); + enemy.setStatStage(Stat.SPDEF, -6); + enemy.setStatStage(Stat.SPD, 3); + + player.setStatStage(Stat.ATK, 4); + player.setStatStage(Stat.DEF, 1); + player.setStatStage(Stat.SPATK, 0); + player.setStatStage(Stat.SPDEF, 0); + player.setStatStage(Stat.SPD, -2); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + /** + * enemy has +6 ATK and player +4 => player only steals +2 + * enemy has -6 DEF and player 1 => player should not steal + * enemy has +6 SPATK and player 0 => player only steals +6 + * enemy has -6 SPDEF and player 0 => player should not steal + * enemy has +3 SPD and player -2 => player only steals +3 + */ + expect(player.getStatStages()).toEqual([ 6, 1, 6, 0, 1, 0, 0 ]); + expect(enemy.getStatStages()).toEqual([ 4, -6, 0, -6, 0, 0, 0 ]); + }); + + it("should steal stat stages before dmg calculation", async () => { + game.override + .enemySpecies(Species.MAGIKARP) + .enemyLevel(50); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + const moveToCheck = allMoves[Moves.SPECTRAL_THIEF]; + const dmgBefore = enemy.getAttackDamage(player, moveToCheck, false, false, false, false).damage; + + enemy.setStatStage(Stat.ATK, 6); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(dmgBefore).toBeLessThan(enemy.getAttackDamage(player, moveToCheck, false, false, false, false).damage); + }); + + it("should steal stat stages as a negative value with Contrary.", async () => { + game.override + .ability(Abilities.CONTRARY); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 6); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(-6); + expect(enemy.getStatStage(Stat.ATK)).toEqual(0); + }); + + it("should steal double the stat stages with Simple.", async () => { + game.override + .ability(Abilities.SIMPLE); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 3); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(6); + expect(enemy.getStatStage(Stat.ATK)).toEqual(0); + }); + + it("should steal the stat stages through Clear Body.", async () => { + game.override + .enemyAbility(Abilities.CLEAR_BODY); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 3); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(3); + expect(enemy.getStatStage(Stat.ATK)).toEqual(0); + }); + + it("should steal the stat stages through White Smoke.", async () => { + game.override + .enemyAbility(Abilities.WHITE_SMOKE); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 3); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(3); + expect(enemy.getStatStage(Stat.ATK)).toEqual(0); + }); + + it("should steal the stat stages through Hyper Cutter.", async () => { + game.override + .enemyAbility(Abilities.HYPER_CUTTER); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 3); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(3); + expect(enemy.getStatStage(Stat.ATK)).toEqual(0); + }); + + it("should bypass Substitute.", async () => { + game.override + .enemyMoveset(Moves.SUBSTITUTE); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 3); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(3); + expect(enemy.getStatStage(Stat.ATK)).toEqual(0); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp() - 1); + }); + + it("should get blocked by protect.", async () => { + game.override + .enemyMoveset(Moves.PROTECT); + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + enemy.setStatStage(Stat.ATK, 3); + + player.setStatStage(Stat.ATK, 0); + + game.move.select(Moves.SPECTRAL_THIEF); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toEqual(0); + expect(enemy.getStatStage(Stat.ATK)).toEqual(3); + expect(enemy.hp).toBe(enemy.getMaxHp()); + }); +});