-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCore.lua
More file actions
1705 lines (1534 loc) · 68.1 KB
/
Copy pathCore.lua
File metadata and controls
1705 lines (1534 loc) · 68.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
-- Manners -- core: the addon object, secret-safe access, failure handling,
-- defaults and their repair, the capability probe, which buff for which
-- person, unit inspection, names, and the lifecycle with its events.
--
-- Blizzard will not let an addon cast a spell on its own: CastSpellByName and
-- friends are protected and only run from a hardware event. So this addon does
-- every part of the job except the keypress -- it decides who deserves a buff
-- and parks that decision on a secure button. Prompt.lua owns that button;
-- this file and the seven after it in Manners.toc work out what goes on it:
--
-- Range.lua how near a passer-by has to be
-- Speech.lua the phrase sets and the line said with a click
-- Queue.lua debts, refusals, the never-offer list, friends, BuildQueue
-- Requests.lua people who ask for a buff in chat
-- Favours.lua noticing that somebody buffed you
-- Clicks.lua what became of a press, the global cooldown, the macro
-- Commands.lua first run, test console, snooze, sharing, slash commands
--
-- A local that one of them needs from another is put on ns where it is
-- defined. A file that loads later copies it into a local of its own at load;
-- one that loads earlier (this one, for the lifecycle) reads it off ns inside
-- the function that needs it, since at load it does not exist yet. Where a
-- comment in another file says "Core", it means this file and those seven.
-- The file's arguments are the addon's folder name and its namespace table.
local ns = select(2, ...)
-- Player-facing text, in the client's language: see Locales/Init.lua.
local L = ns.L
local addon = LibStub("AceAddon-3.0"):NewAddon((...), "AceEvent-3.0", "AceConsole-3.0", "AceTimer-3.0")
ns.addon = addon
local MANA = (Enum and Enum.PowerType and Enum.PowerType.Mana) or 0
ns.MANA = MANA
-- The label for Bindings.xml's entry under Options > Keybindings (this client's
-- game menu has no Key Bindings entry). A CLICK binding's name is not a Lua
-- identifier, so the label is set through _G.
_G["BINDING_NAME_CLICK MannersPrompt:LeftButton"] = L["Buff the prompted player"]
---------------------------------------------------------------------------
-- secret-safe access
---------------------------------------------------------------------------
local issecretvalue = _G.issecretvalue
local InCombatLockdown = _G.InCombatLockdown
local GetTime = _G.GetTime
-- Anything the API hands back may be a secret value. Secrets throw on
-- comparison and arithmetic, so everything we branch on passes through here
-- and becomes nil when we are not allowed to look at it.
local function plain(v)
if issecretvalue and issecretvalue(v) then return nil end
return v
end
ns.plain = plain
local function safecall(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, a, b, c = pcall(fn, ...)
if not ok then return nil end
return plain(a), plain(b), plain(c)
end
ns.safecall = safecall
-- One token swapped for one piece of text, with the text never read as a
-- pattern. A string replacement is a gsub template in which "%" is an escape,
-- and the reason lines and phrases are typed by the player ("10% left"); a
-- function replacement is returned verbatim. Shared here so Core and Prompt
-- cannot disagree. The parentheses drop gsub's second return, the match count.
function ns.Swap(text, token, value)
return ((text or ""):gsub(token, function() return value or "" end))
end
---------------------------------------------------------------------------
-- failure handling
--
-- Script errors are invisible unless the player turned them on, and a repeating
-- timer whose function throws simply stops. Anything that can fail goes through
-- here so the failure is named instead.
---------------------------------------------------------------------------
-- The last thirty failures (Guard wraps the tick, so one can repeat 2.5 times a
-- second), the count of all of them ever, and which labels have been announced.
ns.errors = {}
ns.errorCount = 0
ns.shouted = {}
function ns.Guard(label, fn, ...)
local ok, err = pcall(fn, ...)
if ok then return true end
-- This function is the thing that stops a failure being silent, so it must
-- never be the thing that throws.
label = tostring(label)
err = tostring(err)
ns.errorCount = (ns.errorCount or 0) + 1
ns.errors[#ns.errors + 1] = { at = date("%H:%M:%S"), where = label, err = err }
while #ns.errors > 30 do table.remove(ns.errors, 1) end
if not ns.shouted[label] then
ns.shouted[label] = true
if ns.addon and ns.addon.Print then
-- The label is the code's own name for the handler and the command
-- is typed as it stands, so neither is the translator's to change.
ns.addon:Print(L["|cffff4040something broke in %s|r -- %s |cff808080(%s for the rest)|r"]
:format(label, err, "/manners errors"))
end
-- Repaint Diagnostics once per new failure, but not for a failure of
-- the repaint itself, which would only fail again.
if ns.RepaintOptions and label ~= "options repaint" and label ~= "broker text" then
ns.RepaintOptions()
end
end
return false
end
---------------------------------------------------------------------------
-- telling the settings UI that something changed under it
--
-- AceConfig reads a control's value, name and `hidden` only while drawing, and
-- the broker text is assigned once, so anything that changes a setting outside
-- its own control (slash commands, the minimap right-click, a fight starting
-- or ending) calls this. Both halves are optional and guarded: Options.lua may
-- not have loaded, and a failed repaint must not take down the caller.
---------------------------------------------------------------------------
function ns.RepaintOptions()
if ns.RefreshOptionsDisplay then
ns.Guard("options repaint", ns.RefreshOptionsDisplay)
end
if ns.RefreshBrokerText then
ns.Guard("broker text", ns.RefreshBrokerText)
end
end
---------------------------------------------------------------------------
-- defaults
---------------------------------------------------------------------------
-- LibSharedMedia ships only "None", so Prompt.lua registers a sound of our own.
-- A file id rather than a path, because Register rejects paths under Sound\.
ns.SOUND_KEY = "Manners alert"
ns.SOUND_FILE = 567458
local defaults = {
profile = {
enabled = true,
verbose = true, -- "X buffed you", and why a cast failed
debugClicks = false, -- raw attribute dump on every click; on via /manners clicks
buff = {
choice = "auto",
-- The class's spells switched off, as a sparse set (absent = on).
-- Keys are class-unique, so a shared profile cannot cross classes.
skip = {},
},
sources = {
owed = true, -- people who buffed us
group = true, -- party/raid missing it
strangers = true, -- nearby non-group players
owedClassBuffsOnly = true, -- ignore stray HoTs and procs
-- People who ask for your buff in chat. Off, because reading chat is
-- guesswork, so the player should choose it.
asked = false,
},
-- The order of the queue, not who is on it.
priority = {
target = true, -- a deliberate target outranks a favour owed
-- Friends and guildmates ahead of the rest of their kind. On,
-- because it only reorders people who were offered anyway.
friends = true,
},
-- People never to offer anything to, as a set of filed names. Somebody
-- on it who buffs you is still offered the favour back (STATUS.md).
never = {},
filters = {
relevantOnly = true, -- skip people the buff does nothing for
requireInRange = true,
-- How near a passer-by has to be: cast | near | beside. Not "cast",
-- because thirty yards of a city square is twenty-odd nameplates.
proximity = "near",
-- Passers-by only where the game calls you resting. Off, because
-- it takes away offers somebody gets today.
restingOnly = false,
reachableOnly = true, -- hide people we cannot actually reach
restoreTarget = true, -- hand your target back after buffing
whenBuffed = "skip", -- skip | refresh | always
refreshUnder = 5, -- minutes left before a top-up is offered
minLevel = 1,
-- Off, because a press on a mount dismounts you, which somebody
-- buffing from the saddle may want. Dead, taxi and vehicle need no
-- switch: nothing can be cast there, so BuildQueue offers nobody.
hideMounted = false,
},
timing = {
reciprocateWindow = 120,
retryCooldown = 12,
scanInterval = 0.4,
graceSeconds = 45,
-- Whether a debt survives a reload or disconnect.
keepDebts = true,
},
prompt = {
locked = true,
-- Just above the action bars rather than over the play area, where
-- it would swallow clicks. The bottom edge keeps that distance at
-- any resolution, which a CENTER offset does not.
point = "BOTTOM",
relPoint = "BOTTOM",
x = 0,
y = 300,
width = 220,
height = 44,
scale = 1,
alpha = 1,
-- Only suppresses the click-outcome flash in a fight: Hide() on a
-- protected frame is refused in combat. The key keeps its old name
-- because renaming it would silently reset everyone's setting.
hideInCombat = false,
style = "glass",
accentByReason = true,
-- standard | colourblind: which four reason colours (Prompt.lua).
reasonPalette = "standard",
accentMode = "icon", -- icon | stripe | both | off
flashStyle = "pulse", -- pulse | once | off
-- /thank somebody who buffs you (Favours.lua). Off: an emote is
-- seen by everybody nearby, so the player chooses to make one.
thankEmote = false,
-- full | calm: calm is the prompt without the animations.
effects = "full",
-- The global cooldown swept over the spell icon.
showCooldown = true,
showIcon = true,
iconSize = 30,
roundIcon = false,
showCount = true,
showQueue = false,
queueRows = 3,
font = "Friz Quadrata TT",
fontSize = 13,
fontColor = { 1, 1, 1, 1 },
bgColor = { 0.04, 0.04, 0.06, 0.88 },
accentColor = { 0.45, 0.4, 0.9, 1 },
format = "{name}",
showSub = true,
-- Short to fit the 220px default width. They start in the player's
-- language; AceDB stores a line only once somebody types their own.
-- Each reason has its own words, so colour is never the only cue.
reasonTarget = L["your target"],
reasonOwed = L["buffed you"],
reasonGroup = L["in your group"],
reasonNearby = L["needs {buff}"],
reasonAsked = L["asked for it"],
-- A top-up gets its own line rather than qualifying the player's
-- text. {time} is what their current aura has left.
reasonRefresh = L["expires in {time}"],
reasonUnknown = L["unverified"],
classColor = true,
},
speech = {
enabled = false,
channel = "SAY",
onlyWhenReturning = true,
-- Filled in at load from the Roleplay set.
phrases = "",
},
-- owedOnly, to match the flash, which only pulses for a favour owed.
sound = { enabled = false, file = ns.SOUND_KEY, owedOnly = true },
minimap = { hide = false },
},
}
ns.defaults = defaults
-- Whether the prompt's first line says anything at all; shared by the repair at
-- load and the box's setter so they agree on what an empty line is.
function ns.UsableFormat(text)
return type(text) == "string" and text:find("%S") ~= nil
end
---------------------------------------------------------------------------
-- capability probe
---------------------------------------------------------------------------
local caps = { buffs = {} }
ns.caps = caps
local playerClass
-- For the files that load after this one, which cannot see the local, and
-- read it when they run: the probe sets it again each time it runs.
function ns.PlayerClass() return playerClass end
-- The client's own name for a spell id, or nil when this client does not have
-- the id. Both generations of the call are tried.
local function SpellNameFor(id)
return safecall(C_Spell and C_Spell.GetSpellName, id)
or safecall(_G.GetSpellInfo, id)
end
ns.SpellNameFor = SpellNameFor
-- Whether UnitName's second return is a surname here rather than a realm. Reads
-- ns.Flavour, settled at load, because callers can run before the first probe;
-- caps.unitNameIsSurname is set from this so the two cannot disagree.
local function SurnameClient()
return (ns.Flavour and ns.Flavour.flavour) == "camelot"
end
-- The probe's helpers, in a block of their own for the main chunk's 200 locals.
do
local function ProbeBuff(buff)
local info = { key = buff.key, buff = buff }
for _, id in ipairs(buff.ranks) do
if safecall(_G.IsSpellKnown, id) == true or safecall(_G.IsPlayerSpell, id) == true then
info.known = true
info.topRank = info.topRank or id
end
end
for _, id in ipairs(buff.group or {}) do
if safecall(_G.IsSpellKnown, id) == true or safecall(_G.IsPlayerSpell, id) == true then
info.knownGroup = true
end
end
-- The name resolves whether or not we know the rank, and every rank shares
-- it, so the macro can cast by name and let the game pick the best one.
info.name = SpellNameFor(buff.ranks[1])
info.icon = safecall(C_Spell and C_Spell.GetSpellTexture, buff.ranks[1])
-- Ids this client has never heard of. A wrong id has no symptom but silence,
-- so the mismatch is named in /manners debug and on the Diagnostics page.
-- Every aura id, since a dead group id only breaks the "already has it"
-- check. A diagnostic line rather than a popup, because a client still
-- loading spell data answers nil for everything (the probe re-runs on
-- SPELLS_CHANGED).
info.unresolved = {}
for _, id in ipairs(buff.auraIds) do
if not SpellNameFor(id) then
info.unresolved[#info.unresolved + 1] = id
end
end
-- Secrecy is decided per spell. Long-duration class buffs are the most
-- likely to stay readable, which is what the "who is missing it" feature
-- rests on, so record every id rather than sampling one.
info.secrecy = {}
local readable = 0
for _, id in ipairs(buff.auraIds) do
local secret
if C_Secrets and type(C_Secrets.ShouldSpellAuraBeSecret) == "function" then
secret = safecall(C_Secrets.ShouldSpellAuraBeSecret, id)
end
if secret == nil and C_Secrets and type(C_Secrets.GetSpellAuraSecrecy) == "function" then
local level = safecall(C_Secrets.GetSpellAuraSecrecy, id)
if level ~= nil and Enum and Enum.SecrecyLevel then
secret = (level ~= Enum.SecrecyLevel.NeverSecret)
end
end
info.secrecy[id] = secret
if secret == false then readable = readable + 1 end
end
info.readable = caps.getUnitAuraBySpellID and readable > 0
return info
end
-- Does this client still hand addons the combat log? Where it is gone,
-- registration throws, so one pcall'd RegisterEvent answers it. A frame of our
-- own (Ace's registry would keep the subscription), made once because this
-- re-runs on every SPELLS_CHANGED.
local probeFrame
local function ProbeCombatLog()
if type(_G.CreateFrame) ~= "function" then return nil end
if not probeFrame then
local made, frame = pcall(_G.CreateFrame, "Frame")
if not made then return nil end
probeFrame = frame
end
if type(probeFrame) ~= "table" or type(probeFrame.RegisterEvent) ~= "function" then
return nil
end
local ok = pcall(probeFrame.RegisterEvent, probeFrame, "COMBAT_LOG_EVENT_UNFILTERED")
if ok then
pcall(probeFrame.UnregisterEvent, probeFrame, "COMBAT_LOG_EVENT_UNFILTERED")
end
return ok
end
function ns.ProbeCapabilities()
wipe(caps)
caps.buffs = {}
-- What measures nearness depends on the spellbook, bags and libraries, and
-- this runs whenever those may have changed (SPELLS_CHANGED).
ns.ForgetProximity()
playerClass = plain(select(2, UnitClass("player")))
caps.class = playerClass
caps.getUnitAuraBySpellID = type(C_UnitAuras and C_UnitAuras.GetUnitAuraBySpellID) == "function"
caps.hasSecrets = type(C_Secrets) == "table"
caps.namePlates = type(C_NamePlate and C_NamePlate.GetNamePlates) == "function"
if C_Secrets and type(C_Secrets.ShouldAurasBeSecret) == "function" then
caps.aurasSecretNow = safecall(C_Secrets.ShouldAurasBeSecret)
end
---------------------------------------------------------------------
-- what this client is, and what follows from that
---------------------------------------------------------------------
-- Flavour.lua decided all of this at load; copied so one table answers
-- "what am I allowed to do here" for /manners debug and the saved probe.
local flavour = ns.Flavour or {}
caps.flavour = flavour.flavour
caps.family = flavour.family
caps.interface = flavour.interface
caps.recognised = flavour.recognised == true
-- Whether secrets are enforced right now. C_Secrets exists on clients
-- where nothing is restricted, so caps.hasSecrets is not this answer.
caps.secretRestrictions = safecall(C_Secrets and C_Secrets.HasSecretRestrictions)
-- The combat log: the family answers it for a named client, and the probe
-- only for one nobody here has seen. The probe can prove the log gone (a
-- throw), never present. Never probed on a known modern client, where
-- registering may raise ADDON_ACTION_FORBIDDEN, a popup with our name on it.
if caps.recognised and caps.family == "modern" then
caps.combatLogProbe = nil
else
caps.combatLogProbe = ProbeCombatLog()
end
if flavour.recognised then
caps.combatLog = caps.family == "classic"
else
caps.combatLog = caps.combatLogProbe == true
end
-- Whether the client parses macro conditionals like [@party1,help] at all.
-- It says nothing about whether a name resolves inside one.
caps.unitConditionals = type(_G.SecureCmdOptionParse) == "function"
-- ASSUMPTION: whether /cast [@Name] can replace /target, cast, target back.
-- No API answers it, and [@Name] resolves only for group members, so
-- Camelot keeps the /target route, the only one verified in game. Nothing
-- reads this yet.
caps.conditionalTargeting = flavour.flavour ~= "camelot"
-- /targetexact matches the whole name where /target matches a prefix
-- ("/target Mort" can find Mortimer). A client command, so probed.
local secureCommands = _G.SecureCmdList
caps.targetExact = (type(secureCommands) == "table"
and type(secureCommands.TARGET_EXACT) == "function")
or type(_G.SLASH_TARGET_EXACT1) == "string"
-- UnitName's second return is a surname on Camelot (joined with a space)
-- and a realm elsewhere (a dash, or dropped). Nothing in the value says
-- which, so this is a flavour branch.
caps.unitNameIsSurname = SurnameClient()
caps.anyKnown = false
caps.anyReadable = false
-- How many of this class's buffs carry an id this client does not have.
caps.unresolvedBuffs = 0
for _, buff in ipairs(ns.GetClassBuffs(playerClass) or {}) do
local info = ProbeBuff(buff)
caps.buffs[buff.key] = info
if info.known then caps.anyKnown = true end
if info.readable then caps.anyReadable = true end
if #info.unresolved > 0 then caps.unresolvedBuffs = caps.unresolvedBuffs + 1 end
end
caps.hasClassBuffs = ns.GetClassBuffs(playerClass) ~= nil
return caps
end
end
function ns.BuffInfo(buff)
return buff and caps.buffs[buff.key]
end
function ns.BuffName(buff)
local info = ns.BuffInfo(buff)
return (info and info.name) or buff and buff.key or "?"
end
function ns.IsBuffKnown(buff)
local info = ns.BuffInfo(buff)
return info and info.known == true
end
---------------------------------------------------------------------------
-- which buff for which person
---------------------------------------------------------------------------
-- The spell Automatic would name: reached only in Automatic (ResolveBuff answers
-- pins), so neverAuto is skipped outright. Honours the per-spell switches like
-- CastableBuffs, so the login line and previews never name a switched-off spell.
local function FirstKnownBuff()
local db = addon.db and addon.db.profile
local skip = db and db.buff and db.buff.skip
for _, buff in ipairs(ns.GetClassBuffs(playerClass) or {}) do
if ns.IsBuffKnown(buff) and not buff.neverAuto
and not (skip and skip[buff.key]) then
return buff
end
end
end
-- Everything of this class the player can actually cast, in list order, once
-- per scan. A neverAuto buff (Unending Breath) and a switched-off one are left
-- out unless pinned: a pin is a deliberate ask, and the options page hides the
-- switches while a spell is pinned.
function ns.CastableBuffs()
local db = addon.db and addon.db.profile
local pinned = db and db.buff.choice
local out = {}
for _, buff in ipairs(ns.GetClassBuffs(playerClass) or {}) do
if ns.IsBuffKnown(buff)
and (pinned == buff.key or not (db and db.buff.skip and db.buff.skip[buff.key]))
and (not buff.neverAuto or pinned == buff.key) then
out[#out + 1] = buff
end
end
return out
end
-- Whether everything this character could offer reaches only its party (a
-- warrior's Battle Shout), so "passers-by" means nothing for them. Computed
-- from the switches and pin rather than listed by class; shared by the options
-- page, the greeting and the favour line so they agree. `castable` is
-- CastableBuffs' answer when the caller already has it.
function ns.OnlyReachesGroup(castable)
castable = castable or ns.CastableBuffs()
if #castable == 0 then return false end
for _, buff in ipairs(castable) do
if not buff.partyOnly then return false end
end
return true
end
-- The spell pinned for this character, or nil for Automatic (and for another
-- class's pin). Shared by the walk and the options page so they agree.
function ns.PinnedBuff()
local db = addon.db and addon.db.profile
local choice = db and db.buff and db.buff.choice
if not choice or choice == "auto" then return nil end
return ns.FindBuff(playerClass, choice)
end
-- PickBuffFor in a block of its own with the helpers only it reads, to spare
-- the main chunk's locals (Lua 5.1 allows 200; tests/validate.py counts them).
do
-- Split in two because the exclusive branch needs the halves apart: "wrong
-- spell for this person" holds for the scan, "tried a moment ago" is a
-- cooldown. inParty, not inGroup: a shout reaches only the caster's
-- subgroup (see SameParty).
local function Castable(opts, buff)
if opts.relevantOnly and buff.manaOnly and opts.hasMana == false then return false end
if buff.partyOnly and not opts.inParty then return false end
return true
end
-- opts.blocked is handed opts, so the caller's answer can be a file-level
-- function rather than a closure made per person.
local function Blocked(opts, buff)
if not opts.blocked then return false end
return opts.blocked(buff, opts) == true
end
local function Eligible(opts, buff)
return Castable(opts, buff) and not Blocked(opts, buff)
end
-- The candidate list a pin reduces the walk to, reused for every call.
local PINNED_ONLY = {}
-- Which of their buffs this person should be offered, or nil for none. Every
-- castable buff is walked, so holding the first one never hides somebody.
--
-- `candidates` comes from CastableBuffs. `has(buff)` answers only the aura
-- question: has, remaining, and mine (their copy is our cast; nil unknown).
-- offerAnyway offer even when they are covered: we owe them a favour, and
-- a refresh takes nothing away.
-- rotate false where there is nothing to walk along: the tokenless
-- owed path has one buff per favour and cannot verify it.
--
-- Returns the buff, whether they hold it (true, false, or nil for "nobody
-- could tell", which callers keep apart from false), and for a top-up how
-- long theirs has left.
function ns.PickBuffFor(candidates, opts, has)
local db = addon.db and addon.db.profile
if not db or #candidates == 0 then return nil end
-- A pin means "only ever this one", no walk. A pin of another class's
-- (a profile shared with a priest alt) reads as Automatic.
local pinned = ns.PinnedBuff()
if pinned then
if not ns.IsBuffKnown(pinned) then return nil end
PINNED_ONLY[1] = pinned
candidates = PINNED_ONLY
end
-- Blessings overwrite each other, so holding any one of yours counts as
-- covered and walking would replace it. Another paladin's blessing
-- covers nothing of ours (they stack) but is not ours to give; one that
-- names nobody we can read counts as ours, or ours would be walked over.
-- Never rotated: rotating would take the blessing just given away again,
-- where offering the same one twice only refreshes it (ns.RotatesBuffs).
if ns.EXCLUSIVE_BUFFS[playerClass] then
local pick, allRead, onCooldown = nil, true, false
-- The first blessing they carry from another paladin, kept for a debt
-- with nothing else left to give: see the end of this branch.
local theirs
for _, buff in ipairs(candidates) do
-- Castable rather than Eligible: the blessing we tried moments ago
-- is the one they most likely carry, so it must still be read.
if Castable(opts, buff) then
local held, remaining, mine = has(buff)
if held == true and mine == false then
-- Another paladin's: ours of the same kind would only
-- replace it, so move on to a kind they lack -- unless we
-- offered this one moments ago, which still means "wait".
if Blocked(opts, buff) then
onCooldown = true
elseif not theirs then
theirs = buff
end
elseif held == true then
-- Covered, and for this class that is the end of it. First
-- the cooldown: offered one moments ago means wait.
if Blocked(opts, buff) then return nil, true end
-- The top-up, safest on this class: recasting a blessing
-- replaces it with itself. Ahead of the debt, as on the
-- ordinary path, since only `remaining` puts the top-up
-- wording on the prompt.
if opts.whenBuffed == "refresh" and remaining
and remaining <= (opts.refreshUnder or 5) * 60 then
return buff, true, remaining
end
-- A debt is repaid with the blessing they already hold
-- from us (or from nobody we can name), which refreshes it.
if not opts.offerAnyway then return nil, true end
return buff, true
else
-- "None of mine" only once every one has read back a
-- definite no: BuildQueue promotes over a debt on has ==
-- false, and the prompt drops the unverified wording.
if held ~= false then allRead = false end
if Blocked(opts, buff) then
onCooldown = true
elseif not pick then
pick = buff
end
end
end
end
-- A blessing on cooldown means this person was just offered one: leave
-- them alone until it lifts, readable or not. The aura cache is three
-- seconds deep, so a "none of yours" here is usually the reading from
-- before the cast, and acting on it would replace the blessing just given.
if onCooldown then return nil, nil end
-- A debt, and every blessing we could give is already on them from
-- another paladin: offer anyway, as the debt policy does for every class.
if not pick and opts.offerAnyway and theirs then return theirs, true end
-- Spelled out: `allRead and false or nil` is always nil.
if allRead then return pick, false end
return pick, nil
end
-- Three answers per candidate: they have it, they definitely do not, and
-- the client would not say. Something lacked outright wins, in list order.
local expiring, expiringRemaining
-- Where the rotation below starts, and what it falls back to, gathered on
-- the way past rather than as a list (this runs 2.5 times a second).
local last = ns.lastGave and ns.lastGave[opts.name]
local firstUnknown, afterLast, seenLast
-- The first thing they are known to carry, for a debt: the offer that
-- takes nothing away.
local firstHeld
for _, buff in ipairs(candidates) do
if Eligible(opts, buff) then
local held, remaining = has(buff)
if held == false then return buff, false end
if held ~= true then
if not firstUnknown then firstUnknown = buff end
if seenLast and not afterLast then afterLast = buff end
if buff.key == last then seenLast = true end
else
if not firstHeld then firstHeld = buff end
if opts.whenBuffed == "refresh" and remaining
and remaining <= (opts.refreshUnder or 5) * 60 and not expiring then
expiring, expiringRemaining = buff, remaining
end
end
end
end
-- Nothing definitely missing, but something nobody could read: with no
-- truth to go on, rotate past whatever was given last rather than offering
-- the top of the list forever. The tokenless owed path (rotate == false)
-- asks for the first thing it could cast.
if firstUnknown then
if opts.rotate == false then return firstUnknown, nil end
return afterLast or firstUnknown, nil
end
if expiring then return expiring, true, expiringRemaining end
-- Nothing missing, nothing running out, and a favour outstanding: offer
-- what they already hold, with `true` saying they are covered.
if opts.offerAnyway and firstHeld then return firstHeld, true end
return nil, true
end
end
-- The one spell named as "the spell you are about to cast" (login line,
-- preview, Roll a few, {spell}, /manners look), so it must be one the queue
-- would really offer, or nil. hasMana is passed in so the caller can reuse it.
function ns.ResolveBuff(hasMana)
local db = addon.db and addon.db.profile
if not db then return nil end
-- A pin is the only spell the walk considers, learned or not, so an
-- unlearned pin names nothing rather than falling through to Automatic.
local pinned = ns.PinnedBuff()
if pinned then return ns.IsBuffKnown(pinned) and pinned or nil end
-- The switches and the never-automatic rule apply here as in the walk.
local auto = ns.CLASS_AUTO[playerClass]
if auto then
local key = hasMana and auto.mana or auto.other
local buff = ns.FindBuff(playerClass, key)
if buff and ns.IsBuffKnown(buff) and not buff.neverAuto
and not (db.buff.skip and db.buff.skip[key]) then
return buff
end
end
return FirstKnownBuff()
end
-- Why ResolveBuff has nothing to name, said as the setting that decides it, so
-- a priest with every spell switched off is not sent to a trainer.
function ns.NothingToCast()
local pinned = ns.PinnedBuff()
if pinned and not ns.IsBuffKnown(pinned) then
return L["%s is pinned and not learned on this character"]:format(ns.BuffName(pinned))
end
if not caps.anyKnown then return L["no buff learned"] end
local db = addon.db and addon.db.profile
local skip = db and db.buff and db.buff.skip
for _, buff in ipairs(ns.GetClassBuffs(playerClass) or {}) do
-- Learned, switched on and still not chosen: a spell Automatic never
-- reaches for, which is not a switch anybody can find turned off.
if ns.IsBuffKnown(buff) and not (skip and skip[buff.key]) then
return L["Automatic never offers %s"]:format(ns.BuffName(buff))
end
end
return L["every spell you know is switched off under Who to buff"]
end
-- Whether the buff walk reads ns.lastGave back for this character. Not for a
-- paladin, deliberately: blessings overwrite each other, so rotating takes away
-- what the last click gave. Nothing may write ns.lastGave for them either.
function ns.RotatesBuffs()
return not ns.EXCLUSIVE_BUFFS[playerClass]
end
---------------------------------------------------------------------------
-- shared state
---------------------------------------------------------------------------
-- Nameplate tokens come from NAME_PLATE_UNIT_ADDED rather than off the frames,
-- because namePlateUnitToken read from a frame is a secret value here.
ns.nameplateUnits = {}
---------------------------------------------------------------------------
-- unit inspection
---------------------------------------------------------------------------
-- auraCache[guid][buffKey] = { at, has, expires, mine }, swept periodically
-- because a city puts hundreds of players through here. Keyed by player so
-- UNIT_AURA, the hot path, forgets a whole player in one assignment; the count
-- is of players.
local auraCache = {}
local auraCacheCount = 0
local function ForgetUnitAuras(guid)
if not guid or not auraCache[guid] then return end
auraCache[guid] = nil
auraCacheCount = auraCacheCount - 1
end
ns.ForgetUnitAuras = ForgetUnitAuras
local lastSweep = 0
local function SweepAuraCache(now)
if auraCacheCount < 400 then return end
-- A big table stays big in a city, so the sweep is also rate-limited.
if (now - lastSweep) < 10 then return end
lastSweep = now
for guid, perUnit in pairs(auraCache) do
local newest
for _, entry in pairs(perUnit) do
if not newest or entry.at > newest then newest = entry.at end
end
if not newest or (now - newest) > 10 then ForgetUnitAuras(guid) end
end
end
-- Returns has, secondsRemaining, mine. `has` is nil when the client refuses any
-- one of the buff's ids (the hidden one may be the one they wear);
-- `secondsRemaining` is nil when the timer is unreadable, not "about to expire";
-- `mine` is nil when the aura names nobody we can read.
local function UnitHasBuff(unit, buff, guid)
local info = ns.BuffInfo(buff)
if not info or not info.readable then return nil, nil end
local now = GetTime()
local perUnit = guid and auraCache[guid]
local cached = perUnit and perUnit[buff.key]
if cached and (now - cached.at) < 3 then
return cached.has, cached.expires and (cached.expires - now) or nil, cached.mine
end
-- Refusals (an id declared secret, a read that throws or comes back secret)
-- are counted, not read as absence: BuildQueue promotes a target over a debt
-- on a definite no, and the prompt drops its unverified wording.
local has, expires, mine, refused = false, nil, nil, false
for _, id in ipairs(buff.auraIds) do
if info.secrecy[id] == true then
refused = true
else
local ok, aura = pcall(C_UnitAuras.GetUnitAuraBySpellID, unit, id)
if not ok or (issecretvalue and issecretvalue(aura)) then
refused = true
elseif type(aura) == "table" then
has = true
local expiration = plain(aura.expirationTime)
if type(expiration) == "number" and expiration > 0 then expires = expiration end
-- Whose it is, for a paladin's blessings. isFromPlayerOrPlayerPet
-- is true for any player's aura, so only a readable token answers.
local source = plain(aura.sourceUnit)
if type(source) == "string" then
local same = safecall(UnitIsUnit, source, "player")
if same ~= nil then mine = same == true end
end
break
end
end
end
if not has and refused then has = nil end
-- Negative answers are cached too (a refusal as nil), or the walk re-reads
-- every buff for every person on every tick.
if guid then
if not perUnit then
perUnit = {}
auraCache[guid] = perUnit
auraCacheCount = auraCacheCount + 1
end
-- A stale reading is rewritten in place: nothing else holds one, and a
-- crowd turns them all over every three seconds.
if cached then
cached.at, cached.has, cached.expires, cached.mine = now, has, expires, mine
else
perUnit[buff.key] = { at = now, has = has, expires = expires, mine = mine }
end
end
return has, expires and (expires - now) or nil, mine
end
ns.UnitHasBuff = UnitHasBuff
-- Classes that have a mana bar, for when the client will not tell us a unit's
-- power (always, on the tokenless owed path). Monk and evoker have one; death
-- knight and demon hunter do not.
local MANA_CLASSES = {
MAGE = true, PRIEST = true, WARLOCK = true,
DRUID = true, PALADIN = true, HUNTER = true, SHAMAN = true,
MONK = true, EVOKER = true,
}
ns.MANA_CLASSES = MANA_CLASSES
-- Returns true, false, or nil for "cannot tell". UnitPowerMax is a secret value
-- for players outside your group here; class is not, so it answers instead.
local function UnitHasMana(unit)
local maxMana = plain(UnitPowerMax(unit, MANA))
if maxMana ~= nil then return maxMana > 0 end
local class = plain(select(2, UnitClass(unit)))
if class then return MANA_CLASSES[class] == true end
return nil
end
ns.UnitHasMana = UnitHasMana
-- Returns ok and, when ok is false, whether the rejection was about the person
-- rather than the token: dead, hostile, offline or too low is a judgement the
-- tokenless owed fallback has to honour too, while "no such unit" or "that is
-- you" says nothing about anybody. Only the first return may be tested for
-- truth; the second is advisory.
local function IsBuffableUnit(unit, f)
if not unit or not plain(UnitExists(unit)) then return false end
if plain(UnitIsUnit(unit, "player")) then return false end
if plain(UnitIsPlayer(unit)) ~= true then return false end
if plain(UnitIsDeadOrGhost(unit)) == true then return false, true end
-- Only a definite refusal is a judgement about the person: a withheld
-- answer is nil, and the tokenless fallback exists to reach exactly those.
local canAssist = plain(UnitCanAssist("player", unit))
if canAssist ~= true then return false, canAssist == false end
if plain(UnitIsConnected(unit)) == false then return false, true end
if f.minLevel and f.minLevel > 1 then
local lvl = plain(UnitLevel(unit))
if lvl and lvl > 0 and lvl < f.minLevel then return false, true end
end
return true
end
ns.IsBuffableUnit = IsBuffableUnit
-- nil means "could not tell", which we treat as worth offering rather than
-- silently dropping somebody who is probably standing right next to you.
local function InRange(unit, buff)
local info = ns.BuffInfo(buff)
local name = ns.BuffName(buff)
-- By id first: a name has to be resolved against the spellbook, and this
-- client is unreliable about exactly that.
local r
if info and info.topRank then
r = safecall(C_Spell and C_Spell.IsSpellInRange, info.topRank, unit)
end
if r == nil then r = safecall(C_Spell and C_Spell.IsSpellInRange, name, unit) end
if r == nil then r = safecall(_G.IsSpellInRange, name, unit) end
if r == nil then return nil end
return (r == true or r == 1)
end
ns.InRange = InRange
-- Whether a partyOnly buff the player casts reaches this unit. In a raid a
-- vanilla shout reaches only the caster's subgroup (ns.PARTY_IS_SUBGROUP; later
-- flavours made it raid-wide): UnitInSubgroup where the client has it, as the
-- Camelot class-buff reminder uses, else the raid roster. `inRaid` is the
-- scan's reading of IsInRaid; nil asks here.
local function SameParty(unit, inRaid)
if not unit then return false end
if inRaid == nil then inRaid = plain(IsInRaid and IsInRaid()) == true end
if not inRaid then
return plain(UnitInParty and UnitInParty(unit)) == true
end
if not ns.PARTY_IS_SUBGROUP then
return type(plain(UnitInRaid and UnitInRaid(unit))) == "number"
end
if type(_G.UnitInSubgroup) == "function" then
return safecall(_G.UnitInSubgroup, unit) == true
end
local index = plain(UnitInRaid and UnitInRaid(unit))
local mine = plain(UnitInRaid and UnitInRaid("player"))
if type(index) ~= "number" or type(mine) ~= "number" then return false end
local _, _, theirs = safecall(_G.GetRaidRosterInfo, index)
local _, _, ours = safecall(_G.GetRaidRosterInfo, mine)
return theirs ~= nil and theirs == ours
end
ns.SameParty = SameParty
-- Whether a shout would reach this unit: true, false, or nil for nothing could
-- tell. InRange cannot answer it: a self-cast spell has no range to anybody.
--
-- The follow prompt (CheckInteractDistance 4, about 28 yards) first, where a
-- refusal stays a refusal. LibRangeCheck only after it and only to say yes
-- within 30 yards, since its "far" can be a refusal in disguise (see
-- DirectCheck). Both are looser than an untalented shout (20 yards, 30 with all
-- of Booming Voice) on purpose: they are there to stop the sixty-yard or
-- other-zone case, not to measure exactly. Never the follow prompt in a fight:
-- the game blocks it for a friendly unit and names the addon, which no pcall
-- catches. LibRangeCheck is still asked in a fight, because it switches to its
-- in-combat checkers by itself.
local function ShoutReach(unit)
if not InCombatLockdown() then
local follow = safecall(_G.CheckInteractDistance, unit, 4)