-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFearWardHelper.lua
More file actions
2459 lines (2242 loc) · 103 KB
/
Copy pathFearWardHelper.lua
File metadata and controls
2459 lines (2242 loc) · 103 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
--[[ FearWardHelper ------------------------------------------------------------
A Priest helper for the spell **Fear Ward** (spell id 6346: instant, 30s
cooldown, 10 min buff that blocks the next fear). It does three things:
1. COOLDOWN TRACKING -- lists every priest in the party/raid and shows whose
Fear Ward is ready vs. counting down. Cooldowns are learned two ways:
- locally, by observing SuperWoW's UNIT_CASTEVENT (which fires for *every*
unit's casts, not just the player -- so any priest you can see starts a
CD here, addon or not), and
- over the air, by broadcasting your own casts to other FearWardHelper
users (fills the gap for priests you can't see). See "Sync".
2. WARD TRACKING -- for a configurable watch-list of player names, shows whether
each has the Fear Ward buff and the time left. Fear Ward's duration is a FIXED
10 min (nothing modifies it), so we only need the application *moment*:
- event-driven, via this client's BUFF_ADDED_*/BUFF_REMOVED_* combat-log
events -- exact application/refresh time and instant loss (a fear consumed
it, the player cancelled it, or it expired -- the event is reason-agnostic);
- the 0.25s buff scan + observed/synced cast act as fallback/reconciliation
(for self the scan reads the exact remaining via GetPlayerBuffTimeLeft);
- timers are persisted as a time() epoch, so a countdown survives /reload.
All of these reach only combat-log range; a ward applied out of sight that we
never saw shows "warded" without a timer until a scan or event sees it.
3. CAST HELPER -- click a watched player's row, or call the macro globals, to
cast Fear Ward on them without dropping your current target.
Cooldown + ward tracking work for ANY class (raid-lead observation); only the
cast helper requires being a Priest who knows Fear Ward (`canCast`).
SuperWoW (UNIT_CASTEVENT / SpellInfo / GUIDs / BUFF_* events) gives full tracking.
Without it the frames still show and the cast helper still works; ward presence is
polled via base-API UnitBuff (your own timer read exactly) and cooldowns still
arrive over sync from other FearWardHelper users -- but live cast observation and
the event-driven ward signal are gone, so your *own* cooldown after casting won't
self-populate (no UNIT_CASTEVENT to see it).
Locale-independence (like our sibling addon PrayerHelper): the spell is matched
by **id** (6346) for casts and by its **icon texture** for buff scans -- never by
the translated name. The one place a name is needed (CastSpellByName) uses the
localized name from SpellInfo(6346); without SuperWoW that comes from the
spellbook (matched by the fixed icon) instead, never a hardcoded English string.
Config via `/fw` / `/fw config` (opens the options panel) or `/fw lock` / `/fw
unlock`; everything else lives in that panel, which drives the same setters.
Layout, scale, lock and the watch-list live in FearWardHelperDB.
----------------------------------------------------------------------------- ]]
local FEAR_WARD_ID = 6346
local FEAR_WARD_CD = 30 -- seconds; this server's Fear Ward cooldown
local FEAR_WARD_DURATION = 600 -- seconds; 10 min buff
local FEAR_WARD_RANGE = 30 -- yards; cast range (drives the hover range/LOS check)
local RANGE_SLACK = 3 -- yards of fudge for centre-to-centre vs the server's
-- edge-to-edge check (cf. pfUI's +5). Used by castBlock
-- (cast/LOS gate) and oorDistance (the hover OOR gate).
local ADDON_PREFIX = "FWH" -- addon-message prefix for cooldown sync
-- LibWidgets (Libs\LibWidgets\LibWidgets.lua) is vendored source shared
-- across addons, so it can't hardcode which addon it's running in; every
-- LibWidgets.NewListEditor(...) call below passes this as spec.textureDir.
local LIB_WIDGETS_TEXTURE_DIR = "Interface\\AddOns\\FearWardHelper\\Libs\\LibWidgets\\textures\\"
-- Direction arrow shown on a watch row when the target is in object range but out
-- of cast range -- which way to run to reach them. Uses pfQuest's arrow.tga: a
-- 512x512 sheet, a 9x12 grid of 56x42 frames = 108 pre-rotated arrows (no
-- texture-rotation API on 1.12), cell 0 = pointing straight up / ahead. The world
-- bearing (UnitPosition, +X north/+Y west) and the minimap-arrow facing share a
-- north-up frame, but the atlas frame order (CW vs CCW) and the model's zero-facing
-- reference needed a one-time in-game tune (done -- the values below are calibrated
-- for this client): if the arrow ever points mirror-wrong flip ARROW_SIGN; if it's
-- off by a constant rotation nudge ARROW_OFFSET (radians, e.g. math.pi = 180, pi/2 =
-- a quarter turn).
local ARROW_TEXTURE = "Interface\\AddOns\\FearWardHelper\\textures\\arrow"
local ARROW_FRAMES = 108
local ARROW_COLS = 9
local ARROW_CW = 56
local ARROW_CH = 42
local ARROW_SHEET = 512
local ARROW_SIGN = 1 -- +1/-1: mirror flip (calibrated)
local ARROW_OFFSET = 0 -- radians: constant rotation correction (calibrated)
-- The localized spell name and lowercased icon texture for Fear Ward, resolved
-- once from SpellInfo(6346). The name drives CastSpellByName; the icon drives the
-- locale-free buff scan. SpellInfo works on any id (a SuperWoW DBC lookup),
-- whether or not the player knows the spell.
local fearWardName, fearWardIcon
-- The nine vanilla class tokens (as UnitClass returns them), in the default sweep
-- priority order. Role/spec is undeterminable for other players on this 1.12 client
-- (no talent inspection), so class is the only per-unit proxy the sweep can order by.
-- Healers + casters first (most hurt by an uninterrupted fear); the user reorders.
local CLASS_TOKENS = {
"PRIEST", "PALADIN", "DRUID", "SHAMAN",
"MAGE", "WARLOCK", "HUNTER", "ROGUE", "WARRIOR",
}
-- Frame layout / scale / lock defaults. The two frames are configured
-- independently; `/fw reset` restores exactly these (the watch-list is kept).
local DB_DEFAULTS = {
watchList = {}, -- ordered list of watched names; index = priority
watchDisabled = {}, -- lower(name) -> true for names temporarily not tracked (off)
watchHidden = {}, -- lower(name) -> true for names hidden from the tracker but still priority
wards = {}, -- name -> time() epoch the buff expires (persisted timers)
showWhenSolo = false, -- keep the frames up even when not grouped
hidden = false, -- master toggle: hide the addon entirely regardless of group/solo
bgOpacity = 0.8, -- tracker frame background alpha (0-1)
wardNextSweep = false, -- WardNext: when all tracked are warded, ward any unwarded group member
lowDuration = 60, -- seconds: a ward with less than this left shows orange + becomes a
-- WardNext priority (top-off early); 0 disables (only unwarded count)
sweepClassOrder = CLASS_TOKENS, -- class priority order for the sweep (copied per-DB by applyDefaults)
sweepClassDisabled = {}, -- class token -> true: that class is ignored by the sweep
-- Notifications (the floating message area; see "Notifications" below).
notifyApply = true, -- announce a Fear Ward gain (source -> target)
notifyApplyUntracked = false, -- also announce gains/losses for in-group players not on the watch-list
notifyLoss = true, -- announce a tracked target losing Fear Ward
notifyLowDuration = false, -- announce when a tracked target's ward drops below lowDuration
notifyCastFail = false, -- announce when WardNext blocks on an unreachable visible tracked target
notifyCDReady = false, -- announce when your *own* Fear Ward comes off cooldown
notifyCDReadyGroup = false, -- also announce when other group priests' Fear Ward is ready
notifyDuration = 5, -- seconds a notification stays before it fades out
notifyFontSize = 14, -- notification line font size ("resize" = font size)
cdFrame = { point = "CENTER", x = -220, y = 120, scale = 1.0, width = 170, locked = false },
notifyFrame = { point = "CENTER", x = 0, y = 150, scale = 1.0, width = 240, locked = false },
}
-- Row geometry (frame width is per-frame in the DB; height auto-fits the rows).
local HEADER_H = 18 -- space the title occupies at the top
local ROW_H = 14 -- per-row height
local PAD = 6 -- inner padding
local SUBHEAD_H = 16 -- the "Targets" sub-section header above the watch rows
----------------------------------------------------------------------------
-- Live state (rebuilt from the roster; not persisted)
----------------------------------------------------------------------------
-- Roster, refreshed on every roster change. priests is ordered for display.
local priests = {} -- { {name=, unit=, guid=, class=}, ... } priests in group
local roster = {} -- ordered list of every present group member's name (sweep order)
local unitByName = {} -- actual name -> unit token (for buff scan / casting)
local nameByGuid = {} -- guid -> actual name (resolve cast events)
local presentLower = {} -- lower(name) -> actual name (case-insensitive match)
local classByName = {} -- actual name -> non-localized class token (name colour)
-- Tracked timers, keyed by actual player name.
local cdReadyAt = {} -- name -> GetTime() when Fear Ward comes off cooldown (transient)
local cdNotifyPending = {} -- name -> true while we await its CD elapsing (to notify "ready" once)
local wardExpiresAt = {} -- name -> time() epoch their buff expires; reassigned to the
-- persisted FearWardHelperDB.wards at VARIABLES_LOADED so the
-- countdown survives /reload + relog (epoch, not GetTime)
local wardPresent = {} -- name -> bool/nil; bool once a buff scan has an opinion, nil
-- when unscanned (then the timer alone decides "warded")
-- Row widget pools (created lazily, reused across rebuilds).
local cdRows = {}
local watchRows = {}
local watchVisible = 0 -- number of watch rows currently shown
local hoveredWatchRow -- the watch row under the mouse (cast helper), or nil
local isPriest, knowsFearWard, canCast
local playerName, playerGUID
local active = false -- whether the tracker is live (grouped / showWhenSolo)
local warnedNoSuperWoW = false
-- Forward declaration: the config panel (built lazily near the bottom) mirrors DB
-- state, so anything that mutates layout/watch-list pokes refreshConfig() to keep an
-- open panel in sync. Nil until the config section assigns it; callers guard on it.
local refreshConfig
-- Forward declaration: applyPosition converts DB anchor coords to a TOPLEFT SetPoint
-- (working around a client bug with non-TOPLEFT anchors). Called from rebuildRows
-- after sizing so the anchor edge stays fixed as height changes.
local applyPosition
-- Forward declaration: refreshDisplay (the per-tick status painter, defined lower
-- down) is poked by the watch-row hover handlers, which sit above its definition.
local refreshDisplay
-- Forward declaration: findWatchIndex (watch-list priority lookup, defined with the
-- watch-list editing functions near the bottom) is used early by the loss batcher to
-- rank simultaneous Fear Ward losses by priority.
local findWatchIndex
----------------------------------------------------------------------------
-- Small helpers
----------------------------------------------------------------------------
-- Recursively fill missing keys in `dst` from `src` (so new defaults appear on
-- upgrade without clobbering saved values). Sub-tables are merged, not replaced.
local function applyDefaults(dst, src)
for k, v in pairs(src) do
if type(v) == "table" then
if type(dst[k]) ~= "table" then dst[k] = {} end
applyDefaults(dst[k], v)
elseif dst[k] == nil then
dst[k] = v
end
end
end
-- "Ns" under a minute, "M:SS" at or above one.
local function fmtTime(sec)
sec = math.floor(sec + 0.5)
if sec < 0 then sec = 0 end
if sec >= 60 then
local m = math.floor(sec / 60)
return string.format("%d:%02d", m, sec - m * 60)
end
return sec .. "s"
end
local function superWoW() return SpellInfo ~= nil end
-- Class-colour for a non-localized class token (RAID_CLASS_COLORS), white if
-- unknown. Used to tint the name on each row.
local function classColor(class)
local c = class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
if c then return c.r, c.g, c.b end
return 1, 1, 1
end
-- A player name wrapped in its class colour as an inline |cff...|r escape, for use
-- in notification text (where a font string mixes several coloured spans). Falls
-- back to white for an unknown class, like classColor.
local function colorName(name)
local r, g, b = classColor(classByName[name])
return string.format("|cff%02x%02x%02x%s|r", r * 255, g * 255, b * 255, name)
end
-- Resolve Fear Ward's localized name + icon. SpellInfo (SuperWoW) is preferred;
-- without it, fall back to the locale-free icon path so the cast helper and
-- buff poll still work on a base client. The localized name is then filled
-- from the spellbook in scanKnowsFearWard() instead.
local function resolveFearWard()
if fearWardName and fearWardIcon then return end -- already fully resolved
if SpellInfo then
local name, _, icon = SpellInfo(FEAR_WARD_ID)
if name then
fearWardName = name
fearWardIcon = icon and string.lower(icon)
end
elseif not fearWardIcon then
-- No SuperWoW — use the fixed icon path (locale-free) as fallback.
fearWardIcon = "interface\\icons\\spell_holy_excorcism"
end
end
-- Is Fear Ward in the spellbook? Matched by icon (locale-free). Drives canCast.
-- When the icon matches and fearWardName is still nil (no SuperWoW to provide it),
-- capture the localized name from the spellbook so castOn can use it.
local function scanKnowsFearWard()
knowsFearWard = false
if fearWardIcon then
local book = BOOKTYPE_SPELL or "spell"
local i = 1
while true do
local name = GetSpellName(i, book)
if not name then break end
local tex = GetSpellTexture(i, book)
if tex and string.lower(tex) == fearWardIcon then
knowsFearWard = true
-- Fill the localized name from the spellbook when SpellInfo
-- wasn't available (base client without SuperWoW).
if not fearWardName then fearWardName = name end
break
end
i = i + 1
end
end
canCast = (isPriest == true) and knowsFearWard
end
-- Is a group member offline? UnitIsConnected returns false for a disconnected
-- unit (true for the player and any present, connected member). A nil unit / not
-- in group is treated as online so we never wrongly tag a row "Offline".
local function isOffline(unit)
return unit ~= nil and UnitExists(unit) and not UnitIsConnected(unit)
end
-- CRASH GUARD for the optional native services (UnitXP_SP3 / SuperWoW positional
-- calls). On this client those functions dereference the unit's *world object*
-- directly, so calling them on a unit with no loaded object -- offline, far out of
-- object range, or (the login/zone race) not yet streamed in even though it's in the
-- roster -- is a NATIVE access violation that hard-crashes the client. A Lua pcall
-- does NOT catch a native segfault, so the pcall wrappers below are only a backstop
-- for Lua-level errors; the REAL protection is never calling them unless the unit is
-- safe. A unit is safe only when it exists, is connected (not offline) AND is visible
-- to the client (the object is streamed in).
local function trackable(unit)
return unit ~= nil
and UnitExists(unit)
and UnitIsConnected(unit)
and UnitIsVisible(unit)
end
-- Player + unit world coords (pcall-guarded UnitPosition pairs), or nil if either is
-- unavailable. UnitPosition returns nil *gracefully* for a unit whose world object
-- isn't loaded, so a nil here doubles as the crash-safe signal that UnitXP must NOT
-- be called on this unit (UnitXP would deref that null object and native-crash). nil
-- when this client lacks UnitPosition at all (older SuperWoW).
local function worldPos(unit)
if not UnitPosition then return nil end
local okp, px, py = pcall(UnitPosition, "player")
local okt, tx, ty = pcall(UnitPosition, unit)
if okp and okt and px and py and tx and ty then return px, py, tx, ty end
return nil
end
-- Cast-helper range / line-of-sight. UnitXP_SP3 (present on this client) exposes
-- UnitXP("distanceBetween", a, b) -> yards and UnitXP("inSight", a, b) -> bool.
-- Detected once and pcall-guarded, since UnitXP_SP3 is an optional addon. A few
-- yards of slack is added to the range because distanceBetween is centre-to-centre
-- while the server measures edge-to-edge (cf. pfUI librange's +5 fudge).
local hasUnitXP
local function detectUnitXP()
if hasUnitXP == nil then
hasUnitXP = false
if UnitXP then
local ok, val = pcall(UnitXP, "distanceBetween", "player", "player")
if ok and val then hasUnitXP = true end
end
end
return hasUnitXP
end
-- Why a cast on `unit` would fail right now: "OOR" (out of range), "LOS" (no line of
-- sight) or nil when clear. Range is checked first (the more common, definite gate).
-- Without UnitXP we fall back to CheckInteractDistance for a coarse range gate only
-- (no line-of-sight test is possible then), so LOS simply never trips.
local function castBlock(unit)
if not unit or not UnitExists(unit) then return nil end
-- Offline / out of object range / not yet streamed in (a different zone, just very
-- far, or the login race) -> no position to measure, so range/LOS don't apply and
-- it's unreachable either way. This is also the crash gate: never query a unit whose
-- object may be null (see trackable). Avoids a false "LOS" from UnitXP too.
if not trackable(unit) then return "OOR" end
-- Second, definitive crash gate: only touch UnitXP once UnitPosition confirms the
-- world object is actually loaded (UnitPosition fails *safely*; UnitXP does not).
if UnitPosition and not worldPos(unit) then return "OOR" end
if detectUnitXP() then
local okD, dist = pcall(UnitXP, "distanceBetween", "player", unit)
if okD and dist and dist > FEAR_WARD_RANGE + RANGE_SLACK then return "OOR" end
local okS, inSight = pcall(UnitXP, "inSight", "player", unit)
if okS and inSight == false then return "LOS" end
return nil
end
-- Follow distance (~28y) is the closest interact gate to a 30y spell.
if not CheckInteractDistance(unit, 4) then return "OOR" end
return nil
end
-- Resolve a caster/target GUID (from UNIT_CASTEVENT) to a player name: prefer the
-- roster map, fall back to SuperWoW's "GUID as unit token" (nil if unknown).
local function guidName(guid)
if not guid then return nil end
if nameByGuid[guid] then return nameByGuid[guid] end
return UnitName(guid)
end
-- Each watch-list entry is in one of three states (a linear "level of involvement"):
-- * SHOWN -- tracked, rendered as a tracker row, a WardNext priority, notified.
-- * HIDDEN -- still a WardNext priority and still notified, but NOT rendered as a
-- tracker row (background priority you don't want cluttering the list).
-- * OFF -- kept in the list (with its priority slot) but otherwise inert: no
-- row, no priority, no notification -- e.g. someone not tanking tonight
-- but back next raid, so you don't remove + re-add them.
-- Stored as two sets keyed by lowercased name: watchDisabled (OFF) and watchHidden
-- (HIDDEN); OFF wins if both are somehow set. Both default empty so old lists upgrade.
local function watchState(name)
local key = string.lower(name)
if FearWardHelperDB.watchDisabled[key] then return "off" end
if FearWardHelperDB.watchHidden[key] then return "hidden" end
return "shown"
end
-- Whether the entry participates in tracking (priority/notify) at all -- true for
-- both SHOWN and HIDDEN, false only for OFF.
local function isWatchEnabled(name)
return watchState(name) ~= "off"
end
-- The actual roster name for a watched entry that is enabled (SHOWN or HIDDEN) and
-- present in the group (nil otherwise). The gate WardNext + notifications share, so
-- an OFF name silently drops out of both; HIDDEN names stay in (display gates below).
local function activeWatchTarget(name)
if not isWatchEnabled(name) then return nil end
return presentLower[string.lower(name)]
end
-- The actual roster name for an entry that should be RENDERED as a tracker row:
-- active, present AND not hidden. Hidden entries are active targets that simply
-- don't draw a row (so rebuildRows / the visibleTargets count use this, not the gate
-- above). Returns the roster name or nil.
local function visibleWatchTarget(name)
if watchState(name) ~= "shown" then return nil end
return presentLower[string.lower(name)]
end
----------------------------------------------------------------------------
-- Tracking state setters (shared by local observation and sync)
----------------------------------------------------------------------------
local function startCD(name)
if name then
cdReadyAt[name] = GetTime() + FEAR_WARD_CD
cdNotifyPending[name] = true -- fire a "ready" notification when this elapses
end
end
-- Mark a player warded until `expiry` (a Unix time() epoch), defaulting to a full
-- duration from now. Epoch (not GetTime) so the timer persists across /reload via
-- SavedVariables -- wardExpiresAt *is* FearWardHelperDB.wards, so writes are saved.
local function setWard(name, expiry)
if name then
wardExpiresAt[name] = expiry or (time() + FEAR_WARD_DURATION)
wardPresent[name] = true
end
end
-- Whether `name` is currently warded, plus their expiry epoch (or nil). A scan's
-- explicit true/false wins; an unscanned (nil) player is judged by the timer alone
-- (so a restored or out-of-range ward still shows). An elapsed timer = not warded.
local function isWarded(name)
local exp = wardExpiresAt[name]
local flag = wardPresent[name]
local now = time()
local present
if flag == nil then present = (exp and exp > now) and true or false
else present = flag end
if present and exp and exp <= now then present = false end
return present, exp
end
-- Mark a player's ward gone (explicitly absent), dropping its timer. Used by the
-- buff-removed event so a consumed/cancelled/expired ward clears instantly.
local function clearWard(name)
if name then
wardExpiresAt[name] = nil
wardPresent[name] = false
end
end
-- WardNext eligibility: a player needs a (re)ward if they're unwarded, OR warded but
-- with less than the configured low-duration threshold (lowDuration) remaining -- so a
-- soon-to-expire ward is topped off early rather than waiting for it to drop. A
-- threshold of 0 disables that early tier (only truly-unwarded players qualify). Shares
-- isWarded's timer logic; a "warded, no timer" (nil exp) entry is never low-duration.
local function needsWard(name)
local present, exp = isWarded(name)
if not present then return true end
local low = FearWardHelperDB.lowDuration or 0
return low > 0 and exp ~= nil and (exp - time()) < low
end
----------------------------------------------------------------------------
-- Notifications
--
-- A floating message area (FearWardHelper_Notify) showing transient lines that
-- fade out after a configurable delay. Two kinds, each independently toggleable:
-- * CAST -- "<source>: Fear Ward > <target>" (target class-coloured); fired from
-- the cast paths (UNIT_CASTEVENT + sync) which carry source AND target.
-- An in-range addon priest fires both paths for one cast, so a tiny
-- recentApply window dedups the pair. Tracked targets only, unless
-- notifyApplyUntracked.
-- * LOSS -- a tracked target losing Fear Ward. An AoE fear strips several at once,
-- so non-expiry losses are batched over a short window and condensed to
-- "<top priority> [and N more] lost Fear Ward"; a natural 10-min expiry
-- (predicted timer ~0 at removal) is reported on its own as "expired".
-- The notify frame is moveable + anchorable like the trackers but has no resize grip
-- -- its "size" is the font size. It is shown whenever loaded (a faint grab box +
-- handle while unlocked, fully transparent while locked + empty), so it can be placed
-- any time; only the message *generation* is gated on being active.
----------------------------------------------------------------------------
local NOTIFY_MAX = 6 -- max simultaneous lines (a burst can't run away)
local NOTIFY_FADE = 1.0 -- seconds of fade-out at the end of a line's life
local NOTIFY_BATCH = 0.4 -- seconds to gather simultaneous losses before condensing
local notifyLines = {} -- pool of font strings on the notify frame, by display row
local notifications = {} -- active lines, newest first: { born = GetTime(), text = }
local recentApply = {} -- caster..":"..target -> GetTime(); dedups observed+synced
local pendingLosses = {} -- set of names whose loss is waiting to be condensed
local lossFlushAt -- GetTime() to emit the batched loss line, or nil
local lowNotified = {} -- name -> true once we've announced its ward dropping below
-- lowDuration; reset when it rises back above (so a re-ward
-- re-arms the warning). Tracks the crossing edge regardless of
-- the notifyLowDuration toggle (cf. cdNotifyPending).
-- Wrap a notification's non-name text in the default label colour (yellow); player
-- names are class-coloured by colorName, everything else reads as this.
local NOTIFY_LABEL_COLOR = "ffffd200"
local function notifyLabel(text)
return "|c" .. NOTIFY_LABEL_COLOR .. text .. "|r"
end
-- A pooled notification font string for display row i (created lazily).
local function getNotifyLine(i)
if not notifyLines[i] then
notifyLines[i] = FearWardHelper_Notify:CreateFontString(nil, "OVERLAY")
end
return notifyLines[i]
end
-- The notify frame's anchor point doubles as the notification *alignment* + growth
-- direction (so the area "fills" away from the corner you pinned it to):
-- horizontal -- LEFT -> left-aligned, RIGHT -> right-aligned, else centred;
-- vertical -- TOP -> grow down, BOTTOM or vertical-centre -> grow up.
-- Returns the line anchor point (a valid SetPoint corner/edge), the JustifyH, and the
-- vertical offset sign (newest line sits at the anchor edge, older ones stack away).
local function notifyAlignment()
local point = FearWardHelperDB.notifyFrame.point or "CENTER"
local hEdge, justify
if string.find(point, "LEFT") then hEdge, justify = "LEFT", "LEFT"
elseif string.find(point, "RIGHT") then hEdge, justify = "RIGHT", "RIGHT"
else hEdge, justify = "", "CENTER" end
local vEdge, grow
if string.find(point, "TOP") then vEdge, grow = "TOP", -1 -- grow down
else vEdge, grow = "BOTTOM", 1 end -- grow up (BOTTOM/centre)
return vEdge .. hEdge, justify, grow
end
-- Inset offset (px, py) that nudges text in from whichever edges `anchor` touches, so
-- a corner-anchored line/handle clears the notify frame's border instead of escaping it.
local NOTIFY_PAD = 6
local function notifyPadOffset(anchor)
local px, py = 0, 0
if string.find(anchor, "LEFT") then px = NOTIFY_PAD
elseif string.find(anchor, "RIGHT") then px = -NOTIFY_PAD end
if string.find(anchor, "TOP") then py = -NOTIFY_PAD
elseif string.find(anchor, "BOTTOM") then py = NOTIFY_PAD end
return px, py
end
-- (Re)position + paint the active notifications. Newest sits at the anchor edge;
-- older lines stack away from it (direction from notifyAlignment). The font (and thus
-- line height) tracks the configurable notifyFontSize.
local function layoutNotifications()
local size = FearWardHelperDB.notifyFontSize or 14
local lineH = size + 4
local anchor, justify, grow = notifyAlignment()
local px, py = notifyPadOffset(anchor)
local n = table.getn(notifications)
for i = 1, n do
local fs = getNotifyLine(i)
fs:SetFont("Fonts\\FRIZQT__.TTF", size, "OUTLINE")
fs:SetJustifyH(justify)
fs:ClearAllPoints()
fs:SetPoint(anchor, FearWardHelper_Notify, anchor, px, py + grow * (i - 1) * lineH)
fs:SetText(notifications[i].text)
fs:Show()
end
for i = n + 1, table.getn(notifyLines) do notifyLines[i]:Hide() end
end
-- Add a notification line (already colour-coded). Newest lines push older ones down;
-- past NOTIFY_MAX the oldest is dropped so a spam burst stays bounded.
local function pushNotification(text)
table.insert(notifications, 1, { born = GetTime(), text = text })
while table.getn(notifications) > NOTIFY_MAX do table.remove(notifications) end
layoutNotifications()
end
-- Per-frame: age out expired lines and fade the rest over their last NOTIFY_FADE
-- seconds. Called every OnUpdate tick (not throttled) so the fade is smooth.
local function updateNotifications()
local now = GetTime()
local dur = FearWardHelperDB.notifyDuration or 5
local removed = false
local i = 1
while i <= table.getn(notifications) do
if now - notifications[i].born >= dur then
table.remove(notifications, i)
removed = true
else
i = i + 1
end
end
if removed then layoutNotifications() end
for j = 1, table.getn(notifications) do
local fs = notifyLines[j]
if fs then
local age = now - notifications[j].born
local a = 1
if age > dur - NOTIFY_FADE then
a = (dur - age) / NOTIFY_FADE
if a < 0 then a = 0 end
end
fs:SetAlpha(a)
end
end
end
-- Drop all transient notification state (lines + pending batch). Called when the
-- tracker deactivates so a re-group starts clean.
local function clearNotifications()
notifications = {}
pendingLosses = {}
lossFlushAt = nil
recentApply = {}
lowNotified = {}
layoutNotifications()
end
-- Whether a Fear Ward gain/loss on `name` should notify. We only ever announce about
-- someone actually in the raid/party -- never an outsider we merely see in combat-log
-- range -- and this holds even for the include-untracked case. A tracked (enabled +
-- present) name always qualifies; an untracked one only when notifyApplyUntracked is
-- on AND they are in the group.
local function notifyAllowed(name)
if not name then return false end
if activeWatchTarget(name) then return true end
if not FearWardHelperDB.notifyApplyUntracked then return false end
return presentLower[string.lower(name)] ~= nil
end
-- A Fear Ward cast we saw (locally or over sync): announce source -> target if
-- enabled, deduping the observed+synced double-fire for one cast.
local function notifyApply(caster, target)
if not FearWardHelperDB.notifyApply then return end
if not caster or not target then return end
if not notifyAllowed(target) then return end
local key = caster .. ":" .. target
local now = GetTime()
if recentApply[key] and now - recentApply[key] < 1.5 then return end
recentApply[key] = now
pushNotification(notifyLabel("Fear Ward gained by ") .. colorName(target)
.. notifyLabel(" (") .. colorName(caster) .. notifyLabel(")"))
end
-- Emit the condensed loss line for everyone who lost Fear Ward inside the batch
-- window, headlined by the highest-priority (lowest watch index) name.
local function flushLosses()
lossFlushAt = nil
local names = {}
for name in pairs(pendingLosses) do table.insert(names, name) end
pendingLosses = {}
local n = table.getn(names)
if n == 0 then return end
local best, bestIdx
for i = 1, n do
local idx = findWatchIndex(names[i]) or 9999
if not bestIdx or idx < bestIdx then bestIdx = idx; best = names[i] end
end
if n == 1 then
pushNotification(notifyLabel("Fear Ward lost by ") .. colorName(best))
else
pushNotification(notifyLabel("Fear Ward lost by ") .. colorName(best)
.. notifyLabel(" (+" .. (n - 1) .. " more)"))
end
end
-- A Fear Ward just dropped (BUFF_REMOVED) on someone in the group (notifyAllowed --
-- tracked, or untracked + in-group when enabled). A predicted expiry (the timer was
-- due ~now) is its own "expired" line; any earlier loss is a fear/cancel, which we
-- batch (AoE fears strip several at once) -- see flushLosses.
local function notifyLoss(name)
if not FearWardHelperDB.notifyLoss then return end
if not notifyAllowed(name) then return end
local display = presentLower[string.lower(name)] or name
local exp = wardExpiresAt[name]
if exp and exp <= time() + 1 then
pushNotification(notifyLabel("Fear Ward expired on ") .. colorName(display))
else
pendingLosses[display] = true
if not lossFlushAt then lossFlushAt = GetTime() + NOTIFY_BATCH end
end
end
-- Announce a priest's Fear Ward coming off cooldown. Your own ready is gated on
-- notifyCDReady ("Fear Ward ready"); another group priest's on notifyCDReadyGroup
-- ("Fear Ward ready (<priest>)"). Non-group priests we merely observed are skipped.
local function notifyCDReadyFor(name)
if name == playerName then
if FearWardHelperDB.notifyCDReady then
pushNotification(notifyLabel("Fear Ward ready"))
end
elseif FearWardHelperDB.notifyCDReadyGroup and presentLower[string.lower(name)] then
pushNotification(notifyLabel("Fear Ward ready (") .. colorName(name) .. notifyLabel(")"))
end
end
-- Poll pending cooldowns and fire the "ready" notification as each elapses (once).
-- Pending is cleared on elapse regardless of the config gates, so a disabled toggle
-- doesn't leave stale entries; a gate flipped on mid-cooldown still announces.
local function checkCDReady()
local now = GetTime()
for name in pairs(cdNotifyPending) do
local ready = cdReadyAt[name]
if not ready or now >= ready then
cdNotifyPending[name] = nil
notifyCDReadyFor(name)
end
end
end
-- Poll warded targets and announce once when one crosses below the lowDuration
-- threshold ("running low" -- top them off soon). The crossing edge is tracked in
-- lowNotified regardless of the notifyLowDuration toggle (so toggling it on mid-low
-- doesn't retroactively fire for every already-low ward, and a re-ward that lifts the
-- timer back above the threshold re-arms the warning). Only names notifyAllowed -- a
-- tracked active target, or an untracked in-group member when notifyApplyUntracked --
-- actually push a line. A 0 threshold disables the feature and forgets all crossings.
local function checkLowDuration()
local low = FearWardHelperDB.lowDuration or 0
if low <= 0 then
if next(lowNotified) then lowNotified = {} end
return
end
local now = time()
for name, exp in pairs(wardExpiresAt) do
local remaining = exp - now
if remaining > 0 and remaining < low then
if not lowNotified[name] then
lowNotified[name] = true
if FearWardHelperDB.notifyLowDuration and notifyAllowed(name) then
local display = presentLower[string.lower(name)] or name
pushNotification(notifyLabel("Fear Ward running low on ") .. colorName(display))
end
end
else
lowNotified[name] = nil
end
end
end
----------------------------------------------------------------------------
-- Sync (broadcast our own Fear Ward casts; apply others')
--
-- One tiny addon message per cast ("cast <target>"), so no throttling library is
-- needed. Receivers start the sender's cooldown and mark the target warded. This
-- only fills gaps -- casts you can see are already handled by local observation,
-- and the two paths call the same idempotent setters, so duplicates are harmless.
----------------------------------------------------------------------------
local function broadcastCast(target)
if not SendAddonMessage then return end
local channel
if GetNumRaidMembers() > 0 then channel = "RAID"
elseif GetNumPartyMembers() > 0 then channel = "PARTY" end
if channel then
SendAddonMessage(ADDON_PREFIX, "cast " .. (target or "-"), channel)
end
end
local function handleAddonMessage()
-- arg1 prefix, arg2 message, arg3 channel, arg4 sender.
if arg1 ~= ADDON_PREFIX then return end
if arg4 == playerName then return end -- our own echo; handled locally already
startCD(arg4)
local _, _, target = string.find(arg2 or "", "^cast%s+(.+)$")
if target and target ~= "-" then
setWard(target)
notifyApply(arg4, target) -- arg4 = sender = the casting priest
end
end
----------------------------------------------------------------------------
-- Cast detection (SuperWoW UNIT_CASTEVENT)
----------------------------------------------------------------------------
local function handleCast()
-- Cheapest possible filter first: ignore every cast that isn't Fear Ward.
if arg4 ~= FEAR_WARD_ID then return end
-- Fear Ward is instant, so it emits "CAST" (no "START") when it goes off.
if arg3 ~= "CAST" then return end
if not playerGUID then local _, g = UnitExists("player"); playerGUID = g end
local caster = guidName(arg1)
if not caster then return end
startCD(caster)
local target = guidName(arg2)
if target then setWard(target) end
notifyApply(caster, target)
-- Share our own casts so out-of-range priests' clients learn the cooldown.
if arg1 == playerGUID then broadcastCast(target) end
end
-- Buff gain/loss, via this client's combat-log events BUFF_ADDED_*/BUFF_REMOVED_*
-- (arg1 = unit GUID, arg3 = spell id). Filtered to Fear Ward. This is the precise,
-- event-driven ward signal that the 0.25s poll only approximates:
-- ADDED -> applied or REFRESHED right now; Fear Ward's duration is a fixed 10
-- min, so an exact application time means an exact countdown.
-- REMOVED -> gone right now for ANY reason (a fear consumed it, the player
-- cancelled it, or it expired) -> clear instantly, no poll lag.
-- The events only reach combat-log range, same as the cast event / buff scan; a
-- ward applied out of sight still falls back to the poll + persisted prediction.
local function handleBuffEvent(added)
if tonumber(arg3) ~= FEAR_WARD_ID then return end
local name = guidName(arg1)
if not name then return end
if added then
setWard(name)
else
notifyLoss(name) -- reads the predicted timer before clearWard wipes it
clearWard(name)
end
end
----------------------------------------------------------------------------
-- Cast helper (Priest + knows Fear Ward only)
----------------------------------------------------------------------------
-- Cast Fear Ward on `unit` without losing the current target. On a Nampower
-- client CastSpellByName takes a unit parameter directly; otherwise fall back to
-- the AutoSelfCast-off + SpellTargetUnit dance (cf. pfUI mouseover).
local function castOn(unit)
if not unit or not UnitExists(unit) or not fearWardName then return end
-- Refuse an unreachable target (out of object range -- a different zone or just
-- very far): the cast can't land, and some client paths silently retarget us and
-- ward ourselves instead. The hover row already flags this as "OOR".
if not UnitIsVisible(unit) then return end
if GetNampowerVersion then
CastSpellByName(fearWardName, unit)
return
end
local selfcast = GetCVar("AutoSelfCast")
if selfcast ~= "0" then SetCVar("AutoSelfCast", "0") end
CastSpellByName(fearWardName)
if SpellIsTargeting() then SpellTargetUnit(unit) end
if SpellIsTargeting() then SpellStopTargeting() end
if selfcast ~= "0" then SetCVar("AutoSelfCast", selfcast) end
end
-- Cast Fear Ward on a named player if they are in the group. Macro-callable.
function FearWardHelper_Ward(name)
if not canCast then
DEFAULT_CHAT_FRAME:AddMessage("FearWardHelper: you can't cast Fear Ward (Priest who knows it only).")
return
end
local actual = presentLower[string.lower(name or "")]
local unit = actual and unitByName[actual]
if not unit then
DEFAULT_CHAT_FRAME:AddMessage("FearWardHelper: " .. tostring(name) .. " is not in your group.")
return
end
-- Don't waste a Fear Ward (and its cooldown) topping off a target who's still safely
-- warded: only (re)cast when they actually need it -- unwarded, or warded with less
-- than the low-duration threshold left (needsWard, the same gate WardNext uses).
if not needsWard(actual) then
local _, exp = isWarded(actual)
local left = exp and (exp - time())
local msg = "FearWardHelper: " .. actual .. " already has Fear Ward"
if left and left > 0 then msg = msg .. " (" .. fmtTime(left) .. " left)" end
DEFAULT_CHAT_FRAME:AddMessage(msg .. ".")
return
end
castOn(unit)
end
-- Exactly castOn's hard preconditions plus a clear range/LOS: used by the SOFT tiers to
-- skip an uncastable candidate (so they fall through instead of returning after a no-op).
-- castBlock alone isn't enough -- it returns nil for a missing unit.
local function reachable(name)
local unit = unitByName[name]
return unit and UnitExists(unit) and UnitIsVisible(unit) and not castBlock(unit)
end
-- The two TRACKED WardNext tiers, shared by FearWardHelper_WardNext (which then falls
-- through to the sweep) and FearWardHelper_WardNextTracked (which stops here). "Needs a
-- ward" (needsWard) means unwarded OR warded with less than the lowDuration threshold
-- left, so a soon-to-expire ward is topped off early.
-- 1. SHOWN tracked (priority order) -- HARD: the highest-priority present+unwarded
-- shown target is authoritative. If it's unreachable we BLOCK (cast nobody) and
-- optionally report a failed cast, rather than warding someone lower -- you want
-- to know your top tank can't be reached. This is the only tier that blocks.
-- 2. HIDDEN tracked (priority order) -- SOFT: a preferred unit before the sweep, but
-- an unreachable one is skipped (never blocks).
-- Returns true if it handled the request (a cast went out, OR tier 1 blocked on an
-- unreachable shown target); false only when nothing tracked needed a ward, so WardNext
-- may continue to the sweep.
local function wardNextTrackedTiers()
local wl = FearWardHelperDB.watchList
local n = table.getn(wl)
-- Tier 1: shown tracked.
for i = 1, n do
local actual = visibleWatchTarget(wl[i])
if actual and needsWard(actual) then
local block = castBlock(unitByName[actual])
if not block then
castOn(unitByName[actual])
elseif FearWardHelperDB.notifyCastFail then
local why = (block == "LOS") and "no line of sight" or "out of range"
pushNotification(notifyLabel("Fear Ward blocked: ") .. colorName(actual)
.. notifyLabel(" (" .. why .. ")"))
end
return true -- shown target is authoritative: cast it or block, never fall through
end
end
-- Tier 2: hidden tracked (preferred, but skip-if-unreachable).
for i = 1, n do
local name = wl[i]
if watchState(name) == "hidden" then
local actual = presentLower[string.lower(name)]
if actual and needsWard(actual) and reachable(actual) then
castOn(unitByName[actual])
return true
end
end
end
return false
end
-- Ward the next player. Macro-callable -- the keyboard-driven workflow. Runs the two
-- tracked tiers (see wardNextTrackedTiers) and, if neither warded anyone, sweeps:
-- 3. SWEEP (wardNextSweep) -- any present group member not on the watch-list, in
-- roster order: once here we just want *a* ward out, so unreachable ones are
-- skipped and the specific target is secondary.
function FearWardHelper_WardNext()
if not canCast then return end
if wardNextTrackedTiers() then return end
-- Tier 3: sweep any unwarded, reachable group member who isn't already an enabled
-- (shown/hidden) tracked priority -- so untracked members AND disabled (off) watch
-- entries are both eligible (off = "don't prioritize", not "never ward"); only the
-- shown/hidden entries are skipped here since tiers 1/2 already covered them.
-- Candidates are taken by class in sweepClassOrder (a disabled class is ignored
-- entirely), then roster order within a class.
if FearWardHelperDB.wardNextSweep then
local order = FearWardHelperDB.sweepClassOrder
local disabled = FearWardHelperDB.sweepClassDisabled
for c = 1, table.getn(order) do
local class = order[c]
if not disabled[class] then
for i = 1, table.getn(roster) do
local name = roster[i]
if classByName[name] == class then
local enabledTracked = findWatchIndex(name) and isWatchEnabled(name)
if not enabledTracked and needsWard(name) and reachable(name) then
castOn(unitByName[name])
return
end
end
end
end
end
end
end
-- Ward the next TRACKED player. Like FearWardHelper_WardNext but limited to the tracked
-- tiers (shown HARD + hidden SOFT) -- it INTENTIONALLY never sweeps untracked/off group
-- members, regardless of the wardNextSweep config. For a "top off my watch-list only"
-- keybind that won't spend a Fear Ward on someone you didn't ask to track.
function FearWardHelper_WardNextTracked()
if not canCast then return end
wardNextTrackedTiers()
end
----------------------------------------------------------------------------
-- Rows / display
----------------------------------------------------------------------------
-- Direction-arrow geometry / facing helpers (used by refreshHover below) --------
-- The minimap player-arrow Model, whose facing tracks the player's heading on a
-- non-rotating minimap (the arrow rotates; the map stays north-up). Found once by
-- scanning Minimap children for the Model whose path is exactly the player arrow.
-- The match is the full path segment "minimap\minimaparrow" (NOT a bare
-- "minimaparrow", which also matches the static "Rotating-MinimapArrow" decoy
-- models this client parents to the minimap -- they sit at facing 0 and would
-- freeze the heading). Locale-free, like pfQuest's compat/client.lua.
local minimapArrow
local function findMinimapArrow()
if minimapArrow then return minimapArrow end -- cache only a successful find
if not Minimap then return nil end
local kids = { Minimap:GetChildren() }
for _, v in pairs(kids) do
if v.IsObjectType and v:IsObjectType("Model") and v.GetModel and not v:GetName() then
local ok, m = pcall(function() return v:GetModel() end)
if ok and m and string.find(string.lower(m), "minimap\\minimaparrow", 1, true) then
minimapArrow = v
return v
end
end
end
return nil
end
-- Player heading in radians (north-up frame). Prefer a real GetPlayerFacing if the
-- client exposes one; otherwise read it off the minimap -- the compass ring on a
-- rotating minimap, else the player-arrow model. nil if none is available.
local function playerFacing()
if GetPlayerFacing then return GetPlayerFacing() end
-- GetCVar throws on this client for an unknown CVar, so pcall it; a missing
-- rotateMinimap CVar just means "not rotating" -> read the player-arrow model.
local ok, rot = pcall(GetCVar, "rotateMinimap")
if ok and rot == "1" then
if MiniMapCompassRing then return -MiniMapCompassRing:GetFacing() end
return nil
end
local arrow = findMinimapArrow()
if arrow then return arrow:GetFacing() end
return nil
end
-- Distance (yards) to `unit` IF it's in object range but out of cast reach -- i.e.
-- the "chase me" case -- else nil. Also returns the world delta (dx, dy) so the
-- bearing can be derived without a second UnitPosition pair. World coords from