-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAssignment.lua
More file actions
1279 lines (1114 loc) · 50 KB
/
Copy pathAssignment.lua
File metadata and controls
1279 lines (1114 loc) · 50 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
--[[----------------------------------------------------------------------------
WarbandMeDowns/Assignment.lua
The global warband assignment engine.
Instead of answering "is this hovered item an upgrade?" from scratch on
every tooltip, this precomputes, per warband character and per equipment
slot, which item they should end up with - considering every equipped,
bagged, banked, and mailed item across the whole warband at once, so a
spare in one character's bag can cascade down to a lower-priority
character instead of being invisible to the recommendation. See
WarbandMeDowns.lua for the license header covering the whole addon.
Every item instance in the warband falls into exactly one bucket, decided
once per recompute in ScanWarband:
1. Equipped (any bind type) - always a floor a candidate must beat for
that character/slot. Never reassigned to anyone else.
2. Bag/bank/mail, NOT sendable to a twink (soulbound, actually already
Soulbound despite a sendable-looking bind type, or below Uncommon
quality) - also a floor, but can never be claimed by another
character.
3. Bag/bank/mail, sendable to a twink - the only items ever reassigned;
up for grabs by any eligible character in the warband, including
their current owner.
A bind type like "Bind on Equip" or "Warbound until equipped" only
describes what an item *will* do, not whether a specific instance has
already bound - see Data.IsActuallySoulbound for how that's resolved.
The expensive part (the full warband scan) happens once per recompute.
Settling any one (slot-class, target equip location) pair is lazy and
memoized for the current generation, so hovering many items that all
share a slot-class (e.g. browsing a vendor's rings) only ever pays for
that settlement once.
*** The answer must not depend on who is logged in ***
The engine reads stored, account-wide DataStore snapshots, so the same
item must produce the same recommendation from every character. Three
things used to break that invariant, and the design below exists to keep
them fixed:
- Unreadable items are a distinct state, never a zero. C_Item.GetItemInfo
and C_Item.GetDetailedItemLevelInfo both return nil for an item the
client has not cached, which is routine for an alt's stored gear right
after login. Scoring that as "item level 0" made the alt look like they
had an empty slot and hand them the item - and since cache warmth
differs every session, the winner moved around. Anything unreadable now
yields Assignment.Pending and no tooltip line until the data arrives.
- Comparisons are transitive. A single *basis* (Pawn score, or item level)
is chosen once per character per settlement and used for every
comparison in it, instead of falling back per pair. Mixing two orderings
in one chain made the greedy winner depend on scan order.
- Iteration is deterministic. Characters are walked in priority order and
every candidate bucket is sorted on a stable key, so Lua's `pairs`
ordering over DataStore's container tables can never leak into the
result. Priority order itself is a total order down to the character
key, since table.sort is not stable and would otherwise let the input
order decide between equally-ranked characters.
Note Recompute() runs the scan *before* it computes the priority order, not
after: the order ranks characters on what they own, which only exists once
the scan has run. See the comment there for why scanning under a provisional
order is safe.
----------------------------------------------------------------------------]]--
WarbandMeDowns.Assignment = WarbandMeDowns.Assignment or {}
local Assignment = WarbandMeDowns.Assignment
local Data = WarbandMeDowns.Data
local Characters = WarbandMeDowns.Characters
local Pawn = WarbandMeDowns.Pawn
-- Sentinels, mirroring the old CacheMiss idiom.
Assignment.Sell = {} -- confirmed upgrade for nobody: drives the tooltip's sell line
Assignment.Ineligible = {} -- this character can never use this slot-class at all
Assignment.Pending = {} -- the answer depends on item data the client has not cached yet
-- Where a pool/floorExtra entry was found. Only used to order entries
-- deterministically and to match a hovered item back to its exact instance.
local SourceContainer = 1
local SourceMail = 2
-- Bucket for an unreadable item whose equip location we could not determine
-- either; it has to count against every slot of its class.
local AnyEquipLocation = "*"
-- Per-generation state, wiped wholesale by Recompute()/Reset() - see below.
Assignment.dirty = true
Assignment.pool = {} -- pool[slotClassKey][rawEquipLoc] = { entry, ... } (sendable bag/bank/mail items)
Assignment.floorExtra = {} -- floorExtra[slotClassKey][rawEquipLoc] = { entry, ... } (unsendable bag/bank/mail items)
Assignment.entriesByLink = {} -- entriesByLink[itemLink] = { entry, ... } (every pool entry sharing a link)
Assignment.settledBestCache = {} -- settledBestCache[slotClassKey][targetEquipLoc][character] = itemLink | false | Ineligible
Assignment.unresolvedCache = {} -- unresolvedCache[slotClassKey][targetEquipLoc][character] = true
Assignment.unresolvedOwners = {} -- unresolvedOwners[slotClassKey][equipLoc][character] = true (owns an unreadable item competing there)
Assignment._sortedCharacters = {}
Assignment._debounceTimer = nil
Assignment.settledAll = false -- every group in the pool has been settled this generation
Assignment._settleTimer = nil
Assignment._settleQueue = nil
-- *** Scoring
--
-- A "scorer" fixes one comparison basis for one character for the duration of
-- one decision. Pawn's score is authoritative whenever a scale resolves for
-- the character AND Pawn can score every item involved; otherwise item level
-- is used for all of them. Committing to a single basis up front is what makes
-- the comparison a total order, which _ClaimBestCandidate's greedy scan relies
-- on. Falling back per pair - Pawn for A vs B, item level for B vs C - is not
-- transitive and made the winner depend on iteration order.
---@class WarbandMeDownsScorer
---@field basis string # "pawn" or "itemlevel"
---@field scaleName string? # the resolved Pawn scale, when basis is "pawn"
---@field resolved boolean # false when some item involved could not be read at all
---@field scoreOf fun(itemLink: ItemInfo?): number?
---@param character string
---@param items ItemInfo[] # every item this decision depends on
---@return WarbandMeDownsScorer
function Assignment:_BuildScorerForCharacter(character, items)
local scaleName = Pawn.GetPawnScaleNameForCharacter(character)
if scaleName then
local scores = {}
local scoredEverything = true
for _, itemLink in ipairs(items) do
if scores[itemLink] == nil then
local value = Pawn.GetPawnItemValue(itemLink, scaleName)
if not value then
scoredEverything = false
break
end
scores[itemLink] = value
end
end
if scoredEverything then
return {
basis = "pawn",
scaleName = scaleName,
-- Pawn can only score an item it was able to parse, so a
-- complete Pawn pass implies every item was readable.
resolved = true,
scoreOf = function(itemLink)
if not itemLink then
return nil
end
return scores[itemLink]
end,
}
end
end
local resolved = true
for _, itemLink in ipairs(items) do
if not Data.IsItemInfoResolved(itemLink) then
resolved = false
end
end
return {
basis = "itemlevel",
scaleName = nil,
resolved = resolved,
scoreOf = function(itemLink)
if not itemLink then
return nil
end
return Data.GetActualItemLevel(itemLink)
end,
}
end
---Compares two items on a scorer's single basis. A missing item (nil/false,
---an empty slot) always loses to a present, scoreable one and ties against
---another missing item - but an item that is *present and unreadable* is not
---a loss, it is an unanswered question, and returns nil so the caller can
---decline to decide rather than silently treating it as worthless.
---@param scorer WarbandMeDownsScorer
---@param itemLinkA ItemInfo?
---@param itemLinkB ItemInfo?
---@return integer? # 1 if A is preferred, -1 if B is preferred, 0 if tied, nil if undecidable
local function CompareWithScorer(scorer, itemLinkA, itemLinkB)
if not itemLinkA and not itemLinkB then
return 0
end
local scoreA = itemLinkA and scorer.scoreOf(itemLinkA)
local scoreB = itemLinkB and scorer.scoreOf(itemLinkB)
if (itemLinkA and not scoreA) or (itemLinkB and not scoreB) then
return nil
end
scoreA = scoreA or -math.huge
scoreB = scoreB or -math.huge
if scoreA == scoreB then
return 0
end
return scoreA > scoreB and 1 or -1
end
Assignment.CompareWithScorer = CompareWithScorer
---Compares two items of the same slot-class for a character. Convenience
---wrapper that builds a throwaway scorer for just this pair; the settlement
---path builds one scorer per character instead, so every comparison inside a
---settlement shares one basis.
---@param character string
---@param itemLinkA ItemInfo?
---@param itemLinkB ItemInfo?
---@return integer? # 1 if A is preferred, -1 if B is preferred, 0 if tied, nil if undecidable
function Assignment.CompareItemsForCharacter(character, itemLinkA, itemLinkB)
local items = {}
if itemLinkA then
table.insert(items, itemLinkA)
end
if itemLinkB then
table.insert(items, itemLinkB)
end
local scorer = Assignment:_BuildScorerForCharacter(character, items)
return CompareWithScorer(scorer, itemLinkA, itemLinkB)
end
-- *** Phase A: single full pass over every character's bag/bank/mail items
local WarnedMissingMailAPI = false
---Total order over entries, so the candidate list never depends on Lua's
---`pairs` ordering over DataStore's container tables (which is arbitrary and
---differs between sessions).
---@param left table
---@param right table
---@return boolean
local function CompareEntries(left, right)
if left.character ~= right.character then
return left.character < right.character
end
if left.source ~= right.source then
return left.source < right.source
end
if left.containerId ~= right.containerId then
return left.containerId < right.containerId
end
if left.slotId ~= right.slotId then
return left.slotId < right.slotId
end
return tostring(left.link) < tostring(right.link)
end
---A table's keys in sorted order. Every walk over the bucket tables goes
---through this: `pairs` order over them is arbitrary and differs between
---sessions, and the whole engine rests on that never reaching a result.
---@param source table
---@return string[]
local function SortedKeys(source)
local keys = {}
for key in pairs(source) do
table.insert(keys, key)
end
table.sort(keys)
return keys
end
---Buckets one bag/bank/mail item into the pool (sendable) or floorExtra
---(unsendable) table for its slot-class, keyed by its own equip location.
---@param character string
---@param itemLink ItemInfo?
---@param itemLocation ItemLocation? # live location, only ever set for the current character's own bag/bank items
---@param source integer # SourceContainer or SourceMail
---@param containerId number
---@param slotId number
function Assignment:_ClassifyItem(character, itemLink, itemLocation, source, containerId, slotId)
if not itemLink then
return
end
-- GetItemInfoInstant works straight off the link with no cache involved,
-- so this cheap filter runs before anything that can fail.
local classID, subclassID = Data.GetItemClassAndSubclass(itemLink)
if classID ~= Data.ItemClassArmor and classID ~= Data.ItemClassWeapon then
return
end
local slotClassKey = Data.SlotClassKey(classID, subclassID)
-- Everything below needs the client's item cache. Remember that this
-- character has something unreadable, so any recommendation that would
-- depend on them is withheld rather than being made on partial data.
--
-- Record it against the exact slot it would compete for, so an unreadable
-- helm cannot suppress an answer about rings. Both halves of that key come
-- from GetItemInfoInstant, which is served off the link and needs no
-- cache; note the slot-class alone is far too coarse, since
-- Data.SlotClassKey deliberately collapses every kind of armour into one
-- bucket and leaves the equip location to tell a helm from a ring. An
-- unknown location falls back to the AnyEquipLocation wildcard.
--
-- The IsItemInfoResolved call itself queues the async load, and
-- GET_ITEM_INFO_RECEIVED brings us back for another pass.
if not Data.IsItemInfoResolved(itemLink) then
local instantEquipLoc = Data.GetInstantEquipLocation(itemLink)
if not instantEquipLoc or instantEquipLoc == "" then
instantEquipLoc = AnyEquipLocation
end
local byLocation = self.unresolvedOwners[slotClassKey]
if not byLocation then
byLocation = {}
self.unresolvedOwners[slotClassKey] = byLocation
end
local owners = byLocation[instantEquipLoc]
if not owners then
owners = {}
byLocation[instantEquipLoc] = owners
end
owners[character] = true
return
end
local equipLoc = Data.GetItemEquipLocation(itemLink)
if not equipLoc or equipLoc == "" then
return
end
local bind = Data.GetItemBind(itemLink)
local quality = Data.GetItemQuality(itemLink)
local sendable = bind and Data.CanItemBeSentToTwink(bind)
and Data.IsQualityEligible(quality)
and not Data.IsActuallySoulbound(bind, false, itemLocation)
local entry = {
link = itemLink,
character = character,
source = source,
containerId = containerId,
slotId = slotId,
}
local bucketTable = sendable and self.pool or self.floorExtra
bucketTable[slotClassKey] = bucketTable[slotClassKey] or {}
bucketTable[slotClassKey][equipLoc] = bucketTable[slotClassKey][equipLoc] or {}
table.insert(bucketTable[slotClassKey][equipLoc], entry)
if sendable then
local entries = self.entriesByLink[itemLink]
if not entries then
entries = {}
self.entriesByLink[itemLink] = entries
end
table.insert(entries, entry)
end
end
---One pass over every character's bags, bank, and mail. Equipped items are
---deliberately not scanned here: they're read on demand from DataStore (a
---handful of O(1) slot lookups) inside _ReplaceableFloorItem, since they're
---never reassignable and don't need bulk bucketing.
---
---Requires self._sortedCharacters to already be populated - Recompute() sorts
---before scanning so this walk is in a stable order.
function Assignment:ScanWarband()
self.pool = {}
self.floorExtra = {}
self.entriesByLink = {}
self.settledBestCache = {}
self.unresolvedCache = {}
self.unresolvedOwners = {}
for _, character in ipairs(self._sortedCharacters) do
local isCurrentCharacter = character == DataStore.ThisCharKey
Characters.IterateStoredContainerItems(character, function(containerId, _, slotId, _, itemLink)
local itemLocation = isCurrentCharacter and Data.GetLiveBagItemLocation(containerId, slotId) or nil
self:_ClassifyItem(character, itemLink, itemLocation, SourceContainer, containerId, slotId)
end)
if DataStore.IterateMails then
local mailIndex = 0
DataStore:IterateMails(character, function(_, _, mailItemLink)
mailIndex = mailIndex + 1
-- no ItemLocation for mail, and no container to key on
self:_ClassifyItem(character, mailItemLink, nil, SourceMail, 0, mailIndex)
end)
elseif not WarnedMissingMailAPI then
WarnedMissingMailAPI = true
--@alpha@
WarbandMeDowns:Print("warn: DataStore.IterateMails not available.")
--@end-alpha@
end
end
self:_SortBuckets()
end
---Puts every bucket into CompareEntries order once, after the scan, so the
---arbitrary `pairs` order DataStore's container tables are iterated in cannot
---influence which candidate a character claims.
function Assignment:_SortBuckets()
for _, bucketsBySlotClass in ipairs({ self.pool, self.floorExtra }) do
for _, buckets in pairs(bucketsBySlotClass) do
for _, entries in pairs(buckets) do
table.sort(entries, CompareEntries)
end
end
end
for _, entries in pairs(self.entriesByLink) do
table.sort(entries, CompareEntries)
end
end
-- *** Phase C: lazy, memoized cascading assignment per (slot-class, target equip location)
---@param itemLink ItemInfo
---@param slotClassKey string
---@return boolean
local function IsSameSlotClass(itemLink, slotClassKey)
local classID, subclassID = Data.GetItemClassAndSubclass(itemLink)
return Data.SlotClassKey(classID, subclassID) == slotClassKey
end
---The item this character would actually give up to take something new -
---the bar a candidate has to beat.
---
---For a location with more than one slot (two rings, two trinkets, both
---hands) a new item displaces the *worst* of them, so the worst is the bar.
---Using the best instead - as an earlier version did - meant a character with
---one good ring rejected every ring, and the item cascaded down the warband to
---someone who needed it less.
---
---A slot contributes "nothing to give up" when it is empty, or when it holds
---an item of a different slot-class. GetEquippedItemsForEquipLocation resolves
---purely by inventory slot (both hands for INVTYPE_WEAPON, both offhand-slot
---types for INVTYPE_SHIELD/INVTYPE_HOLDABLE), so it can hand back an item that
---is not comparable at all - a Sword sharing INVTYPE_WEAPON with an equipped
---Dagger, a Shield sharing INVSLOT_OFFHAND with an equipped Holdable. Such an
---item cannot serve as the bar, and the slot is going to be taken over anyway,
---so either case drives the floor to nil.
---
---Unsendable bag/bank/mail items are not slots but alternatives: if the
---character already owns a better ring they cannot send away, they would equip
---that instead of receiving this one. So the final floor is the better of the
---worst equipped slot and the best unsendable item they already hold.
---@param character string
---@param slotClassKey string
---@param floorExtra table[] # unsendable bag/bank/mail entries relevant to this target
---@param scorer WarbandMeDownsScorer
---@param equipped ItemInfo[]
---@param slotCount number
---@return ItemInfo?
function Assignment:_ReplaceableFloorItem(character, slotClassKey, floorExtra, scorer, equipped, slotCount)
local equippedFloor = nil
for index = 1, slotCount do
local equippedLink = equipped[index]
if not equippedLink or not IsSameSlotClass(equippedLink, slotClassKey) then
-- Nothing to give up in this slot; no other slot can be lower.
equippedFloor = nil
break
end
if not equippedFloor or CompareWithScorer(scorer, equippedLink, equippedFloor) == -1 then
equippedFloor = equippedLink
end
end
local ownedFloor = nil
for _, entry in ipairs(floorExtra) do
if entry.character == character then
if not ownedFloor or CompareWithScorer(scorer, entry.link, ownedFloor) == 1 then
ownedFloor = entry.link
end
end
end
if not equippedFloor then
return ownedFloor
end
if not ownedFloor then
return equippedFloor
end
return CompareWithScorer(scorer, ownedFloor, equippedFloor) == 1 and ownedFloor or equippedFloor
end
---Unions every pool/floorExtra bucket whose own equip location satisfies
---Data.EquipLocationsMatch(candidateLoc, targetEquipLoc) against targetEquipLoc -
---reusing that predicate exactly as-is, including its asymmetry. The result is
---re-sorted because the buckets are visited in `pairs` order.
---@param bucketsBySlotClass table
---@param slotClassKey string
---@param targetEquipLoc string
---@return table[]
local function UnionMatchingBuckets(bucketsBySlotClass, slotClassKey, targetEquipLoc)
local result = {}
local buckets = bucketsBySlotClass[slotClassKey]
if not buckets then
return result
end
for loc, entries in pairs(buckets) do
if Data.EquipLocationsMatch(loc, targetEquipLoc) then
for _, entry in ipairs(entries) do
table.insert(result, entry)
end
end
end
table.sort(result, CompareEntries)
return result
end
---The entries this character could actually wear.
---
---Necessary because a slot-class is coarser than equippability: Data.SlotClassKey
---collapses *all* armor into one key, so the candidate union for a plate chest
---also contains the cloth, leather and cosmetic chests sitting in the warband's
---bags. SettleGroup's group-level check only vets the representative subclass,
---which is not the same question as "can this character wear this particular
---item".
---
---Without this a plate wearer claims the higher-item-level cloth chest, and the
---plate upgrade they should have received is left unclaimed and reported as
---vendor trash - a wrong answer, not just a wasted comparison.
---@param character string
---@param entries table[]
---@return table[]
local function EquippableEntries(character, entries)
local result = {}
for _, entry in ipairs(entries) do
if Characters.CanCharacterEquipItem(character, entry.link) then
table.insert(result, entry)
end
end
return result
end
---Every item one character's decision in this group depends on, so the scorer
---can commit to a basis that covers all of them at once.
---@param character string
---@param equipped ItemInfo[]
---@param slotCount number
---@param floorExtra table[]
---@param candidates table[]
---@return ItemInfo[]
local function CollectRelevantItems(character, equipped, slotCount, floorExtra, candidates)
local items = {}
for index = 1, slotCount do
if equipped[index] then
table.insert(items, equipped[index])
end
end
for _, entry in ipairs(floorExtra) do
if entry.character == character then
table.insert(items, entry.link)
end
end
for _, entry in ipairs(candidates) do
if not entry.claimedBy then
table.insert(items, entry.link)
end
end
return items
end
---Everyone who owns an unreadable item that would compete for this exact
---target slot, and so cannot be judged for it yet. Uses the same
---Data.EquipLocationsMatch predicate the candidate buckets do, so an
---unreadable ambidextrous one-hander counts against both weapon hands
---exactly like a readable one would.
---@param slotClassKey string
---@param targetEquipLoc string
---@return table<string, boolean>
function Assignment:_UnreadableOwnersFor(slotClassKey, targetEquipLoc)
local result = {}
local byLocation = self.unresolvedOwners[slotClassKey]
if not byLocation then
return result
end
for equipLoc, owners in pairs(byLocation) do
if equipLoc == AnyEquipLocation or Data.EquipLocationsMatch(equipLoc, targetEquipLoc) then
for character in pairs(owners) do
result[character] = true
end
end
end
return result
end
---Scans the whole unclaimed candidate pool for the single best entry that
---beats the character's floor, claiming it if found. Unlike a plain
---item-level ordering, a lower-ilvl entry can still win once Pawn score is
---authoritative, so this can't stop early the way an item-level-sorted scan
---could - it has to consider every unclaimed candidate. Correct despite
---comparing entries pairwise against a moving `best` rather than against
---`floor` every time, because a scorer is a single total order: once `best`
---already beats `floor`, anything that beats `best` also beats `floor`.
---@param character string
---@param candidates table[]
---@param floor ItemInfo?
---@param scorer WarbandMeDownsScorer
---@return table? claimedEntry
function Assignment:_ClaimBestCandidate(character, candidates, floor, scorer)
local best = nil
for _, entry in ipairs(candidates) do
if not entry.claimedBy then
local reference = best and best.link or floor
if CompareWithScorer(scorer, entry.link, reference) == 1 then
best = entry
end
end
end
return best
end
---Settles one (slot-class, target equip location) pair: walks the warband
---in priority order, greedily giving each eligible character the better of
---their own floor and the best still-unclaimed pool candidate. A claimed
---pool entry is removed from play for every other target group it could
---also have satisfied (e.g. an ambidextrous one-hander claimed while
---settling INVTYPE_WEAPON is no longer available when INVTYPE_WEAPONMAINHAND
---is settled later). No-ops if already settled this generation.
---@param slotClassKey string
---@param targetEquipLoc string
---@param classID number
---@param subclassID number
function Assignment:SettleGroup(slotClassKey, targetEquipLoc, classID, subclassID)
self.settledBestCache[slotClassKey] = self.settledBestCache[slotClassKey] or {}
self.unresolvedCache[slotClassKey] = self.unresolvedCache[slotClassKey] or {}
if self.settledBestCache[slotClassKey][targetEquipLoc] then
return
end
local candidates = UnionMatchingBuckets(self.pool, slotClassKey, targetEquipLoc)
local floorExtra = UnionMatchingBuckets(self.floorExtra, slotClassKey, targetEquipLoc)
local unreadableOwners = self:_UnreadableOwnersFor(slotClassKey, targetEquipLoc)
local settledBest = {}
local unresolved = {}
for _, character in ipairs(self._sortedCharacters) do
if not Characters.CanCharacterEquipItemClass(character, classID, subclassID) then
settledBest[character] = self.Ineligible
else
local equipped, slotCount = Characters.GetEquippedItemsForEquipLocation(character, targetEquipLoc)
-- Narrowed to what this character can actually wear before anything
-- is scored or claimed - see EquippableEntries. The entry tables are
-- the same objects, so claims still propagate to every other group
-- the entry could have satisfied.
local ownCandidates = EquippableEntries(character, candidates)
local ownFloorExtra = EquippableEntries(character, floorExtra)
local scorer = self:_BuildScorerForCharacter(
character,
CollectRelevantItems(character, equipped, slotCount, ownFloorExtra, ownCandidates)
)
local floor = self:_ReplaceableFloorItem(character, slotClassKey, ownFloorExtra, scorer, equipped, slotCount)
local claimed = self:_ClaimBestCandidate(character, ownCandidates, floor, scorer)
if claimed then
claimed.claimedBy = character
claimed.claimedOverFloor = floor
settledBest[character] = claimed.link
else
settledBest[character] = floor or false
end
if not scorer.resolved or unreadableOwners[character] then
unresolved[character] = true
end
end
end
self.settledBestCache[slotClassKey][targetEquipLoc] = settledBest
self.unresolvedCache[slotClassKey][targetEquipLoc] = unresolved
end
-- *** Phase D: exposing results
---@param character string
---@param itemLink ItemInfo
---@param compareItem ItemInfo?
---@return [string, number, number, boolean] upgradeInfo character, compareItemLevel, itemLevel, statOnlyUpgrade
function Assignment:BuildUpgradeInfo(character, itemLink, compareItem)
local itemLevel = Data.GetActualItemLevel(itemLink) or 0
local compareItemLevel = (compareItem and Data.GetActualItemLevel(compareItem)) or 0
-- Not a strict item-level increase: either an equal-ilvl stat-only pick,
-- or - now that Pawn score can be authoritative - a lower-ilvl item Pawn
-- prefers anyway. Tooltip.lua distinguishes the two by re-comparing
-- itemLevel to compareItemLevel itself.
local statOnlyUpgrade = itemLevel <= compareItemLevel
return { character, compareItemLevel, itemLevel, statOnlyUpgrade }
end
---@param itemLocation ItemLocation?
---@return number?, number?
local function GetBagAndSlot(itemLocation)
if not itemLocation or not itemLocation.IsBagAndSlot or not itemLocation:IsBagAndSlot() then
return nil, nil
end
return itemLocation:GetBagAndSlot()
end
---Finds the pool entry for the exact item instance the tooltip is showing.
---
---Two physically distinct items can share one item link (a second copy of the
---same ring), and they are claimed independently, so answering with whichever
---entry happened to be recorded first can name the character who received the
---*other* copy. Pin the real instance down by bag and slot when the tooltip
---gives us a live location, then prefer the current character's own copy,
---and only then fall back to the first entry in the (now deterministic) order.
---@param itemLink ItemInfo
---@param itemLocation ItemLocation?
---@return table?
function Assignment:_FindEntryForHoveredItem(itemLink, itemLocation)
local entries = self.entriesByLink[itemLink]
if not entries then
return nil
end
local bagID, slotID = GetBagAndSlot(itemLocation)
if bagID then
for _, entry in ipairs(entries) do
if entry.source == SourceContainer and entry.containerId == bagID and entry.slotId == slotID then
return entry
end
end
end
for _, entry in ipairs(entries) do
if entry.character == DataStore.ThisCharKey then
return entry
end
end
return entries[1]
end
---Whether any character the answer depends on is still missing item data.
---Walking only as far as the character who would win keeps this precise: a
---cold item on some low-priority alt cannot suppress an answer that was
---already decided above them, and one permanently unreadable link cannot
---silence the addon for good.
---@param unresolved table<string, boolean>
---@param stopCharacter string? # inclusive; nil checks the whole warband
---@return boolean
function Assignment:_AnyUnresolvedUpTo(unresolved, stopCharacter)
for _, character in ipairs(self._sortedCharacters) do
if unresolved[character] then
return true
end
if stopCharacter and character == stopCharacter then
return false
end
end
return false
end
---Finds the destination for a given item: the character who should keep or
---receive it, WarbandMeDowns.Assignment.Sell if it's confirmed to be an
---upgrade for nobody, WarbandMeDowns.Assignment.Pending if the answer depends
---on item data the client hasn't cached yet, or nil if it was never eligible
---to be sent to a twink at all (wrong bind type, already Soulbound, below
---Uncommon quality, not armor/weapon, or not equippable).
---
---Already-owned items resolve in O(1) once their slot-class/target group is
---settled. Items nobody in the warband owns yet (loot window, vendor,
---trade, chat links) fall back to an O(#characters) walk against each
---character's already-settled best - no warband rescan needed.
---@param itemLink ItemInfo
---@param itemLocation ItemLocation? # live location of the hovered item, when available (see Data.IsActuallySoulbound)
---@return string|table|nil
function Assignment:GetBestCharacterForItem(itemLink, itemLocation)
-- Nothing below can be trusted until the client has cached this item:
-- bind type, quality and equip location all come out of C_Item.GetItemInfo
-- and are nil for an uncached link, which would look exactly like "not
-- eligible". Asking queues the load and GET_ITEM_INFO_RECEIVED brings us
-- back, so this resolves itself a moment later.
if not Data.IsItemInfoResolved(itemLink) then
return self.Pending
end
local bind = Data.GetItemBind(itemLink)
if not bind or not Data.CanItemBeSentToTwink(bind) then
return nil
end
if Data.IsActuallySoulbound(bind, false, itemLocation) then
return nil
end
local quality = Data.GetItemQuality(itemLink)
if not Data.IsQualityEligible(quality) then
return nil
end
local classID, subclassID = Data.GetItemClassAndSubclass(itemLink)
if classID ~= Data.ItemClassArmor and classID ~= Data.ItemClassWeapon then
return nil
end
local targetEquipLoc = Data.GetItemEquipLocation(itemLink)
if not targetEquipLoc or targetEquipLoc == "" then
return nil
end
self:EnsureFresh()
local slotClassKey = Data.SlotClassKey(classID, subclassID)
self:SettleGroup(slotClassKey, targetEquipLoc, classID, subclassID)
local unresolved = self.unresolvedCache[slotClassKey][targetEquipLoc]
-- Owned path: this exact item was seen during the warband scan.
local entry = self:_FindEntryForHoveredItem(itemLink, itemLocation)
if entry then
if entry.claimedBy then
if self:_AnyUnresolvedUpTo(unresolved, entry.claimedBy) then
return self.Pending
end
return self:BuildUpgradeInfo(entry.claimedBy, itemLink, entry.claimedOverFloor)
end
-- "Nobody wants it" is only safe to say once everybody could be asked.
if self:_AnyUnresolvedUpTo(unresolved, nil) then
return self.Pending
end
return self.Sell
end
-- Foreign path: not part of the precomputed pool - compare against every
-- character's already-settled best instead of rescanning the warband.
local settledBest = self.settledBestCache[slotClassKey][targetEquipLoc]
for _, character in ipairs(self._sortedCharacters) do
if unresolved[character] then
return self.Pending
end
local best = settledBest[character]
if best ~= self.Ineligible then
local scorer = self:_BuildScorerForCharacter(character, { itemLink, best or nil })
local comparison = CompareWithScorer(scorer, itemLink, best)
if comparison == nil then
return self.Pending
elseif comparison == 1 then
return self:BuildUpgradeInfo(character, itemLink, best)
end
end
end
return self.Sell
end
-- *** Bulk settlement
--
-- The tooltip path only ever needs the one group the hovered item belongs to,
-- so SettleGroup is lazy. Anything that wants to know what the engine hands
-- *every* character - the settings page's projected item levels - needs the
-- claims from every group instead, which is what SettleAllGroups forces.
---Every (slot-class, target equip location) pair that has at least one
---sendable candidate, in a stable order.
---
---Order matters and is not cosmetic: claims are cross-group stateful (an
---ambidextrous one-hander claimed while settling INVTYPE_WEAPON is gone by the
---time INVTYPE_WEAPONMAINHAND is settled), so settling in `pairs` order would
---make the result differ between sessions. Walking the keys in sorted order
---pins it down.
---@return table[] # { slotClassKey, equipLoc, classID, subclassID }
function Assignment:_SettleableGroups()
local groups = {}
for _, slotClassKey in ipairs(SortedKeys(self.pool)) do
local buckets = self.pool[slotClassKey]
for _, equipLoc in ipairs(SortedKeys(buckets)) do
local first = buckets[equipLoc][1]
if first then
local classID, subclassID = Data.GetItemClassAndSubclass(first.link)
if classID and subclassID then
table.insert(groups, {
slotClassKey = slotClassKey,
equipLoc = equipLoc,
classID = classID,
subclassID = subclassID,
})
end
end
end
end
return groups
end
---Settles every group that has a candidate in it, so `entry.claimedBy` is
---populated across the whole pool. Idempotent within a generation - SettleGroup
---no-ops on an already-settled pair - and deterministic, so calling this does
---not change any answer a tooltip would otherwise have given; it only makes
---later tooltip answers independent of the order groups happened to be
---hovered in.
function Assignment:SettleAllGroups()
for _, group in ipairs(self:_SettleableGroups()) do
self:SettleGroup(group.slotClassKey, group.equipLoc, group.classID, group.subclassID)
end
self.settledAll = true
end
---Whether every group has been settled this generation, i.e. whether
---`entry.claimedBy` can be trusted to be complete.
---@return boolean
function Assignment:IsFullySettled()
return self.settledAll
end
---Settles every group the way SettleAllGroups does, but one group per timer
---tick instead of all of them in the frame that asked.
---
---Settling the whole pool at once is expensive in a way the tooltip path never
---is: with Pawn installed it asks Pawn to evaluate every warbound item in the
---warband, and Pawn parses a hidden tooltip per item it has not seen. Several
---hundred of those in one frame is a visible stall, which is what made opening
---the settings panel hitch.
---
---One group per tick is the finest grain available - SettleGroup mutates claims
---across the whole pool and has to complete atomically, so it cannot be split.
---
---Safe to call repeatedly: a run already in flight is left alone, and a
---Recompute cancels it (see CancelIncrementalSettle) rather than letting it
---continue against a wiped pool.
---@param onComplete fun()? # called once the last group is settled
function Assignment:SettleAllGroupsIncrementally(onComplete)
if self.settledAll then
if onComplete then
onComplete()
end
return
end
if self._settleTimer then
return -- already running
end
self._settleQueue = self:_SettleableGroups()
local index = 0
local generation = self._settleQueue
local function step()
self._settleTimer = nil
-- A Recompute since the last tick replaced the queue; this run is stale.
if self._settleQueue ~= generation then
return
end
index = index + 1
local group = generation[index]
if not group then
self.settledAll = true
self._settleQueue = nil
if onComplete then
onComplete()
end
return
end
self:SettleGroup(group.slotClassKey, group.equipLoc, group.classID, group.subclassID)
self._settleTimer = WarbandMeDowns:ScheduleTimer(step, 0.05)
end
self._settleTimer = WarbandMeDowns:ScheduleTimer(step, 0)
end
---Stops an in-flight incremental settle. The pool it was walking is about to be
---thrown away, so its remaining groups are meaningless.
function Assignment:CancelIncrementalSettle()
if self._settleTimer then
WarbandMeDowns:CancelTimer(self._settleTimer)
self._settleTimer = nil
end
self._settleQueue = nil
end
---Every bag/bank/mail entry the last scan produced - sendable pool entries
---first, then unsendable floorExtra ones - in a deterministic order, so