-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathQuestieGuide.lua
More file actions
executable file
·3557 lines (3283 loc) · 158 KB
/
Copy pathQuestieGuide.lua
File metadata and controls
executable file
·3557 lines (3283 loc) · 158 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
-- QuestieGuide: zone-bucketed quest browser sourced from Questie.
local ADDON_NAME = ...
local DEFAULTS = {
sortMode = "xp",
sortDir = "desc",
filters = {
inLog = true,
available = true,
pickedUpElsewhere = true,
missingPre = true,
dungeons = true,
eliteGroup = true,
repeatable = true,
},
framePos = nil,
frameSize = { w = 680, h = 620 },
zoneCollapsed = {},
groupCollapsed = {},
minimap = { hide = false, minimapPos = 215 },
useQuestieLevelRange = false,
levelBelow = 5,
levelAbove = 5,
showCompleted = true,
}
local LEVEL_RANGE_MIN = 0
local LEVEL_RANGE_MAX = 10
-- Color tokens, one per role, all escape strings except HEADER. RGB values mirror Classic Era's QuestDifficultyColors (Blizzard_FrameXMLBase/Classic/Constants.lua) and C_UIColor.GetColors() font color globals; QUEST_TAG_COLORS below is data, not tokens. No raw |cff literals belong outside this table.
local COLOR = {
GREY = "|cff7f7f7f",
YELLOW = "|cffffff00",
GREEN = "|cff00ff00",
GOLD = "|cffffd200",
ORANGE = "|cffff7f00",
BLUE = "|cffaaaaff",
LINK = "|cff71d5ff", -- NATIVE: Blizzard chat hyperlink blue.
REPEAT = "|cffff80ff", -- repeatable-quest tooltip marker.
-- Header grey as a ColorMixin because header rows need SetTextColor numbers, not an escape string.
HEADER = CreateColor(0.7, 0.7, 0.7),
}
local INTRO_PREFIX = COLOR.YELLOW .. "[Questie Guide]:|r "
local SORT_BY_OPTIONS = {
{ value = "xp", label = "Total XP" },
{ value = "count", label = "Total Quest Count" },
{ value = "avgLevel", label = "Average Quest Level" },
{ value = "name", label = "Alphabetical by Zone" },
}
local SORT_DIR_OPTIONS = {
{ value = "asc", label = "Ascending" },
{ value = "desc", label = "Descending" },
}
-- Two sections per zone, split by where the quest is picked up. Every row carries a bracket status label instead of sections per status: in-log and blocked rows live inside these buckets, toggled by the inLog and missingPre filters.
local SUBCAT_ORDER = { "available", "pickedUpElsewhere" }
local SUBCAT_LABEL = {
available = "Picked Up in Zone",
pickedUpElsewhere = "Picked Up Outside of Zone",
}
-- Completed-quests section: sentinel collapse key that can't collide with a real zone name ("||" never appears in area names).
local COMPLETED_KEY = "||completed"
local COMPLETED_LABEL = "Completed Quests"
-- Window metrics copied from Blizzard's own ButtonFrameTemplate panels instead of a private grid. AddonList and ChannelFrame (same XML on both clients) start their insets 4px in from the left and 6px from the right, end the attic 60px down (PANEL_INSET_ATTIC_OFFSET) and keep a 26px button bar (PANEL_INSET_BOTTOM_BUTTON_OFFSET) with 22px buttons 4px off its corners. AddonList's search box sits 31px down, 10px in from the right.
local LAYOUT = {
FRAME_W = 680,
FRAME_H = 620,
MIN_W = 640,
-- Tall enough for the settings column with its sliders shown.
MIN_H = 480,
MAX_W = 1200,
MAX_H = 960,
INSET_LEFT = 4,
INSET_RIGHT = 6,
INSET_TOP = 60,
INSET_BOTTOM = 26,
COLUMN_GAP = 2,
BAR_PAD = 4,
BUTTON_W = 120,
BUTTON_H = 22,
-- PanelResizeButtonTemplate's own size.
GRIP_SIZE = 16,
SEARCH_W = 200,
SEARCH_H = 22,
SEARCH_TOP = 31,
SEARCH_RIGHT = 10,
-- Settings column; the quest list takes the rest of the width.
PANE_W = 260,
PANE_PAD = 12,
HEADING_H = 18,
GROUP_GAP = 12,
CONTROL_GAP = 6,
CHECK_SIZE = 26,
DROPDOWN_H = 25,
-- WowStyle1DropdownTemplate's art bleeds about 9px past its frame on both clients.
DROPDOWN_INDENT = 6,
SLIDER_H = 40,
LIST_PAD = 4,
}
-- Quest list metrics mirror Classic Era's QuestLogFrame instead of a private grid: 16px title rows (QUESTLOG_QUEST_HEIGHT), the +/- toggle 3px in and header text 20px in, grey header text that whitens on hover. Rows grow to fit their wrapped two-line text, and gaps loosen per nesting level so zones, buckets and quests read as separate tiers.
local LIST = {
ROW_HEIGHT = 16,
SUBHEADER_HEIGHT = 16,
HEADER_HEIGHT = 20,
ROW_GAP = 2,
GROUP_GAP = 6,
ZONE_GAP = 12,
INDENT_STEP = 16,
TEXT_PAD = 4,
TEXT_INSET = 20,
TOGGLE_INSET = 3,
TOGGLE_SIZE = 16,
-- Centers one 12px GameFontHighlight line in a 16px row.
ROW_PAD = 2,
LINE_GAP = 2,
}
-- Portrait and launcher art; the toc's IconTexture feeds the Forever addon compartment.
local ADDON_ICON = "Interface\\Icons\\INV_Misc_Map02"
local TOGGLE_PLUS = "Interface\\Buttons\\UI-PlusButton-Up"
local TOGGLE_MINUS = "Interface\\Buttons\\UI-MinusButton-Up"
local TOGGLE_HILIGHT = "Interface\\Buttons\\UI-PlusButton-Hilight"
local MAX_CHAIN_DEPTH = 12
-- Classic Era (1.15.x) caps at 60; reject post-vanilla quests Questie may ship.
local CLASSIC_MAX_LEVEL = 60
local function passesClassicCaps(level, requiredLevel)
return level <= CLASSIC_MAX_LEVEL and (requiredLevel or 0) <= CLASSIC_MAX_LEVEL
end
-- Every Classic Era 1.15 versus WoW Forever 1.60 difference, kept in one place. Each test probes the API it needs, never the client or interface number, because Forever runs the Mainline UI and shares some names with Era but not others. These only hook the game UI or read the player's own state (completion flags, green range, the same flags Questie mirrors); quest data itself always comes from Questie.
local Client = {}
-- C_QuestLog.IsQuestFlaggedCompleted is in both clients' API docs.
function Client.IsQuestCompleted(questId)
local isFlagged = C_QuestLog and C_QuestLog.IsQuestFlaggedCompleted
return isFlagged ~= nil and isFlagged(questId) == true
end
-- Levels below the player a quest stays green. Era's own difficulty colors read GetQuestGreenRange(), Forever's read UnitQuestTrivialLevelRange("player"); 5 is the vanilla value if neither exists.
function Client.GetGreenRange()
if GetQuestGreenRange then
return GetQuestGreenRange() or 5
end
if UnitQuestTrivialLevelRange then
return UnitQuestTrivialLevelRange("player") or 5
end
return 5
end
-- Forever's user waypoint plus in-world beacon. UiMapPoint, C_Map.SetUserWaypoint and C_SuperTrack are Mainline-only, so Era returns false and callers fall back to the Questie pin pulse.
function Client.SetNativeWaypoint(uiMapId, x, y)
if not (UiMapPoint and C_Map.SetUserWaypoint and C_Map.CanSetUserWaypointOnMap) then
return false
end
if not C_Map.CanSetUserWaypointOnMap(uiMapId) then
return false
end
C_Map.SetUserWaypoint(UiMapPoint.CreateFromCoordinates(uiMapId, x, y))
if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
C_SuperTrack.SetSuperTrackedUserWaypoint(true)
end
return true
end
-- The open chat edit box, or nil. ChatFrameUtil is the chat API on both clients; the ChatEdit_ globals only exist through Blizzard's deprecation shim.
function Client.GetChatEditBox()
local chat = ChatFrameUtil
return chat and chat.GetActiveWindow and chat.GetActiveWindow() or nil
end
-- Puts a link into the open chat box, or opens chat with it; false when neither API exists.
function Client.InsertChatLink(link)
local chat = ChatFrameUtil
if not chat then
return false
end
local editBox = Client.GetChatEditBox()
if editBox and editBox:IsVisible() and chat.InsertLink then
chat.InsertLink(link)
return true
end
if chat.OpenChat then
chat.OpenChat(link)
return true
end
return false
end
-- Forever's retail-style quest log lives in the world map (QuestMapFrame); Era only loads that file for wrath and later, so Era keeps the classic QuestLogFrame.
function Client.HasQuestMapLog()
return QuestMapFrame_OpenToQuestDetails ~= nil
and C_QuestLog ~= nil and C_QuestLog.GetLogIndexForQuestID ~= nil
end
-- Calls onItem(tooltip, itemId) for every item shown in the given tooltips. Era fires OnTooltipSetItem and has GameTooltip:GetItem(); Forever's tooltips have neither and report items through TooltipDataProcessor instead. Only one path is hooked, so a line is never added twice.
function Client.HookItemTooltips(tooltips, onItem)
if GameTooltip:HasScript("OnTooltipSetItem") then
for tooltip in pairs(tooltips) do
tooltip:HookScript("OnTooltipSetItem", function(self)
local _, link = self:GetItem()
onItem(self, link and tonumber(string.match(link, "item:(%d+)")))
end)
end
return
end
if TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall then
TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Item, function(tooltip, data)
if tooltips[tooltip] then
onItem(tooltip, data and data.id)
end
end)
end
end
-- Questie modules. `## Dependencies: Questie` loads Questie's files before ours, but its database is built later in a login coroutine, so these stay nil until loadQuestie finds it finished.
local QuestieDB
local QuestieLib
local ZoneDB
local QuestiePlayer
local QuestXP
local QuestieMap
local QuestieCorrections
local QuestieTooltips
local mainFrame
local scrollChild
local rowPool = {}
local lastZoneOrder = {}
-- Turn-in zones rendered by the completed section on the last pass; drives Collapse All parity.
local lastCompletedZones = {}
-- questId -> { row, top } for pickable and in-log rows, rebuilt every render; powers the jump-to-prerequisite scroll.
local rowTargets = {}
-- zoneName -> header top offset, rebuilt every render; powers the banner's jump-to-zone scroll.
local zoneHeaderTops = {}
local renderList
local expandAndScrollToZone
local searchText = ""
-- Quest carrying the native-quest-log selection look; moved by row left-clicks, list jumps, and Questie map icon clicks.
local selectedQuestId
local getQuestTagLabel
local getQuestXp
local formatNumber
local QUEST_TAG_LABELS = {
[1] = "Elite",
[41] = "PvP",
[62] = "Raid",
[81] = "Dungeon",
}
local QUEST_TAG_COLORS = {
Elite = "ff8000",
Dungeon = "a335ee",
Raid = "ff4040",
PvP = "ffd200",
}
local function getZoneCollapsed()
return (QuestieGuideDB and QuestieGuideDB.zoneCollapsed) or {}
end
local function getGroupCollapsed()
return (QuestieGuideDB and QuestieGuideDB.groupCollapsed) or {}
end
-- Questie has no public quest API, so these are the internals QuestieGuide reads, each checked against Questie 11.37.1 and Questie master (12.x, backed by the QuestieDB addon). Presence is tested per field because QuestieLoader:ImportModule hands back an empty table for an unknown module. An entry without a feature is required and keeps the panel closed with a message; the others each switch off only the feature they name.
local QUESTIE_INTERNALS = {
{ module = "QuestieDB", field = "QuestPointers" },
{ module = "QuestieDB", field = "QueryQuestSingle" },
{ module = "QuestieDB", field = "IsDoable" },
{ module = "QuestieDB", field = "IsPreQuestSingleFulfilled" },
{ module = "QuestieDB", field = "IsPreQuestGroupFulfilled" },
{ module = "QuestiePlayer", field = "currentQuestlog" },
{ module = "QuestieDB", field = "GetNPC", feature = "quest giver names and locations" },
{ module = "QuestieDB", field = "QueryNPCSingle", feature = "hiding quests with unreachable givers" },
{ module = "QuestieDB", field = "QueryObjectSingle", feature = "turn-in objects and unreachable-giver checks" },
{ module = "QuestieDB", field = "QueryItemSingle", feature = "quest-starting items in item tooltips" },
{ module = "QuestieDB", field = "QueryQuest", feature = "quest lines in item tooltips" },
{ module = "QuestieDB", field = "GetQuest", feature = "quest tooltips" },
{ module = "QuestieDB", field = "IsRepeatable", feature = "the Repeatable filter" },
{ module = "QuestieDB", field = "GetQuestTagInfo", feature = "Dungeon and Elite tags" },
{ module = "QuestieDB", field = "IsComplete", feature = "the Completed Quests section" },
{ module = "QuestieDB", field = "autoBlacklist", feature = "hiding quests Questie blacklists at runtime" },
{ module = "QuestieCorrections", field = "hiddenQuests", feature = "hiding quests Questie blacklists" },
{ module = "QuestieLib", field = "GetEffectiveQuestLevel", feature = "Questie's level scaling (raw database levels are used)" },
{ module = "QuestieLib", field = "GetColoredQuestName", feature = "Questie-colored quest names in tooltips" },
{ module = "QuestieLib", field = "GetDifficultyColorPercent", feature = "difficulty colors" },
{ module = "ZoneDB", field = "GetUiMapIdByAreaId", feature = "opening the map at a quest giver" },
{ module = "ZoneDB", field = "GetLocalizedDungeonName", feature = "dungeon zone names" },
{ module = "QuestiePlayer", field = "HasRequiredRace", feature = "race-gated quest filtering" },
{ module = "QuestiePlayer", field = "HasRequiredClass", feature = "class-gated quest filtering" },
{ module = "QuestiePlayer", field = "GetCurrentZoneId", feature = "the current-zone marker and button" },
{ module = "QuestXP", field = "GetQuestLogRewardXP", feature = "XP figures" },
{ module = "QuestieMap", field = "GetFramesForQuest", feature = "the map pin pulse" },
{ module = "QuestieTooltips", field = "lookupByKey", feature = "skipping lines Questie already shows on item tooltips" },
{ module = "QuestieFrame", field = "CreateIconFrame", feature = "opening the guide from Questie map icons" },
{ module = "Questie", field = "db", feature = "Questie's tooltip and hidden-quest settings" },
{ module = "Questie", field = "Colorize", feature = "status labels in item tooltips" },
}
-- First required internal the running Questie lacks; once set, the panel stays closed for the session.
local missingQuestieField
-- Reports every missing internal once: the first required one is returned, each optional one prints which feature is off, so an incompatible Questie build explains itself instead of erroring mid-scan.
local function checkQuestieInternals(loader)
local missingRequired
for _, internal in ipairs(QUESTIE_INTERNALS) do
local owner = internal.module == "Questie" and _G.Questie or loader:ImportModule(internal.module)
if type(owner) ~= "table" or owner[internal.field] == nil then
local name = internal.module .. "." .. internal.field
if internal.feature then
print(INTRO_PREFIX .. "Questie has no " .. name .. ", so this is off: " .. internal.feature .. ".")
else
missingRequired = missingRequired or name
end
end
end
return missingRequired
end
local function loadQuestie()
if QuestieDB then
return QuestieDB.QuestPointers ~= nil
end
-- Questie.started is set in QuestieInit's Stage3, after Stage1 built the quest database and before map pins are drawn; importing earlier would read a half-built database.
if missingQuestieField or not (_G.Questie and _G.Questie.started) then
return false
end
local loader = _G.QuestieLoader
if not (loader and loader.ImportModule) then
return false
end
missingQuestieField = checkQuestieInternals(loader)
if missingQuestieField then
return false
end
QuestieDB = loader:ImportModule("QuestieDB")
QuestieLib = loader:ImportModule("QuestieLib")
ZoneDB = loader:ImportModule("ZoneDB")
QuestiePlayer = loader:ImportModule("QuestiePlayer")
QuestXP = loader:ImportModule("QuestXP")
QuestieMap = loader:ImportModule("QuestieMap")
QuestieCorrections = loader:ImportModule("QuestieCorrections")
QuestieTooltips = loader:ImportModule("QuestieTooltips")
return true
end
-- Why the panel can't open yet, printed by every launcher.
local function describeQuestieState()
if missingQuestieField then
return "This Questie version has no " .. missingQuestieField .. ", which the quest list needs. Update Questie; WoW Forever needs Questie 12 with the QuestieDB addon."
end
if not (_G.Questie and _G.QuestieLoader) then
return "Questie is not running on this client. WoW Forever needs Questie 12 with the QuestieDB addon."
end
return "Questie has not finished loading yet. Try again in a moment."
end
-- Repeatable lives in the specialFlags bit rather than the quest tag, so it needs its own lookup next to getQuestTagLabel.
local function isQuestRepeatable(questId)
return (QuestieDB and QuestieDB.IsRepeatable and QuestieDB.IsRepeatable(questId)) and true or false
end
-- The catch-all bucket for quests whose zoneOrSort is a sort category rather than a real area id. Pinned to the bottom of the list by sortZones because it's mostly noise (class quests, faction quests, profession quests, ...).
local OTHER_ZONE_NAME = "Other"
-- zoneOrSort > 0 is a Blizzard area ID; <= 0 is a sort category we collapse into "Other". Names are static client data, and the scan plus the chain projection resolve them for thousands of quests per rescan, so results cache for the session.
local zoneNameCache = {}
local function getZoneName(zoneOrSort)
if not zoneOrSort or zoneOrSort <= 0 then
return OTHER_ZONE_NAME
end
local cached = zoneNameCache[zoneOrSort]
if cached then
return cached
end
local name
if C_Map and C_Map.GetAreaInfo then
name = C_Map.GetAreaInfo(zoneOrSort)
end
if not name and ZoneDB and ZoneDB.GetLocalizedDungeonName then
name = ZoneDB:GetLocalizedDungeonName(zoneOrSort)
end
name = name or ("Zone " .. zoneOrSort)
zoneNameCache[zoneOrSort] = name
return name
end
-- Mirrors the hidden-quest exclusions IsDoable applies before its prereq logic: Questie's curated blacklist, quests the player hid manually, and IsDoable's own autoBlacklist verdicts. Needed wherever quests are classified after IsDoable already said no (missing-prereq rows, chain projection), because those paths never receive IsDoable's verdict on hidden state and would otherwise resurrect blacklisted or inactive-event quests.
local function isQuestHidden(questId)
if QuestieCorrections and QuestieCorrections.hiddenQuests and QuestieCorrections.hiddenQuests[questId] then
return true
end
if QuestieDB.autoBlacklist and QuestieDB.autoBlacklist[questId] then
return true
end
local char = _G.Questie and _G.Questie.db and _G.Questie.db.char
return (char and char.hidden and char.hidden[questId]) and true or false
end
-- Questie's GetEffectiveQuestLevel (a dot function in 11.37.1 and master) resolves scaled quests; the raw database fields stand in if a future Questie drops it.
local function queryQuestLevels(questId, playerLevel)
if QuestieLib.GetEffectiveQuestLevel then
return QuestieLib.GetEffectiveQuestLevel(questId, playerLevel)
end
local level = QuestieDB.QueryQuestSingle(questId, "questLevel")
local requiredLevel = QuestieDB.QueryQuestSingle(questId, "requiredLevel") or 0
-- Mirror Questie rule that questLevel -1 means the quest scales to player level.
if level == -1 then
local currentLevel = playerLevel or UnitLevel("player")
if requiredLevel > currentLevel then
level = requiredLevel
else
level = currentLevel
requiredLevel = currentLevel
end
end
return level, requiredLevel, QuestieDB.QueryQuestSingle(questId, "requiredMaxLevel")
end
local function getEffectiveLevel(questId, playerLevel)
local level, requiredLevel, requiredMaxLevel = queryQuestLevels(questId, playerLevel)
requiredMaxLevel = requiredMaxLevel or 0
if level and level > 0 then
return level, requiredLevel or 0, requiredMaxLevel
end
return requiredLevel or 0, requiredLevel or 0, requiredMaxLevel
end
local function getQuestName(questId)
return QuestieDB.QueryQuestSingle(questId, "name") or ("Quest " .. questId)
end
-- Questie stores a spawn without a map position (dungeon interiors) as {-1, -1}; such a spawn still names its zone but must not show coordinates or set a waypoint.
local function getSpawnCoords(spawn)
local x, y = type(spawn) == "table" and spawn[1], type(spawn) == "table" and spawn[2]
if type(x) == "number" and type(y) == "number" and x >= 0 and y >= 0 and (x > 0 or y > 0) then
return spawn
end
return nil
end
-- Picks a spawn from Questie's per-zone spawn table: prefer the quest's own zone (zoneOrSort) so the labeled location matches the bucket; fall back to the smallest area id when no spawn lives there (deterministic, but arbitrary). Zone and spawn always come from the same entry.
local function pickPreferredSpawn(spawns, preferZoneId)
local preferredZoneId, preferredSpawn
local fallbackZoneId, fallbackSpawn
for zoneId, list in pairs(spawns) do
if type(list) == "table" and list[1] then
if preferZoneId and zoneId == preferZoneId then
preferredZoneId = zoneId
preferredSpawn = list[1]
elseif not fallbackZoneId or zoneId < fallbackZoneId then
fallbackZoneId = zoneId
fallbackSpawn = list[1]
end
end
end
if preferredZoneId then
return preferredZoneId, getSpawnCoords(preferredSpawn)
end
return fallbackZoneId, getSpawnCoords(fallbackSpawn)
end
local function getPreferredZoneId(questId)
local questZone = QuestieDB.QueryQuestSingle(questId, "zoneOrSort")
return (questZone and questZone > 0) and questZone or nil
end
-- Returns name, zoneName, {x, y}, areaId for the quest's start source. Questie's `startedBy` is a 3-tuple: [1] NPC ids, [2] object ids, [3] item ids. Object/item start (no NPC giver): use the quest's own zone as a best-effort location and "Quest Item" as the generic giver name.
local function computeQuestStartInfo(questId)
if not QuestieDB then
return nil, nil, nil, nil
end
local startedBy = QuestieDB.QueryQuestSingle(questId, "startedBy")
if type(startedBy) ~= "table" then
return nil, nil, nil, nil
end
local npcIds = startedBy[1]
if type(npcIds) == "table" and npcIds[1] and QuestieDB.GetNPC then
local npc = QuestieDB:GetNPC(npcIds[1])
if npc then
if type(npc.spawns) ~= "table" then
return npc.name, nil, nil, nil
end
local bestZoneId, bestSpawn = pickPreferredSpawn(npc.spawns, getPreferredZoneId(questId))
if not bestZoneId then
return npc.name, nil, nil, nil
end
return npc.name, getZoneName(bestZoneId), bestSpawn, bestZoneId
end
end
local hasObjectStart = type(startedBy[2]) == "table" and startedBy[2][1] ~= nil
local hasItemStart = type(startedBy[3]) == "table" and startedBy[3][1] ~= nil
if hasObjectStart or hasItemStart then
local questZone = QuestieDB.QueryQuestSingle(questId, "zoneOrSort")
if questZone and questZone > 0 then
return "Quest Item", getZoneName(questZone), nil, questZone
end
return "Quest Item", nil, nil, nil
end
return nil, nil, nil, nil
end
-- Start info is static DB data, but the scan resolves it for every doable quest on every rescan (accept, turn-in, level-up) and quest row tables are rebuilt each scan, so a per-row cache would not survive. Cache per questId for the session instead; only compute when QuestieDB is actually loaded so a nil result is never frozen in.
local startInfoCache = {}
local function getQuestStartInfo(questId)
local cached = startInfoCache[questId]
if cached then
return cached.npcName, cached.zoneName, cached.spawn, cached.areaId
end
local npcName, zoneName, spawn, areaId = computeQuestStartInfo(questId)
if QuestieDB then
startInfoCache[questId] = { npcName = npcName, zoneName = zoneName, spawn = spawn, areaId = areaId }
end
return npcName, zoneName, spawn, areaId
end
-- Row and tooltip callers keep the table shape they already use; it now just fronts the session cache.
local function resolveStartInfo(quest)
if quest.startInfo then
return quest.startInfo
end
local npcName, zoneName, spawn, areaId = getQuestStartInfo(quest.id)
quest.startInfo = {
npcName = npcName,
zoneName = zoneName,
spawn = spawn,
areaId = areaId,
}
return quest.startInfo
end
-- Returns name, zoneName, {x, y}, areaId for the quest's turn-in target. Questie's `finishedBy` is a 2-tuple: [1] NPC ids, [2] object ids. The turn-in location only exists in Questie's data; no native API exposes it.
local function computeQuestFinishInfo(questId)
if not QuestieDB then
return nil, nil, nil, nil
end
local finishedBy = QuestieDB.QueryQuestSingle(questId, "finishedBy")
if type(finishedBy) ~= "table" then
return nil, nil, nil, nil
end
local npcIds = finishedBy[1]
if type(npcIds) == "table" and npcIds[1] and QuestieDB.GetNPC then
local npc = QuestieDB:GetNPC(npcIds[1])
if npc then
if type(npc.spawns) ~= "table" then
return npc.name, nil, nil, nil
end
local bestZoneId, bestSpawn = pickPreferredSpawn(npc.spawns, getPreferredZoneId(questId))
if not bestZoneId then
return npc.name, nil, nil, nil
end
return npc.name, getZoneName(bestZoneId), bestSpawn, bestZoneId
end
end
local objectIds = finishedBy[2]
if type(objectIds) == "table" and objectIds[1] and QuestieDB.QueryObjectSingle then
local name = QuestieDB.QueryObjectSingle(objectIds[1], "name")
local spawns = QuestieDB.QueryObjectSingle(objectIds[1], "spawns")
if type(spawns) == "table" then
local bestZoneId, bestSpawn = pickPreferredSpawn(spawns, getPreferredZoneId(questId))
if bestZoneId then
return name, getZoneName(bestZoneId), bestSpawn, bestZoneId
end
end
return name, nil, nil, nil
end
return nil, nil, nil, nil
end
-- Turn-in targets are static DB data like start info; cache per questId for the session and only freeze results once QuestieDB is loaded.
local finishInfoCache = {}
local function getQuestFinishInfo(questId)
local cached = finishInfoCache[questId]
if cached then
return cached.npcName, cached.zoneName, cached.spawn, cached.areaId
end
local npcName, zoneName, spawn, areaId = computeQuestFinishInfo(questId)
if QuestieDB then
finishInfoCache[questId] = { npcName = npcName, zoneName = zoneName, spawn = spawn, areaId = areaId }
end
return npcName, zoneName, spawn, areaId
end
local HIGHLIGHT_PULSE_SCALE = 1.25
local HIGHLIGHT_PULSE_DIM = 0.55
local HIGHLIGHT_HALF_DURATION = 0.45
local HIGHLIGHT_PULSE_COUNT = 3
-- Combined scale + alpha "breathing" pulse on every Questie icon for the quest. Scale and alpha run in parallel so the pin grows brighter at the peak; using SetLooping("REPEAT") lets WoW reset the frame state between cycles cleanly, which avoids the velocity discontinuities pure scale animation suffered from.
local function highlightQuestOnMap(questId)
if not QuestieMap or not QuestieMap.GetFramesForQuest then
return
end
local frames = QuestieMap:GetFramesForQuest(questId)
if not frames then
return
end
for _, frame in pairs(frames) do
if frame and frame.CreateAnimationGroup and frame:IsObjectType("Frame") then
local pulse = frame.wtqPulse
if not pulse then
pulse = frame:CreateAnimationGroup()
pulse:SetLooping("REPEAT")
local scaleUp = pulse:CreateAnimation("Scale")
scaleUp:SetOrder(1)
scaleUp:SetDuration(HIGHLIGHT_HALF_DURATION)
scaleUp:SetSmoothing("IN_OUT")
if scaleUp.SetScale then scaleUp:SetScale(HIGHLIGHT_PULSE_SCALE, HIGHLIGHT_PULSE_SCALE) end
if scaleUp.SetScaleFrom then scaleUp:SetScaleFrom(1, 1) end
if scaleUp.SetScaleTo then scaleUp:SetScaleTo(HIGHLIGHT_PULSE_SCALE, HIGHLIGHT_PULSE_SCALE) end
local fadeDown = pulse:CreateAnimation("Alpha")
fadeDown:SetOrder(1)
fadeDown:SetDuration(HIGHLIGHT_HALF_DURATION)
fadeDown:SetSmoothing("IN_OUT")
if fadeDown.SetChange then fadeDown:SetChange(HIGHLIGHT_PULSE_DIM - 1) end
if fadeDown.SetFromAlpha then fadeDown:SetFromAlpha(1) end
if fadeDown.SetToAlpha then fadeDown:SetToAlpha(HIGHLIGHT_PULSE_DIM) end
local scaleDown = pulse:CreateAnimation("Scale")
scaleDown:SetOrder(2)
scaleDown:SetDuration(HIGHLIGHT_HALF_DURATION)
scaleDown:SetSmoothing("IN_OUT")
if scaleDown.SetScale then scaleDown:SetScale(1 / HIGHLIGHT_PULSE_SCALE, 1 / HIGHLIGHT_PULSE_SCALE) end
if scaleDown.SetScaleFrom then scaleDown:SetScaleFrom(HIGHLIGHT_PULSE_SCALE, HIGHLIGHT_PULSE_SCALE) end
if scaleDown.SetScaleTo then scaleDown:SetScaleTo(1, 1) end
local fadeUp = pulse:CreateAnimation("Alpha")
fadeUp:SetOrder(2)
fadeUp:SetDuration(HIGHLIGHT_HALF_DURATION)
fadeUp:SetSmoothing("IN_OUT")
if fadeUp.SetChange then fadeUp:SetChange(1 - HIGHLIGHT_PULSE_DIM) end
if fadeUp.SetFromAlpha then fadeUp:SetFromAlpha(HIGHLIGHT_PULSE_DIM) end
if fadeUp.SetToAlpha then fadeUp:SetToAlpha(1) end
pulse:SetScript("OnLoop", function(self)
self._wtqCount = (self._wtqCount or 0) + 1
if self._wtqCount >= HIGHLIGHT_PULSE_COUNT then
self:Stop()
self._wtqCount = 0
end
end)
frame.wtqPulse = pulse
end
pulse:Stop()
pulse._wtqCount = 0
pulse:Play()
end
end
end
-- Some Classic Era UI maps (notably dungeon interiors) have no art layers and crash Blizzard_MapCanvas when passed to SetMapID. Walk up to the first ancestor that actually has art so we open something instead of erroring.
local function resolveRenderableMapId(uiMapId)
if not uiMapId or not C_Map or not C_Map.GetMapArtLayers then
return nil
end
local current = uiMapId
for _ = 1, 5 do
local layers = C_Map.GetMapArtLayers(current)
if layers and #layers > 0 then
return current
end
local mapInfo = C_Map.GetMapInfo and C_Map.GetMapInfo(current)
if not mapInfo or not mapInfo.parentMapID or mapInfo.parentMapID == 0 or mapInfo.parentMapID == current then
return nil
end
current = mapInfo.parentMapID
end
return nil
end
local function openMapForQuest(quest)
if not loadQuestie() then
return
end
local startInfo = resolveStartInfo(quest)
if not startInfo.areaId or not ZoneDB or not ZoneDB.GetUiMapIdByAreaId then
if WorldMapFrame and not WorldMapFrame:IsShown() then
ShowUIPanel(WorldMapFrame)
end
return
end
local uiMapId = ZoneDB:GetUiMapIdByAreaId(startInfo.areaId)
if not uiMapId then
return
end
local renderMapId = resolveRenderableMapId(uiMapId)
if not renderMapId then
if WorldMapFrame and not WorldMapFrame:IsShown() then
ShowUIPanel(WorldMapFrame)
end
return
end
if not WorldMapFrame:IsShown() then
ShowUIPanel(WorldMapFrame)
end
if WorldMapFrame.SetMapID then
WorldMapFrame:SetMapID(renderMapId)
end
-- Spawn coords belong to uiMapId's coordinate space, so a waypoint is only set when that map is the one shown. TomTom wins when installed; otherwise Forever gets its native waypoint and beacon, while Era has neither and relies on the Questie icon pulse below.
if renderMapId == uiMapId and startInfo.spawn then
local x, y = startInfo.spawn[1] / 100, startInfo.spawn[2] / 100
if type(TomTom) == "table" and TomTom.AddWaypoint then
pcall(function()
TomTom:AddWaypoint(uiMapId, x, y, {
title = quest.name or getQuestName(quest.id),
persistent = false,
minimap = true,
world = true,
})
end)
else
Client.SetNativeWaypoint(uiMapId, x, y)
end
end
-- Questie draws icons asynchronously after SetMapID, so wait a tick before pulsing.
C_Timer.After(0.2, function()
highlightQuestOnMap(quest.id)
end)
end
local isQuestCompleted = Client.IsQuestCompleted
local function clampRange(value)
if type(value) ~= "number" then
return nil
end
value = math.floor(value + 0.5)
if value < LEVEL_RANGE_MIN then return LEVEL_RANGE_MIN end
if value > LEVEL_RANGE_MAX then return LEVEL_RANGE_MAX end
return value
end
local function getLevelRange()
local db = QuestieGuideDB or {}
local below = clampRange(db.levelBelow) or DEFAULTS.levelBelow
local above = clampRange(db.levelAbove) or DEFAULTS.levelAbove
return below, above
end
-- Quest passes when its effective level sits in [player - below, player + above].
local function isLevelInBand(questLevel, playerLevel, below, above)
if not playerLevel then
return true
end
if not questLevel or questLevel <= 0 then
return true
end
if (playerLevel - questLevel) > below then
return false
end
if (questLevel - playerLevel) > above then
return false
end
return true
end
local getGreenRange = Client.GetGreenRange
-- True when the quest would render grey on the player (below Blizzard's difficulty floor — quest level is more than greenRange below the player). Used to exclude outgrown quests from the discovery sections.
local function isQuestTrivialForPlayer(questLevel, playerLevel)
if not playerLevel or not questLevel or questLevel <= 0 then
return false
end
return (playerLevel - questLevel) > getGreenRange()
end
-- True when the quest would render red on the player (levelDiff >= 5, the "impossible" tier in GetRelativeDifficultyColor, Classic Era's Vanilla/UIParent.lua). Red quests never count toward the XP figures, even when the slider band reaches them.
local function isQuestRedForPlayer(questLevel, playerLevel)
if not playerLevel or not questLevel or questLevel <= 0 then
return false
end
return (questLevel - playerLevel) >= 5
end
-- True when the quest's difficulty color for the player is yellow or green. Mirrors GetRelativeDifficultyColor in Classic Era's Vanilla/UIParent.lua: yellow covers levelDiff -2..+2, green covers -greenRange..-3. Orange/red (levelDiff >= 3) and grey (below -greenRange) are excluded.
local function isQuestYellowOrGreen(questLevel, playerLevel)
if not playerLevel then
return true
end
if not questLevel or questLevel <= 0 then
return true
end
if (playerLevel - questLevel) > getGreenRange() then
return false
end
if (questLevel - playerLevel) >= 3 then
return false
end
return true
end
-- Single authority for the player's level band, shared by display, XP and routing: the explicit ± slider band minus red quests, or Questie's yellow/green tier when the bypass checkbox is on.
local function passesPlayerBand(level, playerLevel)
playerLevel = playerLevel or UnitLevel("player")
if QuestieGuideDB and QuestieGuideDB.useQuestieLevelRange then
return isQuestYellowOrGreen(level, playerLevel)
end
local below, above = getLevelRange()
return isLevelInBand(level, playerLevel, below, above)
and not isQuestRedForPlayer(level, playerLevel)
end
-- QuestieDB.IsDoable does not enforce requiredLevel, so we gate it explicitly.
local function meetsRequiredLevel(requiredLevel, playerLevel)
if not playerLevel then
return true
end
if not requiredLevel or requiredLevel <= 0 then
return true
end
return requiredLevel <= playerLevel
end
-- Mirrors AvailableQuests.IsLevelRequirementsFulfilled: a quest carrying a requiredMaxLevel is permanently unobtainable once the player outlevels it. IsDoable does not check this either.
local function exceedsRequiredMaxLevel(requiredMaxLevel, playerLevel)
if not playerLevel or not requiredMaxLevel or requiredMaxLevel == 0 then
return false
end
return playerLevel > requiredMaxLevel
end
-- Mirrors _AddStarter in Questie's AvailableQuests module: a quest only gets a map icon when at least one approachable starter exists. NPC givers hostile to the player's faction are unreachable, and NPC or object givers need at least one spawn or waypoint in the world. Item-started quests count as reachable because Questie draws them at their drop sources. Anything failing this can never be picked up, so it must not be listed or counted. Reachability is static per character (DB plus faction), so results cache for the session.
local reachableStarterCache = {}
local function hasReachableStarter(questId)
local cached = reachableStarterCache[questId]
if cached ~= nil then
return cached
end
-- Older Questie builds without the single-field queries get the permissive answer instead of an empty panel.
if not QuestieDB.QueryNPCSingle or not QuestieDB.QueryObjectSingle then
return true
end
local reachable = false
local startedBy = QuestieDB.QueryQuestSingle(questId, "startedBy")
if type(startedBy) == "table" then
local playerFaction = UnitFactionGroup("player")
local npcIds = startedBy[1]
if type(npcIds) == "table" then
for _, npcId in ipairs(npcIds) do
local friendlyToFaction = QuestieDB.QueryNPCSingle(npcId, "friendlyToFaction")
local hostile = (playerFaction == "Alliance" and friendlyToFaction == "H")
or (playerFaction == "Horde" and friendlyToFaction == "A")
if not hostile then
local spawns = QuestieDB.QueryNPCSingle(npcId, "spawns")
if type(spawns) == "table" and next(spawns) then
reachable = true
break
end
local waypoints = QuestieDB.QueryNPCSingle(npcId, "waypoints")
if type(waypoints) == "table" and next(waypoints) then
reachable = true
break
end
end
end
end
if not reachable and type(startedBy[2]) == "table" then
for _, objectId in ipairs(startedBy[2]) do
local spawns = QuestieDB.QueryObjectSingle(objectId, "spawns")
if type(spawns) == "table" and next(spawns) then
reachable = true
break
end
end
end
if not reachable and type(startedBy[3]) == "table" and startedBy[3][1] then
reachable = true
end
end
reachableStarterCache[questId] = reachable
return reachable
end
-- True when the quest is not gated by the player's race or class.
local function matchesPlayerFaction(questId)
if not QuestiePlayer then
return true
end
local requiredRaces = QuestieDB.QueryQuestSingle(questId, "requiredRaces")
if requiredRaces and QuestiePlayer.HasRequiredRace and not QuestiePlayer.HasRequiredRace(requiredRaces) then
return false
end
local requiredClasses = QuestieDB.QueryQuestSingle(questId, "requiredClasses")
if requiredClasses and QuestiePlayer.HasRequiredClass and not QuestiePlayer.HasRequiredClass(requiredClasses) then
return false
end
return true
end
-- Returns the active prereq table for a quest along with which type it is. preQuestSingle (OR) takes precedence over preQuestGroup (AND); Questie treats them as exclusive.
local function getQuestPrereqs(questId)
local preIds = QuestieDB.QueryQuestSingle(questId, "preQuestSingle")
if type(preIds) == "table" and preIds[1] then
return preIds, "single"
end
preIds = QuestieDB.QueryQuestSingle(questId, "preQuestGroup")
if type(preIds) == "table" and preIds[1] then
return preIds, "group"
end
return nil, nil
end
-- True when the quest has prereqs and Questie reports them as not yet satisfied.
local function isBlockedByPrereqs(questId)
local preIds, kind = getQuestPrereqs(questId)
if not preIds then
return false
end
if kind == "single" then
return not QuestieDB:IsPreQuestSingleFulfilled(preIds)
end
return not QuestieDB:IsPreQuestGroupFulfilled(preIds)
end
-- Returns a list of chains [initial, ..., target] of incomplete prereqs. preQuestSingle (OR) takes the first incomplete alternative; preQuestGroup (AND) branches into one chain per incomplete prereq so the player sees every initial they have to pick up, not just one arbitrary path.
local function findMissingChains(targetId)
local results = {}
local function walk(questId, chain, depth, visited)
if depth > MAX_CHAIN_DEPTH then
results[#results + 1] = chain
return
end
if not isBlockedByPrereqs(questId) then
results[#results + 1] = chain
return
end
local preIds, kind = getQuestPrereqs(questId)
if not preIds then
results[#results + 1] = chain
return
end
local pending = {}
for _, preId in ipairs(preIds) do
if not visited[preId] and not isQuestCompleted(preId) then
pending[#pending + 1] = preId
end
end
if #pending == 0 then
results[#results + 1] = chain
return
end
-- OR: any one prereq satisfies the gate, so the first incomplete option is enough. AND: every prereq must be done, so each becomes its own chain.
if kind == "single" then
pending = { pending[1] }
end
for _, preId in ipairs(pending) do
local subChain = { preId }
for _, c in ipairs(chain) do subChain[#subChain + 1] = c end
-- Clone visited per branch so AND siblings don't shadow each other's nodes.
local nextVisited = {}
for k in pairs(visited) do nextVisited[k] = true end
nextVisited[preId] = true
walk(preId, subChain, depth + 1, nextVisited)
end
end
walk(targetId, { targetId }, 0, { [targetId] = true })
local valid = {}
for _, p in ipairs(results) do
if #p > 1 then valid[#valid + 1] = p end
end
return valid
end
-- Reverse prereq index: preQuestId -> { followerQuestId, ... }. Built once per session because the quest DB is static; only completion state changes at runtime. Negative preQuestGroup ids are indexed by absolute value so those followers stay discoverable through that edge.
local followerIndex