Repository navigation
Expand file tree
/
Copy pathLedger.lua
More file actions
2014 lines (1845 loc) · 79.8 KB
/
Copy pathLedger.lua
File metadata and controls
2014 lines (1845 loc) · 79.8 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 unasked. 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 tells it what happened at the four
-- moments a favour changes hands -- noticed, repaid, refused after all, let go
-- -- and nothing reads it to decide what to offer: the debt table in Queue.lua
-- is the one opinion about who is owed. So the ledger can only be wrong by
-- missing something, and one that throws takes nothing with it (Guard).
--
-- The window is plain UI with no secure frame, so it works in a fight.
local _, ns = ...
local L = ns.L
local Ledger = {}
ns.Ledger = Ledger
local GetTime = _G.GetTime
-- Read once at load, like Core's own copy, and asked directly: a name kept
-- here goes to disk, and a secret must never get that far.
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, repeated by the minimap tooltip and the options
-- page. "Today" is this computer's calendar day. It counts only favours
-- something you cast could have returned: a warrior's shout to a mage scored
-- as not returned would be a failure the player can do nothing about.
-- "Recorded" rather than "nobody buffed you": the ledger hears nothing while
-- the addon is off or has nothing to cast, or after Clear.
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 buff 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."],
-- A row's second line reads "badge detail": a coloured status word, two
-- spaces, then one of the details below, which finishes the badge's phrase
-- without repeating it. The badge is its own key because it takes its own
-- colour and can stand alone (a returned favour with no spell to name). A
-- detail whose words need 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"],
-- A group cast: %s is the spell, %d how many the queue had lined up for it,
-- which is not everybody in the party, only those who needed it.
GAVE_COVERED = L["%s, to %d who needed it"],
-- 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."],
-- A paladin's Salvation, which reaches the whole raid but nobody outside it.
TIP_OWED_GROUP = L["Still owed. What you cast reaches only your own party or raid, 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."],
-- The setting's own name and place, through the options page's keys, so a
-- translation names what the player will find there; a scenario holds it to.
-- Keep favours through a /reload is on When to offer, under Favours.
TIP_LETGO_NOTKEPT = L["Let go: \"%s\" (%s tab, under %s) is off, so it was forgotten when you logged out or reloaded."]
:format(L["Keep favours through a /reload"], L["When to offer"], L["Favours"]),
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, 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 buff me\" is off."],
TIP_OWED_NOTHING = L["Still owed, but there is nothing on this character the prompt can cast."],
-- Flagged for PvP when last read, while "Skip players flagged for PvP"
-- holds them back (Queue.lua, "flagged for PvP"). Worded as the snooze
-- line is: a flag often outlasts the favour.
TIP_OWED_PVP = L["Still owed. They are flagged for PvP, and buffing them would flag you, so the prompt offers them only if their flag drops before the time to return it runs out."],
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: 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, so only if time is left then."],
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, so only if time is left then."],
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, once you are no longer mounted, until the time 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, once you are no longer mounted, until the time runs out."],
TIP_OWED_SNOOZED_GROUP = L["Still owed. What you cast reaches only your own party or raid, so the prompt offers them only while they are in it, and it is snoozed until %s, so only if time is left then."],
TIP_OWED_MOUNTED_GROUP = L["Still owed. What you cast reaches only your own party or raid, so the prompt offers them only while they are in it, once you are no longer mounted, until the time 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."],
-- Under either of those for a buff somebody asked for, which is listed
-- with the rest but not counted as given unprompted.
TIP_GAVE_ASKED = L["They asked for it in chat."],
-- Under TIP_GAVE_GROUP for a group cast, which is counted as one buff. The
-- group is not named: a party's, a class's or (on Forever) a whole raid's.
TIP_GAVE_COVERED = L["One cast gave it to %d who needed it, and counts as one buff given."],
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."],
-- The title the favours you have returned earn (TITLES below): at the top of
-- the window, the title on the left and the way to the next on the right,
-- "37 of 50 to Courteous".
UNTITLED = L["Untitled, for now"],
RANK_PROGRESS = L["%d of %d to %s"],
RANK_TOP = L["every title earned"],
-- Hovering it: what the title is for, and where the next one is.
RANK_TIP_NEXT = L["Titles come from the favours you return. The next, %s, comes at %d."],
RANK_TIP_TOP = L["Every title there is, and all of them earned. Nobody has better manners."],
-- The same in the minimap tooltip, one line each.
BROKER_TITLED = L["Title: %s -- %d of %d to %s"],
BROKER_TOP = L["Title: %s -- every title earned"],
BROKER_FIRST = L["%d of %d favours returned to your first title, %s"],
-- The one chat line a new title gets: its name, then its line of flavour.
EARNED = L["A new title for your manners: |cffffd100%s|r. %s"],
}
Ledger.TEXT = TEXT
-- The titles, earned by favours returned: the lifetime count, which Clear keeps,
-- so a title once earned stays. Returned rather than received, because a title
-- for being buffed a lot would be a title for standing in Stormwind. Each has a
-- line of flavour, said once in chat when it is earned and again on hovering it.
local TITLES = {
{ at = 10, name = L["Well Brought Up"], flavour = L["Somebody raised you right."] },
{ at = 25, name = L["Well Mannered"], flavour = L["You would hold the door, if dungeons had doors."] },
{ at = 50, name = L["Courteous"], flavour = L["Innkeepers have started to nod when you come in."] },
{ at = 100, name = L["Gracious"], flavour = L["You bow a little when you cast now. People have noticed."] },
{ at = 250, name = L["Magnanimous"], flavour = L["Strangers argue over who gets to buff you first."] },
{ at = 500, name = L["Paragon of Etiquette"], flavour = L["Somewhere, a butler weeps with pride."] },
{ at = 1000, name = L["The Very Soul of Courtesy"], flavour = L["Azeroth has never been buffed so politely."] },
}
Ledger.TITLES = TITLES
-- A title's colour wherever one is named: a paler gold than the headline's, so
-- the two do not read as one line in the window.
local RANK_INK = { 0.96, 0.84, 0.52 }
---------------------------------------------------------------------------
-- 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, so a mage buffing a
-- city's strangers cannot push every favour off the end of the list.
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
-- How long a new title waits before it is said: Core's SETTLE_SECONDS, in
-- which a refusal can still take back the favour that earned it, and a second
-- over for the refusal's event to arrive.
local PROMOTE_SECONDS = 3
Ledger.PROMOTE_SECONDS = PROMOTE_SECONDS
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" }
-- Today's counts of favours, kept beside today's count of gifts.
local TODAY = { "received", "returned", "useless" }
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 debt table's rules, restated because
-- this too goes to disk and the window prints it. A secret, anything longer
-- than a name and a surname (twelve characters each of up to four bytes, and
-- a space), and anything with a chat escape or macro punctuation are refused.
local function CleanName(name)
if Secret(name) or type(name) ~= "string" then return nil end
if name == "" or #name > 97 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
local function Whole(v)
return type(v) == "number" and v >= 0 and v == math.floor(v) and v ~= math.huge
end
-- Which of TITLES this many favours returned earns, 0 for none yet. Seven
-- comparisons, asked only when a favour is returned or something is drawn.
local function TitleLevel(returned)
local level = 0
for i, t in ipairs(TITLES) do
if Count(returned) >= t.at then level = i end
end
return level
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.min(59, math.max(1, math.floor(seconds / 60 + 0.5)))) end
if seconds < 86400 then return TEXT.HOURS_AGO:format(math.min(23, 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.min(59, 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. Made from today's date by time(),
-- which applies daylight saving: counting back the hours on the clock is an
-- hour out on the day the clocks change, and "today" would then move at the
-- change. An answer that does not read back as midnight today (a time() that
-- ignores the table) is not trusted, and the count back is used, stepped on to
-- today where the clocks skip midnight; should date() not answer with a table,
-- "today" is the last day's worth of seconds.
local function StartOfToday(now)
local ok, t = pcall(_G.date, "*t", now)
if not (ok and type(t) == "table") then return now - 86400 end
local okTime, midnight = pcall(_G.time,
{ year = t.year, month = t.month, day = t.day, hour = 0, min = 0, sec = 0 })
if okTime and type(midnight) == "number" and midnight <= now then
local okBack, back = pcall(_G.date, "*t", midnight)
if okBack and type(back) == "table" and back.hour == 0
and back.day == t.day and back.month == t.month then
return midnight
end
end
if type(t.hour) == "number" and type(t.min) == "number" and type(t.sec) == "number" then
local c = now - ((t.hour * 60 + t.min) * 60 + t.sec)
-- Where the clocks skip midnight (00:00 -> 01:00) there is no midnight to
-- read back, and the count back lands an hour into yesterday: step on to
-- the first moment that is today.
for _ = 1, 4 do
local okC, ct = pcall(_G.date, "*t", c)
if not (okC and type(ct) == "table" and (ct.day ~= t.day or ct.month ~= t.month)) then break end
c = c + 1800
end
return c
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, asked }
-- ledger.totals lifetime counts, never trimmed and kept by Clear
-- ledger.title the highest of TITLES announced in chat, so none is twice
-- ledger.today { day = local midnight, given, received, returned, useless }:
-- today's counts, which the trim cannot touch and Clear resets
-- ledger.filter the window's tab
-- ledger.window where the window was dragged to
---------------------------------------------------------------------------
local store
-- `now` is the wall clock, nil when it cannot be read. A stamp past it was
-- written while the clock ran fast and has since been put right: it counts as
-- now, as a remembered favour's does (Queue.lua, RestoreDebts), or the row
-- counts as today's on every day until real time catches it up.
local function CleanEntry(e, now)
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
if now and at > now then at = now end
local class = CleanClass(e.class)
if e.kind == "given" then
local out = { kind = "given", name = name, class = class, at = at,
spell = CleanSpell(e.spell), to = e.to == "group" and "group" or "stranger",
asked = e.asked == true or nil }
-- How many one group cast reached, never more than a raid holds;
-- nothing for a single cast.
local covered = math.min(Count(e.covered), 40)
if covered > 1 then out.covered = covered end
return out
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 missing state is read as over: called owed, the row would promise
-- an offer for somebody the prompt 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
-- Never settled before it happened, nor after now.
local doneAt = CleanTime(e.doneAt)
if doneAt and now and doneAt > now then doneAt = now end
if doneAt and doneAt < at then doneAt = at end
out.doneAt = 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: a settle that finds no row
-- makes one and counts the favour twice. Owed rows 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: an
-- old profile has nothing here, and a hand-edited or half-written file can
-- have anything. Rebuilt rather than patched, so no unknown key survives.
local function Repair(char)
local s = char.ledger
if type(s) ~= "table" then
s = {}
char.ledger = s
end
local kept, now = {}, Wall()
if type(s.entries) == "table" then
for i = 1, #s.entries do
local e = CleanEntry(s.entries[i], now)
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
-- A ledger from before titles, or one whose mark is damaged, takes the
-- title its count already earns without a word: a player with three hundred
-- favours behind them is not greeted by five announcements at once. A mark
-- above what the count earns is kept -- a late refusal can take back the
-- favour that earned a title -- so no title is ever announced twice.
local title = s.title
if Whole(title) then
s.title = math.min(title, #TITLES)
else
s.title = TitleLevel(totals.returned)
end
s.filter = FILTERS[s.filter] and s.filter or "all"
-- Today's counts, kept apart from the list: see Summary. Kept only with a
-- day that is a time and a count of gifts that is whole; any other count
-- that is not a whole number reads as none.
local today = s.today
if type(today) == "table" and CleanTime(today.day) and Whole(today.given) then
s.today = { day = today.day, given = today.given }
for _, key in ipairs(TODAY) do
s.today[key] = Whole(today[key]) and today[key] or 0
end
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
-- A title the count has reached, and none before it has, gets its one line in
-- chat. Said whether or not chat lines are on, because it happens a handful of
-- times in a character's life.
local function Announce(s)
local level = TitleLevel(s.totals.returned)
if level <= (s.title or 0) then return end
s.title = level
local t = TITLES[level]
if ns.addon and ns.addon.Print then ns.addon:Print(TEXT.EARNED:format(t.name, t.flavour)) end
end
-- When the latest favour that could earn a title was settled, while its
-- announcement waits; nil when none waits.
local promoteAt
-- The wait is over unless a later favour has moved it on, in which case that
-- favour gets its own full wait.
local function PromoteDue()
local left = promoteAt and promoteAt + PROMOTE_SECONDS - GetTime()
if left and left > 0 then
C_Timer.After(left, function() ns.Guard("ledger title", PromoteDue) end)
return
end
promoteAt = nil
local s = Store()
if s then Announce(s) end
end
-- After a favour returned. Core settles when the cast is sent and the server
-- can refuse it a moment later: a title said at once would stand in chat while
-- the window took it away. So the title waits out the refusal and is said
-- only if the count still holds it; with no timer to wait on, at once.
local function Promote(s)
if TitleLevel(s.totals.returned) <= (s.title or 0) then return end
if not (C_Timer and C_Timer.After) then return Announce(s) end
local waiting = promoteAt ~= nil
promoteAt = GetTime()
if not waiting then
C_Timer.After(PROMOTE_SECONDS, function() ns.Guard("ledger title", PromoteDue) end)
end
end
-- Today's counts, counted apart from the list, which keeps only MAX_ENTRIES
-- rows and so cannot count a busy day to its end. Started afresh on a new day.
local function Today(s, now)
local day = StartOfToday(now)
if not (s.today and s.today.day == day) then
s.today = { day = day, given = 0, received = 0, returned = 0, useless = 0 }
end
return s.today
end
-- One off today's `key` for a row dated `at`, while today is still the day it
-- was counted on: after midnight, or after Clear, it is not in the count.
local function Untoday(s, key, at)
if s.today and s.today.day == StartOfToday(at) then
s.today[key] = math.max(0, (s.today[key] or 0) - 1)
end
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. Repainted only while that tab is on screen,
-- because a repaint rebuilds the whole page, controls the player may be
-- using included. A tab nobody can name (a library without the status
-- table) is always repainted.
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. An owed row with no
-- debt behind it any more ran out while you were away, or was not kept (the
-- setting is off), and is let go so the row agrees with 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 a favour nothing this character casts
-- could return: recorded, and let go the moment it arrives. `partyOnly` is a
-- favour only a party-wide buff could return, which the row's tooltip says.
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 newest buff's reading of partyOnly wins: it 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")
local today = Today(s, now)
local key = useless and "useless" or "received"
today[key] = today[key] + 1
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
-- A group cast went out as its own spell, whatever the client reported.
local group = type(pending) == "table" and type(pending.group) == "table" and pending.group
id = group and CleanSpell(group.spell)
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 }
local today = Today(s, now)
if wasOwed then
local e = FindOpen(s, name)
if not e then
-- A debt the ledger never saw (kept from before the ledger
-- existed): a favour received all the same, dated from 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")
if StartOfToday(at) == today.day then today.received = today.received + 1 end
undo.created = true
end
e.state, e.doneAt, e.gave = "returned", now, gave
Bump(s, "returned")
Promote(s)
-- Today's headline scores the favours received today, so a return
-- counts for today only when the favour does.
if StartOfToday(e.at) == today.day then today.returned = today.returned + 1 end
undo.entry = e
else
local to = type(pending) == "table" and pending.inGroup == true and "group" or "stranger"
local group = type(pending) == "table" and type(pending.group) == "table" and pending.group
-- A buff somebody asked for in chat is listed, but it was not given
-- unprompted, which is what today's count of gifts says. A group cast
-- only when everybody it covered asked (GroupBuffs.lua): aimed at an
-- asker, it still reached the rest of the party unprompted.
local asked = type(pending) == "table" and pending.reason == "asked"
and (not group or group.asked == true)
local e = { kind = "given", name = name, at = now, spell = gave, to = to, asked = asked or nil,
class = CleanClass(type(pending) == "table" and pending.class or nil) }
-- One cast that covered a party is one buff given, counted once like
-- any cast, and the row says how many it reached.
if group and type(group.members) == "table" and #group.members > 0 then
e.covered = #group.members + 1
end
Append(s, e)
Bump(s, to == "group" and "group" or "strangers")
if not asked then today.given = today.given + 1 end
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: Clear can be pressed between a
-- settle and its refusal. The counts, which Clear keeps, are taken back all
-- the same, and a favour Core has just put back gets its owed row back, or
-- the next return would make a row and count the favour twice.
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, which never had a buff somebody asked for.
if not e.asked then Untoday(s, "given", e.at) end
else
Bump(s, "returned", -1)
Untoday(s, "returned", e.at)
if undo.created then
Remove(s, e)
Bump(s, "received", -1)
Untoday(s, "received", e.at)
else
-- Somebody who buffed you again before the refusal arrived already
-- has an owed row, and Core keeps one debt per person, so the two
-- are folded into one favour, counted once. Today's count gives
-- back the later of the two, which it holds even when the other
-- was yesterday's.
local open = FindOpen(s, e.name)
if open and open ~= e then
Untoday(s, "received", math.max(open.at, e.at))
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" is
-- the never-offer list, which must not read as time running out. No reason is
-- the expiry sweep's case, the time ran out.
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 each debt
-- is still live on the prompt, and a settle that found no row would count the
-- favour twice. The settles kept for a refusal stay too: a refusal can still
-- arrive for a row that is gone, and Refused copes with that.
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, on any tab: a row other than a favour
-- still owed, or a count of buffs given today. The window hides Clear when not.
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