-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCore.lua
More file actions
1168 lines (1075 loc) · 52 KB
/
Copy pathCore.lua
File metadata and controls
1168 lines (1075 loc) · 52 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
-- AutoFeed: keeps a single macro pointed at the best food/drink in your bags.
-- WoW: Forever (modern 12.x client API). Shares the private table `AF` across files via the addon vararg.
local ADDON, AF = ...
AF.version = C_AddOns.GetAddOnMetadata(ADDON, "Version") or "?"
local LIB = LibStub("LibForever-1.0", true)
-- The modern client dropped the global item functions; only the C_Item versions exist.
local GetItemInfo = C_Item.GetItemInfo
local GetItemInfoInstant = C_Item.GetItemInfoInstant
local GetItemSpell = C_Item.GetItemSpell
-- Backpack + equipped bags (the modern client adds a reagent bag slot after bag 4).
local LAST_BAG = NUM_TOTAL_EQUIPPED_BAG_SLOTS or NUM_BAG_SLOTS or 4
-- The dynamic "?" macro icon. With #showtooltip this auto-shows the item's icon.
local DYNAMIC_ICON = 134400
local MAX_BLACKLIST = 250 -- excluded items kept in SavedVariables
local MAX_CACHE = 400 -- classified items kept in memory
-- ---------------------------------------------------------------------------
-- Defaults / saved variables
-- ---------------------------------------------------------------------------
local defaults = {
filterBuffFood = true, -- ignore food/drink that grants Well Fed / stat buffs
wellFedXP = true, -- ...except while leveling without Well Fed: its +5% XP makes buff food worth it
prioritizeConjured = true, -- put conjured (mage) food/water first
includeDrink = true, -- manage the water macro (only matters for mana classes)
oneButton = false, -- if true, the food macro also drinks (one click does both)
includeHealPot = true, -- manage the healing-potion macro
includeManaPot = true, -- manage the mana-potion macro (only matters for mana classes)
potionWeakestFirst = false, -- false = strongest potion first; true = drain weak ones, save the big
includeScrolls = true, -- manage the scroll-buff cycler macro
includeBandage = true, -- manage the bandage macro
macroName = "AutoFeed",
drinkMacroName = "AutoDrink",
healMacroName = "AutoHealPot",
manaMacroName = "AutoManaPot",
scrollMacroName = "AutoScroll",
bandageMacroName = "AutoBandage",
blacklist = {}, -- [itemID] = time() it was excluded, never use these
}
local function ApplyDefaults()
AutoFeedDB = AutoFeedDB or {}
for k, v in pairs(defaults) do
if AutoFeedDB[k] == nil then
if type(v) == "table" then
AutoFeedDB[k] = {}
else
AutoFeedDB[k] = v
end
end
end
AF.db = AutoFeedDB
-- Per character: which macros AutoFeed made (never touch anyone else's).
AutoFeedCharDB = AutoFeedCharDB or {}
if type(AutoFeedCharDB.owned) ~= "table" then AutoFeedCharDB.owned = {} end
AF.char = AutoFeedCharDB
-- The exclusion list is the only saved table that grows with use. Each entry is the time it was
-- excluded; keep the newest MAX_BLACKLIST and drop the rest, so it can't grow without bound.
local ids = {}
for id, v in pairs(AutoFeedDB.blacklist) do
ids[#ids + 1] = { id = id, t = tonumber(v) or 0 }
end
if #ids > MAX_BLACKLIST then
table.sort(ids, function(a, b) return a.t > b.t end)
for i = MAX_BLACKLIST + 1, #ids do AutoFeedDB.blacklist[ids[i].id] = nil end
end
end
-- ---------------------------------------------------------------------------
-- Tooltip reading (locale: tuned for enUS health/mana strings)
-- ---------------------------------------------------------------------------
-- Empty when the item's tooltip hasn't loaded yet; Classify() then leaves the item uncached and
-- UpdateMacro retries.
local function GetTooltipLines(bag, slot)
local lines = {}
local data = C_TooltipInfo.GetBagItem(bag, slot)
if not (data and data.lines) then return lines end
for _, line in ipairs(data.lines) do
-- TooltipUtil only loads for game type "mainline", so on Forever the fields are already
-- surfaced and there is nothing to call.
if TooltipUtil and TooltipUtil.SurfaceArgs then TooltipUtil.SurfaceArgs(line) end
if line.leftText then
lines[#lines + 1] = line.leftText
end
end
return lines
end
-- ---------------------------------------------------------------------------
-- Classify a single bag slot -> consumable info table or nil
-- ---------------------------------------------------------------------------
local function num(s) return tonumber((s:gsub(",", ""))) end
-- Reads a "Restores N <resource>" or "Restores N to M <resource>" amount.
-- Potions use ranges (instant); food/drink use a single number (over time).
local function ParseRestore(t, resource)
local lo, hi = t:match("restores%s+([%d,]+)%s+to%s+([%d,]+)%s+" .. resource)
if lo and hi then return (num(lo) + num(hi)) / 2 end
local n = t:match("restores%s+([%d,]+)%s+" .. resource)
if n then return num(n) end
return nil
end
-- An item's tooltip never changes for a given itemID, so parse it once and
-- cache the static classification. false = "not a consumable we care about".
-- Bounded by distinct items seen in bags - tiny.
local classifyCache = {}
local classifyCount = 0
local function CacheVerdict(itemID, verdict)
if classifyCount >= MAX_CACHE then -- rebuilt on demand; bounded by items actually seen
classifyCache, classifyCount = {}, 0
end
if classifyCache[itemID] == nil then classifyCount = classifyCount + 1 end
classifyCache[itemID] = verdict
end
local function Classify(bag, slot, ignoreBlacklist)
local info = C_Container.GetContainerItemInfo(bag, slot)
if not info or not info.itemID then return nil end
local itemID = info.itemID
if not ignoreBlacklist and AF.db.blacklist[itemID] then return nil end
local cached = classifyCache[itemID]
if cached == false then return nil end
if cached then
return {
id = itemID,
name = cached.name,
count = info.stackCount or 1,
reqLevel = cached.reqLevel,
buff = cached.buff,
conjured = cached.conjured,
health = cached.health,
mana = cached.mana,
kind = cached.kind,
}
end
-- Gate on Consumable (classID 0). Food shows up under several different
-- subclasses, so we don't trust the subclass number -- we classify food vs
-- potion from the tooltip wording below ("restores X over N sec" = food).
local _, _, _, _, _, classID, subClassID = GetItemInfoInstant(itemID)
if classID ~= 0 then CacheVerdict(itemID, false); return nil end
local itemName = GetItemInfo(itemID)
local loaded = itemName ~= nil
if not itemName then
-- Not cached yet: ask for it, flag the scan incomplete (so UpdateMacro
-- retries once data arrives), and skip caching this pass.
C_Item.RequestLoadItemDataByID(itemID)
AF.scanPending = true
itemName = (info.hyperlink and info.hyperlink:match("%[(.-)%]")) or ("item:" .. itemID)
end
local lname = itemName:lower()
local lines = GetTooltipLines(bag, slot)
-- Only cache verdicts from fully-loaded data. A not-yet-loaded item can
-- momentarily look like "nothing"; caching that would wrongly hide a real
-- food/potion until /reload (see the "Use:" check below for negatives).
local cacheable = loaded and #lines > 0
local health, mana, reqLevel, buff, overTime = 0, 0, 0, false, false
for _, raw in ipairs(lines) do
local t = raw:lower()
local h = ParseRestore(t, "health")
if h then health = h; if t:find("over%s+%d") then overTime = true end end
local m = ParseRestore(t, "mana")
if m then mana = m; if t:find("over%s+%d") then overTime = true end end
local rl = t:match("requires level%s+(%d+)")
if rl then reqLevel = tonumber(rl) or reqLevel end
if t:find("well fed") then buff = true end
end
-- Bandages say "Heals X damage over N sec" (not "Restores") and are named
-- "... Bandage". Detect them before the food/potion logic.
if lname:find("bandage") then
local heal = 0
for _, raw in ipairs(lines) do
local n = raw:lower():match("heals%s+([%d,]+)")
if n then heal = num(n); break end
end
if heal > 0 then
local rec = { name = itemName, reqLevel = reqLevel, buff = false,
conjured = false, health = heal, mana = 0, kind = "bandage" }
if cacheable then CacheVerdict(itemID, rec) end
return {
id = itemID, name = itemName, count = info.stackCount or 1,
reqLevel = reqLevel, buff = false, conjured = false,
health = heal, mana = 0, kind = "bandage",
}
end
end
if health == 0 and mana == 0 then
-- Nothing we use (Swiftness, Free Action, Rage, alcohol...). Once the item and its
-- "Use:" text have loaded, cache that for good; without the use text, a Potion (1) or
-- Food & Drink (5) probably hasn't loaded its effect yet, so retry.
local hasUseText = false
for _, raw in ipairs(lines) do
if raw:lower():match("^use:%s*%S") then hasUseText = true; break end
end
if cacheable and hasUseText then
CacheVerdict(itemID, false)
elseif subClassID == 1 or subClassID == 5 then
AF.scanPending = true
end
return nil
end
-- Food/drink restores over time; potions are instant. Use the subclass when
-- it's the well-known value, otherwise fall back to the "over time" wording.
local kind
if subClassID == 5 then
kind = "food"
elseif subClassID == 1 then
kind = "potion"
elseif overTime then
kind = "food"
else
kind = "potion"
end
-- Avoid grabbing Healthstones / mana gems as "potions": they have a separate
-- cooldown and would double-fire in the fallback list. Real potions are
-- subclass 1 or literally contain "Potion" in the name (enUS).
if kind == "potion" and subClassID ~= 1 and not lname:find("potion") then
if cacheable then CacheVerdict(itemID, false) end
return nil
end
local conjured = (lname:find("conjured") ~= nil)
if cacheable then
CacheVerdict(itemID, {
name = itemName, reqLevel = reqLevel, buff = buff, conjured = conjured,
health = health, mana = mana, kind = kind,
})
end
return {
id = itemID,
name = itemName,
count = info.stackCount or 1,
reqLevel = reqLevel,
buff = buff,
conjured = conjured,
health = health,
mana = mana,
kind = kind,
}
end
-- ---------------------------------------------------------------------------
-- Scan all bags -> filtered + unfiltered candidate lists
-- ---------------------------------------------------------------------------
-- What the bags hold right now, as a cheap string. Item data isn't read, only IDs and counts.
local function BagSignature()
local parts = {}
for bag = 0, LAST_BAG do
for slot = 1, C_Container.GetContainerNumSlots(bag) do
local info = C_Container.GetContainerItemInfo(bag, slot)
if info and info.itemID then
parts[#parts + 1] = info.itemID .. "x" .. (info.stackCount or 1)
end
end
end
parts[#parts + 1] = "L" .. UnitLevel("player")
return table.concat(parts, ",")
end
local function ScanBags()
AF.scanPending = false -- Classify() sets this true if any item wasn't loaded yet
local level = UnitLevel("player")
local foods, foodsAll, drinks, drinksAll = {}, {}, {}, {}
local healPots, manaPots, bandages, wellFed = {}, {}, {}, {}
for bag = 0, LAST_BAG do
local slots = C_Container.GetContainerNumSlots(bag)
for slot = 1, slots do
-- Classify everything, excluded items too: they stay out of the macro lists below, but
-- the Well Fed answer has to know they are in the bags (see PublishConsumables).
local c = Classify(bag, slot, true)
if c and c.reqLevel <= level and c.buff then wellFed[#wellFed + 1] = c end
if c and c.reqLevel <= level and not AF.db.blacklist[c.id] then
if c.kind == "food" then
if c.health > 0 then
foodsAll[#foodsAll + 1] = c
if not (AF.db.filterBuffFood and c.buff) then
foods[#foods + 1] = c
end
end
if c.mana > 0 then
drinksAll[#drinksAll + 1] = c
if not (AF.db.filterBuffFood and c.buff) then
drinks[#drinks + 1] = c
end
end
elseif c.kind == "potion" then
if c.health > 0 then healPots[#healPots + 1] = c end
if c.mana > 0 then manaPots[#manaPots + 1] = c end
elseif c.kind == "bandage" then
bandages[#bandages + 1] = c
end
end
end
end
-- foodsAll/drinksAll include the buff food the filter keeps out of foods/drinks; they word the
-- "nothing usable" message, feed the Well Fed exception, and rank everything for BagWarden.
-- wellFed is every Well Fed food and drink in the bags, excluded ones included.
return {
foods = foods, foodsAll = foodsAll,
drinks = drinks, drinksAll = drinksAll,
healPots = healPots, manaPots = manaPots, bandages = bandages,
wellFed = wellFed,
}
end
-- Pick the best candidate: conjured-first (optional), then strongest restore,
-- then smallest stack (to drain partial stacks).
local function Pick(list, key)
if not list or #list == 0 then return nil end
local sorted = {}
for i = 1, #list do sorted[i] = list[i] end
table.sort(sorted, function(a, b)
if AF.db.prioritizeConjured and a.conjured ~= b.conjured then
return a.conjured
end
if a[key] ~= b[key] then
return a[key] > b[key]
end
return a.count < b.count
end)
return sorted[1]
end
-- Returns up to n distinct items for the combat-potion fallback lists, ranked
-- purely by strength. Strongest first by default; weakest first if the user
-- prefers to drain small potions and save the big ones.
local function PickTop(list, key, n)
if not list or #list == 0 then return {} end
local sorted = {}
for i = 1, #list do sorted[i] = list[i] end
local weakFirst = AF.db.potionWeakestFirst
table.sort(sorted, function(a, b)
if a[key] ~= b[key] then
if weakFirst then return a[key] < b[key] end
return a[key] > b[key]
end
return a.count < b.count
end)
local out, seen = {}, {}
for _, c in ipairs(sorted) do
if not seen[c.id] then
seen[c.id] = true
out[#out + 1] = c
if #out >= n then break end
end
end
return out
end
-- ---------------------------------------------------------------------------
-- Scroll buffs: cycle through scrolls for buffs you don't have yet
-- ---------------------------------------------------------------------------
local SCROLL_STATS = { "Stamina", "Strength", "Agility", "Intellect", "Spirit", "Protection" }
local STAT_SET = {}
for _, s in ipairs(SCROLL_STATS) do STAT_SET[s] = true end
-- Class buffs that raise the SAME stat and so don't stack with the scroll - if
-- one is up, using the scroll just fails ("a more powerful spell is already
-- active"), so we don't suggest it. enUS buff names.
local STAT_BUFFS = {
Stamina = { "Power Word: Fortitude", "Prayer of Fortitude" },
Intellect = { "Arcane Intellect", "Arcane Brilliance" },
Spirit = { "Divine Spirit", "Prayer of Spirit" },
}
local ROMAN = { I = 1, II = 2, III = 3, IV = 4, V = 5, VI = 6 }
local function ParseRank(name)
local r = name:match("%s([IVX]+)$")
return (r and ROMAN[r]) or 1
end
-- The player's helpful auras: names and spell IDs we can read, plus whether any aura was secret.
-- Walks the list by index: C_UnitAuras.GetAuraDataBySpellName needs a non-secret aura and just
-- returns nil otherwise, which looked like "buff missing" (Well Fed was re-suggested).
-- GetAuraDataByIndex throws a Lua error for a secret aura (combat, encounters, PvP), so every index
-- is asked about first; a secret one is skipped and marks the result unreadable.
local function ReadPlayerAuras()
local names, ids, unreadable = {}, {}, false
for i = 1, 255 do
if C_Secrets.ShouldUnitAuraIndexBeSecret("player", i, "HELPFUL") then
unreadable = true
else
local a = C_UnitAuras.GetAuraDataByIndex("player", i, "HELPFUL")
if not a then break end
local name, id = a.name, a.spellId
if issecretvalue(name) or issecretvalue(id) then
unreadable = true
else
if name then names[name] = true end
if id then ids[id] = true end
end
end
end
return names, ids, unreadable
end
-- Best (highest-rank) scroll per stat that's in your bags.
-- Which scrolls you own only changes when the bags do, so the sweep is keyed on the bag signature;
-- which one you'd use changes with your buffs and is still decided per update in PickScroll. Out of
-- combat every buff change runs an update, and this is a full bag walk with an item lookup per slot.
local scrollScan, scrollScanSig
local function ScanScrolls()
if scrollScan and scrollScanSig == AF.bagSig then return scrollScan end
local found, incomplete = {}, false
for bag = 0, LAST_BAG do
for slot = 1, C_Container.GetContainerNumSlots(bag) do
local info = C_Container.GetContainerItemInfo(bag, slot)
if info and info.itemID and not AF.db.blacklist[info.itemID] then
local name = GetItemInfo(info.itemID)
if not name then
-- Not loaded yet (right after login): ask for it and let UpdateMacro retry,
-- as Classify does, instead of reporting "all scroll buffs active".
C_Item.RequestLoadItemDataByID(info.itemID)
AF.scanPending = true
incomplete = true
end
local stat = name and name:match("^Scroll of (%a+)")
if stat and STAT_SET[stat] then
local rank = ParseRank(name)
if not found[stat] or rank > found[stat].rank then
-- The aura a scroll grants isn't always named after the stat
-- (Scroll of Protection -> "Armor"), so capture the actual
-- on-use buff name and check that.
local buffName = GetItemSpell(info.itemID)
if not buffName then AF.scanPending = true; incomplete = true end
found[stat] = { id = info.itemID, rank = rank, name = name,
stat = stat, buffName = buffName }
end
end
end
end
end
-- Never cache a sweep that ran before the item data had loaded: the retry has to see the gap.
if incomplete then
scrollScan, scrollScanSig = nil, nil
else
scrollScan, scrollScanSig = found, AF.bagSig
end
return found
end
-- Potions and scrolls currently in bags, for the settings exclude list.
-- Ignores the blacklist so excluded items remain visible (and re-includable).
function AF:GetExcludables()
local out, seen = {}, {}
for bag = 0, LAST_BAG do
for slot = 1, C_Container.GetContainerNumSlots(bag) do
local info = C_Container.GetContainerItemInfo(bag, slot)
local id = info and info.itemID
if id and not seen[id] then
seen[id] = true
local name = GetItemInfo(id)
if name then
local stat = name:match("^Scroll of (%a+)")
if stat and STAT_SET[stat] then
out[#out + 1] = { id = id, name = name, kind = "scroll" }
else
local c = Classify(bag, slot, true)
if c and c.kind == "potion" then
out[#out + 1] = { id = id, name = name,
kind = (c.health > 0) and "heal" or "mana" }
end
end
end
end
end
end
table.sort(out, function(a, b) return a.name < b.name end)
return out
end
-- A stat is "covered" if the scroll's own buff is up, an aura named after the
-- stat is up, or a non-stacking class buff for that stat is up (using the scroll
-- then would only fail with "a more powerful spell is already active").
local function StatCovered(s, auras)
if (s.buffName and auras[s.buffName]) or auras[s.stat] then return true end
local conflicts = STAT_BUFFS[s.stat]
if conflicts then
for _, b in ipairs(conflicts) do
if auras[b] then return true end
end
end
return false
end
-- The next scroll whose stat isn't already covered (in stat order); nil if done. Also returns
-- whether some buffs couldn't be read (so the answer can't be trusted), and every scroll found.
local function PickScroll()
local found = ScanScrolls()
local auras, _, unreadable = ReadPlayerAuras() -- once, not per stat
if unreadable then return nil, true, found end
for _, stat in ipairs(SCROLL_STATS) do
local s = found[stat]
if s and not StatCovered(s, auras) then return s, false, found end
end
return nil, false, found
end
-- ---------------------------------------------------------------------------
-- Well Fed XP: on Forever every Well Fed buff also carries "Well Fed XP Boost"
-- (+5% experience from kills), so buff food is worth eating while leveling.
-- ---------------------------------------------------------------------------
local WELL_FED_XP_SPELL = 1243969 -- the hidden "Well Fed XP Boost" aura every Well Fed carries
-- "yes", "no", or "unknown" (an aura was secret, so we can't be sure it's missing).
function AF:WellFedState()
local names, ids, unreadable = ReadPlayerAuras()
if names["Well Fed"] or ids[WELL_FED_XP_SPELL] then return "yes" end -- aura name is enUS
return unreadable and "unknown" or "no"
end
-- true/false, or the previous answer when buffs can't be read (keep the macro as it was).
local function WantsWellFed()
if not AF.db.wellFedXP then return false end
if IsXPUserDisabled() then return false end
if UnitLevel("player") >= GetMaxLevelForPlayerExpansion() then return false end
local state = AF:WellFedState()
if state == "unknown" then return AF.lastWantBuff or false end
AF.lastWantBuff = (state == "no")
return AF.lastWantBuff
end
-- Macro lines are built from our own text plus an item ID as a number, never from an item name,
-- link or tooltip text, so nothing a weird item name contains can end up as macro commands.
local function UseLine(id, self_)
id = tonumber(id)
if not id then return nil end
return self_ and ("/use [@player] item:%d"):format(id) or ("/use item:%d"):format(id)
end
-- How much each consumable would be missed, so BagWarden can offer the cheap stacks first and the
-- food you are saving last. Covers everything the scan saw, not only what the macros point at: buff
-- food is the most precious thing in the bags even while a macro ignores it.
local RANK = { critical = 3, useful = 2, spare = 1 }
local function BuildTiers(lists, protect, foundScrolls, hasMana)
local tiers = {}
local function Rank(id, tier)
local cur = tiers[id]
if not cur or RANK[tier] > RANK[cur] then tiers[id] = tier end
end
-- Conjured is checked before anything else on purpose: the tier answers "how annoyed would you
-- be to lose this?", and a mage remakes conjured food and water in seconds, for free - even the
-- conjured kind that grants a buff.
for _, c in ipairs(lists.foodsAll) do
if c.conjured then Rank(c.id, "spare")
elseif c.buff then Rank(c.id, "critical") -- Well Fed / stat / +5% XP food
elseif protect[c.id] then Rank(c.id, "useful") -- the plain food the macro eats
else Rank(c.id, "spare") end -- surplus stacks further down the ranking
end
for _, c in ipairs(lists.drinksAll) do
if c.conjured then Rank(c.id, "spare")
elseif c.buff then Rank(c.id, "critical")
elseif not hasMana then Rank(c.id, "spare") -- a rage/energy class doesn't drink
elseif protect[c.id] then Rank(c.id, "critical") -- water matters most to a mana class
else Rank(c.id, "useful") end
end
for _, key in ipairs({ "healPots", "manaPots", "bandages" }) do
for _, c in ipairs(lists[key]) do
Rank(c.id, protect[c.id] and "useful" or "spare")
end
end
for _, s in pairs(foundScrolls or {}) do Rank(s.id, "useful") end
return tiers
end
-- The best Well Fed food in the bags, as an itemID, or false when there is none. Food before
-- drink (both grant the buff), and a usable one before an excluded one - but an excluded item
-- still counts, because excluding something from a macro isn't the same as not owning it.
local function PickWellFed(wellFed, blacklist)
local foodOk, foodNo, drinkOk, drinkNo = {}, {}, {}, {}
for _, c in ipairs(wellFed) do
local excluded = blacklist[c.id] ~= nil
if c.health > 0 then
local list = excluded and foodNo or foodOk
list[#list + 1] = c
end
if c.mana > 0 then
local list = excluded and drinkNo or drinkOk
list[#list + 1] = c
end
end
local best = Pick(foodOk, "health") or Pick(drinkOk, "mana")
or Pick(foodNo, "health") or Pick(drinkNo, "mana")
return best and best.id or false
end
local function BuffOnly(list)
local out = {}
for _, c in ipairs(list) do
if c.buff then out[#out + 1] = c end
end
return out
end
-- ---------------------------------------------------------------------------
-- Macro writing
-- ---------------------------------------------------------------------------
-- Everything the macros would say right now: one body per managed macro, what they point at (for
-- BagWarden) and the picks. Pure on purpose - it writes nothing - so the self-test can build the
-- same bodies and throw them away.
local function BuildPlan(db, lists, hasMana)
local plan = { bodies = {}, protect = {}, picks = {} }
local protect = plan.protect
-- Use the FILTERED lists: when "filter buff food" is on, Well Fed / stat food is intentionally
-- never auto-suggested (saved for raids) - even if it's all you have. foodsAll/drinksAll only
-- word the "nothing usable" message and feed the Well Fed exception below.
-- Exception: while leveling without Well Fed, buff food wins (it's +5% XP). One buff item is
-- enough, so buff drink is only picked when there's no buff food.
local wantBuff = WantsWellFed()
-- What BuffWarden asks about: is there anything in the bags that grants Well Fed at all?
plan.buffFood = PickWellFed(lists.wellFed, db.blacklist)
-- What the food macro would eat for the buff: the same ranking, but from the macro lists, so
-- excluded items are out.
local buffFood = Pick(BuffOnly(lists.foodsAll), "health")
local buffDrink = Pick(BuffOnly(lists.drinksAll), "mana")
-- The same food, but only when the player is actually willing to eat it: with the buff-food
-- filter on it is being saved for raids, and the only thing that overrides that is their own
-- Well Fed XP setting while leveling.
local eatable = (wantBuff or not db.filterBuffFood) and (buffFood or buffDrink) or nil
plan.eatableBuffFood = eatable and eatable.id or false
local food = wantBuff and buffFood or nil
food = food or Pick(lists.foods, "health")
local drink
if db.includeDrink and hasMana then
if wantBuff and not (food and food.buff) then
drink = buffDrink
end
drink = drink or Pick(lists.drinks, "mana")
end
plan.forWellFed = wantBuff and ((food and food.buff) or (drink and drink.buff)) or false
local function Note(msg)
return '/run print("|cff66ccffAutoFeed|r: ' .. msg .. '")'
end
-- Food macro: food, plus the drink line too when one-button mode is on.
local foodBody = { "#showtooltip" }
if food then
foodBody[#foodBody + 1] = UseLine(food.id)
protect[food.id] = "your food macro eats this"
end
if db.oneButton and drink and (not food or drink.id ~= food.id) then
foodBody[#foodBody + 1] = UseLine(drink.id)
end
if #foodBody == 1 then
foodBody[2] = Note((#lists.foodsAll > 0)
and "only buff food in bags - saved for raids (/autofeed to change)"
or "no usable food in bags")
end
plan.bodies.macroName = table.concat(foodBody, "\n")
-- Water macro: drink only. Only managed for mana classes with drink enabled.
if db.includeDrink and hasMana then
local body = { "#showtooltip" }
if drink then
body[2] = UseLine(drink.id)
protect[drink.id] = "your water macro drinks this"
else
body[2] = Note((#lists.drinksAll > 0)
and "only buff drink in bags - saved for raids (/autofeed to change)"
or "no usable water in bags")
end
plan.bodies.drinkMacroName = table.concat(body, "\n")
end
-- Potion and bandage macros: best-first fallback lists (the potions work mid-fight, so if the
-- top one runs out the next fires; they share a cooldown, so only one is used).
local function Fallbacks(slot, list, key, n, reason, empty)
local top = PickTop(list, key, n)
local body = { "#showtooltip" }
for _, c in ipairs(top) do
body[#body + 1] = UseLine(c.id)
protect[c.id] = reason
end
if #body == 1 then body[2] = Note(empty) end
plan.bodies[slot] = table.concat(body, "\n")
return top[1]
end
if db.includeHealPot then
plan.picks.heal = Fallbacks("healMacroName", lists.healPots, "health", 3,
"your healing-potion macro uses this", "no healing potion in bags")
end
if db.includeManaPot and hasMana then
plan.picks.mana = Fallbacks("manaMacroName", lists.manaPots, "mana", 3,
"your mana-potion macro uses this", "no mana potion in bags")
end
if db.includeBandage then
plan.picks.bandage = Fallbacks("bandageMacroName", lists.bandages, "health", 2,
"your bandage macro uses this", "no bandage in bags")
end
-- Scroll-buff cycler: the next scroll whose buff you lack; blank once fully buffed. Buffs that
-- can't be read (secret) mean no body at all, so the macro keeps what it had: neither "covered"
-- nor "missing".
if db.includeScrolls then
local scroll, aurasUnreadable, foundScrolls = PickScroll()
plan.foundScrolls = foundScrolls
-- The cycler works through every stat in turn, so all of them are "in use", not just today's.
for _, s in pairs(foundScrolls or {}) do
protect[s.id] = "your scroll macro uses this"
end
if not aurasUnreadable then
plan.scrollKnown, plan.picks.scroll = true, scroll
plan.bodies.scrollMacroName = "#showtooltip\n" .. (scroll
and UseLine(scroll.id, true) -- always buff yourself
or Note("all scroll buffs active (or none in bags)"))
end
end
plan.picks.food, plan.picks.drink = food, drink
return plan
end
-- A body only AutoFeed writes: "#showtooltip", then /use lines with a plain item ID, or one of
-- our own /run print notes. Used to recognise our macros from before we recorded them.
local function LooksLikeOurs(body)
if not body or body == "" then return false end
local lines, first = 0, true
for line in body:gmatch("[^\n]+") do
lines = lines + 1
if first then
if line ~= "#showtooltip" then return false end
first = false
elseif not (line:match("^/use item:%d+$")
or line:match("^/use %[@player%] item:%d+$")
or line:match('^/run print%("|cff66ccffAutoFeed|r: [^"]*"%)$')) then
return false
end
end
return lines > 0
end
-- Only ever edit a macro AutoFeed made. Installs from before we recorded that adopt a macro whose
-- body is one we would have written; anything else with the same name is somebody's own macro and
-- is left alone (said once per name).
local function OwnedMacro(name)
local owned = AF.char and AF.char.owned
if not owned then return false end
if owned[name] then return true end
if LooksLikeOurs(GetMacroBody(name)) then
owned[name] = true
return true
end
AF.warnedForeign = AF.warnedForeign or {}
if not AF.warnedForeign[name] then
AF.warnedForeign[name] = true
print("|cff66ccffAutoFeed|r: '" .. tostring(name)
.. "' is a macro AutoFeed didn't create, so it is left untouched. "
.. "Rename or delete it, then create the AutoFeed macro again.")
end
return false
end
-- Updates an EXISTING macro's body (skips the call if unchanged). It never
-- creates a macro - macros cost a per-character slot, so the player makes the
-- ones they want from the welcome window or settings. Missing macros are skipped.
function AF:WriteMacro(name, body)
self.lastBody = self.lastBody or {}
local idx = GetMacroIndexByName(name)
if not (idx and idx > 0) then
self.lastBody[name] = nil -- doesn't exist; nothing to keep in sync
return
end
if not OwnedMacro(name) then return end
if self.lastBody[name] == body then return end
-- The self-test builds bodies and throws them away; if it ever reaches here, that is a bug.
assert(not AF.noWrite, "AutoFeed self-test tried to edit a macro")
self.lastBody[name] = body
pcall(EditMacro, idx, name, DYNAMIC_ICON, body)
self.writeCount = (self.writeCount or 0) + 1
end
-- The macros AutoFeed manages, in display order. need="mana" entries only matter
-- for mana users; toggle = the "manage this" setting to switch on when created.
AF.MACROS = {
{ key = "food", slot = "macroName", short = "Food", need = "always" },
{ key = "drink", slot = "drinkMacroName", short = "Water", need = "mana", toggle = "includeDrink" },
{ key = "heal", slot = "healMacroName", short = "Heal pot", need = "always", toggle = "includeHealPot" },
{ key = "mana", slot = "manaMacroName", short = "Mana pot", need = "mana", toggle = "includeManaPot" },
{ key = "scroll", slot = "scrollMacroName", short = "Scroll", need = "always", toggle = "includeScrolls" },
{ key = "bandage", slot = "bandageMacroName", short = "Bandage", need = "always", toggle = "includeBandage" },
}
-- Create every macro that applies to this class, in one go (for a "Create all" button).
local function CombatBlocked()
if not InCombatLockdown() then return false end
print("|cff66ccffAutoFeed|r: can't create macros in combat - try again after the fight.")
return true
end
function AF:CreateAllMacros()
if CombatBlocked() then return end
local hasMana = (UnitPowerMax("player", 0) or 0) > 0
for _, m in ipairs(AF.MACROS) do
if not (m.need == "mana" and not hasMana) then AF:CreateMacroByKey(m.key) end
end
end
-- Create one managed macro on demand (per-character). Enables its "manage" toggle
-- so UpdateMacro keeps it current, then fills the body immediately. Returns false
-- (with a chat note) if the character's macro slots are full. `quiet` drops the "drag it onto
-- your bars" note, for a caller that places the macro itself.
function AF:CreateMacroByKey(key, quiet)
if not self.db or CombatBlocked() then return false end
local def
for _, m in ipairs(AF.MACROS) do if m.key == key then def = m; break end end
if not def then return false end
local name = self.db[def.slot]
if def.toggle then self.db[def.toggle] = true end
local idx = GetMacroIndexByName(name)
if idx and idx > 0 then
if not OwnedMacro(name) then return false end -- somebody else's macro with that name
else
assert(not AF.noWrite, "AutoFeed self-test tried to create a macro")
local ok = pcall(CreateMacro, name, DYNAMIC_ICON, "#showtooltip", true) -- per-character
self.createCount = (self.createCount or 0) + 1
if not ok then
print("|cff66ccffAutoFeed|r: couldn't create '" .. name
.. "' - your character macro slots are full. Free one (Esc > Macros) and try again.")
return false
end
self.char.owned[name] = true -- ours from now on
if not quiet then
print("|cff66ccffAutoFeed|r: created '" .. name
.. "'. Drag it from Esc > Macros onto your action bars.")
end
end
self.lastBody = self.lastBody or {}
self.lastBody[name] = nil -- force a fresh body on the next write
self:UpdateMacro()
return true
end
function AF:UpdateMacro()
if not self.db then return end
if InCombatLockdown() then
self.pending = true
return
end
self.pending = nil
-- Bag events fire for all sorts of reasons (moving things around, a stack merging). When the
-- bags and the level are what they were at the last scan, reuse that scan's lists instead of
-- classifying everything again; a settings change clears bagSig to force a fresh scan.
local sig = BagSignature()
if sig ~= self.bagSig or not self.lists then
self.lists = ScanBags()
self.bagSig = sig
end
local lists = self.lists
local hasMana = (UnitPowerMax("player", 0) or 0) > 0
local plan = BuildPlan(self.db, lists, hasMana)
-- The only place a macro is written. WriteMacro skips any macro AutoFeed didn't create.
for _, m in ipairs(AF.MACROS) do
local body = plan.bodies[m.slot]
if body then self:WriteMacro(self.db[m.slot], body) end
end
self.forWellFed = plan.forWellFed
self.lastFood, self.lastDrink = plan.picks.food, plan.picks.drink
self.lastHealPot, self.lastManaPot = plan.picks.heal, plan.picks.mana
self.lastBandage = plan.picks.bandage
if plan.scrollKnown then self.lastScroll = plan.picks.scroll end -- else keep what we last knew
self.protected = plan.protect
self.buffFood = plan.buffFood -- an itemID, or false for "scanned, none in the bags"
self.eatableBuffFood = plan.eatableBuffFood -- the same, but only what they'd actually eat
self.tiers = BuildTiers(lists, plan.protect, plan.foundScrolls, hasMana)
-- Right after login (and sometimes after big bag changes) item data isn't
-- cached yet, so the scan can miss food/potions that really are in the bags.
-- When ScanBags flagged that, retry shortly - bounded - until it settles.
-- One retry timer at a time, however many updates run meanwhile.
if self.scanPending and (self.loadRetries or 0) < 8 then
self.loadRetries = (self.loadRetries or 0) + 1
if not self.retryTimer then
self.retryTimer = C_Timer.NewTimer(1.5, function()
AF.retryTimer = nil
AF:UpdateMacro()
end)
end
elseif not self.scanPending then
self.loadRetries = 0
end
end
-- Debounced update (bag events can fire in bursts)
function AF:ScheduleUpdate()
self.loadRetries = 0 -- a fresh event gets a fresh retry budget
if self.timer then return end
self.timer = C_Timer.NewTimer(0.4, function()
AF.timer = nil
AF:UpdateMacro()
end)
end
-- ---------------------------------------------------------------------------
-- Our macros, made on request by another YippYapp addon
-- ---------------------------------------------------------------------------
-- An addon that sets up action bars asks us to make our own macro rather than copying
-- another character's: a copy is a second macro with our name that we don't own, so we would refuse
-- to fill it. Create(name) makes the macro configured under that name, exactly as the "Create"
-- button does, and returns its macro index - or nil if no macro of ours has that name, or it could
-- not be made (in combat, macro slots full, or somebody else's macro already has the name).
function AF:PublishMacroMaker()
if not (LIB and LIB.ProvideData) then return end
LIB.ProvideData("AutoFeedMacros", {
Create = function(name)
for _, m in ipairs(AF.MACROS) do
if AF.db and AF.db[m.slot] == name then
if not AF:CreateMacroByKey(m.key, true) then return nil end
local idx = GetMacroIndexByName(name)
return (idx and idx > 0) and idx or nil
end
end
end,
})
end
-- ---------------------------------------------------------------------------
-- What our macros use, for the other YippYapp addons
-- ---------------------------------------------------------------------------
-- BagWarden asks before it offers an item for deletion: Keep() for the ones our macros point at,
-- Tier() for how much everything else would be missed. Both are table lookups, because they are
-- called for every candidate while the bags are open; the tables are rebuilt with the macros, so
-- they follow the current settings and ranking and are never stale. Excluded items are in neither.
function AF:PublishConsumables()
if not (LIB and LIB.ProvideData) then return end
LIB.ProvideData("AutoFeedConsumables", {
Keep = function(itemID)
local protect = AF.protected
if not protect then return nil end -- no opinion until the first scan
return protect[tonumber(itemID) or itemID]
end,
-- "critical" (buff food, and water for a mana class), "useful" (what the macros use),
-- "spare" (conjured, and stacks further down the ranking), or nil for no opinion.
Tier = function(itemID)
local tiers = AF.tiers
if not tiers then return nil end
return tiers[tonumber(itemID) or itemID]
end,
-- The best Well Fed food (or drink) in the bags, so BuffWarden can drop its Well Fed row
-- when there is nothing to eat for it. Three answers, and the difference matters:
-- itemID the best one in the bags right now
-- false we have scanned, and there is none
-- nil no opinion yet, because the first scan hasn't run
-- nil means "don't know", never "there is none": hide the row only on an explicit false,
-- or it vanishes for the seconds before the first scan - or silently, if a scan never ran.
-- The answer is about what the bags hold, not about what AutoFeed would eat, so no setting
-- changes whether it is false - including the exclusion list, which only says "don't put
-- this in my macro", not "pretend I don't own it". An excluded item still counts; a usable
-- one is just preferred when the bags hold both.
BuffFood = function()
return AF.buffFood
end,
-- The difference in one line: BuffFood is what the player OWNS, EatableBuffFood is what
-- they are willing to USE. Ask the first to decide whether a Well Fed row can be acted on
-- at all; ask this one before telling them to eat something. Same three answers, same rule
-- that nil means "don't know". This one respects their settings: with the buff-food filter
-- on, the food is being saved for raids, so the answer is false even though they own it -
-- unless their own Well Fed XP setting says to eat it while leveling.
EatableBuffFood = function()
return AF.eatableBuffFood
end,
})
end