-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathTransfer.lua
More file actions
1674 lines (1559 loc) · 68.1 KB
/
Copy pathTransfer.lua
File metadata and controls
1674 lines (1559 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
-- Quartermaster -- Transfer
-- Moves stock both INTO and OUT OF the current character's carried bags:
-- * from/to the BANK while the bank is open -- top up shortfalls toward `target`,
-- or bank excess (/qm banksync), filling existing partial stacks first.
-- * via MAIL -- one stack per mail (1.12 allows a single attachment), sequenced
-- on MAIL_SEND_SUCCESS. Either supplying another character's shortfall, or
-- dumping the transferable list's excess to a configured recipient.
--
-- Mail sequencer idiom from ../QuickStash (AutoMailer): one stack per mail on this
-- 1.12 client, sequenced on MAIL_SEND_SUCCESS.
local QM = Quartermaster
QM.Transfer = {}
local T = QM.Transfer
-- The transferable list never rejects an add (like consumables' classifier), and
-- defaults new rows to Keep=0 (ship everything) with bankable on. It has no `low`
-- (no warn-threshold concept for this list).
QM.itemValidators["transferable"] = function(id, name, itype, isub)
return true, nil, { target = 0, bankable = true }, { "low" }
end
-- ---------------------------------------------------------------------------
-- Planning: what does the raid character still need?
-- ---------------------------------------------------------------------------
-- For a character's desired list, compute the shortfall per item and where the rest
-- lives (bank / other characters). Returns ordered rows { id, name, short, fromBank,
-- fromAlt, alts }, where `alts` is an ordered { { char, amount }, ... } naming exactly
-- which other characters are holding it (sorted by character name), and `fromAlt` is
-- just its total (kept for callers that don't care about the breakdown).
function T.plan(charKey, kind)
local key = charKey or QM.charKey()
local c = QM.db and QM.db.chars[key]
local list = c and c[kind]
if not list then return {} end
local rows = {}
for i = 1, table.getn(list) do
local e = list[i]
if not QM.isDivider(e) and QM.itemActive(e) then
local bags, bank
if key == QM.charKey() then
bags, bank = QM.itemCount(e.id)
else
local inv = c.inventory and c.inventory[e.id]
bags = inv and inv.bags or 0
bank = inv and inv.bank or 0
end
local short = (e.target or 0) - bags
if short > 0 then
local fromBank = short
if fromBank > bank then fromBank = bank end
local fromAlt, alts = 0, {}
QM.eachChar(function(otherKey, rec)
if otherKey ~= key then
local inv = rec.inventory and rec.inventory[e.id]
if inv and inv.total and inv.total > 0 then
fromAlt = fromAlt + inv.total
table.insert(alts, { char = otherKey, amount = inv.total })
end
end
end)
table.sort(alts, function(a, b) return a.char < b.char end)
table.insert(rows, { id = e.id, name = e.name, short = short, fromBank = fromBank, fromAlt = fromAlt, alts = alts })
end
end
end
return rows
end
-- ---------------------------------------------------------------------------
-- Bag <-> bank stack mover (fill partial stacks before making new ones)
-- ---------------------------------------------------------------------------
local BANK_CONTAINER = -1
local function bagContainers()
local list = {}
for bag = 0, 4 do table.insert(list, bag) end
return list
end
local function bankContainers()
local list = { BANK_CONTAINER }
local n = NUM_BAG_SLOTS or 4
local nb = NUM_BANKBAGSLOTS or 6
for bag = n + 1, n + nb do table.insert(list, bag) end
return list
end
-- The first completely empty slot in `containers`, or nil. Used at EXECUTION time
-- (runAction's "trim" kind) where a live re-scan is correct/necessary -- see
-- collectEmptySlots below for the planning-time equivalent.
local function findEmptySlot(containers)
for i = 1, table.getn(containers) do
local bag = containers[i]
local slots = GetContainerNumSlots(bag) or 0
for slot = 1, slots do
if not GetContainerItemLink(bag, slot) then return bag, slot end
end
end
end
-- Every stack of itemID currently in `containers`, sorted largest-first. The one
-- full scan both planStacks (mail) and planTransfer (bank) plan an entire batch
-- from up front -- neither re-scans mid-execution (see runItemActions below for why).
local function scanStacks(containers, itemID)
local stacks = {}
for i = 1, table.getn(containers) do
local bag = containers[i]
local slots = GetContainerNumSlots(bag) or 0
for slot = 1, slots do
local link = GetContainerItemLink(bag, slot)
if link and QM.itemID(link) == itemID then
local _, count = GetContainerItemInfo(bag, slot)
table.insert(stacks, { bag = bag, slot = slot, count = count or 0 })
end
end
end
table.sort(stacks, function(a, b) return a.count > b.count end)
return stacks
end
-- Every currently-empty slot in `containers`, in scan order. Unlike findEmptySlot,
-- this is a planning-time snapshot: planTransfer claims slots from this list one at
-- a time as it needs fresh destinations, so two different actions in the same plan
-- never get handed the same empty slot (a live re-scan would return the same first
-- empty slot for both, since nothing has actually moved yet at plan time).
local function collectEmptySlots(containers)
local list = {}
for i = 1, table.getn(containers) do
local bag = containers[i]
local slots = GetContainerNumSlots(bag) or 0
for slot = 1, slots do
if not GetContainerItemLink(bag, slot) then table.insert(list, { bag = bag, slot = slot }) end
end
end
return list
end
-- Plans moving `amount` of itemID from `fromContainers` to `toContainers` as a
-- sequence of `move`/`splitmove` actions (the same kinds runAction already executes
-- for mail's packStacks) -- one full scan of both sides, no re-scanning mid-plan.
-- Prefers topping off existing destination partials (least room left first, so a
-- stack finishes instead of remainder spreading across many) before opening a fresh
-- empty slot; within either, prefers exact-size source stacks then the largest
-- remaining one, splitting only what's needed. A destination "target" (existing
-- partial or freshly opened slot) can absorb several source stacks across the loop --
-- e.g. two source partials of 7 and 6 both feed the same fresh 13-stack rather than
-- landing in two separate slots -- which is what keeps this from re-fragmenting stock
-- that's merely spread across a couple of source stacks. Returns (actions, queue,
-- shortfall); shortfall > 0 means source stock or destination room ran out.
local function planTransfer(itemID, amount, fromContainers, toContainers)
local _, _, _, _, _, _, maxStack = GetItemInfo("item:" .. itemID)
maxStack = maxStack or 1
local sources = scanStacks(fromContainers, itemID)
local dests = scanStacks(toContainers, itemID)
local targets = {}
for i = 1, table.getn(dests) do
local d = dests[i]
if d.count < maxStack then
table.insert(targets, { bag = d.bag, slot = d.slot, room = maxStack - d.count })
end
end
table.sort(targets, function(a, b) return a.room < b.room end)
local empties = collectEmptySlots(toContainers)
local nextEmpty = 0
local function anySourceLeft()
for i = 1, table.getn(sources) do
if sources[i].count > 0 then return true end
end
return false
end
-- Claims up to `need` from one source stack in a single manipulation: an
-- exact-size stack first (no split needed), else the largest remaining one
-- (split off `need` if it overshoots). Returns bag, slot, amount, isSplit.
local function takeFrom(need)
for i = 1, table.getn(sources) do
if sources[i].count == need then
local s = sources[i]
s.count = 0
return s.bag, s.slot, need, false
end
end
for i = 1, table.getn(sources) do
if sources[i].count > 0 then
local s = sources[i]
local take = s.count < need and s.count or need
local split = take < s.count
s.count = s.count - take
return s.bag, s.slot, take, split
end
end
end
local actions, queue = {}, {}
local remaining = amount
local ti = 0
while remaining > 0 and anySourceLeft() do
ti = ti + 1
local target = targets[ti]
if not target then
nextEmpty = nextEmpty + 1
local slot = empties[nextEmpty]
if not slot then break end
target = { bag = slot.bag, slot = slot.slot, room = maxStack }
table.insert(targets, target)
end
while target.room > 0 and remaining > 0 do
local need = remaining < target.room and remaining or target.room
local bag, slot, take, split = takeFrom(need)
if not bag then break end
if split then
table.insert(actions, { kind = "splitmove", bag = bag, slot = slot,
amount = take, destBag = target.bag, destSlot = target.slot })
else
table.insert(actions, { kind = "move", bag = bag, slot = slot,
destBag = target.bag, destSlot = target.slot })
end
table.insert(queue, { bag = target.bag, slot = target.slot })
remaining = remaining - take
target.room = target.room - take
end
end
return actions, queue, remaining
end
-- ---------------------------------------------------------------------------
-- Status label: what the prep/mail machinery is doing right now, floated above
-- the mailbox (everything here runs only while mail is open). nil text hides it.
-- ---------------------------------------------------------------------------
local Status
local function setStatus(text)
if not text then
if Status then Status:Hide() end
return
end
if not Status then
Status = CreateFrame("Frame", nil, UIParent)
Status:SetFrameStrata("DIALOG")
Status:SetHeight(26)
Status:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 } })
Status:SetBackdropColor(0, 0, 0, 0.85)
Status.label = Status:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
Status.label:SetPoint("CENTER", 0, 0)
end
Status:ClearAllPoints()
if QM.mailFrame and QM.mailFrame:IsShown() then
Status:SetPoint("BOTTOM", QM.mailFrame, "TOP", 0, 4)
elseif MailFrame then
Status:SetPoint("BOTTOM", MailFrame, "TOP", 0, 8)
else
Status:SetPoint("CENTER", UIParent, "CENTER", 0, 120)
end
Status.label:SetText(text)
Status:SetWidth(Status.label:GetStringWidth() + 26)
Status:Show()
end
-- Forward declares: the paced/settled action engine, the generic per-item
-- sequencer, and cancellation both live further down (alongside the Prep frame /
-- Mailer), but T.fromBank/T.toBank/T.bankSync and the Mailer's MAIL_CLOSED handler
-- all need them here. `bankSyncing` guards against a second /qm banksync overlapping
-- an in-progress one, the same idea as Mailer.sending.
local runItemActions
local runItemsSequential
local cancelPrep
local bankSyncing = false
-- Async: moves `amount` of itemID from the bank into bags via planTransfer, one
-- paced/settled manipulation at a time (see runItemActions) -- never more than one
-- Pickup/Split pair per tick, unlike the old synchronous version. `done(confirmed)`
-- fires once settled, with the amount actually verified moved (a bag-count diff via
-- QM.scanInventory/QM.itemCount), not just the amount attempted.
function T.fromBank(itemID, amount, done)
if not QM.bankOpen then
QM.print("open your bank first")
if done then done(0) end
return
end
QM.scanInventory()
local bagsBefore = QM.itemCount(itemID)
local actions, queue = planTransfer(itemID, amount, bankContainers(), bagContainers())
runItemActions(actions, queue, function()
QM.scanInventory()
local bagsAfter = QM.itemCount(itemID)
local confirmed = bagsAfter - bagsBefore
if done then done(confirmed > 0 and confirmed or 0) end
end)
end
-- Async counterpart of T.fromBank: moves `amount` of itemID from bags into the open
-- bank. `done(confirmed)` fires once settled, verified via a bank-count diff.
function T.toBank(itemID, amount, done)
if not QM.bankOpen then
QM.print("open your bank first")
if done then done(0) end
return
end
QM.scanInventory()
local _, bankBefore = QM.itemCount(itemID)
local actions, queue = planTransfer(itemID, amount, bagContainers(), bankContainers())
runItemActions(actions, queue, function()
QM.scanInventory()
local _, bankAfter = QM.itemCount(itemID)
local confirmed = bankAfter - bankBefore
if done then done(confirmed > 0 and confirmed or 0) end
end)
end
-- The floor to leave behind for a transferable-list entry. If the same item is ALSO in
-- the active tracked list (and not turned off), that list's target always wins -- the
-- transferable list's own Keep only applies to items the tracked list doesn't already
-- cover, so the two lists can't fight over how much of a dual-listed item to ship (e.g.
-- a reagent you keep 20 of on the tracker but would otherwise list as Keep=0 here).
local function transferableFloor(c, e)
local list = c.consumables or {}
for i = 1, table.getn(list) do
local te = list[i]
if not QM.isDivider(te) and te.id == e.id and QM.itemActive(te) then
return te.target or 0
end
end
return e.target or 0
end
-- True when itemID is also an active row in the transferable list. QM.itemCount reads a
-- CACHED inventory snapshot (only refreshed by QM.scanInventory, which bankSync/
-- mailDumpExcess only call once at the very end), so a tracked-overage pass that ran
-- BOTH for a dual-listed item's own target AND again via transferableFloor would compute
-- the second pass off the same stale (pre-first-pass) bag count and over-process it --
-- this is what keeps the two passes from ever touching the same item: a dual-listed item
-- is handled exactly once, by the transferable-list pass (which already resolves the
-- right floor via transferableFloor), never by the plain tracked-overage pass.
local function inTransferableList(c, id)
local list = c.transferable or {}
for i = 1, table.getn(list) do
local te = list[i]
if not QM.isDivider(te) and te.id == id and QM.itemActive(te) then return true end
end
return false
end
-- ---------------------------------------------------------------------------
-- /qm banksync -- top up tracked-list shortfalls from the bank, then (optionally)
-- bank tracked-list overage and everything bankable in the transferable list.
-- ---------------------------------------------------------------------------
-- Runs `items` ({ {id=,name=,amount=}, ... }) one at a time through `mover`
-- (T.fromBank or T.toBank), accumulating the verified (not attempted) total moved,
-- warning per item when it fell short of the plan. `done(total)` fires once every
-- item has settled.
local function bankSyncPass(items, mover, done)
local total = 0
runItemsSequential(items, function(item, next)
mover(item.id, item.amount, function(confirmed)
if confirmed < item.amount then
local name = item.name or GetItemInfo("item:" .. item.id) or ("item " .. item.id)
QM.print(confirmed .. "x " .. name .. " moved -- short of the planned " .. item.amount)
end
total = total + confirmed
next()
end)
end, function() done(total) end)
end
function T.bankSync()
if not QM.bankOpen then QM.print("open your bank first"); return end
if bankSyncing then QM.print("banksync already running"); return end
local c = QM.me
if not c then return end
local topUp, overage = {}, {}
local plan = T.plan(QM.charKey(), "consumables")
for i = 1, table.getn(plan) do
local row = plan[i]
if row.fromBank > 0 then
table.insert(topUp, { id = row.id, name = row.name, amount = row.fromBank })
end
end
if QM.db.options.transfer.dumpTrackedOverage then
local list = c.consumables or {}
for i = 1, table.getn(list) do
local e = list[i]
-- Dual-listed items are handled below instead (transferableFloor already
-- resolves to this same target for them) -- see inTransferableList's comment
-- for why processing one here too would over-bank it.
if not QM.isDivider(e) and QM.itemActive(e) and not inTransferableList(c, e.id) then
local bags = QM.itemCount(e.id)
local amt = bags - (e.target or 0)
if amt > 0 then table.insert(overage, { id = e.id, name = e.name, amount = amt }) end
end
end
end
local tlist = c.transferable or {}
for i = 1, table.getn(tlist) do
local e = tlist[i]
if not QM.isDivider(e) and QM.itemActive(e) and e.bankable then
local bags = QM.itemCount(e.id)
local amt = bags - transferableFloor(c, e)
if amt > 0 then table.insert(overage, { id = e.id, name = e.name, amount = amt }) end
end
end
bankSyncing = true
bankSyncPass(topUp, T.fromBank, function(topped)
bankSyncPass(overage, T.toBank, function(banked)
bankSyncing = false
setStatus(nil)
QM.scanInventory()
QM.print("banksync: topped up " .. topped .. ", banked " .. banked)
end)
end)
end
-- Closing the bank mid-sync leaves nothing left to move into/out of -- cancel
-- whatever's in flight (mirrors Mailer's MAIL_CLOSED handling below) rather than
-- letting it stall on a settle timeout against a container that's no longer open.
QM.on("BANKFRAME_CLOSED", function()
if bankSyncing then
cancelPrep()
bankSyncing = false
setStatus(nil)
end
end)
-- ---------------------------------------------------------------------------
-- Mail sequencer (one item per mail)
-- ---------------------------------------------------------------------------
local Mailer = CreateFrame("Frame")
Mailer:RegisterEvent("MAIL_CLOSED")
Mailer:RegisterEvent("MAIL_SEND_SUCCESS")
Mailer:RegisterEvent("UI_ERROR_MESSAGE")
Mailer.queue = {} -- ordered list of { bag, slot }
Mailer.index = 0
Mailer.recipient = nil
Mailer.sending = false
Mailer.pending = false -- counting down to the next attach
Mailer.elapsed = 0
Mailer.pendingSend = false -- attached, counting down before the SendMail call itself
Mailer.sendElapsed = 0
Mailer.pendingSubject = nil
Mailer.waiting = false -- attached + SendMail called, awaiting confirmation
Mailer.waitElapsed = 0
Mailer.sentCount = 0
Mailer.onDone = nil -- optional callback, fired once this batch finishes/aborts --
-- lets a caller chain several recipients through this one
-- shared Mailer (see T.mailDumpExcess).
Mailer.expected = 0 -- attachments queued this batch (progress display)
Mailer.attachRetries = 0 -- consecutive failed attach attempts for the current queue entry
Mailer.sendRetries = 0 -- SendMail calls for the current entry that got no server reply
local MAIL_SEND_DELAY = 0.3
local MAIL_SEND_TIMEOUT = 5 -- per SendMail: no MAIL_SEND_SUCCESS *or* error in this long -> retry
local MAIL_ATTACH_RETRIES = 3 -- attach attempts per queue entry before skipping that stack
local MAIL_SEND_RETRIES = 2 -- unanswered SendMail retries per entry before aborting the batch
-- TurtleMail globally REPLACES ClickSendMailItemButton with an async, cursor-polling
-- version (installed at PLAYER_LOGIN) that never attaches anything within a single
-- synchronous call -- the native attach sequence silently attaches nothing through it
-- (no error, GetSendMailItem just stays nil). The ORIGINAL is kept in TurtleMail.orig
-- and works fine alongside its other hooks, so the one sequencer below drives every
-- setup through this helper. (An earlier compat mode handed the whole queue to
-- TurtleMail.sendmail_send instead; on this server a SendMail sometimes gets no reply
-- at all -- no MAIL_SEND_SUCCESS, no UI error -- and that path had no per-item
-- confirmation to retry from, so the whole batch just died on a watchdog. Driving
-- items one at a time is what makes the retry below possible.)
local function clickSendSlot()
if QM.caps and QM.caps.turtleMail and TurtleMail and TurtleMail.orig and TurtleMail.orig.ClickSendMailItemButton then
TurtleMail.orig.ClickSendMailItemButton()
else
ClickSendMailItemButton()
end
end
local function finishMailing()
Mailer:SetScript("OnUpdate", nil)
Mailer.sending = false
Mailer.pending = false
Mailer.pendingSend = false
Mailer.waiting = false
-- Retrieve whatever an aborted batch left in the native send slot (ClearCursor
-- returns the picked-up stack to the bag slot it came from).
if GetSendMailItem() then
ClearCursor()
clickSendSlot()
ClearCursor()
end
-- Never leave TurtleMail state behind that marks bag slots "attached" -- its
-- GetContainerItemInfo hook renders those locked and its SplitContainerItem hook
-- silently no-ops on them, corrupting the next batch's prep.
if QM.caps and QM.caps.turtleMail and TurtleMail then
TurtleMail.sendmail_state = nil
end
Mailer.queue = {}
Mailer.index = 0
ClearCursor()
setStatus(nil)
if Mailer.sentCount > 0 then
QM.print("mailed " .. Mailer.sentCount .. " stack(s) to " .. (Mailer.recipient or "?"))
end
local done = Mailer.onDone
Mailer.onDone = nil
if done then done() end
end
local function sendCurrentMailItem()
Mailer.pending = false
-- Empty the send slot first: a retry's attachment from the previous attempt goes
-- back to the bag slot it came from, so the scan below finds it again.
ClearCursor()
clickSendSlot()
ClearCursor()
-- skip queue entries whose slot no longer holds an item
while Mailer.index <= table.getn(Mailer.queue) do
local entry = Mailer.queue[Mailer.index]
if GetContainerItemLink(entry.bag, entry.slot) then break end
Mailer.index = Mailer.index + 1
Mailer.attachRetries, Mailer.sendRetries = 0, 0
end
if Mailer.index > table.getn(Mailer.queue) then finishMailing(); return end
local entry = Mailer.queue[Mailer.index]
ClearCursor()
PickupContainerItem(entry.bag, entry.slot)
clickSendSlot() -- attach the picked-up stack
local itemName, _, stackCount = GetSendMailItem()
if not itemName then
-- The slot holds an item but won't attach (usually a lock that hasn't settled
-- yet); retry a few beats before giving up on this stack.
ClearCursor()
Mailer.attachRetries = Mailer.attachRetries + 1
if Mailer.attachRetries > MAIL_ATTACH_RETRIES then
QM.print("skipping bag " .. entry.bag .. " slot " .. entry.slot .. " -- attach kept failing")
Mailer.index = Mailer.index + 1
Mailer.attachRetries = 0
end
Mailer.pending = true
Mailer.elapsed = 0
return
end
Mailer.attachRetries = 0
-- Name each mail after its item, the way TurtleMail does.
local subject = itemName
if stackCount and stackCount > 1 then subject = subject .. " (" .. stackCount .. ")" end
Mailer.pendingSubject = subject
-- The attach (ClickSendMailItemButton) and the SendMail call are two separate
-- server round-trips, same as any other container-touching action on this
-- client -- calling SendMail in the same breath as the attach can get NO reply at
-- all if the server hasn't caught up on the attach yet (confirmed: this happens
-- with or without TurtleMail, and only when something is actually attached first --
-- a bare SendMail with nothing attached always gets an instant reply). Give the
-- attach one more beat to land before asking the server to send it.
Mailer.pendingSend = true
Mailer.sendElapsed = 0
end
local function dispatchMailSend()
Mailer.pendingSend = false
SendMail(Mailer.recipient, Mailer.pendingSubject, "")
Mailer.waiting = true
Mailer.waitElapsed = 0
end
local function onMailUpdate()
local elapsed = arg1
if Mailer.pending then
Mailer.elapsed = Mailer.elapsed + elapsed
if Mailer.elapsed >= MAIL_SEND_DELAY then sendCurrentMailItem() end
elseif Mailer.pendingSend then
Mailer.sendElapsed = Mailer.sendElapsed + elapsed
if Mailer.sendElapsed >= MAIL_SEND_DELAY then dispatchMailSend() end
elseif Mailer.waiting then
Mailer.waitElapsed = Mailer.waitElapsed + elapsed
if Mailer.waitElapsed >= MAIL_SEND_TIMEOUT then
-- SendMail got neither MAIL_SEND_SUCCESS nor an error: the observed OctoWoW
-- failure mode (the request just dies in transit). Report what the send slot
-- looks like and route back through sendCurrentMailItem -- if the stack is
-- still around it gets re-attached and re-sent, if it genuinely left (a
-- success whose event we missed) its now-empty bag slot gets skipped.
Mailer.sendRetries = Mailer.sendRetries + 1
if Mailer.sendRetries > MAIL_SEND_RETRIES then
QM.print("mail send timed out " .. (MAIL_SEND_RETRIES + 1) .. " times -- stopping")
finishMailing()
return
end
local itemName = GetSendMailItem()
QM.print("no server reply to SendMail (send slot: " .. (itemName or "empty") .. ") -- retrying")
Mailer.waiting = false
Mailer.pending = true
Mailer.elapsed = 0
end
end
end
-- Public entry: mail a prepared queue of { bag, slot } stacks to `recipient`. Requires
-- the mail window open. `onDone`, if given, fires once this batch finishes (or aborts) --
-- lets a caller chain several recipients through this one shared Mailer. Works with or
-- without TurtleMail: the attach goes through clickSendSlot (see its comment), and any
-- TurtleMail state that would make its bag hooks interfere is dropped up front.
function T.mailItems(recipient, queue, onDone)
if Mailer.sending then
QM.print("already mailing -- wait for the current batch to finish")
return
end
if QM.caps and QM.caps.turtleMail and TurtleMail and TurtleMail.sendmail_sending then
QM.print("TurtleMail is mid-send -- wait for it to finish")
return
end
if not recipient or recipient == "" then
QM.print("no mail recipient set")
if onDone then onDone() end
return
end
if not queue or table.getn(queue) < 1 then
QM.print("nothing to mail")
if onDone then onDone() end
return
end
if MailFrameTab_OnClick then MailFrameTab_OnClick(2) end -- switch to Send Mail tab
-- Neutralize TurtleMail before driving the native flow: drop any leftover
-- sendmail_state (its GetContainerItemInfo/SplitContainerItem hooks treat those
-- slots as attached) and detach anything staged in its attachment UI.
if QM.caps and QM.caps.turtleMail and TurtleMail then
TurtleMail.sendmail_state = nil
if TurtleMail.sendmail_clear then TurtleMail.sendmail_clear() end
end
Mailer.recipient = recipient
Mailer.sentCount = 0
Mailer.sending = true
Mailer.onDone = onDone
Mailer.expected = table.getn(queue)
Mailer.attachRetries, Mailer.sendRetries = 0, 0
setStatus("Mailing to " .. recipient .. " (0/" .. Mailer.expected .. ")")
Mailer.queue = queue
Mailer.index = 1
Mailer.waiting = false
Mailer.pendingSend = false
Mailer.pending = true
Mailer.elapsed = 0
Mailer:SetScript("OnUpdate", onMailUpdate)
end
Mailer:SetScript("OnEvent", function()
if event == "MAIL_SEND_SUCCESS" then
if not Mailer.sending then return end
Mailer.sentCount = Mailer.sentCount + 1
Mailer.attachRetries, Mailer.sendRetries = 0, 0
setStatus("Mailing to " .. (Mailer.recipient or "?") .. " (" .. Mailer.sentCount .. "/" .. Mailer.expected .. ")")
Mailer.waiting = false
Mailer.index = Mailer.index + 1
Mailer.pending = true -- defer the next send a beat so item locks settle
Mailer.elapsed = 0
elseif event == "MAIL_CLOSED" then
if cancelPrep then cancelPrep() end
if Mailer.sending then finishMailing() end
elseif event == "UI_ERROR_MESSAGE" then
if Mailer.sending and (arg1 == ERR_MAIL_TO_SELF
or arg1 == ERR_PLAYER_WRONG_FACTION
or arg1 == ERR_MAIL_TARGET_NOT_FOUND
or arg1 == ERR_MAIL_REACHED_CAP
or arg1 == ERR_NOT_ENOUGH_MONEY) then
QM.print("mail failed: " .. (arg1 or ""))
finishMailing()
elseif Mailer.sending and arg1 then
-- A server-side rejection can carry error text we don't match above; surface
-- anything that fires mid-batch so it names itself (don't abort -- it may be
-- unrelated to mail entirely).
QM.print("during send: " .. arg1)
end
end
end)
-- ---------------------------------------------------------------------------
-- /qm mailtest <recipient> [delaySeconds] -- one bare SendMail with NO attachment,
-- fully instrumented, to isolate the SendMail call itself from the bag/attach
-- machinery. Reports every signal for 10s: MAIL_SEND_SUCCESS (with latency), any
-- UI_ERROR_MESSAGE, and the postage delta -- money only leaves the character when
-- the server actually processed the send. The optional delay defers the dispatch
-- (hands off mouse/keyboard!) to test whether the server only honors SendMail
-- close to real user input -- the typed slash command itself is a hardware event,
-- a delayed dispatch provably isn't.
-- ---------------------------------------------------------------------------
local MailTest = CreateFrame("Frame")
MailTest:RegisterEvent("MAIL_SEND_SUCCESS")
MailTest:RegisterEvent("UI_ERROR_MESSAGE")
MailTest.active = false
local function mailTestStop()
MailTest.active = false
MailTest:SetScript("OnUpdate", nil)
end
local function mailTestDispatch()
MailTest.startedAt = GetTime()
MailTest.startMoney = GetMoney()
MailTest.elapsed = 0
-- Keep this probe MINIMAL -- a bare SendMail call and nothing else -- so it
-- isolates the send itself. Only touch the send slot if something is attached.
if GetSendMailItem() then
ClearCursor()
clickSendSlot()
ClearCursor()
end
QM.print("mailtest: SendMail('" .. MailTest.recipient .. "', no attachment) dispatched -- watching 10s")
SendMail(MailTest.recipient, "QM mail test", "test")
end
MailTest:SetScript("OnEvent", function()
if not MailTest.active or not MailTest.startedAt then return end
if event == "MAIL_SEND_SUCCESS" then
QM.print("mailtest: MAIL_SEND_SUCCESS after "
.. string.format("%.1f", GetTime() - MailTest.startedAt) .. "s, money spent: "
.. (MailTest.startMoney - GetMoney()) .. "c")
mailTestStop()
elseif event == "UI_ERROR_MESSAGE" then
QM.print("mailtest: UI error: " .. (arg1 or "?"))
end
end)
function T.mailTest(recipient, delay)
if not recipient or recipient == "" then
QM.print("usage: /qm mailtest <recipient> [delaySeconds] (mailbox must be open)")
return
end
if Mailer.sending then QM.print("mailer is busy"); return end
delay = tonumber(delay) or 0
MailTest.recipient = recipient
MailTest.active = true
MailTest.startedAt = nil
MailTest.waitLeft = delay
MailTest.elapsed = 0
if delay > 0 then
QM.print("mailtest: dispatching to " .. recipient .. " in " .. delay
.. "s -- do NOT touch mouse/keyboard until it fires")
end
MailTest:SetScript("OnUpdate", function()
if MailTest.waitLeft > 0 then
MailTest.waitLeft = MailTest.waitLeft - arg1
if MailTest.waitLeft <= 0 then mailTestDispatch() end
return
end
MailTest.elapsed = MailTest.elapsed + arg1
if MailTest.elapsed >= 10 then
QM.print("mailtest: NO reply after 10s (no success, no error), money spent: "
.. (MailTest.startMoney - GetMoney()) .. "c")
mailTestStop()
end
end)
if delay <= 0 then mailTestDispatch() end
end
-- ---------------------------------------------------------------------------
-- Mail pickup: clear this character's own in-flight placeholder once the real
-- item actually lands (QM.clearInFlight) -- the counterpart to T.supplySend's
-- QM.addInFlight on the sending side. Reads the itemID by link the same way the
-- rest of this file does (QM.itemID), since GetInboxItemLink's extra returns
-- aren't reliable on this client. A no-op for any item that isn't in flight.
-- GetInboxItemLink itself is a ClassicAPI client-patch addition, not native to this
-- client (QM.caps.inboxItemLink) -- without it, fall back to GetInboxItem's own name
-- return resolved through ItemDB (exact match; the name IS the item's real name here).
-- ---------------------------------------------------------------------------
local origTakeInboxItem = TakeInboxItem
function TakeInboxItem(index, attachIndex)
local charKey = QM.me and QM.charKey()
local itemID, count
if charKey then
local name, _, c = GetInboxItem(index, attachIndex)
if QM.caps.inboxItemLink then
local link = GetInboxItemLink(index, attachIndex)
itemID = link and QM.itemID(link)
else
itemID = name and QM.resolveName(name)
end
if itemID then count = c or 1 end
end
origTakeInboxItem(index, attachIndex)
if itemID then QM.clearInFlight(charKey, itemID, count) end
end
-- Opening the mailbox resets this character's own in-flight bookkeeping outright
-- rather than trusting it. It's meant to be a short-lived placeholder cleared by
-- TakeInboxItem above the moment the real mail is picked up -- but a corrupted send
-- (T.mailItems/TurtleMail failing after QM.addInFlight already ran, e.g. a broken
-- multi-attachment batch) can otherwise strand an entry forever, permanently masking
-- a real shortfall. Wiping it here can also make us forget mail that's genuinely
-- still in transit, understating QM.inFlightCount and letting a second mule
-- double-queue a resend -- but a redundant mail is far cheaper than a shortfall that
-- silently never gets covered because the cache thinks it's already handled.
QM.on("MAIL_SHOW", function()
local c = QM.me
if c then c.inFlight = nil end
end)
-- ---------------------------------------------------------------------------
-- Mail-queue building: turn "item X, send N" into a { bag, slot } queue
-- ---------------------------------------------------------------------------
-- One bag slot (stack) per queue entry -> one mail each (1.12's single-attachment
-- limit). Splits `amount` into the fewest possible stacks (maxStack-sized chunks,
-- remainder last) and, for each chunk, scans ALL of itemID's stacks for an exact
-- size match before planning any moves, so a stack that's already the right size
-- costs zero manipulations. A chunk with no exact match is assembled by topping up
-- the largest unclaimed stack from the next-largest ones, splitting only the
-- contributor that would overshoot. This favors full stacks over whatever's already
-- sitting in bags: e.g. two equal partials that already sum to a full stack plus a
-- remainder get merged into that shape even though sending them as-is would cost
-- zero manipulations -- a full stack is worth the extra move.
--
-- Packs `amount` out of `stacks` (already scanned & sorted by count descending) into
-- the fewest maxStack-sized chunks (remainder last), marking whichever stacks it
-- claims `used`. An exact-size unclaimed stack is claimed as a chunk for free; a chunk
-- with no exact match tops up the largest unclaimed stack from the next-largest ones,
-- splitting only the contributor that would overshoot. Returns the finished
-- { bag, slot } queue for `amount` (a `pending` placeholder marks a chunk whose final
-- slot isn't known until execution -- see the "trim" action in runAction) plus the
-- ordered Pickup/Split `actions` needed to realize it.
local function packStacks(stacks, amount, maxStack)
local chunks, claimed = {}, {}
local remaining = amount
while remaining > 0 do
local size = remaining > maxStack and maxStack or remaining
table.insert(chunks, size)
remaining = remaining - size
end
local chunkCount = table.getn(chunks)
local queue, actions = {}, {}
-- Exact-size stacks need no manipulation at all -- claim those first.
for c = 1, chunkCount do
for i = 1, table.getn(stacks) do
local s = stacks[i]
if not s.used and s.count == chunks[c] then
s.used = true
claimed[c] = true
table.insert(queue, { bag = s.bag, slot = s.slot })
break
end
end
end
-- Whatever's left is assembled by topping up the largest unclaimed stack from
-- the next-largest ones, splitting only the contributor that would overshoot.
for c = 1, chunkCount do
if not claimed[c] then
local size = chunks[c]
local destIndex
for i = 1, table.getn(stacks) do
if not stacks[i].used then destIndex = i; break end
end
if not destIndex then break end
local dest = stacks[destIndex]
dest.used = true
local have = dest.count
if have > size then
-- The largest unclaimed stack already overshoots this (necessarily the
-- smallest/remainder) chunk -- trim it down. Its destination (a free bag
-- slot) is only known once findEmptySlot runs at execution time, so the
-- queue gets a placeholder keyed to this action's index for now.
table.insert(actions, { kind = "trim", bag = dest.bag, slot = dest.slot, amount = size })
table.insert(queue, { pending = table.getn(actions) })
else
while have < size do
local srcIndex
for i = 1, table.getn(stacks) do
if not stacks[i].used then srcIndex = i; break end
end
if not srcIndex then break end
local src = stacks[srcIndex]
local need = size - have
if src.count <= need then
src.used = true
table.insert(actions, { kind = "move", bag = src.bag, slot = src.slot,
destBag = dest.bag, destSlot = dest.slot })
have = have + src.count
else
table.insert(actions, { kind = "splitmove", bag = src.bag, slot = src.slot,
amount = need, destBag = dest.bag, destSlot = dest.slot })
src.count = src.count - need
have = have + need
end
end
table.insert(queue, { bag = dest.bag, slot = dest.slot })
end
end
end
return queue, actions
end
-- Mail can't carry soulbound, quest, or conjured items, and 1.12 has no API flag
-- for any of them -- the tooltip text is the only tell. Per SLOT, not per item:
-- binding is per item instance (a bound and an unbound copy of a BoE can coexist).
local mailTip
local function slotMailable(bag, slot)
if not mailTip then
mailTip = CreateFrame("GameTooltip", "QuartermasterMailTip", nil, "GameTooltipTemplate")
end
mailTip:SetOwner(UIParent, "ANCHOR_NONE")
mailTip:ClearLines()
mailTip:SetBagItem(bag, slot)
for i = 1, mailTip:NumLines() do
local line = getglobal("QuartermasterMailTipTextLeft" .. i)
local text = line and line:GetText()
if text == ITEM_SOULBOUND or text == ITEM_BIND_QUEST or text == ITEM_CONJURED then
return false
end
end
return true
end
-- planStacks itself only READS bag state and is pure planning -- it returns the
-- final { bag, slot } queue to send plus an ordered list of the actual Pickup/Split
-- "actions" needed to realize it (that queue's mail-side use of `pending` placeholders
-- is documented on packStacks above), plus how much of `amount` unmailable slots cost
-- (the caller's count doesn't know about binding). The actions are NOT run here: see
-- runItemActions below for why.
local function planStacks(itemID, amount)
local _, _, _, _, _, _, maxStack = GetItemInfo("item:" .. itemID)
maxStack = maxStack or 1
local scanned = scanStacks(bagContainers(), itemID)
local stacks, mailable, unmailable = {}, 0, 0
for i = 1, table.getn(scanned) do
local s = scanned[i]
if slotMailable(s.bag, s.slot) then
table.insert(stacks, s)
mailable = mailable + s.count
else
unmailable = unmailable + s.count
end
end
local short = amount - mailable
if short < 0 then short = 0 end
if short > unmailable then short = unmailable end
if short > 0 then amount = mailable end
local queue, actions = packStacks(stacks, amount, maxStack)
-- Picking stacks for `amount` can strand an oddly-sized remainder behind -- e.g.
-- trimming a stack down to size for the mail leaves its cut-off leftover sitting
-- next to some other untouched partial instead of merging with it. Consolidate
-- whatever's left (now the unclaimed stacks) with the same packing, so a target of
-- 10 doesn't end up as a 9-stack and a 1-stack when it could be one clean 10. Its
-- queue is discarded -- there's nothing to send -- only the merge actions matter.
local leftover = 0
for i = 1, table.getn(stacks) do
if not stacks[i].used then leftover = leftover + stacks[i].count end
end
if leftover > 0 then
local _, restackActions = packStacks(stacks, leftover, maxStack)
for i = 1, table.getn(restackActions) do table.insert(actions, restackActions[i]) end
end
return queue, actions, short
end
-- ---------------------------------------------------------------------------
-- Paced/settled action engine: executes ONE item's plan (from planStacks or
-- planTransfer) at a time, at most one bag/bank-touching action per
-- PREP_STEP_DELAY tick, then holds at a settle gate before handing the result back.
-- ---------------------------------------------------------------------------
-- IMPORTANT: this is the only place allowed to execute a planStacks()/planTransfer()
-- plan. Firing several Pickup/SplitContainerItem pairs back-to-back with zero delay
-- between them -- even for the SAME item, e.g. merging two partial stacks together and
-- THEN topping the result up from a third -- desyncs this client (compounded by
-- TurtleMail globally hooking PickupContainerItem): items are left locked ("greyed")
-- mid-transaction, an attach never completes, and bank slots can reject a merge the
-- server never actually saw settle. One manipulation per tick, no exceptions, fixes it.
local Prep = CreateFrame("Frame")
local PREP_STEP_DELAY = 0.3 -- lets one manipulation settle before the next touches
-- bags/bank. See above.
local SETTLE_TIMEOUT = 5 -- settle gate: give up waiting for locks and hand back
-- whatever actually resolved (see queueSettled)
local prepBusy = false
local function runAction(a, queue)
ClearCursor()
if a.kind == "move" then
PickupContainerItem(a.bag, a.slot)
PickupContainerItem(a.destBag, a.destSlot)
elseif a.kind == "splitmove" then
SplitContainerItem(a.bag, a.slot, a.amount)
PickupContainerItem(a.destBag, a.destSlot)
elseif a.kind == "trim" then