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

[Move][Checkup] Present RNG Fixes #337

Merged
merged 11 commits into from
Jan 27, 2025
21 changes: 12 additions & 9 deletions src/data/move-attrs/present-power-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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.
frutescens marked this conversation as resolved.
Show resolved Hide resolved
*/
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;
Expand Down
52 changes: 29 additions & 23 deletions src/field/pokemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
frutescens marked this conversation as resolved.
Show resolved Hide resolved
*/
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);
Expand Down Expand Up @@ -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) {
frutescens marked this conversation as resolved.
Show resolved Hide resolved
damage.value = 0;
} else {
damage.value = toDmgValue(damage.value);
}

// This attribute may modify damage arbitrarily, so be careful about changing its order of application.
Expand Down
5 changes: 0 additions & 5 deletions test/abilities/triage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
107 changes: 107 additions & 0 deletions test/moves/present.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading