-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCore.lua
More file actions
1371 lines (1281 loc) · 55.8 KB
/
Copy pathCore.lua
File metadata and controls
1371 lines (1281 loc) · 55.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
-- LFGAlert - Core.lua
-- Sound alert + applicant tracking for your Premade Group (Group Finder) listing.
-- Retail / Midnight (12.x). Lightweight, no dependencies. Optional Raider.IO support.
--
-- What it does (like [Top] Party Alarm, plus more):
-- * Plays a sound + raid-warning + chat message when someone applies
-- * Keeps a log: queued / invited / accepted / declined / cancelled / timeout
-- * Stores class, spec, item level, M+ score (Blizzard dungeonScore + Raider.IO if installed)
-- * Right-click a log row to whisper / invite / decline
local ADDON_NAME = ...
LFGAlert = LFGAlert or {}
local NS = LFGAlert
local L = NS.L or {} -- from Locales\enUS.lua (loaded first per .toc)
local function l(key, fallback) return L[key] or fallback end
NS.BUILD = 19 -- bump every shipment; shown in load message + /lfgalert debug
-- ---------------------------------------------------------------------------
-- Defaults / DB
-- ---------------------------------------------------------------------------
local DEFAULTS = {
enabled = true,
soundEnabled = true,
-- SOUNDKIT id. RAID_WARNING = 8959. Change via /lfgalert sound <id>
soundID = 8959,
-- Custom sound file (optional). Example: "Interface\\AddOns\\LFGAlert\\Sounds\\alert.ogg"
-- or a file path via /lfgalert soundfile <path>. Takes precedence when enabled.
useCustomSound = false,
customSoundPath = "",
useMasterChannel = true,
raidWarning = true,
chatMessage = true,
flashTaskbar = true,
showMinimapButton = true,
minimapAngle = 220,
maxLogEntries = 300,
-- Highlight rules (0 = off). Rows at/above both thresholds get a star + green numbers.
minIlvl = 0,
minScore = 0,
-- Pop open Blizzard's Group Finder on the applicant list when someone queues.
autoOpenLFG = true,
-- Midnight hides listing text from addons: fall back to your own keystone
-- for dungeon/key when nothing readable is found (M+ own-key case).
assumeOwnKey = true,
-- Auto-decline applicants below minIlvl/minScore (default OFF).
autoDecline = false,
stats = { sessions = {}, total = { queued = 0, invited = 0, accepted = 0, declined = 0, auto = 0, gone = 0 } },
log = {}, -- persisted entries
}
local function CopyDefaults(src)
local t = {}
for k, v in pairs(src) do
if type(v) == "table" then
t[k] = CopyDefaults(v)
else
t[k] = v
end
end
return t
end
local function InitDB()
if type(LFGAlertDB) ~= "table" then
LFGAlertDB = CopyDefaults(DEFAULTS)
else
for k, v in pairs(DEFAULTS) do
if LFGAlertDB[k] == nil then
if type(v) == "table" then
LFGAlertDB[k] = CopyDefaults(v)
else
LFGAlertDB[k] = v
end
end
end
end
NS.db = LFGAlertDB
end
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function HasActiveListing()
if not C_LFGList or not C_LFGList.GetActiveEntryInfo then return false end
local ok, info = pcall(C_LFGList.GetActiveEntryInfo)
if not ok then return false end
return info ~= nil
end
local function IsGroupLeader()
-- Top Party Alarm behaviour: only alert as leader. If solo with a listing,
-- UnitIsGroupLeader returns false, so also allow "has listing but not in group".
if not IsInGroup() then return HasActiveListing() end
local ok, res = pcall(UnitIsGroupLeader, "player")
if ok and res then return true end
-- Assistants with listing? Be permissive: anyone with an active listing.
return HasActiveListing()
end
local function ClassColorize(classFileName, text)
if classFileName and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFileName] then
local c = RAID_CLASS_COLORS[classFileName]
if c.WrapTextInColorCode then
return c:WrapTextInColorCode(text)
elseif c.colorStr then
return "|c" .. c.colorStr .. text .. "|r"
end
end
return text
end
NS.ClassColorize = ClassColorize
local function GetSpecName(specID)
if not specID or specID == 0 then return nil end
local ok, _, name = pcall(GetSpecializationInfoByID, specID)
if ok and name and name ~= "" then return name end
return nil
end
-- Try Raider.IO addon if present. Different RIO versions expose different APIs,
-- so probe carefully and never error. Pass a memo table to avoid re-probing the
-- same name many times within one scan (group applications multiply this).
local function GetRaiderIOScore(fullName, memo)
if not fullName or not _G.RaiderIO then return nil end
if memo then
local cached = memo[fullName]
if cached ~= nil then return cached or nil end
end
local score
local RIO = _G.RaiderIO
-- Modern RIO: RaiderIO.GetScore(unitName)
if type(RIO.GetScore) == "function" then
local ok, s1, s2 = pcall(RIO.GetScore, fullName)
if ok then
if type(s1) == "number" and s1 > 0 then score = math.floor(s1) end
if not score and type(s2) == "number" and s2 > 0 then score = math.floor(s2) end
end
end
-- Older RIO: RaiderIO.GetProfile(name, realm)
if not score and type(RIO.GetProfile) == "function" then
local bare, realm = strsplit("-", fullName, 2)
local ok, profile = pcall(RIO.GetProfile, bare, realm or GetRealmName())
if ok and type(profile) == "table" then
local s = profile.mythicPlusScoresBySeason
and profile.mythicPlusScoresBySeason[1]
and profile.mythicPlusScoresBySeason[1].scores
and profile.mythicPlusScoresBySeason[1].scores.all
if type(s) == "number" and s > 0 then score = math.floor(s) end
if not score and type(profile.mplusCurrentScore) == "number" and profile.mplusCurrentScore > 0 then
score = math.floor(profile.mplusCurrentScore)
end
end
end
if memo then memo[fullName] = score or false end
return score
end
local function FormatScore(blizzScore, rioScore)
rioScore = rioScore and rioScore > 0 and rioScore or nil
blizzScore = blizzScore and blizzScore > 0 and blizzScore or nil
local base = rioScore or blizzScore
if not base then return "-" end
-- If both exist and differ, show RIO with Blizzard in parens on tooltip; row shows RIO.
return tostring(base)
end
-- ---------------------------------------------------------------------------
-- Listing context: which dungeon + key level is this applicant queueing for?
-- Captured live at log time (activities + "+N" parsed from your listing title).
-- ---------------------------------------------------------------------------
-- First-letter abbreviation: "Altar of Fangs" -> "AOF", "Necrotic Wake" -> "NW".
-- Single-word names use the first 3 letters ("Freehold" -> "FRE").
local function AbbrevDungeonName(name)
if not name or name == "" then return nil end
local base = name:gsub("%s*%([^%)]*%)%s*", ""):gsub("^%s*[Tt]he%s+", "")
local words = {}
for w in base:gmatch("[%a]+") do words[#words + 1] = w end
if #words == 0 then return nil end
if #words == 1 then
return words[1]:sub(1, 3):upper()
end
local ab = ""
for _, w in ipairs(words) do ab = ab .. w:sub(1, 1):upper() end
return ab ~= "" and ab or nil
end
local function ParseKeyLevel(text)
if not text or text == "" then return nil end
local patterns = { "%+(%d+)", "[Mm]%+%s*(%d+)", "[Mm]%s+(%d+)", "(%d+)%s*%+" }
for _, pat in ipairs(patterns) do
local n = tonumber(text:match(pat))
if n and n >= 2 and n <= 40 then return n end
end
return nil
end
-- Midnight wraps listing/applicant text in kstrings ("|Ku5|k") that addons
-- cannot read. Strip those tokens so we never display or parse garbage.
local function CleanKString(s)
if type(s) ~= "string" then return s end
local cleaned = s:gsub("|K.-|k", "")
if cleaned:match("^%s*$") then return "" end
return cleaned
end
-- Dungeon name for a challenge (keystone) mapID. Tries each known lookup;
-- returns name + which function worked (for /lfgalert listing diagnostics).
local function GetChallengeMapName(mapID)
if not mapID or not C_ChallengeMode then return nil end
for _, fn in ipairs({ "GetMapInfo", "GetMapUIInfo" }) do
local f = C_ChallengeMode[fn]
if type(f) == "function" then
local ok, name = pcall(f, mapID)
if ok and type(name) == "string" and name ~= "" then return name, fn end
end
end
return nil
end
-- Leader's own keystone: the fallback source when Blizzard hides the listing
-- text (the common M+ case is pushing your own key).
local function GetOwnedKeystone()
if not (C_MythicPlus and C_MythicPlus.GetOwnedKeystoneLevel and C_MythicPlus.GetOwnedKeystoneChallengeMapID) then
return nil
end
local okL, lvl = pcall(C_MythicPlus.GetOwnedKeystoneLevel)
local okM, mapID = pcall(C_MythicPlus.GetOwnedKeystoneChallengeMapID)
if not (okL and okM and lvl and mapID and lvl >= 2) then return nil end
local full = GetChallengeMapName(mapID)
return { key = lvl, dungeonFull = full, dungeon = full and AbbrevDungeonName(full) or nil, source = "keystone" }
end
-- { dungeon="AOF", dungeonFull="Altar of Fangs", key=5, title="Hunter: +5 AOF" }
function NS.CurrentListingInfo()
local info = { dungeon = nil, dungeonFull = nil, key = nil, title = nil, source = nil }
if not (C_LFGList and C_LFGList.GetActiveEntryInfo) then return info end
local ok, entry = pcall(C_LFGList.GetActiveEntryInfo)
if not ok or not entry then return info end
local cleanTitle = CleanKString(entry.name or "")
info.title = cleanTitle
local actID = (entry.activityIDs and entry.activityIDs[1]) or entry.activityID
if actID and C_LFGList.GetActivityInfo then
local okA, full, short = pcall(C_LFGList.GetActivityInfo, actID)
if okA and full then
if type(full) == "table" then
-- Struct-shaped return: pick known name fields defensively.
local fn = full.fullName or full.name
local sn = full.shortName or full.short
if type(fn) == "string" and fn ~= "" then
info.dungeonFull = fn
info.dungeon = (type(sn) == "string" and sn ~= "" and #sn <= 12 and sn)
or AbbrevDungeonName(fn) or fn
end
elseif type(full) == "string" and full ~= "" then
info.dungeonFull = full
if type(short) == "string" and short ~= "" and #short <= 12 then
info.dungeon = short
else
info.dungeon = AbbrevDungeonName(full) or full
end
end
end
end
info.key = ParseKeyLevel(cleanTitle .. " " .. CleanKString(entry.comment or ""))
if info.dungeon == nil and info.key == nil and NS.db ~= nil and NS.db.assumeOwnKey ~= false then
local ks = GetOwnedKeystone()
if ks then
info.dungeon, info.dungeonFull, info.key, info.source = ks.dungeon, ks.dungeonFull, ks.key, ks.source
end
end
return info
end
-- ---------------------------------------------------------------------------
-- Applicant snapshot
-- ---------------------------------------------------------------------------
-- known[applicantID] = { status = "applied", time = ..., members = {...}, comment = "..." }
local known = {}
NS._known = known
-- Listing session: applicantIDs reset on every delist/relist, so stamp entries
-- to never mix data across listings. The UI uses this to refuse acting on
-- stale applicantIDs from previous listings (they can be reused by Blizzard).
local listingSession = 1
local hadListing = false
function NS.CurrentListingSession()
return listingSession
end
local function SnapshotApplicant(applicantID, cachedListing, rioMemo)
local infoOk, appInfo = pcall(C_LFGList.GetApplicantInfo, applicantID)
if not infoOk or not appInfo then return nil end
local members = {}
local numMembers = appInfo.numMembers or 1
for i = 1, numMembers do
local mOk, name, class, locClass, level, itemLevel, honorLevel,
tank, healer, damage, assignedRole, relationship,
dungeonScore, pvpItemLevel, factionGroup, raceID, specID, isLeaver =
pcall(C_LFGList.GetApplicantMemberInfo, applicantID, i)
-- pcall returns ok + all returns; if ok is true, name is first real return.
-- NOTE: member data is often not ready on the first event (nil returns);
-- callers retry + backfill, so just skip silently here.
if mOk and name and type(name) == "string" then
-- Group applications sometimes return garbage specIDs; only accept sane ones.
local saneSpec = (type(specID) == "number" and specID > 0 and specID < 10000) and specID or 0
local specName = GetSpecName(saneSpec)
local rio = GetRaiderIOScore(name, rioMemo)
members[#members + 1] = {
name = name, -- "Name-Realm"
class = class, -- "WARRIOR"
localizedClass = locClass,
level = level,
itemLevel = itemLevel or 0,
dungeonScore = dungeonScore or 0,
rioScore = rio or 0,
specID = saneSpec,
specName = specName,
role = assignedRole,
tank = tank, healer = healer, damage = damage,
}
end
end
return {
status = appInfo.applicationStatus,
numMembers = numMembers,
comment = CleanKString(appInfo.comment) or "",
isNew = appInfo.isNew,
members = members,
-- Same listing applies to every applicant in one scan: pass it in to
-- avoid one GetActivityInfo/keystone lookup per applicant.
listing = cachedListing or NS.CurrentListingInfo(),
}
end
local function PrimaryMember(snap)
if snap and snap.members and snap.members[1] then
return snap.members[1]
end
return nil
end
local function ShortName(fullName)
if not fullName then return "?" end
local bare = strsplit("-", fullName, 2)
return bare or fullName
end
NS.ShortName = ShortName
local function MemberSummary(snap)
local m = PrimaryMember(snap)
if not m then return "? - ilvl - - M+ -" end
local classCol = ClassColorize(m.class, ShortName(m.name))
local roleTag = NS.RoleTag(NS.ResolveRole(m))
local specTxt = m.specName or m.localizedClass or m.class or ""
local ilvl = (m.itemLevel and m.itemLevel > 0) and tostring(math.floor(m.itemLevel)) or "-"
local score = FormatScore(m.dungeonScore, m.rioScore)
local extra = ""
if (snap.numMembers or 1) > 1 then
extra = " (+" .. ((snap.numMembers or 1) - 1) .. ")"
end
local runTag = ""
local li = snap and snap.listing
if li and (li.dungeon or li.key) then
runTag = " |cffffd100[" .. (li.key and ("+" .. li.key .. " ") or "") .. (li.dungeon or "?") .. "]|r"
end
return string.format("%s%s %s %s ilvl %s M+ %s%s", classCol, extra, roleTag, specTxt, ilvl, score, runTag)
end
-- ---------------------------------------------------------------------------
-- Status labels
-- ---------------------------------------------------------------------------
local STATUS_META = {
applied = { label = l("st_applied", "QUEUED"), color = "ffd100" }, -- gold
invited = { label = l("st_invited", "INVITED"), color = "5599ff" }, -- blue
inviteaccepted = { label = l("st_inviteaccepted", "ACCEPTED"), color = "33cc33" }, -- green
declined = { label = l("st_declined", "DECLINED"), color = "ff4444" },
declined_full = { label = l("st_declined_full", "DECLINED (FULL)"), color = "ff4444" },
declined_delisted = { label = l("st_declined_delisted", "DECLINED (DELISTED)"), color = "ff4444" },
cancelled = { label = l("st_cancelled", "CANCELLED"), color = "999999" },
timedout = { label = l("st_timedout", "TIMEOUT"), color = "ff8800" },
failed = { label = l("st_failed", "FAILED"), color = "ff4444" },
invitedeclined = { label = l("st_invitedeclined", "DECLINED INVITE"), color = "ff8844" },
}
function NS.StatusLabel(status)
local m = STATUS_META[status]
if m then return m.label, m.color end
return (status or "UNKNOWN"):upper(), "ffffff"
end
-- ---------------------------------------------------------------------------
-- Roles (Tank / Healer / DPS)
-- ---------------------------------------------------------------------------
function NS.ResolveRole(mem)
if mem then
if mem.role and mem.role ~= "" then return (mem.role .. ""):upper() end
if mem.tank then return "TANK" end
if mem.healer then return "HEALER" end
if mem.damage then return "DAMAGER" end
end
return nil
end
-- Returns coloredTag, plainLabel. Role icons resolve lazily via _G so this
-- is safe even when Blizzard's UI addons haven't loaded yet (falls back to text).
function NS.RoleTag(role)
local key = role and (role .. ""):upper() or ""
local label, color, iconKey = "—", "aaaaaa", nil
if key == "TANK" then
label, color, iconKey = l("role_tank", "Tank"), "5b9bff", "INLINE_TANK_ICON"
elseif key == "HEALER" then
label, color, iconKey = l("role_healer", "Heal"), "4dff4d", "INLINE_HEALER_ICON"
elseif key == "DAMAGER" then
label, color, iconKey = l("role_dps", "DPS"), "ff6b6b", "INLINE_DAMAGER_ICON"
end
local icon = (iconKey and _G[iconKey]) or ""
if icon ~= "" then icon = icon .. " " end
return icon .. "|cff" .. color .. label .. "|r", label
end
-- ---------------------------------------------------------------------------
-- Log (persisted) - UI lives in LogFrame.lua
-- ---------------------------------------------------------------------------
local function TrimLog()
local maxN = (NS.db and NS.db.maxLogEntries) or 300
while #NS.db.log > maxN do
table.remove(NS.db.log, 1)
end
end
local function SnapHasData(snap)
return snap and snap.members and snap.members[1]
and type(snap.members[1].name) == "string" and true or false
end
local function CopySnapMembers(dest, snap)
if not (snap and snap.members) then return end
for i, mem in ipairs(snap.members) do
dest[i] = {
name = mem.name,
class = mem.class,
localizedClass = mem.localizedClass,
specName = mem.specName,
level = mem.level,
itemLevel = mem.itemLevel,
dungeonScore = mem.dungeonScore,
rioScore = mem.rioScore,
role = mem.role,
}
if i >= 8 then break end -- cap stored group size
end
end
-- ---------------------------------------------------------------------------
-- Statistics: every status transition funnels through AddLogEntry, so count
-- it here. Sessions are keyed by listingSession; only the last 30 are kept.
-- ---------------------------------------------------------------------------
local autoPending = {} -- applicantID -> true while OUR auto-decline is in flight
local function BucketFor(status)
if status == "applied" then return "queued" end
if status == "invited" then return "invited" end
if status == "inviteaccepted" then return "accepted" end
if status == "cancelled" or status == "timedout" then return "gone" end
return "declined" -- declined, declined_full, declined_delisted, failed, invitedeclined
end
local function EnsureStats()
if type(LFGAlertDB) ~= "table" then return nil end
NS.db = NS.db or LFGAlertDB
if type(NS.db.stats) ~= "table" then
NS.db.stats = { sessions = {}, total = { queued = 0, invited = 0, accepted = 0, declined = 0, auto = 0, gone = 0 } }
end
if type(NS.db.stats.sessions) ~= "table" then NS.db.stats.sessions = {} end
if type(NS.db.stats.total) ~= "table" then
NS.db.stats.total = { queued = 0, invited = 0, accepted = 0, declined = 0, auto = 0, gone = 0 }
end
return NS.db.stats
end
function NS.RecordStat(applicantID, status, session)
if not applicantID or applicantID == 0 then return end -- skip test rows
local st = EnsureStats()
if not st then return end
local bucket = BucketFor(status)
if bucket == "declined" and autoPending[applicantID] then
autoPending[applicantID] = nil
bucket = "auto"
end
session = session or listingSession
local sess = st.sessions[session]
if not sess then
sess = { queued = 0, invited = 0, accepted = 0, declined = 0, auto = 0, gone = 0, started = time() }
st.sessions[session] = sess
-- Cap stored sessions at 30 (drop oldest).
local n = 0
for _ in pairs(st.sessions) do n = n + 1 end
while n > 30 do
local oldest, oldestKey = nil, nil
for k, v in pairs(st.sessions) do
if oldest == nil or (v.started or 0) < oldest then oldest, oldestKey = (v.started or 0), k end
end
if oldestKey == nil then break end
st.sessions[oldestKey] = nil
n = n - 1
end
end
sess[bucket] = (sess[bucket] or 0) + 1
st.total[bucket] = (st.total[bucket] or 0) + 1
end
local function StatLine(label, s)
s = s or {}
local q, a, d, au = s.queued or 0, s.accepted or 0, s.declined or 0, s.auto or 0
local rate = q > 0 and math.floor(a / q * 100 + 0.5) or 0
local autoTxt = au > 0 and l("stats_auto_paren", " (%d auto)"):format(au) or ""
return l("stats_line_fmt", "%s: %d queued • %d accepted (%d%%) • %d declined%s • %d invited • %d left")
:format(label, q, a, rate, d + au, autoTxt, s.invited or 0, s.gone or 0)
end
function NS.PrintStats()
local st = EnsureStats()
if not st then return end
print("|cffffcc00" .. l("stats_header", "LFGAlert stats:") .. "|r")
local cur = st.sessions[listingSession]
if cur and (cur.queued or 0) > 0 then
print(" " .. StatLine(l("this_listing", "This listing"), cur))
else
print(" " .. l("this_listing_none", "This listing: no queues yet"))
end
print(" " .. StatLine(l("all_time", "All time"), st.total))
-- Accepted quality averages from stored log rows.
local n, ilvlSum, scoreSum, scoreN = 0, 0, 0, 0
for _, e in ipairs((NS.db and NS.db.log) or {}) do
if not e.separator and e.status == "inviteaccepted" and e.members and e.members[1] and e.members[1].name then
local m = e.members[1]
n = n + 1
ilvlSum = ilvlSum + (m.itemLevel or 0)
local sc = NS.EffectiveScore(m)
if sc > 0 then scoreSum, scoreN = scoreSum + sc, scoreN + 1 end
end
end
if n > 0 then
local scTxt = scoreN > 0 and l("stats_avg_score", " M+ %d"):format(math.floor(scoreSum / scoreN + 0.5)) or ""
print(l("stats_avg_fmt", " Accepted avg (n=%d): ilvl %d%s"):format(n, math.floor(ilvlSum / n + 0.5), scTxt))
end
end
function NS.AddLogEntry(applicantID, oldStatus, newStatus, snap, isNewApplicant)
NS.db = NS.db or LFGAlertDB
if not NS.db then return end
local m = PrimaryMember(snap)
local li = (snap and snap.listing) or NS.CurrentListingInfo()
-- One row per applicant per listing session: a status transition UPDATES
-- the existing row (shown as lifecycle icons in the log) instead of
-- stacking separate "Queued" / "Invited" / "Accepted" rows.
local entry
for i = #NS.db.log, 1, -1 do
local e = NS.db.log[i]
if e and not e.separator and e.applicantID == applicantID
and (e.session == nil or e.session == listingSession) then
entry = e
break
end
end
local now = time()
if entry then
entry.oldStatus = oldStatus
entry.status = newStatus
-- A (re-)application surfaces the row as fresh again.
if newStatus == "applied" then entry.t = now end
if SnapHasData(snap) then
wipe(entry.members)
CopySnapMembers(entry.members, snap)
end
entry.numMembers = (snap and snap.numMembers) or entry.numMembers or 1
if snap and snap.comment and snap.comment ~= "" then entry.comment = snap.comment end
if entry.dungeon == nil and li then
entry.dungeon, entry.dungeonFull, entry.key, entry.keySource, entry.listingTitle =
li.dungeon, li.dungeonFull, li.key, li.source, li.title
end
entry.history = entry.history or {}
entry.history[#entry.history + 1] = { status = newStatus, t = now }
if #entry.history > 8 then table.remove(entry.history, 1) end
else
entry = {
t = now,
applicantID = applicantID,
oldStatus = oldStatus,
status = newStatus,
isNew = isNewApplicant and true or false,
comment = (snap and snap.comment) or "",
numMembers = (snap and snap.numMembers) or (m and 1) or 1,
members = {},
detailShown = SnapHasData(snap),
dungeon = li and li.dungeon or nil,
dungeonFull = li and li.dungeonFull or nil,
key = li and li.key or nil,
keySource = li and li.source or nil,
listingTitle = li and li.title or nil,
session = listingSession,
history = { { status = newStatus, t = now } },
}
CopySnapMembers(entry.members, snap)
NS.db.log[#NS.db.log + 1] = entry
end
if BucketFor(newStatus) == "declined" and NS._autoReason then
local ar = NS._autoReason[applicantID]
if ar and (ar.session == nil or ar.session == listingSession) then
entry.declineReason = ar.text or "Auto"
entry.autoDeclined = true
end
NS._autoReason[applicantID] = nil
end
TrimLog()
NS.RecordStat(applicantID, newStatus, entry.session)
if NS.RefreshLogUI then
NS.RefreshLogUI()
end
return entry
end
function NS.ClearLog()
if NS.db then NS.db.log = {} end
if NS.RefreshLogUI then NS.RefreshLogUI() end
end
-- ---------------------------------------------------------------------------
-- Alerts
-- ---------------------------------------------------------------------------
function NS.PlayAlertSound()
if not NS.db or not NS.db.soundEnabled then return end
local channel = NS.db.useMasterChannel and "Master" or nil
-- Custom sound file takes precedence when enabled and set.
if NS.db.useCustomSound and NS.db.customSoundPath and NS.db.customSoundPath ~= "" then
local ok, played = pcall(PlaySoundFile, NS.db.customSoundPath, channel)
-- pcall only catches argument errors; PlaySoundFile's boolean return is
-- the real "did it play" signal (false = missing/invalid file). nil means
-- the client returned nothing: assume success so we never double-play.
if ok and played ~= false then return end
-- Fall through to SoundKit ID if the file path failed.
end
local id = tonumber(NS.db.soundID) or 8959
local ok = pcall(PlaySound, id, channel)
if not ok then
pcall(PlaySound, 8959, channel)
end
end
-- ---------------------------------------------------------------------------
-- Highlight thresholds
-- ---------------------------------------------------------------------------
-- Effective M+ score: prefer Raider.IO, fall back to Blizzard dungeonScore.
function NS.EffectiveScore(mem)
if not mem then return 0 end
if mem.rioScore and mem.rioScore > 0 then return mem.rioScore end
return mem.dungeonScore or 0
end
-- Returns meetsIlvl, meetsScore, meetsAll. A 0 threshold means "rule off".
function NS.MeetsThresholds(mem)
if not mem then return true, true, true end
local minIlvl = (NS.db and NS.db.minIlvl) or 0
local minScore = (NS.db and NS.db.minScore) or 0
local meetsIlvl = minIlvl <= 0 or (mem.itemLevel or 0) >= minIlvl
local meetsScore = minScore <= 0 or NS.EffectiveScore(mem) >= minScore
return meetsIlvl, meetsScore, (meetsIlvl and meetsScore)
end
local function CenterMessage(text)
if not NS.db or not NS.db.raidWarning then return end
if RaidWarningFrame and RaidNotice_AddMessage then
local ok = pcall(RaidNotice_AddMessage, RaidWarningFrame, text, ChatTypeInfo and ChatTypeInfo["RAID_WARNING"])
if ok then return end
end
if UIErrorsFrame then
pcall(UIErrorsFrame.AddMessage, UIErrorsFrame, text, 1, 0.2, 0.2, 1.0, 5)
end
end
local function ChatMessage(text)
if not NS.db or not NS.db.chatMessage then return end
print("|cffff2020[LFGAlert]|r " .. text)
end
function NS.AlertNewApplicant(applicantID, snap)
NS.PlayAlertSound()
if NS.db and NS.db.flashTaskbar and FlashClientIcon then
pcall(FlashClientIcon)
end
if NS.db and NS.db.autoOpenLFG then
NS.OpenApplicants()
end
if not SnapHasData(snap) then
-- Details not ready yet: keep the sound + a generic banner now;
-- BackfillLogEntry prints the full line + fills the log row on retry.
CenterMessage(l("alert_banner", "New applicant!"))
return
end
local summary = MemberSummary(snap)
local pm = PrimaryMember(snap)
local name = pm and pm.name or ("#" .. tostring(applicantID))
local _, rolePlain = NS.RoleTag(NS.ResolveRole(pm))
CenterMessage(l("alert_center_fmt", "New applicant: %s (%s)"):format(ShortName(name), rolePlain))
ChatMessage(l("alert_chat_fmt", "New applicant: %s"):format(summary))
if snap and snap.comment and snap.comment ~= "" then
ChatMessage(l("note_fmt", 'Note: "%s"'):format(snap.comment))
end
end
function NS.AnnounceStatusChange(applicantID, oldStatus, newStatus, snap)
local label, color = NS.StatusLabel(newStatus)
local summary = MemberSummary(snap)
ChatMessage(string.format("%s: |cff%s%s|r - %s", ShortName(PrimaryMember(snap) and PrimaryMember(snap).name or ("#" .. applicantID)), color, label, summary))
end
-- Member data is often unavailable on the first event (Blizzard sends the
-- list before the details). This fills previously-logged "?" rows once the
-- data arrives, and prints the full detail line if it was skipped earlier.
local function BackfillLogEntry(applicantID, snap)
if not SnapHasData(snap) then return false end
if not (NS.db and NS.db.log) then return false end
local changed = false
for _, e in ipairs(NS.db.log) do
if not e.separator and e.applicantID == applicantID and (e.session == nil or e.session == listingSession) then
local em = e.members and e.members[1]
if not (em and em.name) then
e.members = e.members or {}
CopySnapMembers(e.members, snap)
e.numMembers = snap.numMembers or e.numMembers
if snap.comment and snap.comment ~= "" then e.comment = snap.comment end
if e.dungeon == nil and snap.listing and snap.listing.dungeon then
e.dungeon, e.dungeonFull, e.key, e.keySource, e.listingTitle =
snap.listing.dungeon, snap.listing.dungeonFull, snap.listing.key, snap.listing.source, snap.listing.title
end
if not e.detailShown then
e.detailShown = true
ChatMessage(l("alert_chat_fmt", "New applicant: %s"):format(MemberSummary({ members = e.members, numMembers = e.numMembers, comment = e.comment, listing = snap.listing })))
if e.comment and e.comment ~= "" then
ChatMessage(l("note_fmt", 'Note: "%s"'):format(e.comment))
end
end
-- MaybeAutoDecline re-verifies the applicant is still pending, so it
-- is safe to attempt on every backfill.
NS.MaybeAutoDecline(applicantID, snap)
changed = true
end
end
end
if changed and NS.RefreshLogUI then NS.RefreshLogUI() end
return changed
end
NS.BackfillLogEntry = BackfillLogEntry
-- ---------------------------------------------------------------------------
-- Auto-decline: when enabled, applicants below minIlvl/minScore are declined
-- automatically once their data is known. NEVER fires without real data.
-- Judges the primary member (the one shown in the log row).
-- ---------------------------------------------------------------------------
function NS.MaybeAutoDecline(applicantID, snap)
if not NS.db or not NS.db.autoDecline then return false end
if not applicantID or applicantID == 0 then return false end
if not ((NS.db.minIlvl or 0) > 0 or (NS.db.minScore or 0) > 0) then return false end
local m = snap and snap.members and snap.members[1]
if not (m and m.name) then return false end -- never judge without data
-- Re-verify still pending (leader may have invited meanwhile).
local ok, info = pcall(C_LFGList.GetApplicantInfo, applicantID)
if not (ok and info and info.applicationStatus == "applied") then return false end
local reasons = {}
local ilvlFail, scoreFail = false, false
if (NS.db.minIlvl or 0) > 0 and (m.itemLevel or 0) > 0 and m.itemLevel < NS.db.minIlvl then
ilvlFail = true
reasons[#reasons + 1] = "ilvl " .. math.floor(m.itemLevel) .. " < " .. NS.db.minIlvl
end
if (NS.db.minScore or 0) > 0 then
local sc = NS.EffectiveScore(m)
if sc > 0 and sc < NS.db.minScore then
scoreFail = true
reasons[#reasons + 1] = "M+ " .. math.floor(sc) .. " < " .. NS.db.minScore
end
end
if #reasons == 0 then return false end
autoPending[applicantID] = true
NS._autoReason = NS._autoReason or {}
NS._autoReason[applicantID] = {
text = (ilvlFail and scoreFail) and l("ar_both", "Low ILvl/M+")
or (ilvlFail and l("ar_ilvl", "Low ILvl") or l("ar_score", "Low M+")),
session = listingSession,
}
pcall(C_LFGList.DeclineApplicant, applicantID)
ChatMessage(l("auto_declined_fmt", "Auto-declined %s (%s)"):format(ShortName(m.name), table.concat(reasons, ", ")))
return true
end
-- ---------------------------------------------------------------------------
-- Scanning
-- ---------------------------------------------------------------------------
-- Already-terminal statuses: seeing these, a later disappearance needs no row.
local TERMINAL_STATUSES = {
declined = true, declined_full = true, declined_delisted = true,
cancelled = true, timedout = true, failed = true,
inviteaccepted = true, invitedeclined = true,
}
local function IsApplicantPresent(applicantID)
if not applicantID or not (C_LFGList and C_LFGList.GetApplicants) then return false end
local ok, ids = pcall(C_LFGList.GetApplicants)
if not ok or type(ids) ~= "table" then return false end
for _, id in ipairs(ids) do if id == applicantID then return true end end
return false
end
-- The applicant is gone from Blizzard's list (or their info call fails):
-- they cancelled, joined, or expired. Synthesize the terminal transition so
-- the log always shows an ending — but ONLY from non-terminal states, so a
-- decline/cancel that was already logged is never duplicated.
local function HandleApplicantGone(applicantID)
local prev = known[applicantID]
if not prev or not prev.status or TERMINAL_STATUSES[prev.status] then return end
local old = prev.status
-- Vanished mid-invite almost always means they accepted and joined.
local newStatus = (old == "invited") and "inviteaccepted" or "cancelled"
known[applicantID] = { status = newStatus, snap = prev.snap, gone = true }
NS.AddLogEntry(applicantID, old, newStatus, prev.snap, false)
NS.AnnounceStatusChange(applicantID, old, newStatus, prev.snap)
end
-- Single funnel for "we just fetched a fresh snapshot of an applicant":
-- both the full-list scan and the per-applicant event path call this, so
-- new-applicant alerts, status-change logging and data backfill behave
-- identically everywhere.
local function HandleApplicantSnapshot(applicantID, snap, reason)
if not snap then return end
local prev = known[applicantID]
if not prev then
known[applicantID] = { status = snap.status, snap = snap }
NS.AddLogEntry(applicantID, nil, snap.status or "applied", snap, true)
if snap.status == "applied" then
NS.AlertNewApplicant(applicantID, snap)
NS.MaybeAutoDecline(applicantID, snap)
else
NS.AnnounceStatusChange(applicantID, nil, snap.status, snap)
end
elseif prev.status ~= snap.status then
local old = prev.status
known[applicantID] = { status = snap.status, snap = snap }
NS.AddLogEntry(applicantID, old, snap.status, snap, false)
NS.AnnounceStatusChange(applicantID, old, snap.status, snap)
-- Re-alert if they re-applied after cancel/decline
if snap.status == "applied" and reason == "list" then
NS.PlayAlertSound()
end
else
-- Same status: refresh snapshot (ilvl/score may have resolved late)
-- and fill any "?" log rows now that data is available.
prev.snap = snap
BackfillLogEntry(applicantID, snap)
end
end
local function ScanApplicants(reason, retryN)
if not NS.db or not NS.db.enabled then return end
if not HasActiveListing() then return end
-- Only the leader's client should scream. (Listing exists => we listed it.)
if not IsGroupLeader() then return end
if not C_LFGList.GetApplicants then return end
local ok, ids = pcall(C_LFGList.GetApplicants)
if not ok or type(ids) ~= "table" then return end
-- The listing is the same for every applicant in this scan: resolve it once
-- (activity-info + keystone lookups) instead of once per applicant.
local listing = NS.CurrentListingInfo()
local rioMemo = {}
local missingData = false
for _, applicantID in ipairs(ids) do
local snap = SnapshotApplicant(applicantID, listing, rioMemo)
if snap then
if not SnapHasData(snap) then missingData = true end
HandleApplicantSnapshot(applicantID, snap, reason)
end
end
-- Reconcile: known IDs missing from the live list left the queue
-- (cancelled, joined, expired). Log the ending exactly once — only from
-- non-terminal states, so declines/cancels never double-log.
for id in pairs(known) do
if not seen[id] then HandleApplicantGone(id) end
end
-- Blizzard fires the list event before member details are queryable, so
-- retry a few times until every visible applicant has data.
retryN = retryN or 0
if missingData and retryN < 4 and HasActiveListing() then
C_Timer.After(2, function() ScanApplicants(reason, retryN + 1) end)
end
end
function NS.Rescan(reason)
-- Defer a moment: Blizzard often fires LIST_UPDATED before member data is ready.
C_Timer.After(0.5, function() ScanApplicants(reason or "list", 0) end)
end
function NS.WipeKnown(reasonLabel)
if reasonLabel then
local st = EnsureStats()
local s = st and st.sessions[listingSession]
if s and (s.queued or 0) > 0 then
ChatMessage(StatLine(l("listing_over", "Listing over"), s))
end
end
wipe(known)
wipe(autoPending)
if NS._autoReason then wipe(NS._autoReason) end
if reasonLabel and NS.db and NS.db.log then
-- Visual separator in the log so sessions don't blur together.
NS.db.log[#NS.db.log + 1] = { t = time(), separator = l("sep_ended", "— listing ended —") }
TrimLog()
if NS.RefreshLogUI then NS.RefreshLogUI() end
end
end
-- ---------------------------------------------------------------------------
-- Open Blizzard's Group Finder on your applicant list
-- Uses live FrameXML entry points (Blizzard_GroupFinder/Mainline/LFGList.lua):
-- PVEFrame_ShowFrame("GroupFinderFrame") + LFGListFrame_SetActivePanel(..., ApplicationViewer)
-- ---------------------------------------------------------------------------
local pendingOpenLFG = false
function NS.OpenApplicants()
-- Never fight the UI during combat: queue the open for when combat drops
-- (PLAYER_REGEN_ENABLED below picks it up).
if InCombatLockdown and InCombatLockdown() then
pendingOpenLFG = true
return
end
-- Blizzard_GroupFinder is load-on-demand in Midnight; make sure it's up.
if C_AddOns and C_AddOns.LoadAddOn then
pcall(C_AddOns.LoadAddOn, "Blizzard_GroupFinder")
elseif LoadAddOn then
pcall(LoadAddOn, "Blizzard_GroupFinder")
end
if GroupFinderFrame and PVEFrame_ShowFrame then
local okV, vis = pcall(GroupFinderFrame.IsVisible, GroupFinderFrame)
if not okV or not vis then
pcall(PVEFrame_ShowFrame, "GroupFinderFrame")
end
elseif PVEFrame and PVEFrame.Show then
local okV, vis = pcall(PVEFrame.IsShown, PVEFrame)
if not okV or not vis then
pcall(PVEFrame.Show, PVEFrame)
end
end
if LFGListFrame and LFGListFrame.ApplicationViewer and LFGListFrame_SetActivePanel then
pcall(LFGListFrame_SetActivePanel, LFGListFrame, LFGListFrame.ApplicationViewer)
end
end
-- ---------------------------------------------------------------------------
-- Right-click actions (called from LogFrame rows)
-- ---------------------------------------------------------------------------
function NS.Whisper(name)
if not name or name == "" then return end
-- Opens a /w edit box prefilled. Works cross-realm with Name-Realm.
if ChatFrame_OpenChat then
local frame = SELECTED_CHAT_FRAME or DEFAULT_CHAT_FRAME
pcall(ChatFrame_OpenChat, ("/w %s "):format(name), frame)
else
print("|cffff2020[LFGAlert]|r /w " .. name)
end
end
function NS.InviteApplicantByID(applicantID)
if applicantID and C_LFGList and C_LFGList.InviteApplicant then
pcall(C_LFGList.InviteApplicant, applicantID)
end
end
function NS.DeclineApplicantByID(applicantID)
if applicantID and C_LFGList and C_LFGList.DeclineApplicant then
pcall(C_LFGList.DeclineApplicant, applicantID)