Repository navigation
Expand file tree
/
Copy pathCore_Suggest.lua
More file actions
297 lines (262 loc) · 11.8 KB
/
Copy pathCore_Suggest.lua
File metadata and controls
297 lines (262 loc) · 11.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
--[[---------------------------------------------------------------------------
Solaryn's Expedition — "what should I do next".
Produces a ranked list of concrete next actions, not a restatement of the
quest log. Four kinds, in rough priority order:
TURNOUT a quest is finished and needs handing in (pure XP/money you are
leaving on the table)
OBJECTIVE a quest with a known, reachable next objective
UNLOCK a neighbouring zone you have barely touched, at your level
EXPLORE a known zone with uncovered ground nearby
Scoring is a weighted sum over the tunables in ns.Defaults.settings, so the
panel's sliders change behaviour without any code changes here.
-----------------------------------------------------------------------------]]
local ADDON_NAME = ...
local ns = _G.SolarynExpedition
ns.Suggest = {}
local Suggest = ns.Suggest
---------------------------------------------------------------------------
-- Public API
---------------------------------------------------------------------------
--- Ranked suggestions. Returns a list of:
-- { kind, title, detail, questID, uiMapID, x, y, world, distance, score, color }
--
-- Sets _computing while it runs so the notifications it emits can't trigger a
-- re-render that calls back into Compute (which recursed to a C stack overflow).
function Suggest:Compute()
local settings = ns.Settings()
local limit = settings.suggestLimit or 5
local out = {}
-- Re-entrancy guard: if Compute is somehow entered while already running,
-- return the previous result rather than recursing.
if Suggest._computing then
return Suggest.last or {}
end
Suggest._computing = true
-- Make sure the map tree exists before we classify anything against it.
if ns.ZoneData and ns.ZoneData.EnsureLoaded then ns.ZoneData:EnsureLoaded() end
local playerLevel = ns.UnitLevelSafe()
local playerMap = ns:PlayerMapID()
local playerPos = ns:PlayerPosition(playerMap)
------------------------------------------------------------------
-- Turn-ins
------------------------------------------------------------------
for _, e in ipairs(ns.QuestData:Scan()) do
if not e.isHeader and not e.isHidden then
if ns.QuestData:IsComplete(e.questID) then
local turnIn = ns.QuestData:TurnInLocation(e.questID)
local dist = turnIn and ns:DistanceBetween(playerPos, turnIn) or nil
table.insert(out, {
kind = ns.SUGGEST.TURNOUT,
title = e.title,
detail = "Ready to hand in" .. (dist and (" — " .. ns:FormatDistance(dist)) or ""),
questID = e.questID,
uiMapID = turnIn and turnIn.uiMapID or playerMap,
x = turnIn and turnIn.x or nil,
y = turnIn and turnIn.y or nil,
world = turnIn and turnIn.world or nil,
distance = dist,
level = e.level,
-- Turn-ins are almost always the right next action: they end
-- a quest you already paid travel for.
score = 1000 + settings.wTurnIn,
color = ns.Colors.turnin,
})
end
end
end
------------------------------------------------------------------
-- Objectives
------------------------------------------------------------------
local chainDepths = ns.Chains:Depths()
for _, e in ipairs(ns.QuestData:Scan()) do
if not e.isHeader and not e.isHidden and not ns.QuestData:IsComplete(e.questID) then
if not (settings.ignoreTasks and e.isTask)
and not (e.isDungeon and not settings.includeDungeons) then
-- Quests with no known location are still listed (ranked
-- lower): silently dropping them hid most of the quest log
-- whenever the client gave no marker.
local wp = ns.QuestData:PrimaryWaypoint(e.questID)
local dist = wp and ns:DistanceBetween(playerPos, wp) or nil
local score = 0
score = score + 100 -- above unlocks/explore
if dist then
score = score - (dist / 1000) * settings.wDistance
else
score = score - 50 -- unknown location: deprioritise
end
if wp and wp.uiMapID == playerMap then
score = score + settings.wSameMap
end
if e.level and playerLevel then
score = score + ns.LevelFit(e.level, playerLevel) * settings.wLevel
end
if e.isStory then score = score + settings.wStory end
if e.isTask then score = score + settings.wTask end
-- Continuity: quests later in a chain you've started are
-- usually the intended order, so nudge them up.
if chainDepths[e.questID] then
score = score + 1
end
local pending = ns.QuestData:NextObjectiveText(e.questID)
table.insert(out, {
kind = ns.SUGGEST.OBJECTIVE,
title = e.title,
detail = pending or "In progress",
questID = e.questID,
uiMapID = wp and wp.uiMapID or nil,
x = wp and wp.x or nil, y = wp and wp.y or nil,
world = wp and wp.world or nil,
distance = dist,
level = e.level,
score = score,
color = ns.Colors.quest,
})
end
end
end
------------------------------------------------------------------
-- Unlocks: neighbouring zones worth moving to
------------------------------------------------------------------
-- Only when it matters: you've outgrown this zone (or nearly), or it's
-- too dangerous for you yet. Then name the nearest zone that fits.
local prog = ns.ZoneData:Progression(1)
local cur = prog.current
local needsMove = cur and cur.status and cur.status ~= "ok"
local target = prog.next[1]
if needsMove and target then
local why = cur.status == "high" and string.format("%s is above your level", cur.name)
or string.format("you're outgrowing %s", cur.name)
table.insert(out, {
kind = ns.SUGGEST.UNLOCK,
title = target.name,
detail = string.format("Levels %d–%d · %s", target.range.min, target.range.max, why),
uiMapID = target.mapID,
distance = target.distance,
level = target.range.min,
score = 10 + (cur.status == "low" and 5 or 0),
color = ns.Colors.unlock,
})
end
------------------------------------------------------------------
-- Explore: current zone, uncovered ground
------------------------------------------------------------------
if playerMap and settings.trackingEnabled then
local cov = ns.Explored:Coverage(playerMap)
if cov and cov < 0.85 then
table.insert(out, {
kind = ns.SUGGEST.EXPLORE,
title = ns.ZoneData:Name(playerMap),
detail = string.format("Only %s of this zone explored", ns:FormatPct(cov)),
uiMapID = playerMap,
distance = nil,
score = 5 + cov * 3,
color = ns.Colors.explore,
})
end
end
------------------------------------------------------------------
-- User overrides win, then sort
------------------------------------------------------------------
local char = ns.CharDB()
local overrides = char and char.suggestOverrides or {}
for _, s in ipairs(out) do
if s.questID and overrides[s.questID] == "hide" then
s.hidden = true
elseif s.questID and overrides[s.questID] == "top" then
s.score = s.score + 5000
s.pinned = true
end
end
local visible = {}
for _, s in ipairs(out) do
if not s.hidden then table.insert(visible, s) end
end
if settings.sortByDistance then
-- Nearest first. Pinned quests stay on top, quests come before zone
-- suggestions, and quests with no known location follow the located
-- ones; ties fall back to the weighted score.
table.sort(visible, function(a, b)
if (a.pinned or false) ~= (b.pinned or false) then return a.pinned or false end
local qa, qb = a.questID ~= nil, b.questID ~= nil
if qa ~= qb then return qa end
local da, db = a.distance, b.distance
if da and db then
if da ~= db then return da < db end
elseif da or db then
return da ~= nil
end
return a.score > b.score
end)
else
table.sort(visible, function(a, b) return a.score > b.score end)
end
for i = #visible, 1, -1 do
if i > limit then table.remove(visible, i) end
end
Suggest.last = visible
-- Order matters: _computing must stay TRUE across the notification, so a
-- listener that calls Compute() hits the re-entrancy guard and returns the
-- cached result instead of starting a fresh compute that fires again.
-- Clearing it before Fire() left exactly the loop that overflowed the C stack.
local shouldNotify = not Suggest._suppressNotify
Suggest._suppressNotify = false
if shouldNotify then
local ok, err = pcall(function() ns:Fire("suggestions_updated", visible) end)
if not ok then ns:Debug("suggestion listener error: %s", tostring(err)) end
end
Suggest._computing = false
return visible
end
--- Recompute and notify.
-- Call this when something real changed (quest log, zone, settings). The panel
-- calls Compute() directly while drawing, which must not re-notify.
function Suggest:Recompute()
if Suggest._computing then return Suggest.last or {} end
Suggest._suppressNotify = false
return self:Compute()
end
--- Recompute without notifying listeners (used during a render).
function Suggest:ComputeQuietly()
Suggest._suppressNotify = true
local ok, res = pcall(self.Compute, self)
Suggest._suppressNotify = false
if ok then return res end
return Suggest.last or {}
end
--- Hide a quest from suggestions (persisted per character).
function Suggest:Hide(questID)
local char = ns.CharDB()
if not char then return end
char.suggestOverrides = char.suggestOverrides or {}
char.suggestOverrides[questID] = "hide"
self:Recompute()
end
--- Pin a quest to the top of the list.
function Suggest:PinTop(questID)
local char = ns.CharDB()
if not char then return end
char.suggestOverrides = char.suggestOverrides or {}
char.suggestOverrides[questID] = "top"
self:Recompute()
end
--- Clear any override on a quest.
function Suggest:ClearOverride(questID)
local char = ns.CharDB()
if not char then return end
char.suggestOverrides = char.suggestOverrides or {}
char.suggestOverrides[questID] = nil
self:Recompute()
end
---------------------------------------------------------------------------
-- Events
---------------------------------------------------------------------------
local function onChanged()
-- A real change trigger (quest log, zone, login): recompute and notify so
-- any visible panel updates.
ns:Debounce("suggest", 0.25, function() Suggest:Recompute() end)
end
ns:RegisterEvent("QUEST_LOG_UPDATE", onChanged, Suggest)
ns:RegisterEvent("QUEST_TURNED_IN", onChanged, Suggest)
ns:RegisterEvent("QUEST_ACCEPTED", onChanged, Suggest)
ns:RegisterEvent("PLAYER_ENTERING_WORLD", onChanged, Suggest)
return Suggest