diff --git a/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java b/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java index ae8ca7d..de3c8cc 100644 --- a/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java +++ b/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java @@ -1158,12 +1158,41 @@ else if (isDarkBow) ); } } - else - { - koChanceCurrent = (hpBeforeCurrent != null) - ? PvpPerformanceTrackerUtils.calculateKoChance(entry.getAccuracy(), entry.getMinHit(), entry.getMaxHit(), hpBeforeCurrent) - : null; - } + else + { + if (hpBeforeCurrent != null) + { + switch (entry.getDamageRollDistribution()) + { + case CLAMPED_TO_MINIMUM: + koChanceCurrent = PvpPerformanceTrackerUtils.calculateClampedKoChance( + entry.getAccuracy(), + entry.getMinHit(), + entry.getMaxHit(), + hpBeforeCurrent + ); + break; + case MULTI_HIT_CLAMPED_TO_MINIMUM: + koChanceCurrent = PvpPerformanceTrackerUtils.calculateMultiHitClampedKoChance( + entry.getAccuracy(), + entry.getMinHit(), + entry.getMaxHit(), + entry.getDamageRollHitCount(), + hpBeforeCurrent + ); + break; + case STANDARD: + default: + koChanceCurrent = PvpPerformanceTrackerUtils.calculateKoChance( + entry.getAccuracy(), + entry.getMinHit(), + entry.getMaxHit(), + hpBeforeCurrent + ); + break; + } + } + } if (koChanceCurrent != null && koChanceCurrent <= 0.0) { diff --git a/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java b/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java index e164d46..d8258c1 100644 --- a/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java +++ b/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java @@ -36,6 +36,7 @@ import static matsyir.pvpperformancetracker.PvpPerformanceTrackerPlugin.PLUGIN; import static matsyir.pvpperformancetracker.utils.NumberFormatter.nf1; import static matsyir.pvpperformancetracker.utils.PvpPerformanceTrackerUtils.fixItemId; +import static matsyir.pvpperformancetracker.utils.PvpPerformanceTrackerUtils.getExpectedHits; import matsyir.pvpperformancetracker.models.EquipmentData; import matsyir.pvpperformancetracker.models.EquipmentData.VoidStyle; import matsyir.pvpperformancetracker.models.CombatLevels; @@ -58,6 +59,13 @@ @Slf4j public class PvpDamageCalc { + public enum DamageRollDistribution + { + STANDARD, + CLAMPED_TO_MINIMUM, + MULTI_HIT_CLAMPED_TO_MINIMUM + } + private static final int STAB_ATTACK = 0, SLASH_ATTACK = 1, CRUSH_ATTACK = 2, MAGIC_ATTACK = 3, RANGE_ATTACK = 4, STAB_DEF = 5, SLASH_DEF = 6, CRUSH_DEF = 7, MAGIC_DEF = 8; public static final int RANGE_DEF = 9; @@ -136,7 +144,12 @@ public class PvpDamageCalc private int minHit = 0; @Getter private int maxHit = 0; + @Getter + private DamageRollDistribution damageRollDistribution = DamageRollDistribution.STANDARD; + @Getter + private int damageRollHitCount = 1; private double rangedExpectedProcDamage = 0; + private int seekingArrowMinHit = 0; private CombatLevels attackerLevels; private CombatLevels defenderLevels; @@ -163,7 +176,10 @@ public void updateDamageStats(Player attacker, Player defender, boolean success, accuracy = 0; minHit = 0; maxHit = 0; + damageRollDistribution = DamageRollDistribution.STANDARD; + damageRollHitCount = 1; rangedExpectedProcDamage = 0; + seekingArrowMinHit = 0; int[] attackerItems = attacker.getPlayerComposition().getEquipmentIds(); int[] defenderItems = defender.getPlayerComposition().getEquipmentIds(); @@ -187,7 +203,7 @@ public void updateDamageStats(Player attacker, Player defender, boolean success, } else if (attackStyle == AttackStyle.RANGED) { - getRangedMaxHit(playerStats[RANGE_STRENGTH], isSpecial, weapon, voidStyle, true, attackerItems); + getRangedMaxHit(playerStats[RANGE_STRENGTH], isSpecial, weapon, voidStyle, true, attackerItems, animationData); getRangeAccuracy(playerStats[RANGE_ATTACK], opponentStats[RANGE_DEF], isSpecial, weapon, voidStyle, true, attackerItems); } // this should always be true at this point, but just in case. unknown animation styles won't @@ -224,7 +240,10 @@ public void updateDamageStats(FightLogEntry atkLog, FightLogEntry defenderLog) accuracy = 0; minHit = 0; maxHit = 0; + damageRollDistribution = DamageRollDistribution.STANDARD; + damageRollHitCount = 1; rangedExpectedProcDamage = 0; + seekingArrowMinHit = 0; EquipmentData weapon = EquipmentData.fromId(fixItemId(attackerItems[KitType.WEAPON.getIndex()])); @@ -245,7 +264,7 @@ public void updateDamageStats(FightLogEntry atkLog, FightLogEntry defenderLog) } else if (attackStyle == AttackStyle.RANGED) { - getRangedMaxHit(playerStats[RANGE_STRENGTH], isSpecial, weapon, voidStyle, successfulOffensive, attackerItems); + getRangedMaxHit(playerStats[RANGE_STRENGTH], isSpecial, weapon, voidStyle, successfulOffensive, attackerItems, animationData); getRangeAccuracy(playerStats[RANGE_ATTACK], opponentStats[RANGE_DEF], isSpecial, weapon, voidStyle, successfulOffensive, attackerItems); } // this should always be true at this point, but just in case. unknown animation styles won't @@ -329,6 +348,13 @@ private void getAverageHit(boolean success, EquipmentData weapon, boolean usingS averageSuccessfulHit = (double) total / maxHit; } + else if (seekingArrowMinHit > 0) + { + minHit = seekingArrowMinHit; + averageSuccessfulHit = damageRollDistribution == DamageRollDistribution.MULTI_HIT_CLAMPED_TO_MINIMUM ? + getAverageSuccessfulMultiHitWithMinimum(minHit, damageRollHitCount) : + getAverageSuccessfulHitWithMinimum(minHit); + } else if (usingSpec && claws) { // if first 1-2 claws miss, it's a 150% dmg multiplier because when the 3rd att hits, the last @@ -441,6 +467,34 @@ else if (usingSpec && voidwaker) } } + private double getAverageSuccessfulHitWithMinimum(int minimumHit) + { + minimumHit = Math.max(0, minimumHit); + maxHit = Math.max(maxHit, minimumHit); + return getAverageSuccessfulHitWithMinimum(minimumHit, maxHit); + } + + private double getAverageSuccessfulMultiHitWithMinimum(int minimumHit, int hitCount) + { + hitCount = Math.max(1, hitCount); + int perHitMinimum = Math.max(0, minimumHit / hitCount); + int perHitMaximum = Math.max(perHitMinimum, maxHit / hitCount); + maxHit = Math.max(maxHit, perHitMaximum * hitCount); + minHit = perHitMinimum * hitCount; + return hitCount * getAverageSuccessfulHitWithMinimum(perHitMinimum, perHitMaximum); + } + + private double getAverageSuccessfulHitWithMinimum(int minimumHit, int maximumHit) + { + int total = 0; + for (int i = 0; i <= maximumHit; i++) + { + total += Math.max(i, minimumHit); + } + + return (double) total / (maximumHit + 1); + } + private int calculateBurningClawTotalDamage(int D) { return (int)Math.floor(0.25 * D) + (int)Math.floor(0.25 * D) + (int)Math.floor(0.5 * D); @@ -493,7 +547,7 @@ private void getMeleeMaxHit(int meleeStrength, boolean usingSpec, EquipmentData maxHit = (int) (damageModifier * baseDamage); } - private void getRangedMaxHit(int rangeStrength, boolean usingSpec, EquipmentData weapon, VoidStyle voidStyle, boolean successfulOffensive, int[] attackerComposition) + private void getRangedMaxHit(int rangeStrength, boolean usingSpec, EquipmentData weapon, VoidStyle voidStyle, boolean successfulOffensive, int[] attackerComposition, AnimationData animationData) { RangeAmmoData weaponAmmo = EquipmentData.getWeaponAmmo(weapon, isLmsFight); EquipmentData head = EquipmentData.fromId(fixItemId(attackerComposition[KitType.HEAD.getIndex()])); @@ -502,12 +556,24 @@ private void getRangedMaxHit(int rangeStrength, boolean usingSpec, EquipmentData boolean ballista = weapon == EquipmentData.HEAVY_BALLISTA; boolean dbow = weapon == EquipmentData.DARK_BOW; + boolean magicShortbow = weapon == EquipmentData.MAGIC_SHORTBOW || weapon == EquipmentData.MAGIC_SHORTBOW_I; boolean dragonCbow = weapon == EquipmentData.DRAGON_CROSSBOW; boolean diamonds = ArrayUtils.contains(RangeAmmoData.DIAMOND_BOLTS, weaponAmmo); boolean opals = ArrayUtils.contains(RangeAmmoData.OPAL_BOLTS, weaponAmmo); boolean skipBoltProcEffects = dragonCbow && usingSpec; + boolean darkBowSpecial = dbow && usingSpec; + int expectedHits = Math.max(1, getExpectedHits(animationData)); + boolean seekingArrowAttack = !darkBowSpecial && RangeAmmoData.usesSeekingArrowUpgrade(weaponAmmo, isLmsFight); int ammoStrength = weaponAmmo == null ? 0 : weaponAmmo.getRangeStr(); + seekingArrowMinHit = seekingArrowAttack ? RangeAmmoData.getSeekingArrowMinimumHit(weaponAmmo, isLmsFight) * expectedHits : 0; + if (seekingArrowMinHit > 0) + { + damageRollHitCount = expectedHits; + damageRollDistribution = expectedHits > 1 ? + DamageRollDistribution.MULTI_HIT_CLAMPED_TO_MINIMUM : + DamageRollDistribution.CLAMPED_TO_MINIMUM; + } rangeStrength += ammoStrength; @@ -583,6 +649,10 @@ else if (!skipBoltProcEffects && opals) maxHit = cap; } } + else if (magicShortbow && usingSpec && expectedHits > 1) + { + maxHit *= expectedHits; + } } private void getMagicMaxHit(EquipmentData shield, int mageDamageBonus, AnimationData animationData, EquipmentData weapon, VoidStyle voidStyle, boolean successfulOffensive) @@ -713,6 +783,7 @@ private void getMeleeAccuracy(int[] playerStats, int[] opponentStats, AttackStyl private void getRangeAccuracy(int playerRangeAtt, int opponentRangeDef, boolean usingSpec, EquipmentData weapon, VoidStyle voidStyle, boolean successfulOffensive, int[] attackerComposition) { RangeAmmoData weaponAmmo = EquipmentData.getWeaponAmmo(weapon, this.isLmsFight); + playerRangeAtt += RangeAmmoData.getSeekingArrowAccuracyBonus(weaponAmmo, this.isLmsFight); boolean diamonds = ArrayUtils.contains(RangeAmmoData.DIAMOND_BOLTS, weaponAmmo); boolean opals = ArrayUtils.contains(RangeAmmoData.OPAL_BOLTS, weaponAmmo); diff --git a/src/main/java/matsyir/pvpperformancetracker/models/AnimationData.java b/src/main/java/matsyir/pvpperformancetracker/models/AnimationData.java index 074fa2f..3e9c8f5 100644 --- a/src/main/java/matsyir/pvpperformancetracker/models/AnimationData.java +++ b/src/main/java/matsyir/pvpperformancetracker/models/AnimationData.java @@ -110,7 +110,7 @@ public enum AnimationData // RANGED RANGED_SHORTBOW(426, AttackStyle.RANGED), // Confirmed same w/ 3 types of arrows, w/ maple, magic, & hunter's shortbow, scorching bow, craw's bow, dbow, dbow spec RANGED_RUNE_KNIFE_PVP(929, AttackStyle.RANGED), // 1 tick animation, has 1 tick delay between attacks. likely same for all knives. Same for morrigan's javelins, both spec & normal attack. - RANGED_MAGIC_SHORTBOW_SPEC(1074, AttackStyle.RANGED, true), + RANGED_MAGIC_SHORTBOW_SPEC(1074, AttackStyle.RANGED, true, 2), RANGED_CROSSBOW_PVP(4230, AttackStyle.RANGED), // Tested RCB & ACB w/ dragonstone bolts (e) & diamond bolts (e) RANGED_BLOWPIPE(5061, AttackStyle.RANGED), // tested in PvP with all styles. Has 1 tick delay between animations in pvp. RANGED_DARTS(6600, AttackStyle.RANGED), // tested w/ addy darts. Seems to be constant animation but sometimes stalls and doesn't animate diff --git a/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java b/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java index 323de40..f68afb1 100644 --- a/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java +++ b/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java @@ -102,6 +102,12 @@ public class FightLogEntry implements Comparable @SerializedName("l") // l for lowest hit private int minHit; @Expose + @SerializedName("dD") // damage distribution + private PvpDamageCalc.DamageRollDistribution damageRollDistribution = PvpDamageCalc.DamageRollDistribution.STANDARD; + @Expose + @SerializedName("dHC") // damage hit count + private int damageRollHitCount = 1; + @Expose @SerializedName("s") private boolean splash; // true if it was a magic attack and it splashed @Expose @@ -256,6 +262,8 @@ public FightLogEntry(Player attacker, Player defender, PvpDamageCalc pvpDamageCa this.accuracy = pvpDamageCalc.getAccuracy(); this.minHit = pvpDamageCalc.getMinHit(); this.maxHit = pvpDamageCalc.getMaxHit(); + this.damageRollDistribution = pvpDamageCalc.getDamageRollDistribution(); + this.damageRollHitCount = pvpDamageCalc.getDamageRollHitCount(); this.splash = animationData.attackStyle == AnimationData.AttackStyle.MAGIC && defender.getGraphic() == GraphicID.SPLASH; this.attackerLevels = levels; // CAN BE NULL this.attackerRingItemId = getLocalPlayerRingItemId(attacker); @@ -310,6 +318,8 @@ public FightLogEntry(FightLogEntry e, PvpDamageCalc pvpDamageCalc) this.accuracy = pvpDamageCalc.getAccuracy(); this.minHit = pvpDamageCalc.getMinHit(); this.maxHit = pvpDamageCalc.getMaxHit(); + this.damageRollDistribution = pvpDamageCalc.getDamageRollDistribution(); + this.damageRollHitCount = pvpDamageCalc.getDamageRollHitCount(); this.splash = e.splash; this.defenderElyProc = e.defenderElyProc; this.defenderSotdMeleeReductionProc = e.defenderSotdMeleeReductionProc; @@ -419,6 +429,16 @@ public boolean success() return animationData.attackStyle.getProtection() != defenderOverhead; } + public PvpDamageCalc.DamageRollDistribution getDamageRollDistribution() + { + return damageRollDistribution != null ? damageRollDistribution : PvpDamageCalc.DamageRollDistribution.STANDARD; + } + + public int getDamageRollHitCount() + { + return Math.max(1, damageRollHitCount); + } + public String toChatMessage() { Color darkRed = new Color(127, 0, 0); // same color as default clan chat color diff --git a/src/main/java/matsyir/pvpperformancetracker/models/RangeAmmoData.java b/src/main/java/matsyir/pvpperformancetracker/models/RangeAmmoData.java index eea5841..6441101 100644 --- a/src/main/java/matsyir/pvpperformancetracker/models/RangeAmmoData.java +++ b/src/main/java/matsyir/pvpperformancetracker/models/RangeAmmoData.java @@ -31,6 +31,9 @@ public interface RangeAmmoData { + int SEEKING_ARROW_ACCURACY_BONUS = 20; + int SEEKING_ARROW_MIN_HIT = 3; + RangeAmmoData[] DIAMOND_BOLTS = { BoltAmmo.DIAMOND_BOLTS_E, StrongBoltAmmo.DIAMOND_BOLTS_E, @@ -41,6 +44,11 @@ public interface RangeAmmoData StrongBoltAmmo.OPAL_DRAGON_BOLTS_E }; + RangeAmmoData[] SEEKING_ARROW_AMMO = { + OtherAmmo.AMETHYST_ARROWS, + OtherAmmo.DRAGON_ARROW + }; + static RangeAmmoData fromId(int itemId) { for (BoltAmmo ammo : BoltAmmo.values()) @@ -74,6 +82,21 @@ static RangeAmmoData fromId(int itemId) return null; } + static boolean usesSeekingArrowUpgrade(RangeAmmoData ammo, boolean isLmsFight) + { + return !isLmsFight && Arrays.stream(SEEKING_ARROW_AMMO).anyMatch(seekingArrowAmmo -> seekingArrowAmmo == ammo); + } + + static int getSeekingArrowAccuracyBonus(RangeAmmoData ammo, boolean isLmsFight) + { + return usesSeekingArrowUpgrade(ammo, isLmsFight) ? SEEKING_ARROW_ACCURACY_BONUS : 0; + } + + static int getSeekingArrowMinimumHit(RangeAmmoData ammo, boolean isLmsFight) + { + return usesSeekingArrowUpgrade(ammo, isLmsFight) ? SEEKING_ARROW_MIN_HIT : 0; + } + int getItemId(); // itemIDs used for DISPLAYING bolts, not getting them. int getRangeStr(); double getBonusMaxHit(int rangeLevel); // damage bonus from bolt specs. @@ -262,6 +285,3 @@ public String toString() } } - - - diff --git a/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java b/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java index e034928..77b3c56 100644 --- a/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java +++ b/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java @@ -84,6 +84,39 @@ public static Double calculateKoChance(double accuracy, int minHit, int maxHit, return Math.max(0.0, Math.min(chance, 1.0)); } + public static Double calculateClampedKoChance(double accuracy, int minHit, int maxHit, int estimatedOpponentHp) + { + return calculateMultiHitClampedKoChance(accuracy, minHit, maxHit, 1, estimatedOpponentHp); + } + + public static Double calculateMultiHitClampedKoChance(double accuracy, int minHitTotal, int maxHitTotal, int hitCount, int estimatedOpponentHp) + { + if (maxHitTotal < estimatedOpponentHp || estimatedOpponentHp <= 0) + { + return null; + } + + double[] dist = buildMultiHitClampedDamageDistribution(accuracy, minHitTotal, maxHitTotal, hitCount); + if (dist == null || dist.length == 0) + { + return null; + } + + int hpNeeded = Math.max(0, estimatedOpponentHp); + if (hpNeeded >= dist.length) + { + return null; + } + + double chance = 0.0; + for (int damage = hpNeeded; damage < dist.length; damage++) + { + chance += dist[damage]; + } + + return Math.max(0.0, Math.min(chance, 1.0)); + } + /** * Returns how many splats an attack animation should produce based on its group pattern. */ @@ -368,6 +401,46 @@ private static double[] buildCappedDamageDistribution(double accuracy, int minHi return dist; } + private static double[] buildMultiHitClampedDamageDistribution(double accuracy, int minHitTotal, int maxHitTotal, int hitCount) + { + hitCount = Math.max(1, hitCount); + int perHitMin = Math.max(0, minHitTotal / hitCount); + int perHitMax = Math.max(perHitMin, maxHitTotal / hitCount); + + double[] perHit = buildCappedDamageDistribution(accuracy, perHitMin, perHitMax, perHitMax); + if (perHit == null || perHit.length == 0) + { + return null; + } + + double[] total = new double[] {1.0}; + for (int hit = 0; hit < hitCount; hit++) + { + total = convolve(total, perHit); + } + return total; + } + + private static double[] convolve(double[] left, double[] right) + { + double[] result = new double[left.length + right.length - 1]; + for (int i = 0; i < left.length; i++) + { + if (left[i] <= 0.0) + { + continue; + } + for (int j = 0; j < right.length; j++) + { + if (right[j] > 0.0) + { + result[i + j] += left[i] * right[j]; + } + } + } + return result; + } + public static int getSpriteForSkill(Skill skill) { switch (skill) diff --git a/src/main/java/matsyir/pvpperformancetracker/views/FightLogDetailFrame.java b/src/main/java/matsyir/pvpperformancetracker/views/FightLogDetailFrame.java index 852600c..762a3b1 100644 --- a/src/main/java/matsyir/pvpperformancetracker/views/FightLogDetailFrame.java +++ b/src/main/java/matsyir/pvpperformancetracker/views/FightLogDetailFrame.java @@ -417,12 +417,14 @@ String getItemEquipmentStatsString(int[] equipment, Integer ammoId, RingData rin .mdmg(bonuses[12]) // 12 .build(); int ammoRangeStr = 0; + int ammoRangeAtt = 0; if (ammoId != null && ammoId > 0) { RangeAmmoData ammoData = RangeAmmoData.fromId(ammoId); if (ammoData != null) { ammoRangeStr = ammoData.getRangeStr(); + ammoRangeAtt = RangeAmmoData.getSeekingArrowAccuracyBonus(ammoData, isLmsFight); } } else @@ -431,6 +433,7 @@ String getItemEquipmentStatsString(int[] equipment, Integer ammoId, RingData rin if (weaponAmmo != null) { ammoRangeStr = weaponAmmo.getRangeStr(); + ammoRangeAtt = RangeAmmoData.getSeekingArrowAccuracyBonus(weaponAmmo, isLmsFight); } } String sep = "
  "; @@ -439,7 +442,7 @@ String getItemEquipmentStatsString(int[] equipment, Integer ammoId, RingData rin "Slash: " + prependPlusIfPositive(stats.getAslash()) + sep + "Crush: " + prependPlusIfPositive(stats.getAcrush()) + sep + "Magic: " + prependPlusIfPositive(stats.getAmagic()) + sep + - "Range: " + prependPlusIfPositive(stats.getArange()) + + "Range: " + prependPlusIfPositive(stats.getArange() + ammoRangeAtt) + "
Defence bonus" + sep + "Stab: " + prependPlusIfPositive(stats.getDstab()) + sep + "Slash: " + prependPlusIfPositive(stats.getDslash()) + sep +