Skip to content

Commit

Permalink
[Ability] Ignore Held Items for Stat Calculation (#5254)
Browse files Browse the repository at this point in the history
* added the ability to ignore held items at stat calculation

* integer -> number in src/field/pokemon.ts

* added tests from @SirzBenjie

* Update test

* Fix test filename

* added turnorder to tests

* added tera_blast changes and tests

---------

Co-authored-by: damocleas <[email protected]>
Co-authored-by: NightKev <[email protected]>
  • Loading branch information
3 people authored Feb 11, 2025
1 parent 8012a1b commit e75ddfa
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 25 deletions.
12 changes: 2 additions & 10 deletions src/data/battler-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1752,7 +1752,7 @@ export class HighestStatBoostTag extends AbilityBattlerTag {
super.onAdd(pokemon);

let highestStat: EffectiveStat;
EFFECTIVE_STATS.map(s => pokemon.getEffectiveStat(s)).reduce((highestValue: number, value: number, i: number) => {
EFFECTIVE_STATS.map(s => pokemon.getEffectiveStat(s, undefined, undefined, undefined, undefined, undefined, undefined, true)).reduce((highestValue: number, value: number, i: number) => {
if (value > highestValue) {
highestStat = EFFECTIVE_STATS[i];
return value;
Expand All @@ -1763,15 +1763,7 @@ export class HighestStatBoostTag extends AbilityBattlerTag {
highestStat = highestStat!; // tell TS compiler it's defined!
this.stat = highestStat;

switch (this.stat) {
case Stat.SPD:
this.multiplier = 1.5;
break;
default:
this.multiplier = 1.3;
break;
}

this.multiplier = this.stat === Stat.SPD ? 1.5 : 1.3;
globalScene.queueMessage(i18next.t("battlerTags:highestStatBoostOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), statName: i18next.t(getStatKey(highestStat)) }), null, false, null, true);
}

Expand Down
6 changes: 3 additions & 3 deletions src/data/move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4559,7 +4559,8 @@ export class TeraMoveCategoryAttr extends VariableMoveCategoryAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const category = (args[0] as Utils.NumberHolder);

if (user.isTerastallized() && user.getEffectiveStat(Stat.ATK, target, move) > user.getEffectiveStat(Stat.SPATK, target, move)) {
if (user.isTerastallized() && user.getEffectiveStat(Stat.ATK, target, move, true, true, false, false, true) >
user.getEffectiveStat(Stat.SPATK, target, move, true, true, false, false, true)) {
category.value = MoveCategory.PHYSICAL;
return true;
}
Expand Down Expand Up @@ -10905,8 +10906,7 @@ export function initMoves() {
.attr(TeraMoveCategoryAttr)
.attr(TeraBlastTypeAttr)
.attr(TeraBlastPowerAttr)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, { condition: (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR) })
.partial(), /** Does not ignore abilities that affect stats, relevant in determining the move's category {@see TeraMoveCategoryAttr} */
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, { condition: (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR) }),
new SelfStatusMove(Moves.SILK_TRAP, Type.BUG, -1, 10, -1, 4, 9)
.attr(ProtectAttr, BattlerTagType.SILK_TRAP)
.condition(failIfLastCondition),
Expand Down
16 changes: 11 additions & 5 deletions src/field/pokemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,11 +947,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param ignoreOppAbility during an attack, determines whether the opposing Pokemon's abilities should be ignored during the stat calculation.
* @param isCritical determines whether a critical hit has occurred or not (`false` by default)
* @param simulated if `true`, nullifies any effects that produce any changes to game state from triggering
* @param ignoreHeldItems determines whether this Pokemon's held items should be ignored during the stat calculation, default `false`
* @returns the final in-battle value of a stat
*/
getEffectiveStat(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreAbility: boolean = false, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): number {
getEffectiveStat(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreAbility: boolean = false, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true, ignoreHeldItems: boolean = false): number {
const statValue = new Utils.NumberHolder(this.getStat(stat, false));
globalScene.applyModifiers(StatBoosterModifier, this.isPlayer(), this, stat, statValue);
if (!ignoreHeldItems) {
globalScene.applyModifiers(StatBoosterModifier, this.isPlayer(), this, stat, statValue);
}

// The Ruin abilities here are never ignored, but they reveal themselves on summon anyway
const fieldApplied = new Utils.BooleanHolder(false);
Expand All @@ -965,7 +968,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
applyStatMultiplierAbAttrs(StatMultiplierAbAttr, this, stat, statValue, simulated);
}

let ret = statValue.value * this.getStatStageMultiplier(stat, opponent, move, ignoreOppAbility, isCritical, simulated);
let ret = statValue.value * this.getStatStageMultiplier(stat, opponent, move, ignoreOppAbility, isCritical, simulated, ignoreHeldItems);

switch (stat) {
case Stat.ATK:
Expand Down Expand Up @@ -2487,9 +2490,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param ignoreOppAbility determines whether the effects of the opponent's abilities (i.e. Unaware) should be ignored (`false` by default)
* @param isCritical determines whether a critical hit has occurred or not (`false` by default)
* @param simulated determines whether effects are applied without altering game state (`true` by default)
* @param ignoreHeldItems determines whether this Pokemon's held items should be ignored during the stat calculation, default `false`
* @return the stat stage multiplier to be used for effective stat calculation
*/
getStatStageMultiplier(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): number {
getStatStageMultiplier(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true, ignoreHeldItems: boolean = false): number {
const statStage = new Utils.IntegerHolder(this.getStatStage(stat));
const ignoreStatStage = new Utils.BooleanHolder(false);

Expand All @@ -2516,7 +2520,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {

if (!ignoreStatStage.value) {
const statStageMultiplier = new Utils.NumberHolder(Math.max(2, 2 + statStage.value) / Math.max(2, 2 - statStage.value));
globalScene.applyModifiers(TempStatStageBoosterModifier, this.isPlayer(), stat, statStageMultiplier);
if (!ignoreHeldItems) {
globalScene.applyModifiers(TempStatStageBoosterModifier, this.isPlayer(), stat, statStageMultiplier);
}
return Math.min(statStageMultiplier.value, 4);
}
return 1;
Expand Down
66 changes: 66 additions & 0 deletions src/test/abilities/protosynthesis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Nature } from "#enums/nature";
import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { BattlerIndex } from "#app/battle";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";

describe("Abilities - Protosynthesis", () => {
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
.moveset([ Moves.SPLASH, Moves.TACKLE ])
.ability(Abilities.PROTOSYNTHESIS)
.battleType("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});

it("should not consider temporary items when determining which stat to boost", async() => {
// Mew has uniform base stats
game.override.startingModifier([{ name: "TEMP_STAT_STAGE_BOOSTER", type: Stat.DEF }])
.enemyMoveset(Moves.SUNNY_DAY)
.startingLevel(100)
.enemyLevel(100);
await game.classicMode.startBattle([ Species.MEW ]);
const mew = game.scene.getPlayerPokemon()!;
// Nature of starting mon is randomized. We need to fix it to a neutral nature for the automated test.
mew.setNature(Nature.HARDY);
const enemy = game.scene.getEnemyPokemon()!;
const def_before_boost = mew.getEffectiveStat(Stat.DEF, undefined, undefined, false, undefined, false, false, true);
const atk_before_boost = mew.getEffectiveStat(Stat.ATK, undefined, undefined, false, undefined, false, false, true);
const initialHp = enemy.hp;
game.move.select(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.toNextTurn();
const unboosted_dmg = initialHp - enemy.hp;
enemy.hp = initialHp;
const def_after_boost = mew.getEffectiveStat(Stat.DEF, undefined, undefined, false, undefined, false, false, true);
const atk_after_boost = mew.getEffectiveStat(Stat.ATK, undefined, undefined, false, undefined, false, false, true);
game.move.select(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.toNextTurn();
const boosted_dmg = initialHp - enemy.hp;
expect(boosted_dmg).toBeGreaterThan(unboosted_dmg);
expect(def_after_boost).toEqual(def_before_boost);
expect(atk_after_boost).toBeGreaterThan(atk_before_boost);
});
});
82 changes: 75 additions & 7 deletions src/test/moves/tera_blast.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BattlerIndex } from "#app/battle";
import { Stat } from "#enums/stat";
import { allMoves } from "#app/data/move";
import { allMoves, TeraMoveCategoryAttr } from "#app/data/move";
import { Type } from "#enums/type";
import { Abilities } from "#app/enums/abilities";
import { HitResult } from "#app/field/pokemon";
Expand All @@ -14,6 +14,7 @@ describe("Moves - Tera Blast", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const moveToCheck = allMoves[Moves.TERA_BLAST];
const teraBlastAttr = moveToCheck.getAttrs(TeraMoveCategoryAttr)[0];

beforeAll(() => {
phaserGame = new Phaser.Game({
Expand Down Expand Up @@ -86,19 +87,86 @@ describe("Moves - Tera Blast", () => {
expect(enemyPokemon.apply).toHaveReturnedWith(HitResult.SUPER_EFFECTIVE);
});

// Currently abilities are bugged and can't see when a move's category is changed
it.todo("uses the higher stat of the user's Atk and SpAtk for damage calculation", async () => {
game.override.enemyAbility(Abilities.TOXIC_DEBRIS);
it("uses the higher ATK for damage calculation", async () => {
await game.startBattle();

const playerPokemon = game.scene.getPlayerPokemon()!;
playerPokemon.stats[Stat.ATK] = 100;
playerPokemon.stats[Stat.SPATK] = 1;

vi.spyOn(teraBlastAttr, "apply");

game.move.select(Moves.TERA_BLAST);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true);
}, 20000);
await game.toNextTurn();
expect(teraBlastAttr.apply).toHaveLastReturnedWith(true);
});

it("uses the higher SPATK for damage calculation", async () => {
await game.startBattle();

const playerPokemon = game.scene.getPlayerPokemon()!;
playerPokemon.stats[Stat.ATK] = 1;
playerPokemon.stats[Stat.SPATK] = 100;

vi.spyOn(teraBlastAttr, "apply");

game.move.select(Moves.TERA_BLAST);
await game.toNextTurn();
expect(teraBlastAttr.apply).toHaveLastReturnedWith(false);
});

it("should stay as a special move if ATK turns lower than SPATK mid-turn", async () => {
game.override.enemyMoveset([ Moves.CHARM ]);
await game.startBattle();

const playerPokemon = game.scene.getPlayerPokemon()!;
playerPokemon.stats[Stat.ATK] = 51;
playerPokemon.stats[Stat.SPATK] = 50;

vi.spyOn(teraBlastAttr, "apply");

game.move.select(Moves.TERA_BLAST);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();
expect(teraBlastAttr.apply).toHaveLastReturnedWith(false);
});

it("does not change its move category from stat changes due to held items", async () => {
game.override
.startingHeldItems([{ name: "SPECIES_STAT_BOOSTER", type: "THICK_CLUB" }])
.starterSpecies(Species.CUBONE);
await game.startBattle();

const playerPokemon = game.scene.getPlayerPokemon()!;

playerPokemon.stats[Stat.ATK] = 50;
playerPokemon.stats[Stat.SPATK] = 51;

vi.spyOn(teraBlastAttr, "apply");

game.move.select(Moves.TERA_BLAST);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();

expect(teraBlastAttr.apply).toHaveLastReturnedWith(false);
});

it("does not change its move category from stat changes due to abilities", async () => {
game.override.ability(Abilities.HUGE_POWER);
await game.startBattle();

const playerPokemon = game.scene.getPlayerPokemon()!;
playerPokemon.stats[Stat.ATK] = 50;
playerPokemon.stats[Stat.SPATK] = 51;

vi.spyOn(teraBlastAttr, "apply");

game.move.select(Moves.TERA_BLAST);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();
expect(teraBlastAttr.apply).toHaveLastReturnedWith(false);
});


it("causes stat drops if user is Stellar tera type", async () => {
game.override.startingHeldItems([{ name: "TERA_SHARD", type: Type.STELLAR }]);
Expand Down

0 comments on commit e75ddfa

Please sign in to comment.