diff --git a/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java b/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java index 6c9a43c..a89809c 100644 --- a/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java +++ b/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java @@ -63,6 +63,7 @@ import lombok.extern.slf4j.Slf4j; import matsyir.pvpperformancetracker.controllers.FightPerformance; import matsyir.pvpperformancetracker.controllers.Fighter; +import matsyir.pvpperformancetracker.models.AnimationData; import matsyir.pvpperformancetracker.models.CombatLevels; import matsyir.pvpperformancetracker.models.FightLogEntry; import matsyir.pvpperformancetracker.models.HitsplatInfo; @@ -204,6 +205,7 @@ public class PvpPerformanceTrackerPlugin extends Plugin // do not cache items in the same way since we could potentially cache a very large amount of them. private final Map> hitsplatBuffer = new HashMap<>(); private final Map> incomingHitsplatsBuffer = new ConcurrentHashMap<>(); // Stores hitsplats *received* by players per tick. + private final Map lastNonGmaulSpecTickByAttacker = new ConcurrentHashMap<>(); private HiscoreEndpoint hiscoreEndpoint = HiscoreEndpoint.NORMAL; // Added field // ################################################################################################################# @@ -857,6 +859,27 @@ else if (opponentIsTrackedCompetitor) // Gmaul can hit twice, others match expected hits int hitsToFind = entry.isGmaulSpecial() ? 2 : toMatch; + // Enforce Dragon Claws 2+2 sequencing: limit phase one to two hits + if (entry.getAnimationData() == AnimationData.MELEE_DRAGON_CLAWS_SPEC && entry.getMatchedHitsCount() < 2) + { + int remainingPhase1 = Math.max(0, 2 - entry.getMatchedHitsCount()); + hitsToFind = Math.min(hitsToFind, remainingPhase1); + } + + // Simple double-GMaul gate: if a different special fired on the previous tick, cap to a single hit + if (entry.isGmaulSpecial()) + { + String attackerName = attacker.getName(); + if (attackerName != null) + { + Integer lastSpec = lastNonGmaulSpecTickByAttacker.get(attackerName); + if (lastSpec != null && lastSpec == entry.getTick() - 1) + { + hitsToFind = Math.min(hitsToFind, 1); + } + } + } + while (matchedThisCycle < hitsToFind && hitsIter.hasNext()) { HitsplatInfo hInfo = hitsIter.next(); @@ -894,15 +917,59 @@ else if (opponentIsTrackedCompetitor) // Fallback to current ratio/scale if polled is unavailable if (ratio < 0 || scale <= 0) { ratio = opponent.getHealthRatio(); scale = opponent.getHealthScale(); } int hpBefore = -1; + int hpBeforeThisCycle = -1; if (ratio >= 0 && scale > 0 && maxHpToUse > 0) { hpBefore = PvpPerformanceTrackerUtils.calculateHpBeforeHit(ratio, scale, maxHpToUse, entry.getActualDamageSum()); + hpBeforeThisCycle = PvpPerformanceTrackerUtils.calculateHpBeforeHit(ratio, scale, maxHpToUse, damageThisCycle); } if (hpBefore > 0) { entry.setEstimatedHpBeforeHit(hpBefore); entry.setOpponentMaxHp(maxHpToUse); } + + if (entry.getAnimationData() == AnimationData.MELEE_DRAGON_CLAWS_SPEC) + { + int matched = entry.getMatchedHitsCount(); + if (matched == 2 && entry.getClawsHpBeforePhase1() == null && hpBeforeThisCycle > 0) + { + entry.setClawsHpBeforePhase1(hpBeforeThisCycle); + entry.setClawsPhase1Damage(damageThisCycle); + entry.setClawsHpAfterPhase1(hpBeforeThisCycle - damageThisCycle); + } + if (matched >= entry.getExpectedHits() && entry.getClawsHpBeforePhase2() == null && hpBeforeThisCycle > 0) + { + entry.setClawsHpBeforePhase2(hpBeforeThisCycle); + } + } + else if (entry.getAnimationData() == AnimationData.RANGED_DARK_BOW || + entry.getAnimationData() == AnimationData.RANGED_DARK_BOW_SPEC) + { + int matchedAfter = entry.getMatchedHitsCount(); + int matchedBefore = matchedAfter - matchedThisCycle; + + if (matchedBefore == 0 && matchedThisCycle >= 2) + { + entry.setDarkBowHitsStacked(true); + if (entry.getDarkBowHpBeforeHit1() == null && hpBeforeThisCycle > 0) + { + entry.setDarkBowHpBeforeHit1(hpBeforeThisCycle); + } + } + else + { + if (matchedAfter >= 1 && entry.getDarkBowHpBeforeHit1() == null && hpBeforeThisCycle > 0) + { + entry.setDarkBowHpBeforeHit1(hpBeforeThisCycle); + entry.setDarkBowHpAfterHit1(hpBeforeThisCycle - damageThisCycle); + } + if (matchedAfter >= entry.getExpectedHits() && entry.getDarkBowHpBeforeHit2() == null && hpBeforeThisCycle > 0) + { + entry.setDarkBowHpBeforeHit2(hpBeforeThisCycle); + } + } + } } } @@ -990,9 +1057,59 @@ else if (opponentIsTrackedCompetitor) entry.setDisplayHpBefore(hpBeforeCurrent); entry.setDisplayHpAfter(hpAfterCurrent); - Double koChanceCurrent = (hpBeforeCurrent != null) - ? PvpPerformanceTrackerUtils.calculateKoChance(entry.getAccuracy(), entry.getMinHit(), entry.getMaxHit(), hpBeforeCurrent) - : null; + Double koChanceCurrent = null; + boolean isClawsSpec = entry.getAnimationData() == AnimationData.MELEE_DRAGON_CLAWS_SPEC && entry.getExpectedHits() >= 4; + boolean isDarkBow = entry.getAnimationData() == AnimationData.RANGED_DARK_BOW || + entry.getAnimationData() == AnimationData.RANGED_DARK_BOW_SPEC; + if (isClawsSpec) + { + if (hpBeforeCurrent != null && entry.getMatchedHitsCount() >= entry.getExpectedHits()) + { + int healBetween = 0; + Integer hpAfterP1 = entry.getClawsHpAfterPhase1(); + Integer hpBeforeP2 = entry.getClawsHpBeforePhase2(); + if (hpAfterP1 != null && hpBeforeP2 != null) + { + healBetween = Math.max(0, hpBeforeP2 - hpAfterP1); + } + koChanceCurrent = PvpPerformanceTrackerUtils.calculateClawsTwoPhaseKo(entry.getAccuracy(), entry.getMaxHit(), hpBeforeCurrent, healBetween); + } + } + else if (isDarkBow) + { + if (hpBeforeCurrent != null && entry.getMatchedHitsCount() >= entry.getExpectedHits()) + { + int healBetween = 0; + if (!entry.isDarkBowHitsStacked()) + { + Integer hpAfterHit1 = entry.getDarkBowHpAfterHit1(); + Integer hpBeforeHit2 = entry.getDarkBowHpBeforeHit2(); + if (hpAfterHit1 != null && hpBeforeHit2 != null) + { + healBetween = Math.max(0, hpBeforeHit2 - hpAfterHit1); + } + } + koChanceCurrent = PvpPerformanceTrackerUtils.calculateDarkBowTwoPhaseKo( + entry.getAccuracy(), + entry.getMinHit(), + entry.getMaxHit(), + hpBeforeCurrent, + healBetween + ); + } + } + else + { + koChanceCurrent = (hpBeforeCurrent != null) + ? PvpPerformanceTrackerUtils.calculateKoChance(entry.getAccuracy(), entry.getMinHit(), entry.getMaxHit(), hpBeforeCurrent) + : null; + } + + if (koChanceCurrent != null && koChanceCurrent <= 0.0) + { + koChanceCurrent = null; + } + entry.setDisplayKoChance(koChanceCurrent); entry.setKoChance(koChanceCurrent); @@ -1030,6 +1147,15 @@ public void onPlayerDespawned(PlayerDespawned event) } } + public void recordNonGmaulSpecial(String attackerName, int tick) + { + if (attackerName == null) + { + return; + } + lastNonGmaulSpecTickByAttacker.put(attackerName, tick); + } + // ################################################################################################################# // ################################## Plugin-specific functions & global helpers ################################### // ################################################################################################################# diff --git a/src/main/java/matsyir/pvpperformancetracker/controllers/Fighter.java b/src/main/java/matsyir/pvpperformancetracker/controllers/Fighter.java index 08ffc0b..7f603d3 100644 --- a/src/main/java/matsyir/pvpperformancetracker/controllers/Fighter.java +++ b/src/main/java/matsyir/pvpperformancetracker/controllers/Fighter.java @@ -267,6 +267,10 @@ else if (weapon == EquipmentData.DRAGON_CROSSBOW && FightLogEntry fightLogEntry = new FightLogEntry(player, opponent, pvpDamageCalc, offensivePray, levels, animationData); fightLogEntry.setGmaulSpecial(isGmaulSpec); + if (animationData.isSpecial && animationData != AnimationData.MELEE_GRANITE_MAUL_SPEC) + { + PvpPerformanceTrackerPlugin.PLUGIN.recordNonGmaulSpecial(player.getName(), fightLogEntry.getTick()); + } if (PvpPerformanceTrackerPlugin.CONFIG.fightLogInChat()) { PvpPerformanceTrackerPlugin.PLUGIN.sendTradeChatMessage(fightLogEntry.toChatMessage()); diff --git a/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java b/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java index 11482bc..753fad4 100644 --- a/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java +++ b/src/main/java/matsyir/pvpperformancetracker/controllers/PvpDamageCalc.java @@ -88,6 +88,7 @@ public class PvpDamageCalc private static final int DBOW_DMG_MODIFIER = 2; private static final int DBOW_SPEC_DMG_MODIFIER = 3; private static final int DBOW_SPEC_MIN_HIT = 16; + private static final int DBOW_SPEC_MAX_HIT_PER_ARROW = 48; private static final double DRAGON_CBOW_SPEC_DMG_MODIFIER = 1.2; private static final double DDS_SPEC_ACCURACY_MODIFIER = 1.25; @@ -526,6 +527,15 @@ private void getRangedMaxHit(int rangeStrength, boolean usingSpec, EquipmentData maxHit *= dmgModifier; } + + if (dbow && usingSpec) + { + int cap = DBOW_SPEC_MAX_HIT_PER_ARROW * 2; + if (maxHit > cap) + { + maxHit = cap; + } + } } private void getMagicMaxHit(EquipmentData shield, int mageDamageBonus, AnimationData animationData, EquipmentData weapon, VoidStyle voidStyle, boolean successfulOffensive) @@ -674,7 +684,12 @@ private void getRangeAccuracy(int playerRangeAtt, int opponentRangeDef, boolean /** * Attacker Chance */ - effectiveLevelPlayer = Math.floor(((attackerLevels.range * (successfulOffensive ? RIGOUR_OFFENSIVE_PRAYER_ATTACK_MODIFIER : 1)) + STANCE_BONUS) + 8); + int stanceBonus = STANCE_BONUS; + if (weapon == EquipmentData.DARK_BOW && usingSpec) + { + stanceBonus += 3; // dark bow spec is assumed to be on accurate + } + effectiveLevelPlayer = Math.floor(((attackerLevels.range * (successfulOffensive ? RIGOUR_OFFENSIVE_PRAYER_ATTACK_MODIFIER : 1)) + stanceBonus) + 8); // apply void bonus if applicable if (voidStyle == VoidStyle.VOID_ELITE_RANGE || voidStyle == VoidStyle.VOID_RANGE) { diff --git a/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java b/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java index 32afa47..15c2642 100644 --- a/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java +++ b/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java @@ -192,6 +192,33 @@ public class FightLogEntry implements Comparable @Getter @Setter private boolean isPartOfTickGroup = false; + // Transient fields for handling multi-tick Dragon Claws special attacks + @Getter + @Setter + private transient Integer clawsPhase1Damage = null; + @Getter + @Setter + private transient Integer clawsHpBeforePhase1 = null; + @Getter + @Setter + private transient Integer clawsHpAfterPhase1 = null; + @Getter + @Setter + private transient Integer clawsHpBeforePhase2 = null; + // Transient fields for handling Dark Bow double-hit sequencing + @Getter + @Setter + private transient Integer darkBowHpBeforeHit1 = null; + @Getter + @Setter + private transient Integer darkBowHpAfterHit1 = null; + @Getter + @Setter + private transient Integer darkBowHpBeforeHit2 = null; + @Getter + @Setter + private transient boolean darkBowHitsStacked = false; + public FightLogEntry(Player attacker, Player defender, PvpDamageCalc pvpDamageCalc, int attackerOffensivePray, CombatLevels levels, AnimationData animationData) { this.isFullEntry = true; diff --git a/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java b/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java index 54ba0d5..1af78ed 100644 --- a/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java +++ b/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java @@ -12,6 +12,8 @@ @Slf4j public class PvpPerformanceTrackerUtils { + private static final int DBOW_MAX_HIT_CAP = 48; // per-arrow cap with dragon arrows in PvP + /** * Calculates the chance of knocking out an opponent with a single hit. * @@ -117,6 +119,230 @@ public static int calculateHpBeforeHit(int ratio, int scale, int maxHp, int dama return hpAfter + damageSum; } + /** + * Attempt-level KO probability for Dragon Claws specials across two ticks, factoring any healing between phases. + */ + public static Double calculateClawsTwoPhaseKo(double specAccuracy, int specMaxHit, int hpBefore, int healBetween) + { + if (specMaxHit <= 0 || hpBefore <= 0) + { + return null; + } + + double accSpec = Math.max(0.0, Math.min(1.0, specAccuracy)); + int baseMax = Math.max(0, (specMaxHit - 1) / 2); + if (baseMax <= 0) + { + return null; + } + + double swingAccuracy; + if (accSpec <= 0.0) + { + swingAccuracy = 0.0; + } + else if (accSpec >= 1.0) + { + swingAccuracy = 1.0; + } + else + { + swingAccuracy = 1.0 - Math.pow(1.0 - accSpec, 0.25); + } + + double missChance = 1.0 - swingAccuracy; + double p1 = swingAccuracy; + double p2 = missChance * swingAccuracy; + double p3 = missChance * missChance * swingAccuracy; + double p4 = missChance * missChance * missChance * swingAccuracy; + + double inverseCount = 1.0 / (baseMax + 1); + double ko = 0.0; + + for (int roll = 0; roll <= baseMax; roll++) + { + int halfCeil = (roll + 1) / 2; + int halfFloor = roll / 2; + int quarterFloor = roll / 4; + int threeQuarterCeil = (int) Math.ceil(0.75 * roll); + int threeQuarterFloor = (int) Math.floor(0.75 * roll); + + // Case E1: first swing connects (two hits tick k, two hits tick k+1) + { + int h1 = roll; + int h2 = halfCeil; + int h3 = quarterFloor; + int used = h1 + h2 + h3; + int remainder = Math.max(0, 2 * roll - used); + int damageTick1 = h1 + h2; + int damageTick2 = h3 + remainder; + double contribution = p1 * inverseCount * (damageTick1 >= hpBefore + ? 1.0 + : (damageTick2 >= (hpBefore - damageTick1 + healBetween) ? 1.0 : 0.0)); + ko += contribution; + } + + // Case E2: second swing connects (phase 1 does no damage) + { + int damageTick1 = roll; + int damageTick2 = roll; + double contribution = p2 * inverseCount * (damageTick1 >= hpBefore + ? 1.0 + : (damageTick2 >= (hpBefore - damageTick1 + healBetween) ? 1.0 : 0.0)); + ko += contribution; + } + + // Case E3: third swing connects (all damage tick k+1) + { + int damageTick1 = 0; + int damageTick2 = threeQuarterCeil + threeQuarterFloor; + double contribution = p3 * inverseCount * (damageTick1 >= hpBefore + ? 1.0 + : (damageTick2 >= (hpBefore - damageTick1 + healBetween) ? 1.0 : 0.0)); + ko += contribution; + } + + // Case E4: fourth swing connects (all damage tick k+1) + { + int damageTick1 = 0; + int damageTick2 = threeQuarterCeil + threeQuarterFloor; + double contribution = p4 * inverseCount * (damageTick1 >= hpBefore + ? 1.0 + : (damageTick2 >= (hpBefore - damageTick1 + healBetween) ? 1.0 : 0.0)); + ko += contribution; + } + } + + if (ko < 0.0) + { + ko = 0.0; + } + else if (ko > 1.0) + { + ko = 1.0; + } + return ko; + } + + /** + * Attempt-level KO probability for Dark Bow double hits, factoring any healing between hits. + * Applies per-arrow damage caps by collapsing rolls above the cap into the capped value. + */ + public static Double calculateDarkBowTwoPhaseKo(double accuracy, int minHitTotal, int maxHitTotal, int hpBefore, int healBetween) + { + if (maxHitTotal <= 0 || hpBefore <= 0) + { + return null; + } + + double acc = Math.max(0.0, Math.min(1.0, accuracy)); + int perArrowMax = Math.max(0, maxHitTotal / 2); + if (perArrowMax <= 0) + { + return null; + } + + int perArrowMin = Math.max(0, minHitTotal / 2); + double[] dist = buildCappedDamageDistribution(acc, perArrowMin, perArrowMax, DBOW_MAX_HIT_CAP); + if (dist == null || dist.length == 0) + { + return null; + } + + int maxDamage = dist.length - 1; + double[] tail = new double[maxDamage + 2]; + for (int dmg = maxDamage; dmg >= 0; dmg--) + { + tail[dmg] = tail[dmg + 1] + dist[dmg]; + } + + double ko = 0.0; + for (int d1 = 0; d1 <= maxDamage; d1++) + { + double p1 = dist[d1]; + if (p1 <= 0.0) + { + continue; + } + + if (d1 >= hpBefore) + { + ko += p1; + continue; + } + + int hpNeeded = hpBefore - d1 + Math.max(0, healBetween); + if (hpNeeded <= 0) + { + ko += p1; + continue; + } + + if (hpNeeded <= maxDamage) + { + ko += p1 * tail[hpNeeded]; + } + } + + if (ko < 0.0) + { + ko = 0.0; + } + else if (ko > 1.0) + { + ko = 1.0; + } + return ko; + } + + private static double[] buildCappedDamageDistribution(double accuracy, int minHit, int maxHit, int maxHitCap) + { + if (maxHit < 0) + { + return null; + } + + int effectiveCap = maxHitCap > 0 ? maxHitCap : maxHit; + int maxDamage = Math.min(maxHit, effectiveCap); + if (maxDamage < 0) + { + return null; + } + + double[] dist = new double[maxDamage + 1]; + double acc = Math.max(0.0, Math.min(1.0, accuracy)); + + if (acc <= 0.0) + { + dist[0] = 1.0; + return dist; + } + + int clampedMin = Math.max(0, minHit); + clampedMin = Math.min(clampedMin, maxHit); + clampedMin = Math.min(clampedMin, effectiveCap); + + int rollCount = maxHit + 1; + double hitProb = acc / rollCount; + + for (int roll = 0; roll <= maxHit; roll++) + { + int dmg = roll < clampedMin ? clampedMin : roll; + if (dmg > effectiveCap) + { + dmg = effectiveCap; + } + if (dmg > maxDamage) + { + dmg = maxDamage; + } + dist[dmg] += hitProb; + } + + dist[0] += 1.0 - acc; + return dist; + } + public static int getSpriteForSkill(Skill skill) { switch (skill)