11import SPELLS from 'common/SPELLS' ;
2+ import TALENTS from 'common/TALENTS/deathknight' ;
23import Analyzer , { Options , SELECTED_PLAYER } from 'parser/core/Analyzer' ;
3- import Events , { ApplyBuffEvent , RemoveBuffEvent } from 'parser/core/Events' ;
4+ import Events , {
5+ ApplyBuffEvent ,
6+ CastEvent ,
7+ RefreshBuffEvent ,
8+ RemoveBuffEvent ,
9+ } from 'parser/core/Events' ;
410import { ThresholdStyle } from 'parser/core/ParseResults' ;
11+ import { formatPercentage } from 'common/format' ;
512import BoringSpellValueText from 'parser/ui/BoringSpellValueText' ;
613import Statistic from 'parser/ui/Statistic' ;
714import { STATISTIC_ORDER } from 'parser/ui/StatisticBox' ;
815
16+ /* The Sudden Doom buff lasts 10 seconds. We use this to distinguish natural expiration
17+ (removal near the 10s mark) from consumption (removal mid-duration via DC/Epidemic).
18+ This is necessary because WCL event ordering can place removebuff before the cast event
19+ at the same timestamp, making flag-based consumption detection unreliable. */
920const BUFF_DURATION_MS = 10000 ;
21+ const EXPIRE_BUFFER_MS = 100 ;
1022
1123class SuddenDoom extends Analyzer {
12- wastedProcs = 0 ;
13- lastProcTime = 0 ;
24+ totalProcs = 0 ;
25+ consumedProcs = 0 ;
26+ wastedRefreshes = 0 ;
27+ wastedExpires = 0 ;
28+
29+ private buffActive = false ;
30+ private buffAppliedAt : number | null = null ;
1431
1532 constructor ( options : Options ) {
1633 super ( options ) ;
34+ this . active = this . selectedCombatant . hasTalent ( TALENTS . SUDDEN_DOOM_TALENT ) ;
35+ if ( ! this . active ) {
36+ return ;
37+ }
1738
1839 this . addEventListener (
19- Events . refreshbuff . by ( SELECTED_PLAYER ) . spell ( SPELLS . SUDDEN_DOOM_BUFF ) ,
20- this . onRefreshBuff ,
40+ Events . applybuff . by ( SELECTED_PLAYER ) . spell ( SPELLS . SUDDEN_DOOM_BUFF ) ,
41+ this . onApplyBuff ,
2142 ) ;
2243 this . addEventListener (
23- Events . applybuff . by ( SELECTED_PLAYER ) . spell ( SPELLS . SUDDEN_DOOM_BUFF ) ,
24- this . onBuff ,
44+ Events . refreshbuff . by ( SELECTED_PLAYER ) . spell ( SPELLS . SUDDEN_DOOM_BUFF ) ,
45+ this . onRefreshBuff ,
2546 ) ;
2647 this . addEventListener (
2748 Events . removebuff . by ( SELECTED_PLAYER ) . spell ( SPELLS . SUDDEN_DOOM_BUFF ) ,
2849 this . onRemoveBuff ,
2950 ) ;
51+ this . addEventListener (
52+ Events . cast . by ( SELECTED_PLAYER ) . spell ( [ SPELLS . DEATH_COIL , SPELLS . EPIDEMIC ] ) ,
53+ this . onCast ,
54+ ) ;
3055 }
3156
32- onBuff ( event : ApplyBuffEvent ) {
33- this . lastProcTime = event . timestamp ;
57+ onApplyBuff ( event : ApplyBuffEvent ) {
58+ this . totalProcs += 1 ;
59+ this . buffActive = true ;
60+ this . buffAppliedAt = event . timestamp ;
61+ }
62+
63+ onRefreshBuff ( event : RefreshBuffEvent ) {
64+ /* Only count as wasted if the previous proc was still unconsumed.
65+ A refreshbuff can fire on the same tick as a consumption cast, in which case
66+ buffActive is already false and the old proc was used — not wasted. */
67+ if ( this . buffActive ) {
68+ this . wastedRefreshes += 1 ;
69+ }
70+
71+ this . totalProcs += 1 ;
72+ this . buffActive = true ;
73+ this . buffAppliedAt = event . timestamp ;
3474 }
3575
3676 onRemoveBuff ( event : RemoveBuffEvent ) {
37- const durationHeld = event . timestamp - this . lastProcTime ;
38- if ( durationHeld > BUFF_DURATION_MS ) {
39- this . wastedProcs += 1 ;
77+ if ( ! this . buffActive ) {
78+ return ;
4079 }
80+
81+ /* Only count as expired if the removal happened near the expected buff expiry.
82+ Consumption removals happen mid-duration and won't match this window. */
83+ const expectedExpireAt = ( this . buffAppliedAt ?? event . timestamp ) + BUFF_DURATION_MS ;
84+ if (
85+ event . timestamp >= expectedExpireAt - EXPIRE_BUFFER_MS &&
86+ event . timestamp <= expectedExpireAt + EXPIRE_BUFFER_MS
87+ ) {
88+ this . wastedExpires += 1 ;
89+ }
90+
91+ this . buffActive = false ;
92+ this . buffAppliedAt = null ;
93+ }
94+
95+ onCast ( _event : CastEvent ) {
96+ if ( ! this . buffActive ) {
97+ return ;
98+ }
99+
100+ this . consumedProcs += 1 ;
101+ this . buffActive = false ;
102+ this . buffAppliedAt = null ;
41103 }
42104
43- onRefreshBuff ( ) {
44- this . wastedProcs += 1 ;
105+ get wastedProcs ( ) {
106+ return this . wastedRefreshes + this . wastedExpires ;
107+ }
108+
109+ get wasteRate ( ) {
110+ return this . totalProcs > 0 ? this . wastedProcs / this . totalProcs : 0 ;
111+ }
112+
113+ get efficiency ( ) {
114+ return this . totalProcs > 0 ? 1 - this . wasteRate : 1 ;
45115 }
46116
47117 get suggestionThresholds ( ) {
48118 return {
49- actual : this . wastedProcs ,
119+ actual : this . wasteRate ,
50120 isGreaterThan : {
51- minor : 0 ,
52- average : 2 ,
53- major : 4 ,
121+ minor : 0.1 ,
122+ average : 0. 2,
123+ major : 0.3 ,
54124 } ,
55- style : ThresholdStyle . NUMBER ,
125+ style : ThresholdStyle . PERCENTAGE ,
56126 } ;
57127 }
58128
@@ -61,11 +131,22 @@ class SuddenDoom extends Analyzer {
61131 < Statistic
62132 position = { STATISTIC_ORDER . CORE ( 12 ) }
63133 size = "flexible"
64- tooltip = "A proc counts as wasted if it fades without being used or if it refreshes"
134+ tooltip = {
135+ < >
136+ < div >
137+ You wasted { this . wastedProcs } out of { this . totalProcs } Sudden Doom procs (
138+ { formatPercentage ( this . wasteRate ) } %).
139+ </ div >
140+ < div >
141+ { this . wastedExpires } procs expired without being used and { this . wastedRefreshes } procs
142+ were overwritten by new procs.
143+ </ div >
144+ </ >
145+ }
65146 >
66147 < BoringSpellValueText spell = { SPELLS . SUDDEN_DOOM_BUFF } >
67148 < >
68- { this . wastedProcs } < small > wasted procs </ small >
149+ { formatPercentage ( this . efficiency ) } % < small > efficiency </ small >
69150 </ >
70151 </ BoringSpellValueText >
71152 </ Statistic >
0 commit comments