[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>
This commit is contained in:
geeilhan 2025-02-11 12:26:01 +01:00 committed by GitHub
parent 5296966f70
commit b31d5fd23e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 329 additions and 3 deletions

View File

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

View File

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

View File

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