diff --git a/src/data/move-attrs/present-power-attr.ts b/src/data/move-attrs/present-power-attr.ts index f7b3d4c72..7e9ac104c 100644 --- a/src/data/move-attrs/present-power-attr.ts +++ b/src/data/move-attrs/present-power-attr.ts @@ -2,14 +2,17 @@ import type { Pokemon } from "#app/field/pokemon"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; -import { randSeedInt, type NumberHolder, toDmgValue } from "#app/utils"; +import { type NumberHolder, toDmgValue } from "#app/utils"; import i18next from "i18next"; import type { Move } from "#app/data/move"; import { VariablePowerAttr } from "#app/data/move-attrs/variable-power-attr"; /** - * Attribute to set move power based on one of four random Presents. One of which - * heals the target for 25% of its maximum HP instead of dealing damage. + * Attribute to set move power based on one of four random outcomes (listed below). + * - 40% : 40 BP attack + * - 30% : 80 BP attack + * - 10% : 120 BP attack + * - 20% : Heal 25% of the target's HP * Used for {@link https://bulbapedia.bulbagarden.net/wiki/Present_(move) | Present}. * @extends VariablePowerAttr */ @@ -19,16 +22,16 @@ export class PresentPowerAttr extends VariablePowerAttr { * If this move is multi-hit, and this attribute is applied to any hit * other than the first, this move cannot result in a heal. */ - const firstHit = user.turnData.hitCount === user.turnData.hitsLeft; + const isFirstHit = user.turnData.hitCount === user.turnData.hitsLeft && user.turnData.hitsLeft > 0; - const powerSeed = randSeedInt(firstHit ? 100 : 80); - if (powerSeed <= 40) { + const powerSeed = user.randSeedInt(isFirstHit ? 100 : 80); + if (powerSeed < 40) { power.value = 40; - } else if (40 < powerSeed && powerSeed <= 70) { + } else if (powerSeed < 70) { power.value = 80; - } else if (70 < powerSeed && powerSeed <= 80) { + } else if (powerSeed < 80) { power.value = 120; - } else if (80 < powerSeed && powerSeed <= 100) { + } else if (powerSeed < 100) { // If this move is multi-hit, disable all other hits user.turnData.hitCount = 1; user.turnData.hitsLeft = 1; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 266b47f85..53cf464ad 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3099,12 +3099,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { */ const targetDef = this.getEffectiveStat(defendingStat.value, source, move, abilityApplyMode, isCritical, simulated); + /** This prevents a move with negative power from possibly dealing positive damage. + * The issue can occur because the base damage is the result of the below equation plus 2. + */ + const damageCalculation = (levelMultiplier * power * sourceAtk) / targetDef/ 50; + if (damageCalculation < 0) { + return damageCalculation; + } /** * The attack's base damage, as determined by the source's level, move power * and Attack stat as well as this Pokemon's Defense stat */ - const baseDamage = (levelMultiplier * power * sourceAtk) / targetDef / 50 + 2; - + const baseDamage = damageCalculation + 2; /** Debug message for non-simulated calls (i.e. when damage is actually dealt) */ if (!simulated) { console.log("base damage", baseDamage, move.name, power, sourceAtk, targetDef); @@ -3330,28 +3336,28 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { ); } - /** If damage is nullified by a form-ability (Eiscue's Ice Face, Mimikyu's Disguise), then damage is set to 0 */ - if (receivedDamageMultiplier.value > 0) { - damage.value = toDmgValue( - baseDamage - * targetMultiplier - * multiStrikeEnhancementMultiplier.value - * arenaAttackTypeMultiplier.value - * glaiveRushMultiplier.value - * criticalMultiplier.value - * randomMultiplier - * stabMultiplier.value - * typeMultiplier - * burnMultiplier.value - * screenMultiplier.value - * hitsTagMultiplier.value - * mistyTerrainMultiplier - * tintedLensMultiplier.value - * receivedDamageMultiplier.value - * alliedFieldDamageMultiplier.value, - ); - } else { + damage.value = + baseDamage + * targetMultiplier + * multiStrikeEnhancementMultiplier.value + * arenaAttackTypeMultiplier.value + * glaiveRushMultiplier.value + * criticalMultiplier.value + * randomMultiplier + * stabMultiplier.value + * typeMultiplier + * burnMultiplier.value + * screenMultiplier.value + * hitsTagMultiplier.value + * mistyTerrainMultiplier + * tintedLensMultiplier.value + * receivedDamageMultiplier.value + * alliedFieldDamageMultiplier.value; + /** If damage is nullified by a form-ability (Eiscue's Ice Face, Mimikyu's Disguise) or the attack has a non-damaging outcome (Present), then damage is set to 0 instead */ + if (damage.value <= 0) { damage.value = 0; + } else { + damage.value = toDmgValue(damage.value); } // This attribute may modify damage arbitrarily, so be careful about changing its order of application. diff --git a/test/abilities/triage.test.ts b/test/abilities/triage.test.ts index 837f2ca5a..93a24c1df 100644 --- a/test/abilities/triage.test.ts +++ b/test/abilities/triage.test.ts @@ -69,11 +69,6 @@ describe("Abilities - Triage", () => { expect(moveToUse.getPriority(playerPokemon)).toBe(originalPriority); }); - it.todo("should not increase the priority of Present if it heals the target", async () => { - game.override.moveset(Moves.PRESENT); - await game.classicMode.startBattle([Species.FEEBAS]); - }); - it("should not increase the priority of Pollen Puff if it heals the user's ally", async () => { game.override .moveset([Moves.POLLEN_PUFF, Moves.SPLASH]) diff --git a/test/moves/present.test.ts b/test/moves/present.test.ts new file mode 100644 index 000000000..7eb41d3b4 --- /dev/null +++ b/test/moves/present.test.ts @@ -0,0 +1,107 @@ +import { allMoves } from "#app/data/all-moves"; +import { PresentPowerAttr } from "#app/data/move-attrs/present-power-attr"; +import { NumberHolder } from "#app/utils"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { GameManager } from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Present", () => { + 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 + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it.each([ + { descriptor: "first hit", hitsLeft: 2, totalOutcomes: 100, expectedHeals: 20 }, + { descriptor: "subsequent hits", hitsLeft: 1, totalOutcomes: 80, expectedHeals: 0 }, + ])("should have correct probabilities on $descriptor", async ({ hitsLeft, totalOutcomes, expectedHeals }) => { + const presentAttr = allMoves[Moves.PRESENT].getAttrs(PresentPowerAttr)[0]; + + await game.classicMode.startBattle([Species.FEEBAS]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + player.turnData.hitsLeft = hitsLeft; + player.turnData.hitCount = 2; + + let rngSweepProgress = 0; // This will simulate entire range of RNG calls by slowly sweeping from 0 to 1 + vi.spyOn(player, "randSeedInt").mockImplementation((range: number, min: number = 0) => { + return Math.floor(min + rngSweepProgress * range); + }); + + let count40power = 0, + count80power = 0, + count120power = 0, + countHeal = 0; + for (let i = 0; i < totalOutcomes; i++) { + rngSweepProgress = (2 * i + 1) / (2 * totalOutcomes); + + const power = new NumberHolder(-1); + presentAttr.apply(player, enemy, allMoves[Moves.PRESENT], power); + switch (power.value) { + case 40: + count40power++; + break; + case 80: + count80power++; + break; + case 120: + count120power++; + break; + case -1: + countHeal++; + } + } + + expect(count40power).toBe(40); + expect(count80power).toBe(30); + expect(count120power).toBe(10); + expect(countHeal).toBe(expectedHeals); + }); + + it("should end multi-hit Present, and should not deal damage, if it heals", async () => { + game.override.ability(Abilities.PARENTAL_BOND).enemyAbility(Abilities.NO_GUARD); + await game.classicMode.startBattle([Species.FEEBAS]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + // Force RNG rolls to be maximum, which corresponds to Present healing + vi.spyOn(player, "randSeedInt").mockImplementation((range: number, min: number = 0) => min + range - 1); + + // Check that enemy never takes positive damage + vi.spyOn(enemy, "damage").mockImplementation((damage: number) => { + expect(damage).toBe(0); + return damage; + }); + + enemy.hp = 1; + game.move.use(Moves.PRESENT); + await game.toNextTurn(); + + expect(enemy.hp).toBe(1 + Math.floor(enemy.getMaxHp() / 4)); + }); +});