-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLedger.lua
More file actions
1771 lines (1623 loc) · 70.2 KB
/
Copy pathLedger.lua
File metadata and controls
1771 lines (1623 loc) · 70.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Manners -- the favour ledger.
--
-- A record of what the addon has done for you: who buffed you, with what and
-- when, whether you returned it and with what, and who you buffed without being
-- asked. The recent entries are listed in a small window (/manners ledger), under
-- the lifetime counts, which the minimap tooltip and the General tab repeat.
--
-- It is a record and never a decision. Core.lua tells it what happened at the
-- four moments a favour changes hands -- noticed, repaid, refused after all, and
-- let go, the never-offer list included -- and nothing anywhere reads a
-- ledger entry to decide what to offer
-- or whom to cast at. That is on purpose: the debt table in Core.lua is what the
-- prompt works from, and a second opinion about who is owed would be exactly the
-- kind of drift the rest of this addon has spent rounds removing. So everything
-- here is allowed to be wrong in one way only -- by missing something -- and a
-- ledger that throws takes nothing with it, because Core calls it through Guard.
--
-- The window is plain UI with no secure frame anywhere in it, so unlike the
-- prompt it can be opened, scrolled, cleared and dragged in a fight.
local ADDON, ns = ...
-- Player-facing text, in the client's language: see Locales/Init.lua.
local L = ns.L
local Ledger = {}
ns.Ledger = Ledger
local GetTime = _G.GetTime
-- Read once at load, like Core's own copy. Asked directly rather than through
-- Core's helper because a name is kept for good here: that helper answers
-- whether a value may be looked at now, and this is the same question asked
-- about the one thing this file writes to disk.
local issecretvalue = _G.issecretvalue
---------------------------------------------------------------------------
-- text
--
-- Every sentence a player reads, in one place and each one whole, because a
-- sentence assembled from pieces cannot be translated. Format strings carry the
-- numbers and names; each is looked up in L once, at load, which is after the
-- locale files have filled it.
---------------------------------------------------------------------------
local TEXT = {
TITLE = L["Favour ledger"],
-- A glyph drawn as the close button, not a word, so it is the same in
-- every language and there is nothing to hand a translator.
CLOSE = "x",
CLOSE_TIP = L["Close (Escape works too)"],
-- The headline over the list, and the line the minimap tooltip and the
-- options page repeat. "Today" is the calendar day on this computer's
-- clock. It counts only the favours something you cast could have
-- returned: a warrior's shout to a mage is listed, but scoring it as a
-- favour not returned would be a failure the player can do nothing about.
--
-- "Recorded" rather than "nobody buffed you": the ledger hears of nothing
-- while the addon is off or has nothing to cast, and after Clear, so all
-- it can say is what it has.
TODAY_NONE = L["No favours recorded today."],
TODAY_ONLY_USELESS = L["Nothing you cast could return today's favours."],
TODAY_ONE = L["Returned %d of 1 favour today."],
TODAY_MANY = L["Returned %d of %d favours today."],
OWED_ONE = L["1 favour still owed."],
OWED_MANY = L["%d favours still owed."],
-- Casts, not people: topping up the same five players three times is
-- fifteen buffs and still five players.
GAVE_ONE = L["You gave 1 buff unprompted today."],
GAVE_MANY = L["You gave %d buffs unprompted today."],
LIFETIME = L["All time -- received: %d, returned: %d, given to your group: %d, to strangers: %d"],
-- The same four numbers as tiles across the window, each with its label
-- underneath, and the caption over them.
ALL_TIME = L["All time"],
STAT_RECEIVED = L["received"],
STAT_RETURNED = L["returned"],
STAT_GROUP = L["to your group"],
STAT_STRANGERS = L["to strangers"],
-- Where the rows on screen sit in the list: "1-8 of 42".
SHOWING = L["%d-%d of %d"],
TAB_ALL = L["Everything"],
TAB_FAVOURS = L["Favours"],
TAB_GIVEN = L["Buffs you gave"],
EMPTY_ALL = L["Nothing recorded. When somebody buffs you it is listed here, and so is what you give back and who you buff unprompted."],
EMPTY_FAVOURS = L["No favours recorded. When somebody buffs you, they are listed here."],
EMPTY_GIVEN = L["No buffs given unprompted. When the prompt buffs somebody who did not buff you first, it is listed here."],
-- In place of the lines above while nothing new can arrive, because "when
-- somebody buffs you it is listed here" is then a promise the addon is not
-- keeping. The owed toggle stops favours only; buffs given still arrive.
EMPTY_OFF = L["Nothing is recorded while Manners is switched off."],
EMPTY_NOTHING = L["Nothing is recorded while the prompt has nothing to cast on this character."],
EMPTY_OWED_OFF = L["Favours are not recorded while \"People who buffed me\" is off, on the Who to buff tab."],
CLEAR = L["Clear"],
CLEAR_ARMED = L["Click again to clear"],
CLEAR_TIP = L["Empties the list, and today's count with it. Favours you still owe stay, and the all-time totals are kept."],
-- The badge at the front of a row's second line, and what follows it. A
-- row reads "badge detail": the badge is a coloured status word, then two
-- spaces, then one of the details below, so each detail finishes the
-- badge's phrase without repeating it. The badges are separate keys because
-- a returned favour with no spell to name shows its badge alone, and
-- because the badge takes its own colour; a detail that needs its words in
-- another order has them all in its own key.
--
-- Badges: a favour owed to the row's player; one returned; one let go
-- unreturned; and a buff you gave them unprompted, when nobody owed it.
STATE_OWED = L["Still owed"],
STATE_RETURNED = L["Returned"],
STATE_LETGO = L["Let go"],
STATE_GAVE = L["Gave"],
-- After "Returned": %s is the spell you returned the favour with.
RETURNED_WITH = L["with %s"],
-- After "Let go": why it was let go.
LETGO_EXPIRED = L["the time to return it ran out"],
LETGO_USELESS = L["nothing you cast is any use to them"],
LETGO_NOTKEPT = L["forgotten at a logout or reload"],
-- A favour the player let go on purpose, by putting its giver on the
-- never-offer list.
LETGO_NEVER = L["you put them on your never-offer list"],
-- After "Gave": %s is the spell you gave the row's player.
GAVE_GROUP = L["%s, in your group"],
GAVE_STRANGER = L["%s, to a stranger"],
-- In place of a spell name the client could not give.
UNKNOWN_SPELL = L["a buff"],
-- The row tooltip, which is where the whole story goes.
TIP_BUFFED = L["Buffed you with %s, %s."],
TIP_TIMES = L["They buffed you %d times; one buff back repays all of it."],
TIP_OWED = L["Still owed. The prompt offers them until you return it or the time runs out."],
-- A warrior's shout and the like reach the caster's party and nobody else,
-- so a favour from outside it waits for them to be in it. Said so it is
-- true whether or not they are in it now.
TIP_OWED_PARTY = L["Still owed. What you cast reaches only your own party, so the prompt offers them only while they are in it."],
TIP_OWED_SUBGROUP = L["Still owed. What you cast reaches only your own party -- in a raid, your own subgroup -- so the prompt offers them only while they are in it."],
TIP_RETURNED = L["You returned it %s later."],
TIP_RETURNED_WITH = L["You returned it %s later, with %s."],
TIP_LETGO_EXPIRED = L["Let go: the time to return it ran out before you did."],
TIP_LETGO_USELESS = L["Let go: nothing you can cast is any use to them."],
-- Quotes the setting by the name it has on the When tab, which a scenario
-- holds it to.
TIP_LETGO_NOTKEPT = L["Let go: \"Remember them across a reload\" (When tab, under Timing) is off, so it was forgotten when you logged out or reloaded."],
TIP_LETGO_NEVER = L["Let go: you put them on your never-offer list."],
-- In place of TIP_OWED while the prompt cannot offer them, which is the
-- rule Quiet() below keeps for the empty list: the window never promises
-- what cannot come. The toggle is quoted by the name it has on the Who to
-- buff tab, and %s is the time the snooze ends, on the player's clock.
TIP_OWED_OFF = L["Still owed, but Manners is switched off, so the prompt will not offer them."],
TIP_OWED_SOURCE_OFF = L["Still owed, but the prompt is not offering favours while \"People who buffed me\" is off."],
TIP_OWED_NOTHING = L["Still owed, but there is nothing on this character the prompt can cast."],
TIP_OWED_SNOOZED = L["Still owed. The prompt is snoozed until %s, so it offers them only if the snooze ends before the time to return it runs out."],
TIP_OWED_MOUNTED = L["Still owed. The prompt stays away while you are mounted, and offers them once you get off, until the time to return it runs out."],
-- The same two for a favour only your party can return, as TIP_OWED_PARTY
-- and TIP_OWED_SUBGROUP say it: the snooze or the ride ending is not
-- enough while they are outside your party.
TIP_OWED_SNOOZED_PARTY = L["Still owed. What you cast reaches only your own party, so the prompt offers them only while they are in it, and it is snoozed until %s: they are offered only if the snooze ends before the time to return it runs out."],
TIP_OWED_SNOOZED_SUBGROUP = L["Still owed. What you cast reaches only your own party -- in a raid, your own subgroup -- so the prompt offers them only while they are in it, and it is snoozed until %s: they are offered only if the snooze ends before the time to return it runs out."],
TIP_OWED_MOUNTED_PARTY = L["Still owed. What you cast reaches only your own party, so the prompt offers them only while they are in it, and it stays away while you are mounted: they are offered once you get off, if they are in it, until the time to return it runs out."],
TIP_OWED_MOUNTED_SUBGROUP = L["Still owed. What you cast reaches only your own party -- in a raid, your own subgroup -- so the prompt offers them only while they are in it, and it stays away while you are mounted: they are offered once you get off, if they are in it, until the time to return it runs out."],
TIP_GAVE = L["You buffed them with %s, %s."],
TIP_GAVE_GROUP = L["They were in your group and had not buffed you."],
TIP_GAVE_STRANGER = L["They were not in your group and had not buffed you."],
JUST_NOW = L["just now"],
MINUTES_AGO = L["%d min ago"],
HOURS_AGO = L["%d hr ago"],
DAY_AGO = L["a day ago"],
DAYS_AGO = L["%d days ago"],
SECONDS = L["%d sec"],
MINUTES = L["%d min"],
HOURS = L["%d hr"],
OPTIONS_EMPTY = L["Nothing has been recorded on this character yet."],
}
Ledger.TEXT = TEXT
---------------------------------------------------------------------------
-- bounds
---------------------------------------------------------------------------
-- The list kept on disk. Two hundred rows is weeks of ordinary play and a few
-- kilobytes in the saved file; the lifetime counts are kept separately and are
-- never trimmed.
local MAX_ENTRIES = 200
-- Of which buffs given unprompted may take at most half. A mage walking through
-- a city buffs a stranger every few seconds, and without a share of their own
-- those would push every favour off the end of the list inside an hour -- the
-- favours being the reason the list exists.
local MAX_GIVEN = 100
-- The spells one favour remembers. A priest lands three buffs at once, and that
-- is one favour with three names on it rather than three rows.
local MAX_SPELLS = 3
-- A second buff from the same person inside this many seconds is the same
-- favour. Wider than one landing needs, narrower than any recast.
local FOLD_SECONDS = 10
-- How long a settle is kept for a refusal to undo it. Core keeps its own record
-- for its SETTLE_SECONDS and only ever calls back inside that; this is a
-- generous bound on a list that would otherwise grow for the session.
local UNDO_SECONDS = 30
local STATES = { owed = true, returned = true, letgo = true }
-- Why a favour was let go: its time ran out, nothing you cast is any use to
-- them, it was forgotten at a reload, or you put them on the never-offer list.
local WHY = { expired = true, useless = true, notkept = true, never = true }
local FILTERS = { all = true, favours = true, given = true }
local TOTALS = { "received", "returned", "letGo", "group", "strangers" }
local POINTS = {
CENTER = true, TOP = true, BOTTOM = true, LEFT = true, RIGHT = true,
TOPLEFT = true, TOPRIGHT = true, BOTTOMLEFT = true, BOTTOMRIGHT = true,
}
---------------------------------------------------------------------------
-- reading values the client hands over
---------------------------------------------------------------------------
local function Secret(v)
return issecretvalue ~= nil and issecretvalue(v) == true
end
-- A name that may be kept, or nil. The same rules the debt table's names pass,
-- restated because this is the other place a name goes to disk, and because the
-- window prints it: a secret, anything too long to be a character's name, and
-- anything carrying a chat escape or macro punctuation are all refused. A name
-- never comes back out of here as something other than the plain string it
-- went in as.
local function CleanName(name)
if Secret(name) or type(name) ~= "string" then return nil end
if name == "" or #name > 48 then return nil end
if name:find("[%[%]\n\r;|]") then return nil end
return name
end
-- The client's class token, MAGE or PRIEST: capitals and nothing else, so it
-- can index the colour table and can never carry anything into the text.
local function CleanClass(class)
if Secret(class) or type(class) ~= "string" or #class > 20 then return nil end
if not class:find("^%u+$") then return nil end
return class
end
local function CleanSpell(id)
if Secret(id) or type(id) ~= "number" then return nil end
if id <= 0 or id ~= math.floor(id) or id > 10000000 then return nil end
return id
end
local function CleanTime(t)
if Secret(t) or type(t) ~= "number" then return nil end
if t ~= t or t <= 0 or t == math.huge then return nil end
return t
end
local function Count(v)
if type(v) ~= "number" or v ~= v or v < 0 or v == math.huge then return 0 end
return math.floor(v)
end
-- The wall clock. Everything stored is on it, for the reason Core's debts are:
-- GetTime() starts again near zero after a reboot and means nothing on disk.
local function Wall()
local ok, t = pcall(_G.time)
if not ok then return nil end
return CleanTime(t)
end
local function Call(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, value = pcall(fn, ...)
if not ok or Secret(value) then return nil end
return value
end
local function SpellName(id)
if not id then return nil end
local name = Call(C_Spell and C_Spell.GetSpellName, id) or Call(_G.GetSpellInfo, id)
return type(name) == "string" and name or nil
end
-- The question mark every spell book falls back to.
local QUESTION_MARK = 134400
local function SpellIcon(id)
if not id then return QUESTION_MARK end
return Call(C_Spell and C_Spell.GetSpellTexture, id) or Call(_G.GetSpellTexture, id)
or QUESTION_MARK
end
local function Coloured(name, class)
local c = class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
if c and type(c.colorStr) == "string" then
return ("|c%s%s|r"):format(c.colorStr, name)
end
return name
end
---------------------------------------------------------------------------
-- time as a player reads it
---------------------------------------------------------------------------
function Ledger.Ago(seconds)
seconds = math.max(0, seconds or 0)
if seconds < 45 then return TEXT.JUST_NOW end
if seconds < 3600 then return TEXT.MINUTES_AGO:format(math.max(1, math.floor(seconds / 60 + 0.5))) end
if seconds < 86400 then return TEXT.HOURS_AGO:format(math.max(1, math.floor(seconds / 3600 + 0.5))) end
local days = math.floor(seconds / 86400)
if days == 1 then return TEXT.DAY_AGO end
return TEXT.DAYS_AGO:format(days)
end
local function Duration(seconds)
seconds = math.max(0, math.floor(seconds or 0))
if seconds < 60 then return TEXT.SECONDS:format(seconds) end
if seconds < 3600 then return TEXT.MINUTES:format(math.floor(seconds / 60 + 0.5)) end
return TEXT.HOURS:format(math.floor(seconds / 3600 + 0.5))
end
-- Local midnight, on this computer's clock. date("*t") is the only thing that
-- knows the time zone; where it will not answer with a table -- nothing this
-- addon runs on, but the fallback has to be something -- "today" is the last
-- day's worth of seconds rather than nothing at all.
local function StartOfToday(now)
local ok, t = pcall(_G.date, "*t", now)
if ok and type(t) == "table" and type(t.hour) == "number"
and type(t.min) == "number" and type(t.sec) == "number" then
return now - ((t.hour * 60 + t.min) * 60 + t.sec)
end
return now - 86400
end
---------------------------------------------------------------------------
-- the store
--
-- db.char, like the debts: a favour is done to a character, and profiles are
-- shared between characters. AceDB keeps it inside the one saved file.
--
-- ledger.entries oldest first; each one either
-- { kind = "received", name, class, spells = { id, ... }, at, times,
-- state = owed | returned | letgo, why, doneAt, gave, partyOnly }
-- { kind = "given", name, class, spell, at, to = group | stranger }
-- ledger.totals lifetime counts, never trimmed and kept by Clear
-- ledger.today { day = local midnight, given = n }: today's buffs given,
-- which the trim cannot touch and Clear resets
-- ledger.filter the window's tab
-- ledger.window where the window was dragged to
---------------------------------------------------------------------------
local store
local function CleanEntry(e)
if type(e) ~= "table" then return nil end
local name, at = CleanName(e.name), CleanTime(e.at)
if not name or not at then return nil end
local class = CleanClass(e.class)
if e.kind == "given" then
return { kind = "given", name = name, class = class, at = at,
spell = CleanSpell(e.spell), to = e.to == "group" and "group" or "stranger" }
elseif e.kind == "received" then
local spells = {}
if type(e.spells) == "table" then
for i = 1, MAX_SPELLS do
local id = CleanSpell(e.spells[i])
if id then spells[#spells + 1] = id end
end
end
-- A state nobody wrote is a favour whose outcome is unknown, and the
-- one outcome that cannot be wrong about it is that it is over. Calling
-- it owed would put a row on screen promising the prompt is offering
-- somebody it has never heard of.
local state = STATES[e.state] and e.state or "letgo"
local out = { kind = "received", name = name, class = class, at = at,
spells = spells, times = math.max(1, Count(e.times)), state = state }
if state == "owed" and e.partyOnly == true then out.partyOnly = true end
if state ~= "owed" then out.doneAt = CleanTime(e.doneAt) end
if state == "returned" then out.gave = CleanSpell(e.gave) end
if state == "letgo" then out.why = WHY[e.why] and e.why or nil end
return out
end
return nil
end
-- Oldest out first, but never a favour still owed: its settle is on its way,
-- and a settle that finds no row makes one and counts the favour a second time.
-- Those are bounded anyway, by how many people can owe you at once.
local function Trim(s)
local entries = s.entries
local given = 0
for i = 1, #entries do
if entries[i].kind == "given" then given = given + 1 end
end
local i = 1
while given > MAX_GIVEN and i <= #entries do
if entries[i].kind == "given" then
table.remove(entries, i)
given = given - 1
else
i = i + 1
end
end
i = 1
while #entries > MAX_ENTRIES and i <= #entries do
if entries[i].state == "owed" then
i = i + 1
else
table.remove(entries, i)
end
end
end
-- Whatever is on disk, made into something every reader below can trust: a
-- profile from before the ledger existed has nothing here, and a file somebody
-- edited, or a crash wrote half of, can have anything at all. Rebuilt rather
-- than patched, so no key this file does not know survives into the next save.
local function Repair(char)
local s = char.ledger
if type(s) ~= "table" then
s = {}
char.ledger = s
end
local kept = {}
if type(s.entries) == "table" then
for i = 1, #s.entries do
local e = CleanEntry(s.entries[i])
if e then kept[#kept + 1] = e end
end
end
-- Oldest first is what everything below assumes, and a damaged file need
-- not be in any order. The index breaks ties so equal times keep theirs.
for i, e in ipairs(kept) do e.order = i end
table.sort(kept, function(a, b)
if a.at ~= b.at then return a.at < b.at end
return a.order < b.order
end)
for _, e in ipairs(kept) do e.order = nil end
s.entries = kept
-- The counts never read lower than the list in front of them. A file that
-- lost its totals but kept its rows would otherwise say "all time: 0" over
-- a list of forty favours.
local seen = { received = 0, returned = 0, letGo = 0, group = 0, strangers = 0 }
for _, e in ipairs(kept) do
if e.kind == "received" then
seen.received = seen.received + 1
if e.state == "returned" then seen.returned = seen.returned + 1 end
if e.state == "letgo" then seen.letGo = seen.letGo + 1 end
elseif e.to == "group" then
seen.group = seen.group + 1
else
seen.strangers = seen.strangers + 1
end
end
local old = type(s.totals) == "table" and s.totals or {}
local totals = {}
for _, key in ipairs(TOTALS) do
totals[key] = math.max(Count(old[key]), seen[key])
end
s.totals = totals
s.filter = FILTERS[s.filter] and s.filter or "all"
-- Today's count of buffs given, kept apart from the list: see Summary.
-- Kept only whole, a day that is a number and a count that is one.
local today = s.today
if type(today) == "table" and CleanTime(today.day) and type(today.given) == "number"
and today.given >= 0 and today.given == math.floor(today.given) and today.given ~= math.huge then
s.today = { day = today.day, given = today.given }
else
s.today = nil
end
local w = s.window
if type(w) == "table" and POINTS[w.point] and POINTS[w.relPoint]
and type(w.x) == "number" and w.x == w.x and math.abs(w.x) < 10000
and type(w.y) == "number" and w.y == w.y and math.abs(w.y) < 10000 then
s.window = { point = w.point, relPoint = w.relPoint, x = w.x, y = w.y }
else
s.window = nil
end
Trim(s)
store = s
return s
end
local function Store()
local char = ns.db and ns.db.char
if type(char) ~= "table" then return nil end
if store == nil or char.ledger ~= store then return Repair(char) end
return store
end
local function Bump(s, key, by)
s.totals[key] = math.max(0, (s.totals[key] or 0) + (by or 1))
end
local function Append(s, e)
s.entries[#s.entries + 1] = e
Trim(s)
end
local function Holds(s, e)
for i = #s.entries, 1, -1 do
if s.entries[i] == e then return true end
end
return false
end
local function Remove(s, e)
for i = #s.entries, 1, -1 do
if s.entries[i] == e then
table.remove(s.entries, i)
return true
end
end
return false
end
-- The favour this person is still owed for, newest first. There is at most one
-- in practice: a second buff from somebody already owed is folded into it,
-- because the debt table keeps one debt per person and one buff back repays it.
local function FindOpen(s, name)
for i = #s.entries, 1, -1 do
local e = s.entries[i]
if e.kind == "received" and e.state == "owed" and e.name == name then return e end
end
return nil
end
local function AddSpell(e, id)
if not id then return end
for _, have in ipairs(e.spells) do
if have == id then return end
end
if #e.spells < MAX_SPELLS then e.spells[#e.spells + 1] = id end
end
---------------------------------------------------------------------------
-- repainting
---------------------------------------------------------------------------
local Render -- the window's, defined with it below
local function Changed()
if Render then Render() end
-- The General tab prints the same numbers, and AceConfig only asks for
-- them while it is drawing. Only while that tab is the one on screen: a
-- repaint rebuilds the whole page, and one on every favour in a busy city
-- redrew the dropdowns and edit boxes of the Prompt tab under the player
-- tuning them, to refresh a line they could not see. A tab nobody can name
-- -- a library without the status table -- is repainted as it always was.
if ns.OptionsOpen and ns.OptionsOpen() and ns.RefreshOptionsDisplay then
local tab = ns.OptionsTab and ns.OptionsTab()
if tab == nil or tab == "general" then
ns.Guard("options repaint", ns.RefreshOptionsDisplay)
end
end
end
---------------------------------------------------------------------------
-- what Core tells it
---------------------------------------------------------------------------
-- At login, after Core has put back the debts it kept. A favour the list still
-- has as owed, with no debt behind it any more, ran out while you were away --
-- or was never kept, when the setting to keep debts is off -- and saying it is
-- still owed would be a row contradicting the prompt.
function Ledger.Load()
local s = Store()
local now = Wall()
if not s or not now then return end
local owed = ns.owed or {}
local profile = ns.db and ns.db.profile
local timing = profile and profile.timing
local window = timing and type(timing.reciprocateWindow) == "number"
and timing.reciprocateWindow or 120
local notKept = timing and timing.keepDebts == false
for _, e in ipairs(s.entries) do
if e.kind == "received" and e.state == "owed" and not owed[e.name] then
e.state = "letgo"
e.why = notKept and "notkept" or "expired"
e.doneAt = math.min(now, e.at + window)
Bump(s, "letGo")
end
end
Changed()
end
-- Somebody buffed you. `seen` is the record NoteFavour files: name, class and
-- the spell id under `key`. `useless` is the favour NoteFavour turns away
-- because nothing this character casts is any use to them -- a favour all the
-- same, and let go in the moment it arrived. `partyOnly` is a favour only a
-- buff that reaches your own party could return, so the prompt offers them only
-- while they are in it; the row's tooltip says so rather than promising an
-- offer that is waiting on them.
function Ledger.Received(seen, useless, partyOnly)
local s, now = Store(), Wall()
if not s or not now or type(seen) ~= "table" then return end
local name = CleanName(seen.name)
if not name then return end
local spell = CleanSpell(seen.key)
-- One favour per person while it is open, however many buffs it arrives
-- as. A useless one folds only into another useless one from the same
-- landing: it has no debt to be part of.
local into
if useless then
local last = s.entries[#s.entries]
if last and last.kind == "received" and last.name == name and last.why == "useless"
and now - last.at <= FOLD_SECONDS then
into = last
end
else
into = FindOpen(s, name)
end
-- The latest word on it wins: what reaches somebody is a question about
-- their class and yours, and the newest buff was read with the most to go
-- on.
partyOnly = (not useless and partyOnly == true) or nil
if into then
into.times = into.times + 1
AddSpell(into, spell)
into.class = into.class or CleanClass(seen.class)
if not useless then into.partyOnly = partyOnly end
else
local e = { kind = "received", name = name, class = CleanClass(seen.class),
spells = {}, at = now, times = 1, state = "owed", partyOnly = partyOnly }
AddSpell(e, spell)
if useless then
e.state, e.why, e.doneAt = "letgo", "useless", now
Bump(s, "letGo")
end
Append(s, e)
Bump(s, "received")
end
Changed()
end
-- The spell that went out: the id the game reported where it would say, and
-- otherwise the rank this character knows of the buff that was armed.
local function GaveSpell(pending, spellId)
local id = CleanSpell(spellId)
if id then return id end
local key = type(pending) == "table" and pending.buffKey
local class = ns.caps and ns.caps.class
local buff = key and ns.FindBuff and ns.FindBuff(class, key)
if not buff then return nil end
local info = ns.BuffInfo and ns.BuffInfo(buff)
return CleanSpell(info and info.topRank) or CleanSpell(buff.ranks and buff.ranks[1])
end
-- Settles kept for a refusal to take back. Session only: a refusal arrives a
-- moment after its settle or not at all.
local recent = {}
-- A press went out and Core settled it. `wasOwed` is the debt that stood before
-- the settle cleared it, nil for somebody who was simply missing the buff.
-- Stamped with GetTime(), which is the stamp Core's own record carries, so the
-- refusal can name this settle and no other.
function Ledger.Settled(name, wasOwed, pending, spellId)
local s, now = Store(), Wall()
name = CleanName(name)
if not s or not now or not name then return end
local clock = GetTime()
for i = #recent, 1, -1 do
if clock - recent[i].clock > UNDO_SECONDS then table.remove(recent, i) end
end
local gave = GaveSpell(pending, spellId)
local undo = { name = name, clock = clock }
if wasOwed then
local e = FindOpen(s, name)
if not e then
-- A debt the ledger never saw: kept from a session before the
-- ledger existed. It is a favour received all the same, and when it
-- was done is on the debt.
local at = now
if type(wasOwed) == "table" and type(wasOwed.at) == "number" then
at = now - math.max(0, clock - wasOwed.at)
end
e = { kind = "received", name = name, spells = {}, at = at, times = 1, state = "owed",
class = CleanClass(type(wasOwed) == "table" and wasOwed.class or nil) }
Append(s, e)
Bump(s, "received")
undo.created = true
end
e.state, e.doneAt, e.gave = "returned", now, gave
Bump(s, "returned")
undo.entry = e
else
local to = type(pending) == "table" and pending.inGroup == true and "group" or "stranger"
local e = { kind = "given", name = name, at = now, spell = gave, to = to,
class = CleanClass(type(pending) == "table" and pending.class or nil) }
Append(s, e)
Bump(s, to == "group" and "group" or "strangers")
-- Counted for today apart from the list, which keeps only MAX_GIVEN of
-- these: counted off the list, a mage in a city read "You gave 100
-- buffs unprompted today" from the hundredth one until midnight.
local day = StartOfToday(now)
if not (s.today and s.today.day == day) then s.today = { day = day, given = 0 } end
s.today.given = s.today.given + 1
undo.entry, undo.to = e, to
end
recent[#recent + 1] = undo
Changed()
end
-- The server refused, after the fact, the cast a settle above was about. Core
-- has put the debt back; this puts the row back the way it was. `clock` is the
-- stamp on Core's record of the settle, which is the stamp on this one.
function Ledger.Refused(name, clock)
local s = Store()
if not s then return end
local undo
for i = #recent, 1, -1 do
if recent[i].name == name and recent[i].clock == clock then
undo = table.remove(recent, i)
break
end
end
if not undo then return end
-- The row the settle wrote may be gone by now: Clear takes every row but
-- the favours still owed, and it can be pressed in the second between a
-- settle and its refusal. The counts are taken back all the same, since
-- Clear keeps those, and a favour Core has just put back gets its owed row
-- back with it -- left out, the next return found no row, made one, and
-- counted the one favour a second time.
local e = undo.entry
if undo.to then
Remove(s, e)
Bump(s, undo.to == "group" and "group" or "strangers", -1)
-- Today's count too, while today is still the day it was counted on:
-- after midnight, or after Clear, the gift is not in it.
if s.today and s.today.day == StartOfToday(e.at) then
s.today.given = math.max(0, s.today.given - 1)
end
else
Bump(s, "returned", -1)
if undo.created then
Remove(s, e)
Bump(s, "received", -1)
else
-- Somebody repaid and then buffing you again before the refusal
-- arrived already has an owed row for the new buff. Reopening this
-- one beside it put two rows on screen for the one debt Core keeps,
-- and only one of them would ever be closed. So the two are folded
-- into one favour, counted once, as a second buff from somebody
-- already owed always is.
local open = FindOpen(s, e.name)
if open and open ~= e then
open.times = open.times + e.times
for _, id in ipairs(e.spells) do AddSpell(open, id) end
open.at = math.min(open.at, e.at)
open.class = open.class or e.class
Remove(s, e)
Bump(s, "received", -1)
else
e.state, e.doneAt, e.gave = "owed", nil, nil
if not Holds(s, e) then Append(s, e) end
end
end
end
Changed()
end
-- The debt was let go before it was returned. `why` is one of WHY: "never" for
-- a favour the player let go by putting its giver on the never-offer list,
-- which is on purpose and must not read as time running out. Anything else,
-- nothing included, is the sweep's case -- the time ran out -- because that is
-- the caller that passes no reason.
function Ledger.LetGo(name, why)
local s, now = Store(), Wall()
name = CleanName(name)
if not s or not now or not name then return end
local e = FindOpen(s, name)
if not e then return end
why = WHY[why] and why or "expired"
e.state, e.why, e.doneAt = "letgo", why, now
Bump(s, "letGo")
Changed()
end
-- Everything but the favours still owed, and today's count of buffs given with
-- the list, as the button's tooltip says. Those favours stay because the debt
-- behind each one is still live on the prompt, and because a settle that found
-- no row would count the favour a second time.
--
-- The settles kept for a refusal stay too. A refusal can still arrive for one
-- the list no longer shows, and Core puts that debt back either way; forgetting
-- the settle here left the return counted and the favour with no row, so the
-- next return counted it again. Refused copes with a row that is gone, and the
-- list of settles prunes itself.
function Ledger.Clear()
local s = Store()
if not s then return end
local kept = {}
for _, e in ipairs(s.entries) do
if e.kind == "received" and e.state == "owed" then kept[#kept + 1] = e end
end
s.entries = kept
s.today = nil
Changed()
end
-- Whether Clear would take anything: a row other than a favour still owed, or
-- a count of buffs given today. The window hides Clear when not, so its
-- warning about losing entries is never raised over a list it cannot change.
-- Every tab at once, because Clear empties every tab.
function Ledger.Clearable()
local s = Store()
if not s then return false end
for _, e in ipairs(s.entries) do
if not (e.kind == "received" and e.state == "owed") then return true end
end
return s.today ~= nil and (s.today.given or 0) > 0
end
---------------------------------------------------------------------------
-- reading it back
---------------------------------------------------------------------------
-- Newest first, for one of the window's tabs.
function Ledger.Entries(filter)
local s = Store()
local out = {}
if not s then return out end
for i = #s.entries, 1, -1 do
local e = s.entries[i]
if filter == "favours" then
if e.kind == "received" then out[#out + 1] = e end
elseif filter == "given" then
if e.kind == "given" then out[#out + 1] = e end
else
out[#out + 1] = e
end
end
return out
end
-- Why nothing new can reach the list right now, or nil when it can: "off" for
-- an addon switched off, "nothing" for a character the prompt has nothing to
-- cast on, "owedoff" for favours not being watched for. The gates NoteFavour
-- and the prompt pass before this file hears of anything, asked the same way,
-- so the window never promises a row that cannot come. Asked, never stored:
-- every one of them is a setting or a spell book that can change under it.
local function Quiet()
local p = ns.db and ns.db.profile
if type(p) ~= "table" then return nil end
if not p.enabled then return "off" end
local ok, nothing = pcall(function()
if #ns.CastableBuffs() == 0 then return true end
local pinned = ns.PinnedBuff()
return pinned ~= nil and not ns.IsBuffKnown(pinned)
end)
if ok and nothing then return "nothing" end
if type(p.sources) == "table" and not p.sources.owed then return "owedoff" end
return nil
end
Ledger.Quiet = Quiet
-- Today's numbers from the list, the lifetime ones from the counts. A favour
-- nothing you cast could return is counted apart from the rest, `useless`, and
-- left out of `received`, which is what the headline scores you against.
function Ledger.Summary()
local s, now = Store(), Wall()
local out = { received = 0, returned = 0, useless = 0, given = 0, owed = 0,
totals = { received = 0, returned = 0, letGo = 0, group = 0, strangers = 0 } }
if not s or not now then return out end
local today = StartOfToday(now)
for _, e in ipairs(s.entries) do
if e.kind == "received" and e.state == "owed" then out.owed = out.owed + 1 end
if e.at >= today then
if e.kind == "received" and e.why == "useless" then
out.useless = out.useless + 1
elseif e.kind == "received" then
out.received = out.received + 1
if e.state == "returned" then out.returned = out.returned + 1 end
else
out.given = out.given + 1
end
end
end
-- The list keeps only MAX_GIVEN buffs given, so on a busy day the count
-- kept apart from it is the one that is right. The larger of the two,
-- because a list from before that count existed has rows it never saw.
if s.today and s.today.day == today then out.given = math.max(out.given, s.today.given) end
for _, key in ipairs(TOTALS) do out.totals[key] = s.totals[key] or 0 end
return out
end
function Ledger.Headline(sum)
sum = sum or Ledger.Summary()
if sum.received == 0 then
return (sum.useless or 0) > 0 and TEXT.TODAY_ONLY_USELESS or TEXT.TODAY_NONE
end
if sum.received == 1 then return TEXT.TODAY_ONE:format(sum.returned) end
return TEXT.TODAY_MANY:format(sum.returned, sum.received)
end
function Ledger.Lifetime(sum)
local t = (sum or Ledger.Summary()).totals
return TEXT.LIFETIME:format(t.received, t.returned, t.group, t.strangers)
end
-- The line under the headline: what is still owed and what was given today,
-- each its own sentence, or nothing.
local function Subline(sum)
local owed = sum.owed == 1 and TEXT.OWED_ONE
or sum.owed > 1 and TEXT.OWED_MANY:format(sum.owed) or nil
local gave = sum.given == 1 and TEXT.GAVE_ONE
or sum.given > 1 and TEXT.GAVE_MANY:format(sum.given) or nil
if owed and gave then return owed .. " " .. gave end
return owed or gave or ""
end
-- For the General tab: the headline and the lifetime counts, or one line saying
-- there is nothing yet.
function Ledger.OptionsText()
local sum = Ledger.Summary()
local t = sum.totals
if t.received == 0 and t.group == 0 and t.strangers == 0 and #Ledger.Entries("all") == 0 then
return TEXT.OPTIONS_EMPTY
end
return Ledger.Headline(sum) .. "\n" .. Ledger.Lifetime(sum)
end
-- For the minimap button's tooltip. AddLine only: every broker display offers
-- that, and not every one offers anything else.
--
-- Left out on a character that is recording nothing and never has: a rogue's
-- tooltip under "Nothing to do" gained a line of zeros and a headline about
-- favours it will never hear of. Kept for one with a history, switched off or
-- not, because those numbers are still true and still theirs.
function Ledger.AddTooltip(tooltip)
if not (tooltip and tooltip.AddLine) or not Store() then return end
local sum = Ledger.Summary()
local quiet = Quiet()
if (quiet == "off" or quiet == "nothing") and #Ledger.Entries("all") == 0 then
local t = sum.totals
if t.received == 0 and t.group == 0 and t.strangers == 0 then return end
end
tooltip:AddLine(Ledger.Headline(sum), 1, 0.82, 0)
tooltip:AddLine(Ledger.Lifetime(sum), 0.62, 0.62, 0.62, true)
end
---------------------------------------------------------------------------
-- the window
--
-- Drawn from the same single white texture the prompt is, for the same reason:
-- an atlas or art file this client lacks renders as a green square, and a solid
-- texture cannot fail. The look follows the prompt's glass panel -- a two-step
-- shadow, a dark translucent body, a hairline of light -- with its accent
-- colour under the title, so the two read as one addon.
---------------------------------------------------------------------------
local WHITE = "Interface\\Buttons\\WHITE8X8"
local LOGO = "Interface\\AddOns\\Manners\\Textures\\Manners64"
-- Top to bottom: the title band, today's headline and up to two lines under
-- it, the all-time numbers as four tiles, the tabs, seven rows, and a footer
-- with the position in the list and Clear. Seven rather than more because the
-- window has to fit a small UI scale, where the whole screen is under eight
-- hundred units tall; the wheel does the rest.
--
-- Every string here is hung by two points on the same edge -- TOPLEFT and
-- TOPRIGHT, never TOPLEFT and RIGHT. With an edge and a centre on one axis the
-- client could take the string's height from its text or from the distance
-- between the two points, and which one it does is not settled: the prompt
-- and addons known to work on this client use that pairing without trouble,
-- which points to the text, but nobody has checked. Two points on one edge put
-- the string in the same place under either reading, so the window avoids the
-- question on purpose, and a scenario in tests/scenarios/ledgerui.lua keeps
-- it that way. tools/render_ledger.py draws the pairing by the other reading,
-- so a string that leans on it stands out in the pictures.
local WIDTH, HEIGHT = 360, 456
local PAD = 12
local ROWS = 7
local ROW_HEIGHT = 34
local HEADLINE_TOP = -40
local SUBLINE_TOP = -60
local STATS_TOP = -108
local STAT_HEIGHT = 32
local TABS_TOP = -150
local TAB_HEIGHT = 22
local LIST_TOP = -180
local FOOTER = 34
-- Where the text of a row starts, clear of its stripe and icon.
local ROW_TEXT_X = 40
-- The widest a state badge's plate grows: a long translation of its word is cut
-- inside it rather than leaving no room for what follows.
local BADGE_MAX = 120
local DEFAULT_POINT, DEFAULT_X, DEFAULT_Y = "LEFT", 40, 40
local CLEAR_SECONDS = 3
-- How often an open window redraws for "5 min ago" to become "6 min ago".
local TICK_SECONDS = 15
-- Each state's colour, taken from the prompt so a colour means the same thing
-- in both places: its amber for a favour owed, its group blue for a gift to