From b1d4037a57c01d794a49c0f0a5d299918e153ac6 Mon Sep 17 00:00:00 2001 From: Leo Kim <47556641+KimJeongSun@users.noreply.github.com> Date: Thu, 22 Aug 2024 14:39:11 +0900 Subject: [PATCH] [Bug] Fix some damage formulas processed with ceil instead of floor (#3557) * fix damage calculations. add test code * define toIntValue function to replace every repeatitive min floor function. * revert unnecessary minimum boundary * update function name `toIntValue` -> `toDmgValue`. update comments. * add missing updates for changing function name * Update src/utils.ts Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> * remove redundant comment * update import code for test with phase --------- Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> --- src/data/ability.ts | 34 +++++------ src/data/arena-tag.ts | 6 +- src/data/battler-tags.ts | 18 +++--- src/data/berry.ts | 2 +- src/data/move.ts | 31 +++++----- src/field/pokemon.ts | 10 +-- src/modifier/modifier.ts | 6 +- src/test/abilities/disguise.test.ts | 7 ++- src/test/abilities/heatproof.test.ts | 3 +- src/test/abilities/parental_bond.test.ts | 5 +- src/test/battle/damage_calculation.test.ts | 71 ++++++++++++++++++++++ src/test/moves/belly_drum.test.ts | 7 ++- src/test/moves/clangorous_soul.test.ts | 7 ++- src/test/moves/fillet_away.test.ts | 7 ++- src/test/moves/tackle.test.ts | 2 +- src/utils.ts | 14 +++++ 16 files changed, 161 insertions(+), 69 deletions(-) create mode 100644 src/test/battle/damage_calculation.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index 27a11eb0b9a..284a9cb4e91 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -301,7 +301,7 @@ export class ReceivedMoveDamageMultiplierAbAttr extends PreDefendAbAttr { applyPreDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, cancelled: Utils.BooleanHolder, args: any[]): boolean { if (this.condition(pokemon, attacker, move)) { - (args[0] as Utils.NumberHolder).value = Math.floor((args[0] as Utils.NumberHolder).value * this.damageMultiplier); + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue((args[0] as Utils.NumberHolder).value * this.damageMultiplier); return true; } @@ -390,7 +390,7 @@ export class TypeImmunityHealAbAttr extends TypeImmunityAbAttr { if (!pokemon.isFullHp() && !simulated) { const abilityName = (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name; pokemon.scene.unshiftPhase(new PokemonHealPhase(pokemon.scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / 4), 1), i18next.t("abilityTriggers:typeImmunityHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true)); + Utils.toDmgValue(pokemon.getMaxHp() / 4), i18next.t("abilityTriggers:typeImmunityHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true)); } return true; } @@ -904,8 +904,8 @@ export class PostDefendContactDamageAbAttr extends PostDefendAbAttr { applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { if (!simulated && move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { - attacker.damageAndUpdate(Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); - attacker.turnData.damageTaken += Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)); + attacker.damageAndUpdate(Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); + attacker.turnData.damageTaken += Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio)); return true; } @@ -2049,7 +2049,7 @@ export class PostSummonAllyHealAbAttr extends PostSummonAbAttr { if (target?.isActive(true)) { if (!simulated) { target.scene.unshiftPhase(new PokemonHealPhase(target.scene, target.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / this.healRatio), 1), i18next.t("abilityTriggers:postSummonAllyHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(target), pokemonName: pokemon.name }), true, !this.showAnim)); + Utils.toDmgValue(pokemon.getMaxHp() / this.healRatio), i18next.t("abilityTriggers:postSummonAllyHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(target), pokemonName: pokemon.name }), true, !this.showAnim)); } return true; @@ -2440,7 +2440,7 @@ export class PreSwitchOutHealAbAttr extends PreSwitchOutAbAttr { applyPreSwitchOut(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean | Promise { if (!pokemon.isFullHp()) { if (!simulated) { - const healAmount = Math.floor(pokemon.getMaxHp() * 0.33); + const healAmount = Utils.toDmgValue(pokemon.getMaxHp() * 0.33); pokemon.heal(healAmount); pokemon.updateInfo(); } @@ -3074,7 +3074,7 @@ export class PostWeatherLapseHealAbAttr extends PostWeatherLapseAbAttr { const abilityName = (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name; if (!simulated) { scene.unshiftPhase(new PokemonHealPhase(scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / (16 / this.healFactor)), 1), i18next.t("abilityTriggers:postWeatherLapseHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true)); + Utils.toDmgValue(pokemon.getMaxHp() / (16 / this.healFactor)), i18next.t("abilityTriggers:postWeatherLapseHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true)); } return true; } @@ -3101,7 +3101,7 @@ export class PostWeatherLapseDamageAbAttr extends PostWeatherLapseAbAttr { if (!simulated) { const abilityName = (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name; scene.queueMessage(i18next.t("abilityTriggers:postWeatherLapseDamage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName })); - pokemon.damageAndUpdate(Math.ceil(pokemon.getMaxHp() / (16 / this.damageFactor)), HitResult.OTHER); + pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / (16 / this.damageFactor)), HitResult.OTHER); } return true; @@ -3181,7 +3181,7 @@ export class PostTurnStatusHealAbAttr extends PostTurnAbAttr { const scene = pokemon.scene; const abilityName = (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name; scene.unshiftPhase(new PokemonHealPhase(scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / 8), 1), i18next.t("abilityTriggers:poisonHeal", { pokemonName: getPokemonNameWithAffix(pokemon), abilityName }), true)); + Utils.toDmgValue(pokemon.getMaxHp() / 8), i18next.t("abilityTriggers:poisonHeal", { pokemonName: getPokemonNameWithAffix(pokemon), abilityName }), true)); } return true; } @@ -3350,7 +3350,7 @@ export class PostTurnHealAbAttr extends PostTurnAbAttr { const scene = pokemon.scene; const abilityName = (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name; scene.unshiftPhase(new PokemonHealPhase(scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / 16), 1), i18next.t("abilityTriggers:postTurnHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true)); + Utils.toDmgValue(pokemon.getMaxHp() / 16), i18next.t("abilityTriggers:postTurnHeal", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true)); } return true; @@ -3402,7 +3402,7 @@ export class PostTurnHurtIfSleepingAbAttr extends PostTurnAbAttr { for (const opp of pokemon.getOpponents()) { if ((opp.status?.effect === StatusEffect.SLEEP || opp.hasAbility(Abilities.COMATOSE)) && !opp.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { if (!simulated) { - opp.damageAndUpdate(Math.floor(Math.max(1, opp.getMaxHp() / 8)), HitResult.OTHER); + opp.damageAndUpdate(Utils.toDmgValue(opp.getMaxHp() / 8), HitResult.OTHER); pokemon.scene.queueMessage(i18next.t("abilityTriggers:badDreams", {pokemonName: getPokemonNameWithAffix(opp)})); } hadEffect = true; @@ -3604,7 +3604,7 @@ export class ReduceBurnDamageAbAttr extends AbAttr { * @returns `true` */ apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { - (args[0] as Utils.NumberHolder).value = Math.max(Math.floor((args[0] as Utils.NumberHolder).value * this.multiplier), 1); + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue((args[0] as Utils.NumberHolder).value * this.multiplier); return true; } @@ -3649,7 +3649,7 @@ export class HealFromBerryUseAbAttr extends AbAttr { new PokemonHealPhase( pokemon.scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() * this.healPercent), 1), + Utils.toDmgValue(pokemon.getMaxHp() * this.healPercent), i18next.t("abilityTriggers:healFromBerryUse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName }), true ) @@ -3840,8 +3840,8 @@ export class PostFaintContactDamageAbAttr extends PostFaintAbAttr { return false; } if (!simulated) { - attacker.damageAndUpdate(Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); - attacker.turnData.damageTaken += Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)); + attacker.damageAndUpdate(Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); + attacker.turnData.damageTaken += Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio)); } return true; } @@ -3922,7 +3922,7 @@ export class ReduceStatusEffectDurationAbAttr extends AbAttr { apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { if (args[0] === this.statusEffect) { - (args[1] as Utils.IntegerHolder).value = Math.floor((args[1] as Utils.IntegerHolder).value / 2); + (args[1] as Utils.IntegerHolder).value = Utils.toDmgValue((args[1] as Utils.IntegerHolder).value / 2); return true; } @@ -5211,7 +5211,7 @@ export function initAbilities() { .conditionalAttr(pokemon => pokemon.formIndex === 0, PostSummonAddBattlerTagAbAttr, BattlerTagType.DISGUISE, 0, false) .attr(FormBlockDamageAbAttr, (target, user, move) => !!target.getTag(BattlerTagType.DISGUISE) && target.getAttackTypeEffectiveness(move.type, user) > 0, 0, BattlerTagType.DISGUISE, (pokemon, abilityName) => i18next.t("abilityTriggers:disguiseAvoidedDamage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName: abilityName }), - (pokemon) => Math.floor(pokemon.getMaxHp() / 8)) + (pokemon) => Utils.toDmgValue(pokemon.getMaxHp() / 8)) .attr(PostBattleInitFormChangeAbAttr, () => 0) .bypassFaint() .ignorable(), diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 026003d0b61..a60ea5c2981 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -427,7 +427,7 @@ class WishTag extends ArenaTag { if (user) { this.battlerIndex = user.getBattlerIndex(); this.triggerMessage = i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(user) }); - this.healHp = Math.max(Math.floor(user.getMaxHp() / 2), 1); + this.healHp = Utils.toDmgValue(user.getMaxHp() / 2); } else { console.warn("Failed to get source for WishTag onAdd"); } @@ -585,7 +585,7 @@ class SpikesTag extends ArenaTrapTag { if (!cancelled.value) { const damageHpRatio = 1 / (10 - 2 * this.layers); - const damage = Math.ceil(pokemon.getMaxHp() * damageHpRatio); + const damage = Utils.toDmgValue(pokemon.getMaxHp() * damageHpRatio); pokemon.scene.queueMessage(i18next.t("arenaTag:spikesActivateTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); pokemon.damageAndUpdate(damage, HitResult.OTHER); @@ -745,7 +745,7 @@ class StealthRockTag extends ArenaTrapTag { const damageHpRatio = this.getDamageHpRatio(pokemon); if (damageHpRatio) { - const damage = Math.ceil(pokemon.getMaxHp() * damageHpRatio); + const damage = Utils.toDmgValue(pokemon.getMaxHp() * damageHpRatio); pokemon.scene.queueMessage(i18next.t("arenaTag:stealthRockActivateTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); pokemon.damageAndUpdate(damage, HitResult.OTHER); if (pokemon.turnData) { diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index ede8d029327..8c05d296e76 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -347,7 +347,7 @@ export class ConfusedTag extends BattlerTag { if (pokemon.randSeedInt(3) === 0) { const atk = pokemon.getBattleStat(Stat.ATK); const def = pokemon.getBattleStat(Stat.DEF); - const damage = Math.ceil(((((2 * pokemon.level / 5 + 2) * 40 * atk / def) / 50) + 2) * (pokemon.randSeedInt(15, 85) / 100)); + const damage = Utils.toDmgValue(((((2 * pokemon.level / 5 + 2) * 40 * atk / def) / 50) + 2) * (pokemon.randSeedInt(15, 85) / 100)); pokemon.scene.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself")); pokemon.damageAndUpdate(damage); pokemon.battleData.hitCount++; @@ -524,7 +524,7 @@ export class SeedTag extends BattlerTag { if (!cancelled.value) { pokemon.scene.unshiftPhase(new CommonAnimPhase(pokemon.scene, source.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.LEECH_SEED)); - const damage = pokemon.damageAndUpdate(Math.max(Math.floor(pokemon.getMaxHp() / 8), 1)); + const damage = pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / 8)); const reverseDrain = pokemon.hasAbilityWithAttr(ReverseDrainAbAttr, false); pokemon.scene.unshiftPhase(new PokemonHealPhase(pokemon.scene, source.getBattlerIndex(), !reverseDrain ? damage : damage * -1, @@ -570,7 +570,7 @@ export class NightmareTag extends BattlerTag { applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); if (!cancelled.value) { - pokemon.damageAndUpdate(Math.ceil(pokemon.getMaxHp() / 4)); + pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / 4)); } } @@ -714,7 +714,7 @@ export class IngrainTag extends TrappedTag { new PokemonHealPhase( pokemon.scene, pokemon.getBattlerIndex(), - Math.floor(pokemon.getMaxHp() / 16), + Utils.toDmgValue(pokemon.getMaxHp() / 16), i18next.t("battlerTags:ingrainLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), true ) @@ -777,7 +777,7 @@ export class AquaRingTag extends BattlerTag { new PokemonHealPhase( pokemon.scene, pokemon.getBattlerIndex(), - Math.floor(pokemon.getMaxHp() / 16), + Utils.toDmgValue(pokemon.getMaxHp() / 16), i18next.t("battlerTags:aquaRingLapse", { moveName: this.getMoveName(), pokemonName: getPokemonNameWithAffix(pokemon) @@ -883,7 +883,7 @@ export abstract class DamagingTrapTag extends TrappedTag { applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); if (!cancelled.value) { - pokemon.damageAndUpdate(Math.ceil(pokemon.getMaxHp() / 8)); + pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / 8)); } } @@ -1067,7 +1067,7 @@ export class ContactDamageProtectedTag extends ProtectedTag { if (effectPhase instanceof MoveEffectPhase && effectPhase.move.getMove().hasFlag(MoveFlags.MAKES_CONTACT)) { const attacker = effectPhase.getPokemon(); if (!attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { - attacker.damageAndUpdate(Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); + attacker.damageAndUpdate(Utils.toDmgValue(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); } } } @@ -1541,7 +1541,7 @@ export class SaltCuredTag extends BattlerTag { if (!cancelled.value) { const pokemonSteelOrWater = pokemon.isOfType(Type.STEEL) || pokemon.isOfType(Type.WATER); - pokemon.damageAndUpdate(Math.max(Math.floor(pokemonSteelOrWater ? pokemon.getMaxHp() / 4 : pokemon.getMaxHp() / 8), 1)); + pokemon.damageAndUpdate(Utils.toDmgValue(pokemonSteelOrWater ? pokemon.getMaxHp() / 4 : pokemon.getMaxHp() / 8)); pokemon.scene.queueMessage( i18next.t("battlerTags:saltCuredLapse", { @@ -1587,7 +1587,7 @@ export class CursedTag extends BattlerTag { applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); if (!cancelled.value) { - pokemon.damageAndUpdate(Math.max(Math.floor(pokemon.getMaxHp() / 4), 1)); + pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / 4)); pokemon.scene.queueMessage(i18next.t("battlerTags:cursedLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); } } diff --git a/src/data/berry.ts b/src/data/berry.ts index ff82a683e5a..d0c9c311e16 100644 --- a/src/data/berry.ts +++ b/src/data/berry.ts @@ -70,7 +70,7 @@ export function getBerryEffectFunc(berryType: BerryType): BerryEffectFunc { if (pokemon.battleData) { pokemon.battleData.berriesEaten.push(berryType); } - const hpHealed = new Utils.NumberHolder(Math.floor(pokemon.getMaxHp() / 4)); + const hpHealed = new Utils.NumberHolder(Utils.toDmgValue(pokemon.getMaxHp() / 4)); applyAbAttrs(DoubleBerryEffectAbAttr, pokemon, null, false, hpHealed); pokemon.scene.unshiftPhase(new PokemonHealPhase(pokemon.scene, pokemon.getBattlerIndex(), hpHealed.value, i18next.t("battle:hpHealBerry", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), berryName: getBerryName(berryType) }), true)); diff --git a/src/data/move.ts b/src/data/move.ts index 682ab0739d5..b1b82009f3e 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1162,7 +1162,7 @@ export class TargetHalfHpDamageAttr extends FixedDamageAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - (args[0] as Utils.IntegerHolder).value = Math.max(Math.floor(target.hp / 2), 1); + (args[0] as Utils.IntegerHolder).value = Utils.toDmgValue(target.hp / 2); return true; } @@ -1208,7 +1208,7 @@ export class CounterDamageAttr extends FixedDamageAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const damage = user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).reduce((total: integer, ar: AttackMoveResult) => total + ar.damage, 0); - (args[0] as Utils.IntegerHolder).value = Math.floor(Math.max(damage * this.multiplier, 1)); + (args[0] as Utils.IntegerHolder).value = Utils.toDmgValue(damage * this.multiplier); return true; } @@ -1234,7 +1234,7 @@ export class RandomLevelDamageAttr extends FixedDamageAttr { } getDamage(user: Pokemon, target: Pokemon, move: Move): number { - return Math.max(Math.floor(user.level * (user.randSeedIntRange(50, 150) * 0.01)), 1); + return Utils.toDmgValue(user.level * (user.randSeedIntRange(50, 150) * 0.01)); } } @@ -1293,8 +1293,9 @@ export class RecoilAttr extends MoveEffectAttr { return false; } - const recoilDamage = Math.max(Math.floor((!this.useHp ? user.turnData.damageDealt : user.getMaxHp()) * this.damageRatio), - user.turnData.damageDealt ? 1 : 0); + const damageValue = (!this.useHp ? user.turnData.damageDealt : user.getMaxHp()) * this.damageRatio; + const minValue = user.turnData.damageDealt ? 1 : 0; + const recoilDamage = Utils.toDmgValue(damageValue, minValue); if (!recoilDamage) { return false; } @@ -1415,7 +1416,7 @@ export class HalfSacrificialAttr extends MoveEffectAttr { // Check to see if the Pokemon has an ability that blocks non-direct damage applyAbAttrs(BlockNonDirectDamageAbAttr, user, cancelled); if (!cancelled.value) { - user.damageAndUpdate(Math.ceil(user.getMaxHp()/2), HitResult.OTHER, false, true, true); + user.damageAndUpdate(Utils.toDmgValue(user.getMaxHp()/2), HitResult.OTHER, false, true, true); user.scene.queueMessage(i18next.t("moveTriggers:cutHpPowerUpMove", {pokemonName: getPokemonNameWithAffix(user)})); // Queue recoil message } return true; @@ -1466,7 +1467,7 @@ export class HealAttr extends MoveEffectAttr { */ addHealPhase(target: Pokemon, healRatio: number) { target.scene.unshiftPhase(new PokemonHealPhase(target.scene, target.getBattlerIndex(), - Math.max(Math.floor(target.getMaxHp() * healRatio), 1), i18next.t("moveTriggers:healHp", {pokemonName: getPokemonNameWithAffix(target)}), true, !this.showAnim)); + Utils.toDmgValue(target.getMaxHp() * healRatio), i18next.t("moveTriggers:healHp", {pokemonName: getPokemonNameWithAffix(target)}), true, !this.showAnim)); } getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): integer { @@ -1750,7 +1751,7 @@ export class HitHealAttr extends MoveEffectAttr { message = i18next.t("battle:drainMessage", {pokemonName: getPokemonNameWithAffix(target)}); } else { // Default healing formula used by draining moves like Absorb, Draining Kiss, Bitter Blade, etc. - healAmount = Math.max(Math.floor(user.turnData.currDamageDealt * this.healRatio), 1); + healAmount = Utils.toDmgValue(user.turnData.currDamageDealt * this.healRatio); message = i18next.t("battle:regainHealth", {pokemonName: getPokemonNameWithAffix(user)}); } if (reverseDrain) { @@ -2710,7 +2711,7 @@ export class CutHpStatBoostAttr extends StatChangeAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): Promise { return new Promise(resolve => { - user.damageAndUpdate(Math.floor(user.getMaxHp() / this.cutRatio), HitResult.OTHER, false, true); + user.damageAndUpdate(Utils.toDmgValue(user.getMaxHp() / this.cutRatio), HitResult.OTHER, false, true); user.updateInfo().then(() => { const ret = super.apply(user, target, move, args); if (this.messageCallback) { @@ -3190,7 +3191,7 @@ export class CompareWeightPowerAttr extends VariablePowerAttr { export class HpPowerAttr extends VariablePowerAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - (args[0] as Utils.NumberHolder).value = Math.max(Math.floor(150 * user.getHpRatio()), 1); + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue(150 * user.getHpRatio()); return true; } @@ -3218,7 +3219,7 @@ export class OpponentHighHpPowerAttr extends VariablePowerAttr { * @returns true */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - (args[0] as Utils.NumberHolder).value = Math.max(Math.floor(this.maxBasePower * target.getHpRatio()), 1); + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue(this.maxBasePower * target.getHpRatio()); return true; } @@ -3412,7 +3413,7 @@ export class PresentPowerAttr extends VariablePowerAttr { // If this move is multi-hit, disable all other hits user.stopMultiHit(); target.scene.unshiftPhase(new PokemonHealPhase(target.scene, target.getBattlerIndex(), - Math.max(Math.floor(target.getMaxHp() / 4), 1), i18next.t("moveTriggers:regainedHealth", {pokemonName: getPokemonNameWithAffix(target)}), true)); + Utils.toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", {pokemonName: getPokemonNameWithAffix(target)}), true)); } return true; @@ -4232,9 +4233,9 @@ const crashDamageFunc = (user: Pokemon, move: Move) => { return false; } - user.damageAndUpdate(Math.floor(user.getMaxHp() / 2), HitResult.OTHER, false, true); + user.damageAndUpdate(Utils.toDmgValue(user.getMaxHp() / 2), HitResult.OTHER, false, true); user.scene.queueMessage(i18next.t("moveTriggers:keptGoingAndCrashed", {pokemonName: getPokemonNameWithAffix(user)})); - user.turnData.damageTaken += Math.floor(user.getMaxHp() / 2); + user.turnData.damageTaken += Utils.toDmgValue(user.getMaxHp() / 2); return true; }; @@ -4944,7 +4945,7 @@ export class RevivalBlessingAttr extends MoveEffectAttr { const pokemon = faintedPokemon[user.randSeedInt(faintedPokemon.length)]; const slotIndex = user.scene.getEnemyParty().findIndex(p => pokemon.id === p.id); pokemon.resetStatus(); - pokemon.heal(Math.min(Math.max(Math.ceil(Math.floor(0.5 * pokemon.getMaxHp())), 1), pokemon.getMaxHp())); + pokemon.heal(Math.min(Utils.toDmgValue(0.5 * pokemon.getMaxHp()), pokemon.getMaxHp())); user.scene.queueMessage(`${getPokemonNameWithAffix(pokemon)} was revived!`,0,true); if (user.scene.currentBattle.double && user.scene.getEnemyParty().length > 1) { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 999ebb3cb21..030297a2126 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2092,7 +2092,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (!isTypeImmune) { const levelMultiplier = (2 * source.level / 5 + 2); const randomMultiplier = ((this.scene.randBattleSeedInt(16) + 85) / 100); - damage.value = Math.ceil((((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2) + damage.value = Utils.toDmgValue((((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2) * stabMultiplier.value * typeMultiplier.value * arenaAttackTypeMultiplier.value @@ -2108,7 +2108,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { const burnDamageReductionCancelled = new Utils.BooleanHolder(false); applyAbAttrs(BypassBurnDamageReductionAbAttr, source, burnDamageReductionCancelled, false); if (!burnDamageReductionCancelled.value) { - damage.value = Math.floor(damage.value / 2); + damage.value = Utils.toDmgValue(damage.value / 2); } } } @@ -2129,7 +2129,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } if (this.scene.arena.terrain?.terrainType === TerrainType.MISTY && this.isGrounded() && move.type === Type.DRAGON) { - damage.value = Math.floor(damage.value / 2); + damage.value = Utils.toDmgValue(damage.value / 2); } const fixedDamage = new Utils.IntegerHolder(0); @@ -3455,7 +3455,7 @@ export class PlayerPokemon extends Pokemon { pokemon.resetTurnData(); pokemon.resetStatus(); - pokemon.heal(Math.min(Math.max(Math.ceil(Math.floor(0.5 * pokemon.getMaxHp())), 1), pokemon.getMaxHp())); + pokemon.heal(Math.min(Utils.toDmgValue(0.5 * pokemon.getMaxHp()), pokemon.getMaxHp())); this.scene.queueMessage(`${pokemon.name} was revived!`,0,true); if (this.scene.currentBattle.double && this.scene.getParty().length > 1) { @@ -4382,7 +4382,7 @@ export class PokemonMove { } getMovePp(): integer { - return this.getMove().pp + this.ppUp * Math.max(Math.floor(this.getMove().pp / 5), 1); + return this.getMove().pp + this.ppUp * Utils.toDmgValue(this.getMove().pp / 5); } getPpRatio(): number { diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 8a6598f5849..99f4540f493 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1160,7 +1160,7 @@ export class TurnHealModifier extends PokemonHeldItemModifier { if (!pokemon.isFullHp()) { const scene = pokemon.scene; scene.unshiftPhase(new PokemonHealPhase(scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / 16) * this.stackCount, 1), i18next.t("modifier:turnHealApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), true)); + Utils.toDmgValue(pokemon.getMaxHp() / 16) * this.stackCount, i18next.t("modifier:turnHealApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), true)); return true; } @@ -1251,7 +1251,7 @@ export class HitHealModifier extends PokemonHeldItemModifier { if (pokemon.turnData.damageDealt && !pokemon.isFullHp()) { const scene = pokemon.scene; scene.unshiftPhase(new PokemonHealPhase(scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.turnData.damageDealt / 8) * this.stackCount, 1), i18next.t("modifier:hitHealApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), true)); + Utils.toDmgValue(pokemon.turnData.damageDealt / 8) * this.stackCount, i18next.t("modifier:hitHealApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), true)); } return true; @@ -1386,7 +1386,7 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier { const pokemon = args[0] as Pokemon; pokemon.scene.unshiftPhase(new PokemonHealPhase(pokemon.scene, pokemon.getBattlerIndex(), - Math.max(Math.floor(pokemon.getMaxHp() / 2), 1), i18next.t("modifier:pokemonInstantReviveApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), false, false, true)); + Utils.toDmgValue(pokemon.getMaxHp() / 2), i18next.t("modifier:pokemonInstantReviveApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), false, false, true)); pokemon.resetStatus(true, false, true); return true; diff --git a/src/test/abilities/disguise.test.ts b/src/test/abilities/disguise.test.ts index 969375c397e..a22c4cb55d5 100644 --- a/src/test/abilities/disguise.test.ts +++ b/src/test/abilities/disguise.test.ts @@ -6,6 +6,7 @@ import { Species } from "#enums/species"; import { StatusEffect } from "#app/data/status-effect.js"; import { BattleStat } from "#app/data/battle-stat.js"; import { SPLASH_ONLY } from "../utils/testUtils"; +import { toDmgValue } from "#app/utils"; import { Mode } from "#app/ui/ui.js"; import { MoveEffectPhase } from "#app/phases/move-effect-phase.js"; import { MoveEndPhase } from "#app/phases/move-end-phase.js"; @@ -47,7 +48,7 @@ describe("Abilities - Disguise", () => { const mimikyu = game.scene.getEnemyPokemon()!; const maxHp = mimikyu.getMaxHp(); - const disguiseDamage = Math.floor(maxHp / 8); + const disguiseDamage = toDmgValue(maxHp / 8); expect(mimikyu.formIndex).toBe(disguisedForm); @@ -80,7 +81,7 @@ describe("Abilities - Disguise", () => { const mimikyu = game.scene.getEnemyPokemon()!; const maxHp = mimikyu.getMaxHp(); - const disguiseDamage = Math.floor(maxHp / 8); + const disguiseDamage = toDmgValue(maxHp / 8); expect(mimikyu.formIndex).toBe(disguisedForm); @@ -121,7 +122,7 @@ describe("Abilities - Disguise", () => { const mimikyu = game.scene.getPlayerPokemon()!; const maxHp = mimikyu.getMaxHp(); - const disguiseDamage = Math.floor(maxHp / 8); + const disguiseDamage = toDmgValue(maxHp / 8); game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); diff --git a/src/test/abilities/heatproof.test.ts b/src/test/abilities/heatproof.test.ts index 64a45c5023f..ee6c0bb6ec9 100644 --- a/src/test/abilities/heatproof.test.ts +++ b/src/test/abilities/heatproof.test.ts @@ -8,6 +8,7 @@ import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { SPLASH_ONLY } from "#test/utils/testUtils"; import { StatusEffect } from "#app/enums/status-effect.js"; +import { toDmgValue } from "#app/utils"; describe("Abilities - Heatproof", () => { let phaserGame: Phaser.Game; @@ -72,6 +73,6 @@ describe("Abilities - Heatproof", () => { await game.toNextTurn(); // Normal burn damage is /16 - expect(enemy.hp).toBe(enemy.getMaxHp() - Math.floor(enemy.getMaxHp() / 32)); + expect(enemy.hp).toBe(enemy.getMaxHp() - toDmgValue(enemy.getMaxHp() / 32)); }); }); diff --git a/src/test/abilities/parental_bond.test.ts b/src/test/abilities/parental_bond.test.ts index ef0ad7785d2..d14d5871ef7 100644 --- a/src/test/abilities/parental_bond.test.ts +++ b/src/test/abilities/parental_bond.test.ts @@ -16,6 +16,7 @@ import { DamagePhase } from "#app/phases/damage-phase.js"; import { MoveEffectPhase } from "#app/phases/move-effect-phase.js"; import { MoveEndPhase } from "#app/phases/move-end-phase.js"; import { TurnEndPhase } from "#app/phases/turn-end-phase.js"; +import { toDmgValue } from "#app/utils"; const TIMEOUT = 20 * 1000; @@ -73,7 +74,7 @@ describe("Abilities - Parental Bond", () => { const secondStrikeDamage = enemyStartingHp - enemyPokemon.hp; expect(leadPokemon.turnData.hitCount).toBe(2); - expect(secondStrikeDamage).toBe(Math.ceil(0.25 * firstStrikeDamage)); + expect(secondStrikeDamage).toBe(toDmgValue(0.25 * firstStrikeDamage)); }, TIMEOUT ); @@ -303,7 +304,7 @@ describe("Abilities - Parental Bond", () => { // This test will time out if the user faints await game.phaseInterceptor.to(BerryPhase, false); - expect(leadPokemon.hp).toBe(Math.floor(leadPokemon.getMaxHp()/2)); + expect(leadPokemon.hp).toBe(toDmgValue(leadPokemon.getMaxHp()/2)); }, TIMEOUT ); diff --git a/src/test/battle/damage_calculation.test.ts b/src/test/battle/damage_calculation.test.ts new file mode 100644 index 00000000000..9b13a266d33 --- /dev/null +++ b/src/test/battle/damage_calculation.test.ts @@ -0,0 +1,71 @@ +import { DamagePhase } from "#app/phases/damage-phase.js"; +import GameManager from "#test/utils/gameManager"; +import { getMovePosition } from "#test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { SPLASH_ONLY } from "#test/utils/testUtils"; +import { toDmgValue } from "#app/utils"; + +describe("Round Down and Minimun 1 test in Damage Calculation", () => { + 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.battleType("single"); + game.override.startingLevel(10); + }); + + it("When the user fails to use Jump Kick with Wonder Guard ability, the damage should be 1.", async () => { + game.override.enemySpecies(Species.GASTLY); + game.override.enemyMoveset(SPLASH_ONLY); + game.override.starterSpecies(Species.SHEDINJA); + game.override.moveset([Moves.JUMP_KICK]); + game.override.ability(Abilities.WONDER_GUARD); + + await game.startBattle(); + + const shedinja = game.scene.getPlayerPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.JUMP_KICK)); + + await game.phaseInterceptor.to(DamagePhase); + + expect(shedinja.hp).toBe(shedinja.getMaxHp() - 1); + }); + + + it("Charizard with odd HP survives Stealth Rock damage twice", async () => { + game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0); + game.override.seed("Charizard Stealth Rock test"); + game.override.enemySpecies(Species.CHARIZARD); + game.override.enemyAbility(Abilities.BLAZE); + game.override.starterSpecies(Species.PIKACHU); + game.override.enemyLevel(100); + + await game.startBattle(); + + const charizard = game.scene.getEnemyPokemon()!; + + const maxHp = charizard.getMaxHp(); + const damage_prediction = toDmgValue(charizard.getMaxHp() / 2); + const currentHp = charizard.hp; + const expectedHP = maxHp - damage_prediction; + + expect(currentHp).toBe(expectedHP); + }); +}); diff --git a/src/test/moves/belly_drum.test.ts b/src/test/moves/belly_drum.test.ts index e579a4587ad..229314c96e6 100644 --- a/src/test/moves/belly_drum.test.ts +++ b/src/test/moves/belly_drum.test.ts @@ -6,6 +6,7 @@ import { getMovePosition } from "#test/utils/gameManagerUtils"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { BattleStat } from "#app/data/battle-stat"; +import { toDmgValue } from "#app/utils"; const TIMEOUT = 20 * 1000; // RATIO : HP Cost of Move @@ -44,7 +45,7 @@ describe("Moves - BELLY DRUM", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); game.doAttack(getMovePosition(game.scene, 0, Moves.BELLY_DRUM)); await game.phaseInterceptor.to(TurnEndPhase); @@ -59,7 +60,7 @@ describe("Moves - BELLY DRUM", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); // Here - BattleStat.ATK -> -3 and BattleStat.SPATK -> 6 leadPokemon.summonData.battleStats[BattleStat.ATK] = -3; @@ -95,7 +96,7 @@ describe("Moves - BELLY DRUM", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); leadPokemon.hp = hpLost - PREDAMAGE; game.doAttack(getMovePosition(game.scene, 0, Moves.BELLY_DRUM)); diff --git a/src/test/moves/clangorous_soul.test.ts b/src/test/moves/clangorous_soul.test.ts index 5b2e8b6e06d..afab4c2e9be 100644 --- a/src/test/moves/clangorous_soul.test.ts +++ b/src/test/moves/clangorous_soul.test.ts @@ -7,6 +7,7 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { BattleStat } from "#app/data/battle-stat"; import { SPLASH_ONLY } from "#test/utils/testUtils"; +import { toDmgValue } from "#app/utils"; const TIMEOUT = 20 * 1000; /** HP Cost of Move */ @@ -45,7 +46,7 @@ describe("Moves - CLANGOROUS_SOUL", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); game.doAttack(getMovePosition(game.scene, 0, Moves.CLANGOROUS_SOUL)); await game.phaseInterceptor.to(TurnEndPhase); @@ -64,7 +65,7 @@ describe("Moves - CLANGOROUS_SOUL", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); //Here - BattleStat.SPD -> 0 and BattleStat.SPDEF -> 4 leadPokemon.summonData.battleStats[BattleStat.ATK] = 6; @@ -113,7 +114,7 @@ describe("Moves - CLANGOROUS_SOUL", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); leadPokemon.hp = hpLost - PREDAMAGE; game.doAttack(getMovePosition(game.scene, 0, Moves.CLANGOROUS_SOUL)); diff --git a/src/test/moves/fillet_away.test.ts b/src/test/moves/fillet_away.test.ts index fcad704ef29..fc87d600eb5 100644 --- a/src/test/moves/fillet_away.test.ts +++ b/src/test/moves/fillet_away.test.ts @@ -7,6 +7,7 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { BattleStat } from "#app/data/battle-stat"; import { SPLASH_ONLY } from "#test/utils/testUtils"; +import { toDmgValue } from "#app/utils"; const TIMEOUT = 20 * 1000; /** HP Cost of Move */ @@ -45,7 +46,7 @@ describe("Moves - FILLET AWAY", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); game.doAttack(getMovePosition(game.scene, 0, Moves.FILLET_AWAY)); await game.phaseInterceptor.to(TurnEndPhase); @@ -62,7 +63,7 @@ describe("Moves - FILLET AWAY", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); //Here - BattleStat.SPD -> 0 and BattleStat.SPATK -> 3 leadPokemon.summonData.battleStats[BattleStat.ATK] = 6; @@ -103,7 +104,7 @@ describe("Moves - FILLET AWAY", () => { await game.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - const hpLost = Math.floor(leadPokemon.getMaxHp() / RATIO); + const hpLost = toDmgValue(leadPokemon.getMaxHp() / RATIO); leadPokemon.hp = hpLost - PREDAMAGE; game.doAttack(getMovePosition(game.scene, 0, Moves.FILLET_AWAY)); diff --git a/src/test/moves/tackle.test.ts b/src/test/moves/tackle.test.ts index f442645baa9..3da8bc6f978 100644 --- a/src/test/moves/tackle.test.ts +++ b/src/test/moves/tackle.test.ts @@ -78,6 +78,6 @@ describe("Moves - Tackle", () => { await game.phaseInterceptor.runFrom(EnemyCommandPhase).to(TurnEndPhase); const hpLost = hpOpponent - game.scene.currentBattle.enemyParty[0].hp; expect(hpLost).toBeGreaterThan(0); - expect(hpLost).toBe(4); + expect(hpLost).toBeLessThan(4); }, 20000); }); diff --git a/src/utils.ts b/src/utils.ts index c51ac2b5b0b..a9bbc93d684 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -560,3 +560,17 @@ export function capitalizeString(str: string, sep: string, lowerFirstChar: boole export function isNullOrUndefined(object: any): boolean { return null === object || undefined === object; } + +/** + * This function is used in the context of a Pokémon battle game to calculate the actual integer damage value from a float result. + * Many damage calculation formulas involve various parameters and result in float values. + * The actual damage applied to a Pokémon's HP must be an integer. + * This function helps in ensuring that by flooring the float value and enforcing a minimum damage value. + * + * @param value - The float value to convert. + * @param minValue - The minimum integer value to return. Defaults to 1. + * @returns The converted value as an integer. + */ +export function toDmgValue(value: number, minValue: number = 1) { + return Math.max(Math.floor(value), minValue); +}