Repository navigation
Expand file tree
/
Copy pathBoard.lua
More file actions
2167 lines (2024 loc) · 86.5 KB
/
Copy pathBoard.lua
File metadata and controls
2167 lines (2024 loc) · 86.5 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
--[[--------------------------------------------------------------------
WhoDidIt - rankings board: boss kill times and full instance clears
Records come from fights WhoDidIt records itself:
- kill time = the boss fight's duration (first hit -> kill)
- clear time = first combat in the instance -> last required boss
(Data.lua D.clears) killed in the same run
The guild of a record is the raid's majority guild (>= half the raid).
Every guild's times on the boards come only from the maintainer's master
feed and the download (Chronicle's logs). A time your own WhoDidIt
records is kept on your PC (your own board and personal bests); players
don't share raid times with each other.
----------------------------------------------------------------------]]
local W = WhoDidIt
local B = {}
W.Board = B
local getn = table.getn
local floor = math.floor
local CHANNEL = "WDIBoard"
local PROTO = "WDIB1"
local SEP = "~"
local FEED = "WDIF1" -- (the master feed's messages; see "master feed" below)
local KILL_MIN, KILL_MAX = 5, 3600
local CHRON_KILL_MIN = 10 -- Chronicle logs with a boss "killed" faster than this are broken
local CLEAR_MIN, CLEAR_MAX = 120, 8 * 3600
local RUN_MAX = 6 * 3600 -- a run older than this can't become a clear
------------------------------------------------------------------ basics
function B.Realm() return GetRealmName() or "Unknown" end
function B.Faction() return UnitFactionGroup("player") or "Neutral" end
function B.MyGuild() return (GetGuildInfo("player")) end
local function charKey()
return (UnitName("player") or "?") .. "-" .. B.Realm()
end
-- m:ss.s for kills, h:mm:ss for clears
function B.Fmt(secs)
if not secs then return "-" end
if secs >= 3600 then
return string.format("%d:%02d:%02d", floor(secs / 3600), floor(math.mod(secs, 3600) / 60), floor(math.mod(secs, 60)))
end
return string.format("%d:%04.1f", floor(secs / 60), math.mod(secs, 60))
end
function B.Date(epoch)
return epoch and W.LDate("%d %b %Y", epoch) or "?"
end
function B:DB(realm)
if type(WhoDidItDB.board) ~= "table" then WhoDidItDB.board = {} end
realm = realm or B.Realm()
local r = WhoDidItDB.board[realm]
if not r then
r = { kills = {}, clears = {} }
WhoDidItDB.board[realm] = r
end
return r
end
-- this character's personal bests
function B:PB()
if type(WhoDidItDB.pb) ~= "table" then WhoDidItDB.pb = {} end
local k = charKey()
local p = WhoDidItDB.pb[k]
if not p then
p = { kills = {}, clears = {} }
WhoDidItDB.pb[k] = p
end
return p
end
------------------------------------------------------------------ Chronicle data
-- tools/WhoDidIt-Sync.ps1 pulls Chronicle's public External API and writes
-- CustomData/WhoDidIt_Chronicle.txt; Nampower lets us read it while playing.
-- WDICHRON|1|<synced epoch>|<server>|<days>|<status>
-- C|realm|instance|guild|faction|secs|ended|players|slug (full clear)
-- K|realm|instance|boss|guild|faction|secs|ended|players|slug (boss kill)
local CHRON_FILE = "WhoDidIt_Chronicle.txt"
local CHRON_ZONE = { ["Temple of Ahn'Qiraj"] = "Ahn'Qiraj" } -- Chronicle name -> WhoDidIt zone
B.chron = nil
local chronHead
-- a guild name as WoW allows it: letters and spaces, 24 characters at most.
-- Anything else (links, colour codes, digits, symbols) is dropped on arrival.
local function okGuild(g)
return g and g ~= "" and string.len(g) <= 24 and not string.find(g, "[%c%d%p]") and true or false
end
B.OkGuild = okGuild
local function splitBar(line)
local out = {}
for part in string.gfind(line .. "|", "(.-)|") do tinsert(out, part) end
return out
end
-- (re)read the sync file; true if it changed
function B:LoadChronicle()
-- the sync helper's file on this PC, or the master's feed (whichever is newer)
local s, fromFile
if ReadCustomFile then
local ok, v = pcall(ReadCustomFile, CHRON_FILE)
if ok and type(v) == "string" and v ~= "" then s, fromFile = v, true end
end
local f = WhoDidItDB and WhoDidItDB.feed
if f and B.FeedSynced() > 0 and not (WhoDidItDB.opts and WhoDidItDB.opts.noFeed) then
local _, _, a = string.find(s or "", "^WDICHRON|%d+|(%d+)")
if not s or B.FeedSynced() > (tonumber(a) or 0) then s, fromFile = B.FeedText(), false end
end
if not s then
local had = B.chron ~= nil
B.chron = nil
chronHead = nil
B.chronRaw, B.chronRawL, B.chronFeed = nil, nil, nil
return had
end
local _, _, head = string.find(s, "^([^\n]*)")
-- (a feed that grew by raid boss lists keeps its header: count those too)
head = (head or "") .. (fromFile and "" or ("#" .. string.len(s)))
if head == chronHead then return false end
chronHead = head
-- the master sends what's in its own file
B.chronRaw = s
B.chronRawL = {}
B.chronFeed = not fromFile
B.chronFrom = (not fromFile) and f and f.from or nil
B.chronLines = 0
local data = { realms = {}, bosses = {}, zones = {}, me = {}, logs = {} }
for line in string.gfind(s, "[^\r\n]+") do
local p = splitBar(line)
local t = p[1]
if t == "WDICHRON" then
data.synced, data.server, data.days, data.status = tonumber(p[3]), p[4], tonumber(p[5]), p[6]
elseif t == "L" then
-- a whole raid: L|slug|realm|instance|guild|faction|ended|players|Boss=secs=into raid=wipes;...
if B.chronRawL then B.chronRawL[p[2]] = line end
local kills = {}
for part in string.gfind((p[9] or "") .. ";", "(.-);") do
local _, _, n, secs, at, w = string.find(part, "^(.-)=([%d%.]*)=([%d%.]*)=(%d*)$")
if n and n ~= "" then tinsert(kills, { n = n, s = tonumber(secs), a = tonumber(at), w = tonumber(w) }) end
end
data.logs[p[2]] = { realm = p[3], zone = CHRON_ZONE[p[4]] or p[4], guild = p[5], f = p[6], d = tonumber(p[7]), n = tonumber(p[8]), kills = kills }
elseif t == "PC" or t == "PK" then
-- your characters' bests: PC|realm|char|instance|secs|ended|guild|slug
-- PK|realm|char|instance|boss|secs|ended|guild|slug
local realm, char = p[2], strlower(p[3] or "")
data.me[realm] = data.me[realm] or {}
local me = data.me[realm][char]
if not me then
me = { kills = {}, clears = {} }
data.me[realm][char] = me
end
local zone = CHRON_ZONE[p[4]] or p[4]
if t == "PC" then
local secs = tonumber(p[5])
if secs and secs >= CLEAR_MIN and secs <= CLEAR_MAX then me.clears[zone] = { t = secs, d = tonumber(p[6]), g = p[7], slug = p[8], chron = true } end
else
local secs = tonumber(p[6])
if secs and secs >= CHRON_KILL_MIN and secs <= KILL_MAX then me.kills[p[5]] = { t = secs, d = tonumber(p[7]), g = p[8], slug = p[9], chron = true } end
end
elseif t == "C" or t == "K" or t == "C2" or t == "K2" then
-- C2 / K2: a guild's best since the raid scaling change, on the "+" boards
local post = (t == "C2" or t == "K2")
if not post then B.chronLines = B.chronLines + 1 end
local realm = p[2]
local zone = CHRON_ZONE[p[3]] or p[3]
local kind, key, guild, fac, secs, d, n, slug
if t == "C" or t == "C2" then
kind, key, guild, fac, secs, d, n, slug = "clears", zone, p[4], p[5], tonumber(p[6]), tonumber(p[7]), tonumber(p[8]), p[9]
else
kind, key, guild, fac, secs, d, n, slug = "kills", p[4], p[5], p[6], tonumber(p[7]), tonumber(p[8]), tonumber(p[9]), p[10]
data.bosses[zone] = data.bosses[zone] or {}
data.bosses[zone][key] = true
end
-- broken logs (a "clear" spanning days, a 4-second boss) would wreck the boards
local maxSecs = (kind == "clears") and CLEAR_MAX or KILL_MAX
local minSecs = (kind == "clears") and CLEAR_MIN or CHRON_KILL_MIN
if realm ~= "" and okGuild(guild) and secs and secs >= minSecs and secs <= maxSecs then
local r = data.realms[realm]
if not r then
r = { kills = {}, clears = {} }
data.realms[realm] = r
end
-- the all-time board, and the "since the scaling change" one: C2 / K2
-- lines, and any all-time best that's itself from since then
local kinds = post and { kind .. "+" } or { kind }
if not post and d and W.Data.SCALING and d >= W.Data.SCALING.at then tinsert(kinds, kind .. "+") end
for ki = 1, getn(kinds) do
local k = kinds[ki]
r[k] = r[k] or {}
local list = r[k][key]
if not list then
list = {}
r[k][key] = list
end
local old = list[guild]
if not old or secs < old.t then
list[guild] = { t = secs, d = d, f = fac, n = n, slug = slug, chron = true }
end
end
data.zones[zone] = true
end
end
end
B.chron = data
-- the master notes which times are new or changed, so updates send only those
if fromFile and B.NoteMasterLines then B.NoteMasterLines(s, data.synced) end
return true
end
-- a whole raid from Chronicle (every boss kill in order), by its log slug
function B:Log(slug)
return slug and B.chron and B.chron.logs[slug]
end
function B.LogURL(slug)
return "https://legacy.chronicleclassic.com/instances/" .. slug
end
-- where a time ranks on a realm's board (1 = fastest)
function B:Place(realm, kind, key, secs)
local list = B:Board(realm, kind, key, "All")
local place = 1
for i = 1, getn(list) do
if list[i][2].t < secs then place = place + 1 end
end
return place, getn(list)
end
-- Chronicle bosses seen for a zone (names as Chronicle writes them)
function B:ChronBosses(zone)
local out = {}
local set = B.chron and B.chron.bosses[zone]
if set then
for name in pairs(set) do tinsert(out, name) end
table.sort(out)
end
return out
end
-- every zone with data: WhoDidIt's raids, then Chronicle-only ones (Karazhan towers...)
function B:Zones()
local list, have = {}, {}
for i = 1, getn(W.Data.clearOrder) do
tinsert(list, W.Data.clearOrder[i])
have[W.Data.clearOrder[i]] = true
end
if B.chron then
local extra = {}
for zone in pairs(B.chron.zones) do
if not have[zone] then tinsert(extra, zone) end
end
table.sort(extra)
for i = 1, getn(extra) do tinsert(list, extra[i]) end
end
return list
end
-- realms we have data for (current realm first)
function B:Realms()
local list, have = { B.Realm() }, {}
have[list[1]] = true
for realm in pairs(WhoDidItDB.board or {}) do
if not have[realm] then tinsert(list, realm); have[realm] = true end
end
if B.chron then
for realm in pairs(B.chron.realms) do
if not have[realm] then tinsert(list, realm); have[realm] = true end
end
end
return list
end
-- the sync helper's progress (CustomData\WhoDidIt_SyncStatus.txt, rewritten
-- at least every 30 seconds while it runs):
-- WDISYNC|1|<now>|<state>|<step>|<steps>|<what>|<done>|<total>|<seconds left>|<next sync>
-- nil = it has never run here. alive = false: it was closed.
local SYNC_FILE, SYNC_ASK = "WhoDidIt_SyncStatus.txt", "WhoDidIt_SyncRequest.txt"
function B:SyncStatus()
if not ReadCustomFile then return nil end
local ok, s = pcall(ReadCustomFile, SYNC_FILE)
if not ok or type(s) ~= "string" or s == "" then return nil end
local p = splitBar((string.gsub(s, "[\r\n]", "")))
if p[1] ~= "WDISYNC" then return nil end
local st = { at = tonumber(p[3]) or 0, state = p[4] or "", step = tonumber(p[5]) or 0, steps = tonumber(p[6]) or 0,
what = p[7] or "", done = tonumber(p[8]) or 0, total = tonumber(p[9]) or 0, eta = tonumber(p[10]) or 0, next = tonumber(p[11]) or 0 }
st.alive = (st.state ~= "stopped") and (time() - st.at) < 120
return st
end
-- Sync now: ask the helper (it checks every few seconds while it waits)
function B:RequestSync()
if not WriteCustomFile then return false end
local ok = pcall(WriteCustomFile, SYNC_ASK, tostring(time()), "w")
if ok then B.asked = time() end
return ok
end
-- re-read the sync file every minute; refresh the window when it changed
W:Every(60, function()
if not WhoDidItDB then return end
local changed = B:LoadChronicle()
-- new times in: anyone who just beat us goes on the rival watch
if (changed or B.rivalsDirty) and B.CheckRivals then
B.rivalsDirty = nil
B:CheckRivals()
changed = true
end
if changed and W.UI and W.UI.mode == "rankings" then W.UI:Refresh() end
end)
-- keep a record if it's that guild's best; true if it improved
function B:Merge(kind, realm, key, guild, rec)
local r = B:DB(realm)
-- a time since the raid scaling change also goes on the "since" board
local s = W.Data.SCALING
if s and string.sub(kind, -1) ~= "+" and rec.d and rec.d >= s.at then
B:Merge(kind .. "+", realm, key, guild, rec)
end
r[kind] = r[kind] or {}
local list = r[kind][key]
if not list then
list = {}
r[kind][key] = list
end
local old = list[guild]
if old and old.t <= rec.t then return false end
list[guild] = rec
-- keep the 60 fastest guilds per board, so the saved boards can't grow forever
local n, slowest, slowT, mine = 0, nil, -1, B.MyGuild()
for g, r2 in pairs(list) do
n = n + 1
if g ~= mine and r2.t > slowT then slowest, slowT = g, r2.t end
end
if n > 60 and slowest then
list[slowest] = nil
if slowest == guild then return false end
end
return true
end
B.ALL = "All realms"
-- before the raid scaling change (Data.lua D.SCALING)? Rankings marks those times.
-- Every board has a twin with only the times since: kind .. "+" ("kills+", "clears+").
function B.PrePatch(rec)
local s = W.Data.SCALING
return s and rec and rec.d and rec.d > 0 and rec.d < s.at and true or false
end
function B.PostKind(kind) return kind .. "+" end
-- once: raid times other players' WhoDidIt sent before 1.23.1 go, so the boards
-- hold only the master's (Chronicle) times and your own
function B:PurgeShared()
if not WhoDidItDB or WhoDidItDB.sharedPurged then return end
for _, r in pairs(WhoDidItDB.board or {}) do
if type(r) == "table" then
for kind, keys in pairs(r) do
if type(keys) == "table" and (kind == "kills" or kind == "clears" or kind == "kills+" or kind == "clears+") then
for _, list in pairs(keys) do
for g, rec in pairs(list) do
if type(rec) == "table" and rec.net then list[g] = nil end
end
end
end
end
end
end
WhoDidItDB.sharedPurged = true
end
-- once: times recorded before this version that are from since the change
-- go on the "since" boards too
function B:SplitEra()
local s = W.Data.SCALING
if not s or not WhoDidItDB or WhoDidItDB.eraSplit == s.at then return end
for realm, r in pairs(WhoDidItDB.board or {}) do
if type(r) == "table" then
for _, kind in ipairs({ "kills", "clears" }) do
for key, list in pairs(r[kind] or {}) do
for g, rec in pairs(list) do
if rec.d and rec.d >= s.at then B:Merge(kind .. "+", realm, key, g, rec) end
end
end
end
end
end
WhoDidItDB.eraSplit = s.at
end
-- guild -> best record on one realm, WhoDidIt and Chronicle merged
local function realmBoard(realm, kind, key)
local merged = {}
local mine = WhoDidItDB.board and WhoDidItDB.board[realm]
for g, rec in pairs(mine and mine[kind] and mine[kind][key] or {}) do merged[g] = rec end
local c = B.chron and B.chron.realms[realm]
if c and c[kind] and c[kind][key] then
for g, rec in pairs(c[kind][key]) do
local old = merged[g]
if not old or rec.t < old.t then merged[g] = rec end
end
end
return merged
end
-- sorted { guild, rec, realm } list. realm may be B.ALL; faction "All" or a
-- faction name. Cross-faction ("Mixed") guilds show in both faction views;
-- guilds of unknown faction only under "All".
function B:Board(realm, kind, key, faction)
local realms = (realm == B.ALL) and B:Realms() or { realm }
local list = {}
for i = 1, getn(realms) do
for g, rec in pairs(realmBoard(realms[i], kind, key)) do
if faction == "All" or rec.f == faction or rec.f == "Mixed" then
tinsert(list, { g, rec, realms[i] })
end
end
end
table.sort(list, function(a, b) return a[2].t < b[2].t end)
return list
end
-- your current character's best: WhoDidIt's own record or Chronicle's
function B:MyBest(kind, key)
local best = B:PB()[kind][key]
local me = B.chron and B.chron.me[B.Realm()]
me = me and me[strlower(UnitName("player") or "")]
local cr = me and me[kind][key]
if cr and (not best or cr.t < best.t) then best = cr end
return best
end
-- your guild's best record for one board (WhoDidIt or Chronicle). Always
-- from your own realm, so viewing another realm compares against it.
function B:GuildBest(realm, kind, key, guild)
realm = B.Realm()
local best = B:DB(realm)[kind][key]
best = best and best[guild]
local c = B.chron and B.chron.realms[realm]
local cr = c and c[kind][key] and c[kind][key][guild]
if cr and (not best or cr.t < best.t) then best = cr end
return best
end
-- rank (1-based) of a guild on a board, and how many guilds are on it
-- (on the all-realms board, your guild = the one on your realm)
function B:Rank(realm, kind, key, guild, faction)
local list = B:Board(realm, kind, key, faction)
local home = B.Realm()
for i = 1, getn(list) do
if list[i][1] == guild and (realm ~= B.ALL or list[i][3] == home) then return i, getn(list) end
end
return nil, getn(list)
end
------------------------------------------------------------------ recording
-- the raid's majority guild (>= half the raid) and raid size
local function raidGuild()
local counts, n = {}, 0
for _, e in pairs(W.roster.byName) do
n = n + 1
local g = GetGuildInfo(e.unit)
if g then counts[g] = (counts[g] or 0) + 1 end
end
local best, bc = nil, 0
for g, c in pairs(counts) do
if c > bc then best, bc = g, c end
end
if best and bc * 2 >= n then return best, n end
return nil, n
end
-- (the guild's Hall of Fame: which guild a fight belongs to)
function B.RaidGuild() return (raidGuild()) end
local function currentRun()
if type(WhoDidItDB.runs) ~= "table" then WhoDidItDB.runs = {} end
return WhoDidItDB.runs[charKey()]
end
local function setRun(r)
if type(WhoDidItDB.runs) ~= "table" then WhoDidItDB.runs = {} end
WhoDidItDB.runs[charKey()] = r
end
-- the timed raid a zone text is (Data.lua D.clears), or nil. A zone text two raids
-- share (D.sharedZones: both Karazhan towers) is the timed one unless more of the raid
-- is in that zone than it lets in. (Each member's zone from the raid roster, compared
-- with your own entry there.)
function B.RunZone(zone)
if W.Data.clears[zone] then return zone end
local s = W.Data.sharedZones[zone]
if not s then return nil end
local me, mine, zs = UnitName("player"), nil, {}
for i = 1, GetNumRaidMembers() do
local name, _, _, _, _, _, z = GetRaidRosterInfo(i)
zs[i] = z or false
if name and name == me then mine = z end
end
local n = 0
for i = 1, getn(zs) do
if zs[i] and zs[i] == (mine or zone) then n = n + 1 end
end
if n > s.max then return nil end
return s.timed
end
-- a run starts with the first combat inside a raid instance we can time
-- (the zone text says it's a raid: the instance type isn't checked)
W:On("PLAYER_REGEN_DISABLED", function()
if not WhoDidItDB then return end
if not IsInInstance() or GetNumRaidMembers() == 0 then return end
local real = GetRealZoneText()
local zone = B.RunZone(real)
local r = currentRun()
if not zone then
-- the other raid behind a shared zone text (Upper Karazhan): the run there isn't this
-- one. (One with bosses down still is: then the roster is behind, say after a swap.)
if r and r.real == real and (r.done or not next(r.kills)) then setRun(nil) end
return
end
local now = time()
if r and r.zone == zone and not r.done and now - r.start < RUN_MAX then return end
setRun({ zone = zone, real = real, start = now, kills = {} })
end)
function B:CurrentRun()
local r = currentRun()
if r and not r.done and time() - r.start < RUN_MAX then return r end
end
-- the run in progress or just finished (the run timer shows its final time)
function B:LastRun()
local r = currentRun()
if r and r.start and time() - r.start < RUN_MAX then return r end
end
-- where a time would rank on your realm's board if it were uploaded to Chronicle:
-- since the raid scaling change (the era it's in), and of all time. nil: no board yet
function B:WouldRank(kind, key, secs)
local realm = B.Realm()
local p2, of2 = B:Place(realm, kind .. "+", key, secs)
local p1, of1 = B:Place(realm, kind, key, secs)
if of1 == 0 and of2 == 0 then return nil end
return p2, of2, p1, of1
end
local function announce(text)
W.Print("|cff33ccffRankings:|r " .. text)
end
------------------------------------------------------------------ realms
-- "N'Zoth (PvE)" - realm types are set in Data.lua
function B.RealmLabel(realm)
local t = W.Data.realmTypes and W.Data.realmTypes[realm]
return t and (realm .. " (" .. t .. ")") or (realm or "?")
end
function B.ServerName()
return (B.chron and B.chron.server) or "the server"
end
-- the fastest guild on every other realm for one board: { guild, rec, realm }, fastest first
function B:OtherRealms(kind, key, home)
local out = {}
local realms = B:Realms()
for i = 1, getn(realms) do
local realm = realms[i]
if realm ~= home then
local best
for g, rec in pairs(realmBoard(realm, kind, key)) do
if not best or rec.t < best[2].t then best = { g, rec, realm } end
end
if best then tinsert(out, best) end
end
end
table.sort(out, function(a, b) return a[2].t < b[2].t end)
return out
end
local function ago(epoch)
if not epoch then return W.L("recently") end
local s = time() - epoch
if s < 3600 then return W.L("within the hour") end
if s < 86400 then return W.LF("%sh ago", floor(s / 3600)) end
local d = floor(s / 86400)
return (d == 1) and W.L("yesterday") or W.LF("%s days ago", d)
end
B.Ago = ago
local function boardTitle(kind, key)
if kind == "clears" then return (W.Data.instanceTitle[key] or key) .. " clear" end
return key
end
------------------------------------------------------------------ posting once per raid
-- Every WhoDidIt user in the raid sees the same kill. Before an automatic
-- post (banter, rival watch) each one claims it on a hidden addon channel;
-- after two seconds only the first name alphabetically posts.
local CLAIM = "WDIPost"
local claims = {}
function B:Claim(tag, fn)
local inRaid = GetNumRaidMembers() > 0
-- (raiders who aren't lead or assist don't take part: theirs shows in their own chat)
if W.Shout:Channel() == "SELF" or (not inRaid and GetNumPartyMembers() == 0) or not W.CanLead() then fn() return end
local c = claims[tag] or { names = {} }
c.at, c.fn = GetTime(), fn
c.names[UnitName("player")] = true
claims[tag] = c
SendAddonMessage(CLAIM, tag, inRaid and "RAID" or "PARTY")
end
W:On("CHAT_MSG_ADDON", function(prefix, msg, channel, sender)
if prefix ~= CLAIM or not msg or not sender or not W.CanLead(sender) then return end
local c = claims[msg]
if not c then
c = { names = {}, at = GetTime() }
claims[msg] = c
end
c.names[sender] = true
end)
-- whoever won a claim has a few seconds to really post it; if nothing from
-- them shows up (a modified client, a disconnect), the next one in line posts
local waiting = {} -- { fn, at }: at = when to post if nobody else has
local function heardPost(msg, sender)
if not sender or sender == UnitName("player") or not msg or not string.find(msg, "WhoDidIt]", 1, true) then return end
waiting = {}
end
for _, ev in ipairs({ "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", "CHAT_MSG_RAID_WARNING", "CHAT_MSG_PARTY",
"CHAT_MSG_GUILD", "CHAT_MSG_OFFICER", "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_CHANNEL" }) do
W:On(ev, heardPost)
end
W:Every(0.5, function()
local now = GetTime()
for tag, c in pairs(claims) do
if now - c.at > 2 then
if c.fn then
local first, mine = nil, 0
local me = UnitName("player")
for n in pairs(c.names) do
if not first or n < first then first = n end
if n < me then mine = mine + 1 end
end
-- second in line waits 6s, third 12s...
if first == me then c.fn()
elseif first then tinsert(waiting, { fn = c.fn, at = now + 6 * mine }) end
end
claims[tag] = nil
end
end
for i = getn(waiting), 1, -1 do
if now >= waiting[i].at then
local fn = waiting[i].fn
tremove(waiting, i)
fn()
end
end
end)
------------------------------------------------------------------ rival watch
-- A time that beats our guild's best and was set *after* it - from the
-- Chronicle sync or a WhoDidIt user on the realm - goes on the rival watch.
-- Other realms count when their fastest guild gets below our time. Rivals
-- show in Rankings, get posted when the raid enters that instance, and the
-- next kill of that boss gets a revenge (or "still behind") banter.
local RIVAL_DAYS = 21
local function rivalDB()
if type(WhoDidItDB.rivals) ~= "table" then WhoDidItDB.rivals = {} end
local r = WhoDidItDB.rivals
r.seen = r.seen or {}
r.list = r.list or {}
return r
end
-- the instance a board key belongs to
function B:KeyZone(kind, key)
if kind == "clears" then return key end
local def = W.Data.encounters[key]
if def then return def.zone end
if B.chron then
for zone, set in pairs(B.chron.bosses) do
if set[key] then return zone end
end
end
end
local function allKeys(kind)
local set = {}
for _, r in pairs(WhoDidItDB.board or {}) do
if type(r) == "table" and type(r[kind]) == "table" then
for k in pairs(r[kind]) do set[k] = true end
end
end
if B.chron then
for _, r in pairs(B.chron.realms) do
for k in pairs(r[kind]) do set[k] = true end
end
end
return set
end
-- look for new times that beat ours; returns how many were found
function B:CheckRivals()
local guild = B.MyGuild()
if not guild or not WhoDidItDB then B.rivalsDirty = true return 0 end -- guild info can lag at login
local db = rivalDB()
local home, now = B.Realm(), time()
local first = not db.ready
local found = 0
local kinds = { "kills", "clears" }
for k = 1, 2 do
local kind = kinds[k]
for key in pairs(allKeys(kind)) do
local ours = B:GuildBest(home, kind, key, guild)
if ours and ours.d then
local cands = B:OtherRealms(kind, key, home)
for g, rec in pairs(realmBoard(home, kind, key)) do
if g ~= guild then tinsert(cands, { g, rec, home }) end
end
for i = 1, getn(cands) do
local g, rec, realm = cands[i][1], cands[i][2], cands[i][3]
if rec.t < ours.t and rec.d and rec.d > ours.d and now - rec.d < RIVAL_DAYS * 86400 then
local id = kind .. "|" .. key .. "|" .. realm .. "|" .. g .. "|" .. rec.t
if not db.seen[id] then
db.seen[id] = rec.d
tinsert(db.list, { kind = kind, key = key, zone = B:KeyZone(kind, key), g = g, realm = realm, t = rec.t, d = rec.d, ours = ours.t })
found = found + 1
end
end
end
end
end
end
-- newest first; drop old ones (and keep their instance up to date: Karazhan's
-- bosses were "Karazhan" before the towers were told apart)
local keep = {}
for i = 1, getn(db.list) do
local e = db.list[i]
if now - (e.d or 0) < RIVAL_DAYS * 86400 then
e.zone = B:KeyZone(e.kind, e.key) or e.zone
tinsert(keep, e)
end
end
table.sort(keep, function(a, b) return (a.d or 0) > (b.d or 0) end)
while getn(keep) > 40 do table.remove(keep) end
db.list = keep
for id, d in pairs(db.seen) do
if d == true or now - (tonumber(d) or 0) > RIVAL_DAYS * 86400 then db.seen[id] = nil end
end
db.ready = true
if found > 0 then
W.Print("|cffff7777Rival watch:|r " .. found .. " time" .. (found == 1 and "" or "s") .. " that beat " .. guild .. "'s best "
.. (first and "in the last " .. RIVAL_DAYS .. " days" or "just came in") .. " - see Rankings.")
end
return found
end
-- rivals still ahead of us: optionally one instance, or one board (kind + key)
function B:Rivals(zone, kind, key)
local out = {}
local guild = B.MyGuild()
if not guild or not WhoDidItDB then return out end
local db = rivalDB()
local now, home = time(), B.Realm()
for i = 1, getn(db.list) do
local e = db.list[i]
local sane = e.t >= ((e.kind == "clears") and CLEAR_MIN or CHRON_KILL_MIN) -- not a broken log
if sane and now - (e.d or 0) < RIVAL_DAYS * 86400 and (not zone or e.zone == zone) and (not key or (e.kind == kind and e.key == key)) then
local ours = B:GuildBest(home, e.kind, e.key, guild)
if not ours or e.t < ours.t then
e.cur = ours and ours.t or e.ours
tinsert(out, e)
end
end
end
return out
end
-- "Care Bears" / "Pumpers of N'Zoth (PvE)"
function B:RivalName(e)
return e.g .. ((e.realm ~= B.Realm()) and (" of " .. B.RealmLabel(e.realm)) or "")
end
function B:RivalText(e)
return B:RivalName(e) .. " beat our " .. boardTitle(e.kind, e.key) .. " " .. ago(e.d) .. " (" .. B.Fmt(e.t) .. " vs our " .. B.Fmt(e.cur or e.ours) .. ")"
end
------------------------------------------------------------------ colours for chat posts
-- W:Send colours names from this map: our guild green, other guilds
-- orange, realms purple, bosses and instances gold (plus raid members in
-- their class colour). Multi-word names and "N'Zoth (PvE)" work too.
function B:ChatColours()
local P = W.Shout.PALETTE -- the same colours as every other post
local CHAT_US, CHAT_THEM, CHAT_REALM, CHAT_BOSS = P.us, P.guild, P.realm, P.boss
local m = {}
for enc in pairs(W.Data.encounters) do m[enc] = CHAT_BOSS end
for zone in pairs(W.Data.clears) do
m[zone] = CHAT_BOSS
if W.Data.instanceTitle[zone] then m[W.Data.instanceTitle[zone]] = CHAT_BOSS end
end
local realms = B:Realms()
for i = 1, getn(realms) do
local r = realms[i]
local label = B.RealmLabel(r)
m[label] = CHAT_REALM
-- "C'Thun" alone is also a boss: plain realm names only when they have no type
if label == r then m[r] = CHAT_REALM end
local kinds = { "kills", "clears" }
for k = 1, 2 do
local boards = {}
local mine = WhoDidItDB.board and WhoDidItDB.board[r]
if type(mine) == "table" and type(mine[kinds[k]]) == "table" then tinsert(boards, mine[kinds[k]]) end
local c = B.chron and B.chron.realms[r]
if c then tinsert(boards, c[kinds[k]]) end
for j = 1, getn(boards) do
for key, list in pairs(boards[j]) do
if kinds[k] == "kills" and not m[key] then m[key] = CHAT_BOSS end
for g in pairs(list) do
if string.len(g) >= 3 then m[g] = CHAT_THEM end
end
end
end
end
end
m[B.ServerName()] = CHAT_REALM
for name, e in pairs(W.roster and W.roster.byName or {}) do
if e.class then m[name] = e.class end
end
local guild = B.MyGuild()
if guild then m[guild] = CHAT_US end
return m
end
------------------------------------------------------------------ rival watch posts
local TAUNT = {
"Are we really letting that slide?",
"Time to take them back.",
"Revenge is on the menu tonight.",
"Let's remind them whose times these are.",
"Pump harder.",
"Somebody's been sleeping on their consumables.",
}
-- "Hell Fire of C'Thun (Hardcore) - Majordomo Executus 1:24.7 (ours 1:29.2, yesterday)"
function B:RivalShort(e)
local what = (e.kind == "clears") and ((W.Data.instanceTitle[e.key] or e.key) .. " clear") or e.key
return B:RivalName(e) .. " - " .. what .. " " .. B.Fmt(e.t) .. " (ours " .. B.Fmt(e.cur or e.ours) .. ", " .. ago(e.d) .. ")"
end
-- a heading line, then one line per rival (our realm first, newest first), at most 3
function B:RivalLines(zone, list)
list = list or B:Rivals(zone)
local n = getn(list)
if n == 0 then return nil end
local home = B.Realm()
local sorted = {}
for i = 1, n do sorted[i] = list[i] end
table.sort(sorted, function(a, b)
local ha, hb = (a.realm == home), (b.realm == home)
if ha ~= hb then return ha end
return (a.d or 0) > (b.d or 0)
end)
local title = W.Data.instanceTitle[zone or ""] or zone or "the raid"
local lines = { "[WhoDidIt] RIVAL WATCH: " .. title .. " - " .. n .. " of our time" .. (n == 1 and " was" or "s were")
.. " beaten. " .. TAUNT[math.random(getn(TAUNT))] }
for i = 1, math.min(3, n) do tinsert(lines, i .. ") " .. B:RivalShort(sorted[i])) end
if n > 3 then tinsert(lines, "...and " .. (n - 3) .. " more. All of them: /wdi rankings") end
return lines
end
-- channel: nil = the Post to channel, "SELF" = only you
function B:PostRivals(zone, list, channel)
local lines = B:RivalLines(zone, list)
if not lines then
W.Print("Nobody has beaten our " .. (W.Data.instanceTitle[zone or ""] or zone or "") .. " times in the last " .. RIVAL_DAYS .. " days.")
return
end
for _, e in ipairs(list or B:Rivals(zone)) do e.posted = true end
W:ConfirmSend(lines, channel, B:ChatColours()) -- other guilds' names: you see it before it's posted
end
-- entering a raid instance: show its rivals once, in your own chat only
-- (rival alerts on). Names in it come from other people, so they're never
-- posted by themselves: Post rivals (Rankings) does that on purpose.
local lastWatch = {}
function B:OnZone()
if not WhoDidItDB or not WhoDidItDB.opts.rivalAlerts then return end
local inInst, typ = IsInInstance()
if not inInst or typ ~= "raid" or GetNumRaidMembers() == 0 then return end
local zone = GetRealZoneText()
zone = B.RunZone(zone) or zone -- (Lower Karazhan Halls says "Tower of Karazhan")
if lastWatch[zone] and GetTime() - lastWatch[zone] < 1800 then return end
local fresh = {}
local all = B:Rivals(zone)
for i = 1, getn(all) do if not all[i].posted then tinsert(fresh, all[i]) end end
if getn(fresh) == 0 then return end
lastWatch[zone] = GetTime()
B:PostRivals(zone, fresh, "SELF")
W.Print("|cff888888(Only you see this. Post rivals on the Rankings tab posts it to " .. W.Shout:ChannelLabel() .. ".)|r")
end
W:On("ZONE_CHANGED_NEW_AREA", function() B:OnZone() end)
------------------------------------------------------------------ banter
-- A fun line in the shout channel after every boss kill and full clear:
-- our time against our own best, the other guilds on our realm, the best
-- of the other realms, and anyone who recently beat us - mocking us or
-- bigging us up. Placeholders: {what} boss / instance, {time} our time,
-- {d} the difference, {prev} our old best, {g} the other guild, {rank} /
-- {of} our place on {realm}; {og} / {orealm} the other realm's best guild
-- and realm; {their} / {ago} the rival's time and when they set it.
local BANTER = {
first = {
"First {what} on the books: {time}. Every speedrun starts somewhere.",
"{what} down for the very first time in {time}. Somebody frame it.",
"New record unlocked: {what} in {time}. Admittedly it's our only record.",
"{what} in {time}. Our best ever, our worst ever, our only ever.",
"{time} on {what}. Write it down, it's the number to beat now.",
"Our first {what} on record: {time}. The only way is down. The time, that is.",
"{what} logged at {time}. Day one of the speedrun era.",
"{time} for {what}. A personal best and a personal worst at the same time. Efficient.",
},
pb = {
"{what} in {time} - {d} faster than our old best. Who even are we?!",
"New guild best on {what}: {time}. {d} shaved off. Barbers are jealous.",
"{d} faster than ever on {what} ({time}). Somebody check if the boss was AFK.",
"{what} at {time}, our best ever by {d}. The grind is paying off.",
"PB! {what} in {time}, {d} quicker than last time. Screenshot it before we wipe again.",
"{what} melted in {time}. That's {d} off our record. Pump it.",
"{what} in {time}. {d} faster than ever. Whoever brought the flasks: thank you.",
"New best on {what}! {time}, beating our old {prev} by {d}. Raid leader is crying happy tears.",
"{time} on {what}. {d} off our best. Someone tell the healers they can blink now.",
"{what} never stood a chance: {time}, {d} faster than our {prev}.",
"Record broken: {what} in {time}. Our old {prev} has been retired with honours.",
"{d} faster on {what} ({time}). Same raid, new legends.",
},
pbtiny = {
"{what} in {time}. A new best... by {d}. We'll take it.",
"New record on {what}! By {d}. Don't blink or you'll miss the improvement.",
"{time} on {what}, {d} under our best. Photo finish.",
"{d} faster on {what}. Every tenth counts. Probably.",
"{what}: {time}. Beat our {prev} by a whisker ({d}). Whisker included.",
},
pbhuge = {
"{what} in {time}. That's {d} off our best. What happened? Who are you people?",
"{d} faster than our old {what} record. Did we skip a phase?",
"{what} in {time}, {d} under our {prev}. That's not an improvement, that's a rebuild.",
"Our {what} record just dropped by {d}. The boss has filed a complaint.",
"{time} on {what}. Old best {prev}. Someone's been reading guides.",
},
slower = {
"{what} in {time}. Our best is {prev}, so that was {d} of sightseeing.",
"{what} down in {time} - only {d} slower than our record. Only.",
"{time} on {what}. {d} off our best. Did someone stop for a snack?",
"{what} took {time}. Our record is {prev}. We'll pretend this one didn't happen.",
"{d} slower than our best on {what}. The boss must have eaten its vegetables today.",
"{what} in {time}. {d} slower than we've done it. Consumables are not decorations, people.",
"{what} in {time}. {d} off our best. Somebody left the oven on?",
"{time} on {what}. Our record {prev} is safe for another week.",
"{what} down, {d} slower than our best. At least it's down.",
"{d} slower on {what}. The repair bill says we tried our best.",
"{what} in {time}. Not a record, but nobody released early. Growth.",
"{time} for {what}. Our {prev} is still on the wall, untouched and smug.",
},
nearmiss = {
"{what} in {time}. {d} off our best. SO close.",
"{d} short of our {what} record. Somebody's /sit is to blame.",
"{time} on {what}, missed our {prev} by {d}. Pain in its purest form.",
"{what}: {d} slower than our best. One more global and it was ours.",
"Missed the {what} record by {d}. Whoever went to pee: we know.",
},
disaster = {
"{what} in {time}. That's {d} slower than our best. Were we fighting it or babysitting it?",
"{time} on {what}. Our best is {prev}. Let's never speak of this.",
"{what} took {d} longer than usual. Somebody check the boss for extra health.",
"{what} in {time}. {d} slower. Hope the scenery was nice.",
"{d} over our {what} record. The boss had time to make a cup of tea.",
},
speedy = {
"{what} in {time}. Blink and you missed it.",
"{time} on {what}. The boss barely finished its opening line.",