Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Ability] Ignore Held Items for Stat Calculation #5254

Merged
Merged
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