Repository navigation
Expand file tree
/
Copy pathQuest.lua
More file actions
869 lines (767 loc) · 32.8 KB
/
Copy pathQuest.lua
File metadata and controls
869 lines (767 loc) · 32.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
setfenv(1, _G.SlackHacks)
--=====================================================================
-- Quest / NPC
--=====================================================================
-- NPC dialog automation (auto-accept, auto-complete, auto-click first option), number/Spacebar shortcuts for dialog
-- options, and an "Extra Action Button" style button for quest items usable in the current quest area.
--
-- Design decisions worth calling out:
-- * Everything is event-driven; there is no ticker or OnUpdate. Dialog events only fire while talking
-- to an NPC, so registering them costs nothing the rest of the time.
-- * The quest item button is a barebones take on the ExtraQuestButton addon. Instead of polling quest
-- distances, it relies on PLAYER_INSIDE_QUEST_BLOB_STATE_CHANGED (the same event Blizzard's objective
-- tracker uses to glow its quest item buttons) plus C_Minimap.IsInsideQuestBlob() for the initial
-- state. Bag/quest-progress events are only registered while inside a quest area that has an item.
-- * The quest item button only steals the EXTRAACTIONBUTTON1 key(s) via override bindings owned by a
-- secure state handler, so a real Extra Action Button ([extrabar]) or a pet battle always wins, even in
-- combat, without ever touching the player's saved keybindings.
-- * Dialog number keys are override bindings owned by a secure state handler whose "[combat]" state
-- clears them from restricted code, so they're disposed of the instant combat starts (when insecure
-- code can no longer touch bindings). They're also cleared whenever the dialog closes.
-- * The quest item button skips items that must be used on a unit (C_Item.IsHelpfulItem/IsHarmfulItem),
-- so it only shows items used on the ground or without a target.
-- * Holding Shift while interacting suppresses all dialog automation for that interaction.
--- The addon module instance. Exposed as `Self.Quest` so Options.lua can call `:SetEnabled()`,
--- `:SetOption()`, and `:IsQuestItemButtonSupported()` from the config UI.
local module = Self:NewModule("Quest", "AceEvent-3.0")
Self.Quest = module
local MAX_DIALOG_KEYS = 10 -- Keys 1-9 then 0.
local AUTO_INTERACT_REPEAT_GUARD = 2 -- Seconds before the same gossip option may be auto-selected again.
local GetItemCount = (C_Item and C_Item.GetItemCount) or _G.GetItemCount
local GetItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or _G.GetItemInfoInstant
local GetItemCooldown = (C_Item and C_Item.GetItemCooldown) or (C_Container and C_Container.GetItemCooldown)
local IsHelpfulItem = (C_Item and C_Item.IsHelpfulItem) or _G.IsHelpfulItem
local IsHarmfulItem = (C_Item and C_Item.IsHarmfulItem) or _G.IsHarmfulItem
-- The NPC dialog automation options, in display order. `label` is shared by Options.lua, chat messages,
-- and on-screen notifications.
AUTOMATION_OPTIONS = {
{ key = "autoAccept", label = "Auto-Accept Quests" },
{ key = "autoComplete", label = "Auto-Complete Quests" },
{ key = "autoClickFirstOption", label = "Auto-Click First Dialog Option" },
}
local ALL_AUTOMATION_LABEL = "All NPC Auto-Interacts"
local function settings()
local s = db.profile.quest
if not s then
s = CopyTable(dbDefaults.profile.quest or {})
db.profile.quest = s
end
return s
end
local function isAutomationSuppressed()
return IsShiftKeyDown()
end
--- Check for a Mythic+ run whose elapsed timer has started, excluding the opening countdown.
---@return boolean
local function isMythicRunTimerRunning()
if not (C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive
and C_ChallengeMode.IsChallengeModeActive()) then return false end
if not (GetWorldElapsedTimers and GetWorldElapsedTime and Enum.WorldElapsedTimerTypes) then return false end
for _, timerID in ipairs({ GetWorldElapsedTimers() }) do
local _, elapsed, timerType = GetWorldElapsedTime(timerID)
if timerType == Enum.WorldElapsedTimerTypes.ChallengeMode and elapsed and elapsed > 0 then
return true
end
end
return false
end
--=====================================================================
-- Dialog Number Keys
--=====================================================================
local dialogKeyOwner -- Secure state handler that owns (and clears in combat) the dialog override bindings.
local dialogKeyButton -- Plain shortcut button; the mouse button name carries the selection/action.
local dialogEntries = {} -- Current dialog's selectable entries, in on-screen order: { select = function }.
local isDialogOpen = false
local dialogStage
local numberedButtons = {}
--- Restores labels without overwriting text Blizzard has already replaced on a reused button.
local function clearDialogLabels()
for button, label in pairs(numberedButtons) do
if button:GetText() == label.numbered then
button:SetText(label.original)
if button.Resize then
button:Resize()
else
button:SetHeight(math.max(button:GetTextHeight() + 2, button.Icon:GetHeight()))
end
end
end
wipe(numberedButtons)
end
--- Numbers a rendered selection, preserving its icon and any leading inline icon markup.
---@param button table Blizzard dialog button.
---@param index number One-based selection index (10 is bound to 0).
local function numberDialogButton(button, index)
if index > MAX_DIALOG_KEYS then return end
local text = button:GetText()
local previous = numberedButtons[button]
if previous and text == previous.numbered then text = previous.original end
if not text then return end
-- Some clients embed the icon in the text instead of using a separate Icon texture.
local prefix, title = "", text
while true do
local icon, rest = title:match("^(%s*|T.-|t%s*)(.*)$")
if not icon then icon, rest = title:match("^(%s*|A.-|a%s*)(.*)$") end
if not icon then break end
prefix, title = prefix .. icon, rest
end
local numbered = prefix .. tostring(index % 10) .. ". " .. title
numberedButtons[button] = { original = text, numbered = numbered }
button:SetText(numbered)
if button.Resize then
button:Resize()
else
button:SetHeight(math.max(button:GetTextHeight() + 2, button.Icon:GetHeight()))
end
end
--- Updates a gossip row after Blizzard initializes or recycles it, including rows reached by scrolling.
---@param button table Rendered gossip row.
---@param data table ScrollBox element data.
local function numberGossipButton(button, data)
if not module:IsEnabled() or not settings().numberKeys or InCombatLockdown()
or dialogStage ~= "gossip" then return end
if data.buttonType == GOSSIP_BUTTON_TYPE_AVAILABLE_QUEST
or data.buttonType == GOSSIP_BUTTON_TYPE_ACTIVE_QUEST or data.buttonType == GOSSIP_BUTTON_TYPE_OPTION then
-- Blizzard's element index already spans all three selection groups, excluding headers/dividers.
numberDialogButton(button, data.index)
end
end
--- Re-applies labels to currently visible rows, or removes them when shortcuts are inactive.
local function updateDialogLabels()
clearDialogLabels()
if not module:IsEnabled() or not settings().numberKeys or InCombatLockdown() then return end
if dialogStage == "gossip" and GossipFrame and GossipFrame.GreetingPanel then
GossipFrame.GreetingPanel.ScrollBox:ForEachFrame(numberGossipButton)
elseif dialogStage == "greeting" and QuestFrameGreetingPanel and QuestFrameGreetingPanel.titleButtonPool then
for button in QuestFrameGreetingPanel.titleButtonPool:EnumerateActive() do
local index = button:GetID()
if button.isActive ~= 1 then index = index + GetNumActiveQuests() end
numberDialogButton(button, index)
end
end
end
--- Returns whether the current turn-in requires a manual reward choice.
---@return boolean
local function hasRewardChoices()
return dialogStage == "complete" and GetNumQuestChoices() > 1
end
--- Finds the normal Blizzard action button for the current quest page.
---@return table|nil
local function getQuestActionButton()
if dialogStage == "detail" then return QuestFrameAcceptButton end
if dialogStage == "progress" then return QuestFrameCompleteButton end
if dialogStage == "complete" and not hasRewardChoices() then return QuestFrameCompleteQuestButton end
end
--- Clears dialog shortcuts, selections, and labels.
local function clearDialogKeys()
wipe(dialogEntries)
clearDialogLabels()
if dialogKeyOwner and not InCombatLockdown() then
ClearOverrideBindings(dialogKeyOwner)
end
end
--- Applies only shortcuts appropriate to the current page; reward choices remain entirely manual.
local function bindDialogKeys()
if not dialogKeyOwner or InCombatLockdown() then return end
ClearOverrideBindings(dialogKeyOwner)
updateDialogLabels()
if not module:IsEnabled() or hasRewardChoices() then return end
if settings().numberKeys then
for index = 1, math.min(#dialogEntries, MAX_DIALOG_KEYS) do
local key = tostring(index % 10)
SetOverrideBindingClick(dialogKeyOwner, true, key, dialogKeyButton:GetName(), tostring(index))
end
end
if settings().spacebar and getQuestActionButton() then
SetOverrideBindingClick(dialogKeyOwner, true, "SPACE", dialogKeyButton:GetName(), "Spacebar")
end
end
--- Creates the combat-safe binding owner and the key-up-only shortcut dispatcher.
local function createDialogKeyFrames()
if dialogKeyOwner then return end
dialogKeyOwner = CreateFrame("Frame", "SlackHacksQuestDialogKeyOwner", UIParent, "SecureHandlerStateTemplate")
dialogKeyOwner:SetAttribute("_onstate-combat", [[
if newstate == "combat" then
self:ClearBindings()
end
]])
dialogKeyButton = CreateFrame("Button", "SlackHacksQuestDialogKeys", UIParent)
dialogKeyButton:Hide()
-- Key-up only: if the dialog changes between key-down and key-up, the leftover key-up can't fall
-- through to an action button that fires on key-up.
dialogKeyButton:RegisterForClicks("AnyUp")
--- Dispatches a shortcut only while its dialog is still safe to interact with.
---@param _ table Shortcut button.
---@param mouseButton string Selection index or Spacebar.
dialogKeyButton:SetScript("OnClick", function(_, mouseButton)
if not module:IsEnabled() or InCombatLockdown() or not isDialogOpen or hasRewardChoices() then return end
if mouseButton == "Spacebar" then
local button = settings().spacebar and getQuestActionButton()
if button and button:IsShown() and button:IsEnabled() then button:Click() end
return
end
if not settings().numberKeys then return end
local entry = dialogEntries[tonumber(mouseButton)]
if entry then entry.select() end
end)
end
--- Called out of combat to start/stop the combat-clearing state driver for dialog shortcuts.
local function applyDialogKeySecureState()
createDialogKeyFrames()
if module:IsEnabled() and (settings().numberKeys or settings().spacebar) then
RegisterStateDriver(dialogKeyOwner, "combat", "[combat] combat; nocombat")
else
UnregisterStateDriver(dialogKeyOwner, "combat")
ClearOverrideBindings(dialogKeyOwner)
end
end
--- Sets the current selection list in the same order Blizzard displays it.
---@param entries table Selectable entries.
---@param stage string Dialog page.
local function setDialogEntries(entries, stage)
clearDialogKeys()
dialogEntries = entries
dialogStage = stage
isDialogOpen = true
bindDialogKeys()
end
--- Releases all shortcuts when leaving a dialog page.
local function onDialogClosed()
isDialogOpen = false
dialogStage = nil
clearDialogKeys()
end
--- Opens a quest action page without retaining any previous numbered selections.
---@param stage string Quest page.
local function setQuestAction(stage)
onDialogClosed()
dialogStage = stage
isDialogOpen = true
bindDialogKeys()
end
--=====================================================================
-- Gossip (GossipFrame) & Greeting (QuestFrameGreetingPanel)
--=====================================================================
local lastAutoOptionID, lastAutoOptionTime
local function sortGossipOptions(a, b)
return (a.orderIndex or 0) < (b.orderIndex or 0)
end
local function selectGossipOption(option)
if option.gossipOptionID and C_GossipInfo.SelectOption then
C_GossipInfo.SelectOption(option.gossipOptionID)
else
C_GossipInfo.SelectOptionByIndex(option.orderIndex)
end
end
--- Returns true if automation acted on the gossip (and therefore the dialog is moving on).
local function automateGossip(availableQuests, activeQuests, options)
local s = settings()
if isAutomationSuppressed() then return false end
if s.autoComplete then
for _, quest in ipairs(activeQuests) do
if quest.isComplete then
C_GossipInfo.SelectActiveQuest(quest.questID)
return true
end
end
end
if s.autoAccept then
for _, quest in ipairs(availableQuests) do
if not quest.isIgnored then
C_GossipInfo.SelectAvailableQuest(quest.questID)
return true
end
end
end
if s.autoClickFirstOption and #availableQuests == 0 and #activeQuests == 0 and #options == 1
and (not s.autoClickFirstOptionMythicOnly or isMythicRunTimerRunning())
and not (C_GossipInfo.ForceGossip and C_GossipInfo.ForceGossip()) then
local option = options[1]
local now = GetTime()
-- Guard against NPCs that re-open the same single-option gossip, which would otherwise loop forever.
if option.gossipOptionID ~= lastAutoOptionID or now - (lastAutoOptionTime or 0) > AUTO_INTERACT_REPEAT_GUARD then
lastAutoOptionID, lastAutoOptionTime = option.gossipOptionID, now
selectGossipOption(option)
return true
end
end
return false
end
--- Builds shortcuts for gossip selections, unless automation advances the dialog.
function module:GOSSIP_SHOW()
onDialogClosed()
local availableQuests = C_GossipInfo.GetAvailableQuests() or {}
local activeQuests = C_GossipInfo.GetActiveQuests() or {}
local options = C_GossipInfo.GetOptions() or {}
table.sort(options, sortGossipOptions)
if automateGossip(availableQuests, activeQuests, options) then return end
-- Same order GossipFrame lists them: available quests, active quests, then options.
local entries = {}
for _, quest in ipairs(availableQuests) do
local questID = quest.questID
entries[#entries + 1] = { select = function() C_GossipInfo.SelectAvailableQuest(questID) end }
end
for _, quest in ipairs(activeQuests) do
local questID = quest.questID
entries[#entries + 1] = { select = function() C_GossipInfo.SelectActiveQuest(questID) end }
end
for _, option in ipairs(options) do
entries[#entries + 1] = { select = function() selectGossipOption(option) end }
end
setDialogEntries(entries, "gossip")
end
--- Builds shortcuts for quest greetings, unless automation advances the dialog.
function module:QUEST_GREETING()
onDialogClosed()
local s = settings()
local numActive = GetNumActiveQuests()
local numAvailable = GetNumAvailableQuests()
if not isAutomationSuppressed() then
if s.autoComplete then
for index = 1, numActive do
local _, isComplete = GetActiveTitle(index)
if isComplete then
SelectActiveQuest(index)
return
end
end
end
if s.autoAccept and numAvailable > 0 then
SelectAvailableQuest(1)
return
end
end
-- Same order QuestFrameGreetingPanel lists them: active quests, then available quests.
local entries = {}
for index = 1, numActive do
entries[#entries + 1] = { select = function() SelectActiveQuest(index) end }
end
for index = 1, numAvailable do
entries[#entries + 1] = { select = function() SelectAvailableQuest(index) end }
end
setDialogEntries(entries, "greeting")
end
--- Offers manual acceptance through Spacebar or applies auto-accept.
function module:QUEST_DETAIL()
setQuestAction("detail")
if not settings().autoAccept or isAutomationSuppressed() then return end
if QuestGetAutoAccept and QuestGetAutoAccept() then
AcknowledgeAutoAcceptQuest()
else
AcceptQuest()
end
end
--- Offers the normal quest-progress action or automatically advances a free turn-in.
function module:QUEST_PROGRESS()
setQuestAction("progress")
if not settings().autoComplete or isAutomationSuppressed() then return end
if IsQuestCompletable() and GetQuestMoneyToGet() == 0 then
CompleteQuest()
end
end
--- Leaves reward choices manual; otherwise offers Spacebar or automatic completion.
function module:QUEST_COMPLETE()
setQuestAction("complete")
if hasRewardChoices() then return end
if not settings().autoComplete or isAutomationSuppressed() then return end
local numChoices = GetNumQuestChoices()
if numChoices <= 1 and GetQuestMoneyToGet() == 0 then
GetQuestReward(numChoices) -- 0 = no choice, 1 = the only choice
end
end
function module:GOSSIP_CLOSED()
onDialogClosed()
end
function module:QUEST_FINISHED()
onDialogClosed()
end
--=====================================================================
-- Quest Item Button ("Extra Action Button" for quest items)
--=====================================================================
local itemContainer -- Secure state handler: hides for [petbattle][extrabar] and owns the EXTRAACTIONBUTTON1 bindings.
local itemButton -- SecureActionButton that uses the current quest item.
local currentItemLink
local isInsideItemQuestArea = false
local needsSecureUpdate = false
-- Runs in the restricted environment, both from the state driver (any time, including combat) and from
-- insecure code via :Execute() (out of combat only) after the item attribute changes.
local ITEM_CONTAINER_UPDATE = [[
local button = self:GetFrameRef("button")
self:ClearBindings()
if self:GetAttribute("state-extra") == "show" and button:GetAttribute("item") then
self:Show()
local key1, key2 = GetBindingKey("EXTRAACTIONBUTTON1")
if key1 then self:SetBindingClick(true, key1, button, "LeftButton") end
if key2 then self:SetBindingClick(true, key2, button, "LeftButton") end
else
self:Hide()
end
]]
function module:IsQuestItemButtonSupported()
return not not (C_Minimap and C_Minimap.IsInsideQuestBlob and GetQuestLogSpecialItemInfo
and C_QuestLog and C_QuestLog.GetNumQuestLogEntries and C_QuestLog.GetInfo)
end
local function isQuestItemButtonActive()
return module:IsEnabled() and settings().questItemButton and module:IsQuestItemButtonSupported()
end
local function createItemButton()
if itemContainer then return end
itemContainer = CreateFrame("Frame", "SlackHacksQuestItemContainer", UIParent, "SecureHandlerStateTemplate")
itemContainer:SetSize(52, 52)
itemContainer:Hide()
itemButton = CreateFrame("Button", "SlackHacksQuestItemButton", itemContainer, "SecureActionButtonTemplate")
itemButton:SetAllPoints()
itemButton:RegisterForClicks("AnyUp", "AnyDown") -- secure item click only fires reliably with both registered
itemButton:SetAttribute("type", "item")
local icon = itemButton:CreateTexture(nil, "BACKGROUND")
icon:SetAllPoints()
itemButton.Icon = icon
local artwork = itemButton:CreateTexture(nil, "OVERLAY")
artwork:SetPoint("CENTER")
artwork:SetSize(256, 128)
artwork:SetTexture("Interface\\ExtraButton\\Default")
local cooldown = CreateFrame("Cooldown", nil, itemButton, "CooldownFrameTemplate")
cooldown:SetPoint("TOPLEFT", 4, -4)
cooldown:SetPoint("BOTTOMRIGHT", -3, 4)
itemButton.Cooldown = cooldown
local overlay = CreateFrame("Frame", nil, itemButton) -- Keeps text above the cooldown swipe.
overlay:SetAllPoints()
overlay:SetFrameLevel(cooldown:GetFrameLevel() + 1)
local hotKey = overlay:CreateFontString(nil, "ARTWORK", "NumberFontNormalGray")
hotKey:SetPoint("TOPRIGHT", -6, -6)
itemButton.HotKey = hotKey
local count = overlay:CreateFontString(nil, "ARTWORK", "NumberFontNormal")
count:SetPoint("BOTTOMRIGHT", -6, 6)
itemButton.Count = count
itemButton:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
itemButton:SetScript("OnEnter", function(self)
if not currentItemLink then return end
GameTooltip:SetOwner(self, "ANCHOR_LEFT")
GameTooltip:SetHyperlink(currentItemLink)
end)
itemButton:SetScript("OnLeave", GameTooltip_Hide)
itemContainer:SetFrameRef("button", itemButton)
itemContainer:SetAttribute("_onstate-extra", ITEM_CONTAINER_UPDATE)
end
--- Places the button where the Blizzard Extra Action Button lives (as set in Edit Mode). Snapshotted
--- rather than anchored, so the secure button isn't tied into Blizzard's frame's anchor family.
local function positionItemContainer()
itemContainer:ClearAllPoints()
local anchor = _G.ExtraAbilityContainer or _G.ExtraActionBarFrame
local x, y
if anchor then x, y = anchor:GetCenter() end
if x and y then
local ratio = anchor:GetEffectiveScale() / UIParent:GetEffectiveScale()
itemContainer:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x * ratio, y * ratio)
else
itemContainer:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 250)
end
end
local function updateItemVisuals()
if not currentItemLink then return end
local count = GetItemCount(currentItemLink)
itemButton.Count:SetText(count > 1 and count or "")
local key = GetBindingKey("EXTRAACTIONBUTTON1")
itemButton.HotKey:SetText(key and GetBindingText(key, 1) or "")
end
local function updateItemCooldown()
if not currentItemLink or not GetItemCooldown then return end
local start, duration = GetItemCooldown((GetItemInfoInstant(currentItemLink)))
if start and duration and duration > 0 then
itemButton.Cooldown:SetCooldown(start, duration)
else
itemButton.Cooldown:Clear()
end
end
--- True for items that must be used on a friendly or hostile unit (clicked on a target), which don't
--- belong on the button. Ground-targeted and no-target items are neither helpful nor harmful.
local function requiresUnitTarget(link)
if not (IsHelpfulItem and IsHarmfulItem) then return false end
return IsHelpfulItem(link) or IsHarmfulItem(link)
end
--- Scans the quest log for a quest whose area the player is inside and that has a usable, non-targeted
--- special item in the player's bags. Prefers the super-tracked quest when several qualify.
---@return string|nil itemLink, boolean isInsideAnyItemQuestArea
local function findQuestItem()
local superTrackedID = C_SuperTrack and C_SuperTrack.GetSuperTrackedQuestID and C_SuperTrack.GetSuperTrackedQuestID()
local found, insideAny
for index = 1, C_QuestLog.GetNumQuestLogEntries() do
local info = C_QuestLog.GetInfo(index)
if info and not info.isHeader then
local link, _, _, showWhenComplete = GetQuestLogSpecialItemInfo(index)
if link and not requiresUnitTarget(link) and C_Minimap.IsInsideQuestBlob(info.questID) then
insideAny = true
if (showWhenComplete or not C_QuestLog.IsComplete(info.questID)) and GetItemCount(link) > 0 then
found = link
if info.questID == superTrackedID then break end
end
end
end
end
return found, insideAny
end
local function setInsideItemQuestArea(inside)
if inside == isInsideItemQuestArea then return end
isInsideItemQuestArea = inside
-- Only while inside a quest area that has an item: catch looting/using up the item or finishing the quest.
if inside then
module:RegisterEvent("BAG_UPDATE_DELAYED", "UpdateQuestItem")
module:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
else
module:UnregisterEvent("BAG_UPDATE_DELAYED")
module:UnregisterEvent("UNIT_QUEST_LOG_CHANGED")
end
end
local function setItemShown(shown)
if shown then
module:RegisterEvent("BAG_UPDATE_COOLDOWN", updateItemCooldown)
module:RegisterEvent("UPDATE_BINDINGS")
else
module:UnregisterEvent("BAG_UPDATE_COOLDOWN")
module:UnregisterEvent("UPDATE_BINDINGS")
end
end
--- Pushes `currentItemLink` into the secure button and re-runs the container's visibility/binding logic.
--- Out of combat only; in combat the change is deferred to PLAYER_REGEN_ENABLED.
local function applyItemSecureState()
if InCombatLockdown() then
needsSecureUpdate = true
return
end
needsSecureUpdate = false
createItemButton()
if isQuestItemButtonActive() then
RegisterStateDriver(itemContainer, "extra", "[petbattle][extrabar] hide; show")
else
UnregisterStateDriver(itemContainer, "extra")
itemContainer:SetAttribute("state-extra", "hide")
end
if currentItemLink then
positionItemContainer()
itemButton:SetAttribute("item", "item:" .. GetItemInfoInstant(currentItemLink))
else
itemButton:SetAttribute("item", nil)
end
itemContainer:Execute(ITEM_CONTAINER_UPDATE)
end
function module:UpdateQuestItem()
local link, insideAny
if isQuestItemButtonActive() then
link, insideAny = findQuestItem()
end
setInsideItemQuestArea(not not insideAny)
if link ~= currentItemLink then
currentItemLink = link
createItemButton()
if link then
itemButton.Icon:SetTexture(select(5, GetItemInfoInstant(link)))
end
setItemShown(not not link)
applyItemSecureState()
end
if currentItemLink then
updateItemVisuals()
updateItemCooldown()
end
end
function module:PLAYER_INSIDE_QUEST_BLOB_STATE_CHANGED()
self:UpdateQuestItem()
end
function module:QUEST_ACCEPTED()
self:UpdateQuestItem()
end
function module:QUEST_REMOVED()
self:UpdateQuestItem()
end
function module:UNIT_QUEST_LOG_CHANGED(_, unit)
if unit == "player" then self:UpdateQuestItem() end
end
--- Quest log data isn't guaranteed to be ready at PLAYER_ENTERING_WORLD, and loading screens don't
--- fire blob events, so do a single scan on the next QUEST_LOG_UPDATE and then stop listening.
function module:PLAYER_ENTERING_WORLD()
self:RegisterEvent("QUEST_LOG_UPDATE")
end
function module:QUEST_LOG_UPDATE()
self:UnregisterEvent("QUEST_LOG_UPDATE")
self:UpdateQuestItem()
end
local lastExtraKeys
--- Only re-binds when the player's EXTRAACTIONBUTTON1 keys actually changed, since setting override
--- bindings can itself fire UPDATE_BINDINGS.
function module:UPDATE_BINDINGS()
local key1, key2 = GetBindingKey("EXTRAACTIONBUTTON1")
local keys = tostring(key1) .. "|" .. tostring(key2)
if keys == lastExtraKeys then return end
lastExtraKeys = keys
updateItemVisuals()
applyItemSecureState()
end
local QUEST_ITEM_EVENTS = {
"PLAYER_INSIDE_QUEST_BLOB_STATE_CHANGED",
"QUEST_ACCEPTED",
"QUEST_REMOVED",
"PLAYER_ENTERING_WORLD",
}
local function setQuestItemEventsRegistered(registered)
for _, event in ipairs(QUEST_ITEM_EVENTS) do
if registered then module:RegisterEvent(event) else module:UnregisterEvent(event) end
end
end
--=====================================================================
-- Module Lifecycle
--=====================================================================
local DIALOG_EVENTS = {
"GOSSIP_SHOW",
"GOSSIP_CLOSED",
"QUEST_GREETING",
"QUEST_DETAIL",
"QUEST_PROGRESS",
"QUEST_COMPLETE",
"QUEST_FINISHED",
}
local function applySecureState()
if InCombatLockdown() then
needsSecureUpdate = true
return
end
applyDialogKeySecureState()
applyItemSecureState()
end
--- Re-applies all current `db.profile.quest` settings. Called on enable and whenever an option changes.
function module:Refresh()
local s = settings()
local dialogFeatures = s.autoAccept or s.autoComplete or s.autoClickFirstOption or s.numberKeys or s.spacebar
for _, event in ipairs(DIALOG_EVENTS) do
if dialogFeatures then self:RegisterEvent(event) else self:UnregisterEvent(event) end
end
if not dialogFeatures then onDialogClosed() end
setQuestItemEventsRegistered(isQuestItemButtonActive())
self:UpdateQuestItem()
applySecureState()
if isDialogOpen then bindDialogKeys() end
end
--- Enables or disables the whole module and persists that choice. Called from the "Enable" checkbox.
---@param enabled boolean
function module:SetEnabled(enabled)
settings().enabled = enabled
if enabled then self:Enable() else self:Disable() end
end
--- Sets a single `db.profile.quest` option and re-applies settings. Called from Options.lua.
---@param key string
---@param value any
function module:SetOption(key, value)
settings()[key] = value
if self:IsEnabled() then self:Refresh() end
end
--=====================================================================
-- Automation Toggles (keybindings & minimap menu)
--=====================================================================
local function getAutomationLabel(key)
for _, option in ipairs(AUTOMATION_OPTIONS) do
if option.key == key then return option.label end
end
return key
end
--- Reports a toggle in chat and with the same on-screen notice Blizzard uses for "Music Enabled".
local function announceToggle(label, enabled)
prn(label .. ": " .. (enabled and color("00FF00")("Enabled") or color("FF4040")("Disabled")))
local message = label .. " " .. (enabled and "Enabled" or "Disabled")
if ActionStatus and ActionStatus.DisplayMessage then
ActionStatus:DisplayMessage(message)
elseif ActionStatus_DisplayMessage then
ActionStatus_DisplayMessage(message, true)
end
end
local function notifyOptionsChanged()
local acr = LibStub("AceConfigRegistry-3.0", true)
if acr then acr:NotifyChange("SlackHacks") end
end
--- Writes automation options and applies them. Turning anything on also enables the module, otherwise
--- the toggle would silently do nothing.
local function setAutomation(values)
local s = settings()
local anyOn = false
for key, value in pairs(values) do
s[key] = value
anyOn = anyOn or value
end
if anyOn and not module:IsEnabled() then
module:SetEnabled(true)
elseif module:IsEnabled() then
module:Refresh()
end
notifyOptionsChanged()
end
function isAllQuestAutomationEnabled()
local s = Self.db and Self.db.profile and Self.db.profile.quest
if not (s and s.enabled) then return false end
for _, option in ipairs(AUTOMATION_OPTIONS) do
if not s[option.key] then return false end
end
return true
end
--- Toggles a single automation option (e.g. "autoAccept"). Used by keybindings.
function toggleQuestAutomation(key)
local s = settings()
local enabled = not (s.enabled and s[key])
setAutomation({ [key] = enabled })
announceToggle(getAutomationLabel(key), enabled)
end
--- Turns all automation options off if they're all on, otherwise turns them all on.
function toggleAllQuestAutomation()
local enabled = not isAllQuestAutomationEnabled()
local values = {}
for _, option in ipairs(AUTOMATION_OPTIONS) do
values[option.key] = enabled
end
setAutomation(values)
announceToggle(ALL_AUTOMATION_LABEL, enabled)
end
--- Creates secure frames and installs label hooks before dialog events begin.
function module:OnInitialize()
-- Create secure frames up front (load time is never in combat), so enabling later in combat is safe.
createDialogKeyFrames()
if GossipFrame and GossipFrame.GreetingPanel then
GossipFrame.GreetingPanel.ScrollBox:RegisterCallback(
ScrollBoxListMixin.Event.OnInitializedFrame,
--- Updates a row after its original text has been set, even if the ScrollBox view changes.
---@param _ table Callback owner.
---@param button table Gossip row.
---@param data table Element data.
function(_, button, data) numberGossipButton(button, data) end, module)
hooksecurefunc(GossipFrame, "Update", updateDialogLabels)
end
if QuestFrameGreetingPanel_OnShow then
hooksecurefunc("QuestFrameGreetingPanel_OnShow", updateDialogLabels)
end
if self:IsQuestItemButtonSupported() then createItemButton() end
if not settings().enabled then self:Disable() end
end
--- Registers dialog and combat handling while enabled.
function module:OnEnable()
-- Always listened to while enabled (a rare event): applies secure changes deferred during combat and
-- restores dialog shortcuts if a dialog is still open after combat ends.
self:RegisterEvent("PLAYER_REGEN_ENABLED")
self:RegisterEvent("PLAYER_REGEN_DISABLED", updateDialogLabels)
self:Refresh()
end
function module:OnDisable()
self:UnregisterAllEvents()
onDialogClosed()
setInsideItemQuestArea(false)
setItemShown(false)
currentItemLink = nil
applySecureState()
if needsSecureUpdate then
-- Disabled mid-combat: still need to tear down the secure frames once combat ends.
self:RegisterEvent("PLAYER_REGEN_ENABLED")
end
end
function module:PLAYER_REGEN_ENABLED()
if needsSecureUpdate then applySecureState() end
if not self:IsEnabled() then
self:UnregisterEvent("PLAYER_REGEN_ENABLED")
return
end
if isDialogOpen then bindDialogKeys() end
end