diff --git a/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java b/src/main/java/matsyir/pvpperformancetracker/PvpPerformanceTrackerPlugin.java index 6c9a43c..ae9a683 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,32 @@ 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); + } + } } } @@ -990,9 +1030,34 @@ 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; + 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 + { + 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 +1095,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/models/FightLogEntry.java b/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java index 32afa47..aa8a341 100644 --- a/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java +++ b/src/main/java/matsyir/pvpperformancetracker/models/FightLogEntry.java @@ -192,6 +192,52 @@ public class FightLogEntry implements Comparable @Getter @Setter private boolean isPartOfTickGroup = false; + // Transient fields for handling multi-tick Dragon Claws special attacks + private transient Integer clawsPhase1Damage = null; + private transient Integer clawsHpBeforePhase1 = null; + private transient Integer clawsHpAfterPhase1 = null; + private transient Integer clawsHpBeforePhase2 = null; + + public Integer getClawsPhase1Damage() + { + return clawsPhase1Damage; + } + + public void setClawsPhase1Damage(Integer clawsPhase1Damage) + { + this.clawsPhase1Damage = clawsPhase1Damage; + } + + public Integer getClawsHpBeforePhase1() + { + return clawsHpBeforePhase1; + } + + public void setClawsHpBeforePhase1(Integer clawsHpBeforePhase1) + { + this.clawsHpBeforePhase1 = clawsHpBeforePhase1; + } + + public Integer getClawsHpAfterPhase1() + { + return clawsHpAfterPhase1; + } + + public void setClawsHpAfterPhase1(Integer clawsHpAfterPhase1) + { + this.clawsHpAfterPhase1 = clawsHpAfterPhase1; + } + + public Integer getClawsHpBeforePhase2() + { + return clawsHpBeforePhase2; + } + + public void setClawsHpBeforePhase2(Integer clawsHpBeforePhase2) + { + this.clawsHpBeforePhase2 = clawsHpBeforePhase2; + } + 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..cde3047 100644 --- a/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java +++ b/src/main/java/matsyir/pvpperformancetracker/utils/PvpPerformanceTrackerUtils.java @@ -117,6 +117,111 @@ 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; + } + public static int getSpriteForSkill(Skill skill) { switch (skill)