Repository navigation
Expand file tree
/
Copy pathTracker.lua
More file actions
1252 lines (1162 loc) · 39.1 KB
/
Copy pathTracker.lua
File metadata and controls
1252 lines (1162 loc) · 39.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[--------------------------------------------------------------------
WhoDidIt - tracker: turns Nampower / SuperWoW / threat events into a
live fight record (W.Tracker.fight). The Analyzer reads it.
----------------------------------------------------------------------]]
local W = WhoDidIt
local T = {}
W.Tracker = T
local floor = math.floor
local getn = table.getn
local RECAP_SIZE = 30 -- events remembered per player
local RECAP_WINDOW = 15 -- seconds of history kept for a death recap
local AGGRO_CONFIRM = 2.0 -- boss must hold a (non-melee) target this long
local TIMELINE_CAP = 400
local HIT_CRIT = 128 -- 0x80
local HIT_CRUSHING = 32768 -- 0x8000
local ENV = {
[0] = "Fatigue", [1] = "Drowning", [2] = "Falling", [3] = "Lava",
[4] = "Slime", [5] = "Burning ground", [6] = "Fell off the edge",
}
local F -- the live fight, same table as T.fight
local isBoss = {} -- guid -> bool
-- bosses seen dying (a respawn has a new GUID): their damage over time keeps ticking after
-- death (Golemagg's Magma Splash, MC 15 Oct 2026) and must not start a fight. Forgotten past 500.
local slain, nSlain = {}, 0
local function noteSlain(guid)
if slain[guid] then return end
nSlain = nSlain + 1
if nSlain > 500 then slain, nSlain = {}, 1 end
slain[guid] = true
end
local function deadBoss(guid)
return slain[guid] or (W.env.superwow and UnitExists(guid) and UnitIsDead(guid)) and true or false
end
local function hasFlag(v, flag)
if not v or not bit or not bit.band then return false end
return bit.band(v, flag) ~= 0
end
------------------------------------------------------------------ helpers
function T:Line(kind, text, who)
if not F or getn(F.timeline) >= TIMELINE_CAP then return nil end
local l = { t = GetTime() - F.t0, k = kind, x = text, w = who }
tinsert(F.timeline, l)
return l
end
function T.IsBoss(guid)
if not guid then return false end
local c = isBoss[guid]
if c ~= nil then return c end
if W.roster.byGuid[guid] or W.roster.pets[guid] then
isBoss[guid] = false
return false
end
local name = UnitName(guid)
if not name or name == "" or name == UNKNOWNOBJECT then return false end
local b = false
if UnitCanAttack("player", guid) then
local cls = UnitClassification(guid)
if W.Data.bosses[name] or cls == "worldboss" then
b = true
elseif WhoDidItDB.opts.trackTrash and (cls == "elite" or cls == "rareelite") then
b = true
end
end
isBoss[guid] = b
return b
end
-- empty player / fight records (also used by the demo)
function T.NewPlayer(name, class)
return {
name = name, class = class or "WARRIOR",
dmg = 0, bossDmg = 0, heal = 0, taken = 0,
ds = {}, hs = {}, ts = {},
-- per spell: hits, crits, biggest hit (damage dn/dc/dm, healing hn/hc/hm)
dn = {}, dc = {}, dm = {}, hn = {}, hc = {}, hm = {},
act = {}, nAct = 0,
deadTotal = 0, aggroTime = 0, bossSwings = 0, crush = 0,
consumes = {}, nCons = 0, kicks = 0, dispels = 0, tranqs = 0,
cbuffs = {}, -- consumable buff name -> fight time first seen (0 = before the pull)
-- two rings: damage taken, and everything else (heals, buffs, items), so a
-- flood of heal ticks can't push the killing blows out of a death recap
recap = {}, rhead = 0, drecap = {}, dhead = 0,
}
end
function T.NewFight(t0, enc)
return {
t0 = t0, date = date("%Y-%m-%d %H:%M"), at = time(), zone = GetRealZoneText(),
enc = enc,
bosses = {}, nBoss = 0,
players = {}, deaths = {}, timeline = {}, aggro = {},
avoid = {}, carry = {}, stacks = {},
interrupts = {}, casting = {}, lastKick = {},
dispelTimes = {}, activeDebuffs = {}, mc = {}, debuffAt = {},
frenzyOn = {}, frenzies = {}, bossDebuffs = {}, raidFails = {},
threat = {}, threatPeak = {}, ffire = {}, taunts = {}, castLines = {},
buffs = {},
saves = {}, shields = {}, tauntCasts = {},
env = {
nampower = W.env.nampower, superwow = W.env.superwow,
threat = W.env.twthreat or WhoDidItDB.opts.queryThreat,
},
}
end
function T:P(name)
local p = F.players[name]
if p then return p end
local e = W.roster.byName[name]
p = T.NewPlayer(name, e and e.class)
F.players[name] = p
return p
end
-- mark a player as "doing something" for every second in [t1, t2]
local function mark(p, t1, t2)
local a = floor(t1 - F.t0)
local b = floor((t2 or t1) - F.t0)
if b > a + 10 then b = a + 10 end
for s = a, b do
if not p.act[s] then
p.act[s] = true
p.nAct = p.nAct + 1
end
end
end
-- death recap ring buffer
local DMG_KIND = { dmg = true, melee = true, env = true }
local function push(p, kind, src, spell, amt, crit, extra)
local ring, h
if DMG_KIND[kind] and p.drecap then
ring = p.drecap
h = p.dhead + 1
if h > RECAP_SIZE then h = 1 end
p.dhead = h
else
ring = p.recap
h = p.rhead + 1
if h > RECAP_SIZE then h = 1 end
p.rhead = h
end
local r = ring[h]
if not r then r = {}; ring[h] = r end
r.t = GetTime(); r.k = kind; r.src = src; r.sp = spell; r.a = amt; r.c = crit; r.x = extra
local e = W.roster.byName[p.name]
if e then
r.hp = UnitHealth(e.unit)
r.hpm = UnitHealthMax(e.unit)
if r.hpm and r.hpm > 0 then p.hpFrac = r.hp / r.hpm end
else
r.hp = nil; r.hpm = nil
end
end
------------------------------------------------------------------ fight lifecycle
function T:AddBoss(guid)
if F.bosses[guid] then return end
local name = W.GuidName(guid)
F.bosses[guid] = { name = name, dead = false }
F.nBoss = F.nBoss + 1
if not F.mainBoss then F.mainBoss = guid end
local enc = W.Data.bosses[name]
if enc and not F.encKnown then
F.enc = enc
F.encKnown = true
F.encDef = W.Data.encounters[enc]
end
if F.started then T:Line("boss", name .. " joins the fight") end
end
function T:Start(guid, puller, how)
F = T.NewFight(GetTime(), W.GuidName(guid))
F.puller = puller
F.pullHow = how
T.fight = F
T:AddBoss(guid)
for name in pairs(W.roster.byName) do T:P(name) end
F.started = true
T:SnapshotBuffs()
local bname = W.GuidName(guid)
if puller then
T:Line("pull", how == "aggro" and (bname .. " attacked " .. puller) or (puller .. " pulled " .. bname), puller)
else
T:Line("pull", bname .. " engaged")
end
if F.encKnown or UnitClassification(guid) == "worldboss" then
W.Print("Tracking |cffffd100" .. F.enc .. "|r.")
end
if W.Logs then W.Logs:OnFightStart() end
if W.UI then W.UI:OnFightStart() end
end
function T:Finish(result)
if not F then return end
local now = GetTime()
F.tEnd = F.killedAt or F.idleSince or now
for _, b in pairs(F.bosses) do T:Accrue(b, F.tEnd) end -- (boss aggro time stops when the fight did)
F.result = result
T:Line(result == "KILL" and "kill" or "wipe", result == "KILL" and "Encounter won" or (result == "WIPE" and "Wipe" or "Fight reset"))
local fight = F
F = nil
T.fight = nil
isBoss = {}
W.ResetCaches()
local dur = fight.tEnd - fight.t0
-- a "reset" where nobody hit a boss wasn't an attempt: a boss's adds still
-- fighting after it died, a role-play, a stray pull. Not saved.
local bossDmg = 0
for _, p in pairs(fight.players) do bossDmg = bossDmg + (p.bossDmg or 0) end
if (result ~= "KILL" and dur < (WhoDidItDB.opts.minDuration or 8)) or (result == "RESET" and bossDmg <= 0) then
if W.UI then W.UI:OnFightEnd() end
return
end
W:SaveFight(W.Analyzer:Build(fight, true))
end
function T:CheckKill()
local def = F.encDef
if def and def.final then
for _, b in pairs(F.bosses) do
if b.name == def.final and b.dead then F.killedAt = GetTime() end
end
return
end
local main = false
for _, b in pairs(F.bosses) do
if b.name == F.enc and b.dead then main = true end
end
for _, b in pairs(F.bosses) do
-- the boss the encounter is named after is dead: what's left (images, adds
-- that despawn instead of dying) doesn't keep the fight going
if not b.dead and not main then return end
end
F.killedAt = GetTime()
end
function T:CheckEnd()
local now = GetTime()
if F.killedAt then
if now - F.killedAt > 1.5 then T:Finish("KILL") end
return
end
local anyCombat, dead, total = false, 0, 0
for _, e in pairs(W.roster.byName) do
total = total + 1
if UnitIsDeadOrGhost(e.unit) then
dead = dead + 1
elseif UnitAffectingCombat(e.unit) then
anyCombat = true
end
end
if anyCombat then
F.idleSince = nil
return
end
F.idleSince = F.idleSince or now
local def = F.encDef
local wiped = total > 0 and dead / total >= 0.5
-- some encounters drop combat between phases (Thaddius, C'Thun)
local grace = (not wiped and def and def.idleGrace) or 3
if now - F.idleSince < grace then return end
if not wiped and W.env.superwow then
local want = (def and def.final) or F.enc
for g, b in pairs(F.bosses) do
if b.name == want and not b.dead and UnitExists(g) and UnitIsDead(g) then
b.dead = true
noteSlain(g)
T:CheckKill()
end
end
if F.killedAt then T:Finish("KILL") return end
end
if wiped then
T:Finish("WIPE")
elseif def and def.noDeath then
T:Finish("KILL")
else
T:Finish("RESET")
end
end
function T:ManualStart()
if F then W.Print("Already tracking " .. F.enc .. ".") return end
local g = W.Guid("target")
if not g or not UnitCanAttack("player", "target") then
W.Print("Target a hostile unit first (needs SuperWoW).")
return
end
isBoss[g] = true
T:Start(g, UnitName("player"), "manual")
end
function T:ManualStop()
if not F then W.Print("Not tracking anything.") return end
F.idleSince = GetTime()
T:Finish("RESET")
end
------------------------------------------------------------------ pull buff snapshot
function T:SnapshotBuffs()
F.buffs = {}
if not W.Cons then return end -- new file: loads after a full restart
for name, e in pairs(W.roster.byName) do
local set = {}
local p = T:P(name)
local ids = W.Cons.Auras(e.unit, e.guid)
for i = 1, getn(ids or {}) do
set[W.SpellName(ids[i])] = true
F.buffsOk = true
local b = W.Cons.BuffName(ids[i])
if b then p.cbuffs[b] = 0 end
end
F.buffs[name] = set
-- unreadable (out of range) = unknown, never "missing" in the consume check
p.read = ids and true or false
p.wpn = W.Cons.Weapon(e.unit)
end
end
-- a raid member gained a buff: remember consumables (protection potions etc.)
function T:RaidBuff(guid, spellId)
if not W.Cons then return end
local e = W.roster.byGuid[guid]
if not e then return end
local sp = W.Cons.BuffName(spellId)
if not sp then return end
local p = T:P(e.name)
p.read = true -- we saw them gain it, so they're readable
if not p.cbuffs[sp] then
p.cbuffs[sp] = GetTime() - F.t0
push(p, "buff", nil, sp)
end
end
------------------------------------------------------------------ damage / healing
function T:Damage(src, dst, spell, amt, crit, swing, hitInfo, absorb)
if not src or not dst then return end
local srcN, srcPet = W.Owner(src)
local dstN, dstPet = W.Owner(dst)
if not F then
if srcN and not dstN and T.IsBoss(dst) and not deadBoss(dst) then
T:Start(dst, srcN, "hit")
elseif dstN and not srcN and not dstPet and T.IsBoss(src) and not deadBoss(src) then
T:Start(src, dstN, "aggro")
else
return
end
end
local now = GetTime()
-- raid -> enemy
if srcN and not dstN then
if not F.bosses[dst] and T.IsBoss(dst) then T:AddBoss(dst) end
local p = T:P(srcN)
p.dmg = p.dmg + amt
local key = srcPet and ("Pet: " .. spell) or spell
p.ds[key] = (p.ds[key] or 0) + amt
if amt > 0 then
p.dn[key] = (p.dn[key] or 0) + 1
if crit then p.dc[key] = (p.dc[key] or 0) + 1 end
if amt > (p.dm[key] or 0) then p.dm[key] = amt end
end
if F.bosses[dst] then p.bossDmg = p.bossDmg + amt end
if not srcPet then mark(p, now, swing and (now + 1.5) or now) end
return
end
if not dstN or dstPet then return end
local p = T:P(dstN)
-- raid -> raid (mind control, friendly fire)
if srcN then
if amt <= 0 then return end
p.taken = p.taken + amt
local mc = F.mc[src]
push(p, "dmg", mc and (srcN .. " (mind controlled)") or srcN, spell, amt, crit)
if not mc and not F.mc[dst] and srcN ~= dstN then
F.ffire[srcN] = (F.ffire[srcN] or 0) + amt
end
return
end
-- enemy -> raid
if not F.bosses[src] and T.IsBoss(src) then T:AddBoss(src) end
local crushing = swing and hasFlag(hitInfo, HIT_CRUSHING)
if swing and F.bosses[src] then
T:BossSwing(src, dst, p, now)
if crushing then p.crush = p.crush + 1 end
end
if absorb and absorb > 0 then T:CheckShield(dst, p, absorb) end
if amt <= 0 then return end
p.taken = p.taken + amt
p.ts[spell] = (p.ts[spell] or 0) + amt
push(p, swing and "melee" or "dmg", W.GuidName(src), spell, amt, crit, crushing and "crushing" or nil)
if not swing then
T:CheckAvoid(p, spell, amt, now)
T:CheckSplash(p, spell, amt, now)
end
end
function T:Env(guid, typ, dmg)
if not F then return end
local e = W.roster.byGuid[guid]
if not e then return end
local p = T:P(e.name)
local sp = ENV[tonumber(typ) or -1] or "Environment"
dmg = tonumber(dmg) or 0
p.taken = p.taken + dmg
p.ts[sp] = (p.ts[sp] or 0) + dmg
push(p, "env", "Environment", sp, dmg)
T:CheckAvoid(p, sp, dmg, GetTime())
end
function T:Heal(targetGuid, casterGuid, spellId, amount, crit, periodic)
if not F then return end
amount = tonumber(amount) or 0
local now = GetTime()
local sp = W.SpellName(spellId)
local cn, cpet = W.Owner(casterGuid)
if cn then
local p = T:P(cn)
p.heal = p.heal + amount
p.hs[sp] = (p.hs[sp] or 0) + amount
if amount > 0 then
p.hn[sp] = (p.hn[sp] or 0) + 1
if tonumber(crit) == 1 then p.hc[sp] = (p.hc[sp] or 0) + 1 end
if amount > (p.hm[sp] or 0) then p.hm[sp] = amount end
end
if not cpet and tonumber(periodic) ~= 1 then mark(p, now) end
end
local te = W.roster.byGuid[targetGuid]
if te then
local q = T:P(te.name)
-- clutch heal: target was under 20% (last known health) and it was a real heal
local pre = q.hpFrac
local mx = UnitHealthMax(te.unit) or 0
if cn and pre and pre < 0.2 and mx > 0 and amount >= mx * 0.1 and not UnitIsDeadOrGhost(te.unit) then
T:Save("heal", cn, te.name, sp, amount, pre)
end
push(q, "heal", cn or W.GuidName(casterGuid), sp, amount, tonumber(crit) == 1)
end
end
------------------------------------------------------------------ heroes
-- a possible game-saving moment; the Analyzer confirms it (e.g. they survived)
function T:Save(kind, who, target, sp, amt, pre)
local now = GetTime() - F.t0
if kind == "heal" then
-- one clutch heal per target per 3s (the first one that landed)
for i = getn(F.saves), 1, -1 do
local s = F.saves[i]
if now - s.t > 3 then break end
if s.kind == "heal" and s.target == target then return end
end
end
tinsert(F.saves, { t = now, kind = kind, who = who, target = target, sp = sp, amt = amt, pre = pre })
end
local function liveFrac(unit, mana)
if mana then
local mx = UnitManaMax(unit) or 0
return mx > 0 and (UnitMana(unit) / mx) or nil
end
local mx = UnitHealthMax(unit) or 0
return mx > 0 and (UnitHealth(unit) / mx) or nil
end
-- an absorb shield took a hit bigger than the health its target had left
function T:CheckShield(guid, p, absorb)
local sh = F.shields[guid]
if not sh or sh.saved or GetTime() - sh.t > 30 then return end
local e = W.roster.byGuid[guid]
if not e then return end
local hp = UnitHealth(e.unit) or 0
if hp > 0 and hp <= absorb then
sh.saved = true
T:Save(sh.by == p.name and "self" or "shield", sh.by, p.name, sh.sp, absorb, p.hpFrac)
end
end
------------------------------------------------------------------ mechanics
function T:CheckAvoid(p, spell, amt, now)
local rule = W.Data.avoid[spell]
-- a listed name only counts in its own raid (trash elsewhere casts the same names)
if rule and rule.zones and not F.demo and not rule.zones[GetRealZoneText() or ""] then rule = nil end
if not rule and WhoDidItDB.avoid[spell] then rule = W.Data.customRule end
if not rule then return end
local a = F.avoid[p.name]
if not a then a = {}; F.avoid[p.name] = a end
local r = a[spell]
if not r then
r = { hits = 0, dmg = 0, inst = 0, last = -999, first = now - F.t0 }
a[spell] = r
end
r.hits = r.hits + 1
r.dmg = r.dmg + amt
if now - r.last > 4 then
r.inst = r.inst + 1
if r.inst <= 3 then
T:Line("avoid", p.name .. " hit by " .. spell .. " (" .. W.FmtNum(amt) .. ")", p.name)
end
end
r.last = now
end
function T:CheckSplash(p, spell, amt, now)
local srcs = W.Data.splashIdx[spell]
if not srcs then return end
for i = 1, getn(srcs) do
local list = F.carry[srcs[i]]
if list then
for j = getn(list), 1, -1 do
local c = list[j]
if now - c.t <= c.win + 1 then
if c.name ~= p.name then
c.hits = c.hits + 1
if not c.victims[p.name] then
c.victims[p.name] = 0
c.nv = c.nv + 1
end
c.victims[p.name] = c.victims[p.name] + amt
end
return
end
end
end
end
end
function T:Debuff(guid, spellId, stacks)
if not F then return end
local now = GetTime()
local sp = W.SpellName(spellId)
local D = W.Data
stacks = tonumber(stacks)
if F.bosses[guid] then
if guid == F.mainBoss and D.raidDebuffs[sp] then
local u = F.bossDebuffs[sp]
if not u then u = { total = 0, max = 0 }; F.bossDebuffs[sp] = u end
if not u.since then u.since = now end
if stacks and stacks > u.max then u.max = stacks end
end
return
end
local e = W.roster.byGuid[guid]
if not e or D.ignoreDebuffs[sp] then return end
local p = T:P(e.name)
local ad = F.activeDebuffs[guid]
if not ad then ad = {}; F.activeDebuffs[guid] = ad end
if not ad[sp] then
ad[sp] = now
F.debuffAt = F.debuffAt or {} -- (kept after it's gone: the dispel event may come after the removal)
F.debuffAt[guid .. sp] = now
push(p, "debuff", nil, sp, stacks)
if D.mc[sp] then
F.mc[guid] = now
T:Line("mc", e.name .. " is mind controlled (" .. sp .. ")", e.name)
end
local cr = D.carriers[sp]
if cr then
local list = F.carry[sp]
if not list then list = {}; F.carry[sp] = list end
tinsert(list, { name = e.name, t = now, win = cr.win or 10, hits = 0, victims = {}, nv = 0 })
T:Line("debuff", e.name .. " has " .. sp, e.name)
end
end
if stacks and D.stacks[sp] then
local s = F.stacks[e.name]
if not s then s = {}; F.stacks[e.name] = s end
if stacks > (s[sp] or 0) then s[sp] = stacks end
end
end
function T:DebuffRemoved(guid, spellId, state)
if not F or tonumber(state) ~= 1 then return end
local now = GetTime()
local sp = W.SpellName(spellId)
if F.bosses[guid] then
local u = F.bossDebuffs[sp]
if u and u.since and guid == F.mainBoss then
u.total = u.total + (now - u.since)
u.since = nil
end
return
end
local ad = F.activeDebuffs[guid]
if not ad or not ad[sp] then return end
local t0 = ad[sp]
ad[sp] = nil
if W.Data.mc[sp] then F.mc[guid] = nil end
local e = W.roster.byGuid[guid]
if W.Data.dispel[sp] and e and not UnitIsDeadOrGhost(e.unit) then
local d = F.dispelTimes[sp]
if not d then d = { n = 0, total = 0, max = 0 }; F.dispelTimes[sp] = d end
local dt = now - t0
d.n = d.n + 1
d.total = d.total + dt
if dt > d.max then d.max = dt; d.maxWho = e.name end
end
end
function T:BossBuff(guid, spellId, added)
if not F then return end
local b = F.bosses[guid]
if not b then return end
local sp = W.SpellName(spellId)
local now = GetTime()
if W.Data.frenzy[sp] then
if added then
if not F.frenzyOn[guid] then
F.frenzyOn[guid] = now
T:Line("boss", b.name .. " gains " .. sp)
end
elseif F.frenzyOn[guid] then
local st = F.frenzyOn[guid]
F.frenzyOn[guid] = nil
local lt = F.lastTranq
local by = lt and (now - lt.t) < 2 and lt.name or nil
tinsert(F.frenzies, { t = st - F.t0, dur = now - st, by = by, boss = b.name })
T:Line("tranq", by and (by .. " tranquilized " .. b.name) or (sp .. " faded from " .. b.name), by)
end
elseif added and W.Data.berserk[sp] then
local mx = UnitHealthMax(guid) or 0
local pct = mx > 0 and ((UnitHealth(guid) or 0) / mx) or 1
if pct > 0.25 then
if not F.enraged then
F.enraged = now - F.t0
F.enrageBoss = b.name
end
T:Line("enrage", b.name .. " goes " .. sp .. " at " .. floor(pct * 100) .. "% health")
else
T:Line("boss", b.name .. " goes " .. sp .. " (low health)")
end
end
end
------------------------------------------------------------------ deaths
-- when each raider last used a potion, a healthstone or a class defensive.
-- Kept for the whole session (not just the fight), so a potion drunk just
-- before the pull still counts as on cooldown when they die.
T.cdLog = {}
function T:NoteUse(name, item)
local log = T.cdLog[name]
if not log then log = {}; T.cdLog[name] = log end
if string.find(item, "Healthstone", 1, true) then
log.healthstone = { GetTime(), item }
elseif string.find(item, "Potion", 1, true) or item == "Restore Mana" or item == "Healing Potion" then
log.potion = { GetTime(), item } -- every potion shares one 2 minute cooldown
end
end
local function warlockInRaid()
for _, e in pairs(W.roster.byName) do
if e.class == "WARLOCK" then return true end
end
end
-- at a death: what was off cooldown ({ { label, kind, note }, ... }) and what
-- wasn't ({ { label, seconds left, what they used }, ... }). log: optional
-- (the demo passes its own)
function T:Readiness(name, class, now, log)
log = log or T.cdLog[name] or {}
local ready, cooling = {}, {}
local function check(key, label, cd, kind, note)
local u = log[key]
local t = (type(u) == "table") and u[1] or u
if t and now - t < cd then
tinsert(cooling, { label, floor(cd - (now - t)), (type(u) == "table") and u[2] or nil })
else
tinsert(ready, { label, kind, note })
end
end
check("potion", "Healing / protection potion", 120, "potion")
if log.healthstone or log.warlock or warlockInRaid() then check("healthstone", "Healthstone", 120, "healthstone") end
local defs = W.Data.defensives[class or ""] or {}
for i = 1, getn(defs) do check(defs[i][1], defs[i][1], defs[i][2], "spell", defs[i][3] and "if they took the talent" or nil) end
return ready, cooling
end
function T:Died(guid)
if not F then return end
local b = F.bosses[guid]
if b then
noteSlain(guid)
if not b.dead then
b.dead = true
b.diedAt = GetTime() - F.t0
T:Line("kill", b.name .. " dies")
T:CheckKill()
end
return
end
local e = W.roster.byGuid[guid]
if e then T:Death(e, guid) end
end
function T:Death(e, guid)
local p = T:P(e.name)
if p.deadAt then return end
local now = GetTime()
p.deadAt = now
local d = {
name = e.name, class = e.class, t = now - F.t0, at = now,
lines = {}, debuffs = {}, verify = now + 1.5,
}
-- both rings (damage taken, everything else), newest back to the window, in time order
local function read(ring, h)
if not ring then return end
for i = 0, RECAP_SIZE - 1 do
local idx = h - i
if idx < 1 then idx = idx + RECAP_SIZE end
local r = ring[idx]
if not r or not r.t or now - r.t > RECAP_WINDOW or r.t < F.t0 or r.t <= (p.lastDeathAt or 0) then break end
tinsert(d.lines, {
t = r.t - F.t0, k = r.k, s = r.src, sp = r.sp, a = r.a, c = r.c,
hp = r.hp, hm = r.hpm, x = r.x,
})
end
end
read(p.recap, p.rhead)
read(p.drecap, p.dhead or 0)
table.sort(d.lines, function(a, b) return a.t < b.t end)
p.lastDeathAt = now
local ad = guid and F.activeDebuffs[guid]
if ad then
for sp in pairs(ad) do tinsert(d.debuffs, sp) end
end
d.cons = p.nCons
-- freeze what was off cooldown at this moment: what could have saved them
d.ready, d.cooling = T:Readiness(e.name, e.class, now)
for _, b in pairs(F.bosses) do
if guid and b.holder == guid then d.hadAggro = b.name end
end
tinsert(F.deaths, d)
d.line = T:Line("death", e.name .. " died", e.name)
end
-- feign death check + resurrections
function T:CheckLiving()
local now = GetTime()
for i = 1, getn(F.deaths) do
local d = F.deaths[i]
if d.verify and now >= d.verify then
d.verify = nil
local e = W.roster.byName[d.name]
if e and UnitHealth(e.unit) > 0 and not UnitIsGhost(e.unit) then
d.fd = true
local p = F.players[d.name]
if p then p.deadAt = nil end
if d.line then d.line.k = "info"; d.line.x = d.name .. " feigned death" end
end
end
end
for name, p in pairs(F.players) do
if p.deadAt and now - p.deadAt > 3 then
local e = W.roster.byName[name]
if e and not UnitIsDeadOrGhost(e.unit) and UnitHealth(e.unit) > 0 then
p.deadTotal = p.deadTotal + (now - p.deadAt)
p.deadAt = nil
T:Line("info", name .. " is back up", name)
end
end
end
end
------------------------------------------------------------------ aggro
function T:Accrue(b, now)
if b.holder and b.lastAcc then
local e = W.roster.byGuid[b.holder]
if e then
local p = F.players[e.name]
if p and now > b.lastAcc then p.aggroTime = p.aggroTime + (now - b.lastAcc) end
end
end
b.lastAcc = now
end
function T:AggroChange(bguid, b, g, now, how)
T:Accrue(b, now)
local old = b.holder
b.holder = g
b.cand = g
b.candSince = now
local e = W.roster.byGuid[g]
if not e then return end
local oe = old and W.roster.byGuid[old]
local th = F.threat[e.name]
local perc = th and (now - th.t) < 5 and th.perc or nil
local op = oe and F.players[oe.name]
local oldDead = op and op.deadAt and (now - op.deadAt) < 8
tinsert(F.aggro, {
t = now - F.t0, boss = b.name, to = e.name, class = e.class,
from = oe and oe.name, perc = perc, how = how, oldDead = oldDead and true or nil,
})
if not F.firstHolder then F.firstHolder = e.name end
T:Line("aggro", b.name .. " -> " .. e.name .. (perc and (" (" .. perc .. "% threat)") or ""), e.name)
end
function T:BossSwing(bguid, vguid, p, now)
local b = F.bosses[bguid]
p.bossSwings = p.bossSwings + 1
if b.holder ~= vguid then T:AggroChange(bguid, b, vguid, now, "melee") end
end
function T:PollTargets()
local now = GetTime()
for guid, b in pairs(F.bosses) do
if not b.dead then
T:Accrue(b, now)
local tg = W.Guid(guid .. "target")
if tg ~= b.cand then
b.cand = tg
b.candSince = now
end
if b.cand and b.cand ~= b.holder and now - b.candSince >= AGGRO_CONFIRM
and (not b.castUntil or now > b.castUntil) then
T:AggroChange(guid, b, b.cand, now, "target")
end
end
end
end
------------------------------------------------------------------ casts (SuperWoW)
function T:CastEvent(caster, target, evt, spellId, dur)
if evt == "CAST" then
local sp = W.SpellName(spellId)
if W.Data.defensiveSpell[sp] then
local who, pet = W.Owner(caster)
if who and not pet then
T.cdLog[who] = T.cdLog[who] or {}
T.cdLog[who][sp] = GetTime()
end
end
end
if not F then return end
local now = GetTime()
local n, isPet = W.Owner(caster)
dur = (tonumber(dur) or 0) / 1000
if n then
local p = T:P(n)
if not isPet then
if evt == "START" or evt == "CHANNEL" then
mark(p, now, now + dur)
elseif evt == "CAST" then
mark(p, now, now + 1)
elseif evt == "MAINHAND" or evt == "OFFHAND" then
mark(p, now, now + 1.5)
end
end
if evt == "CAST" then
local D = W.Data
local sp = W.SpellName(spellId)
if D.kickSpells[sp] then
p.kicks = p.kicks + 1
if target then F.lastKick[target] = { name = n, t = now } end
elseif sp == "Tranquilizing Shot" then
p.tranqs = p.tranqs + 1
F.lastTranq = { name = n, t = now }
end
local te = target and W.roster.byGuid[target]
if D.absorbSpells[sp] then
F.shields[te and target or caster] = { by = n, t = now, sp = sp }
end
if D.taunts[sp] then
tinsert(F.tauntCasts, { t = now - F.t0, who = n })
end
local kind = D.saveCasts[sp]
if kind and te and te.name ~= n then
if kind == "rez" then
if UnitIsDeadOrGhost(te.unit) then T:Save("rez", n, te.name, sp) end
elseif kind == "mana" then
local m = liveFrac(te.unit, true)
if m and m < 0.3 then T:Save("innervate", n, te.name, sp, nil, m) end
else
local hp = liveFrac(te.unit)
local hadAggro = false
for _, b in pairs(F.bosses) do
if b.holder == target then hadAggro = true end
end
if hp and (hp < 0.35 or (hadAggro and sp == "Blessing of Protection")) then
T:Save("cooldown", n, te.name, sp, nil, hp)
end
end
end
if D.selfSaves[sp] and not isPet then
local me = W.roster.byName[n]
local hp = me and liveFrac(me.unit)
if hp and hp < 0.3 then T:Save("self", n, n, sp, nil, hp) end
end
end
return
end
if evt == "MAINHAND" or evt == "OFFHAND" then return end
local sp = W.SpellName(spellId)
local b = F.bosses[caster]
if b then
if evt == "START" or evt == "CHANNEL" then
b.castUntil = now + dur + 0.5
else
b.castUntil = now + 0.5
end
if evt == "START" or (evt == "CAST" and not F.casting[caster]) then
local last = F.castLines[sp]
if not last or now - last > 5 then
F.castLines[sp] = now
T:Line("cast", b.name .. (evt == "START" and " begins casting " or " casts ") .. sp)
end
end
end
if W.Data.interrupts[sp] then
local st = F.interrupts[sp]
if not st then st = { casts = 0, kicked = 0, done = 0, by = {} }; F.interrupts[sp] = st end
if evt == "START" then
st.casts = st.casts + 1
F.casting[caster] = { sp = sp, t = now }
elseif evt == "FAIL" then
local c = F.casting[caster]
if c and c.sp == sp then
st.kicked = st.kicked + 1
local k = F.lastKick[caster]
if k and now - k.t < 1.5 then st.by[k.name] = (st.by[k.name] or 0) + 1 end
F.casting[caster] = nil
end
elseif evt == "CAST" then
local c = F.casting[caster]
if c and c.sp == sp then
st.done = st.done + 1
F.casting[caster] = nil
T:Line("interrupt", sp .. " was not interrupted (" .. W.GuidName(caster) .. ")")
end
end
elseif evt == "CAST" then
F.casting[caster] = nil
end
if evt == "CAST" and W.Data.raidFail[sp] then
F.raidFails[sp] = (F.raidFails[sp] or 0) + 1
T:Line("fail", W.Data.raidFail[sp].tip)
end
end
function T:SpellGo(itemId, spellId, casterGuid)
itemId = tonumber(itemId)
if not itemId or itemId == 0 then return end
local e = W.roster.byGuid[casterGuid]
if not e then return end
-- prefer the item's name ("Major Mana Potion") over its spell ("Restore Mana")
local sp = GetItemInfo(itemId) or W.SpellName(spellId)
T:NoteUse(e.name, sp) -- cooldowns are tracked between fights too
if not F then return end
local p = T:P(e.name)
p.consumes[sp] = (p.consumes[sp] or 0) + 1
p.nCons = p.nCons + 1
-- last-second health potion / healthstone
local D = W.Data
for i = 1, getn(D.healthItems) do
if string.find(sp, D.healthItems[i], 1, true) then
local hp = liveFrac(e.unit)
if hp and hp < 0.3 then T:Save("potion", e.name, e.name, sp, nil, hp) end
break
end
end
push(p, "item", e.name, sp, 0)
end
function T:SpellMiss(casterGuid, targetGuid, spellId, missInfo)
if not F or not F.bosses[targetGuid] then return end
local sp = W.SpellName(spellId)
if not W.Data.taunts[sp] then return end
local n = W.Owner(casterGuid)
if not n then return end
F.taunts[n] = (F.taunts[n] or 0) + 1
T:Line("taunt", n .. "'s " .. sp .. " was resisted", n)