-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPracticePlannerService.lua
More file actions
326 lines (286 loc) · 12.4 KB
/
PracticePlannerService.lua
File metadata and controls
326 lines (286 loc) · 12.4 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
local _, ns = ...
local Constants = ns.Constants
local Helpers = ns.Helpers
local PracticePlannerService = {}
-- ---------------------------------------------------------------------------
-- Configuration
-- ---------------------------------------------------------------------------
local MIN_SPEC_SESSIONS = 10
local WEAK_WR_THRESHOLD = 0.40
local OPENER_WEAK_WR = 0.35
local LATE_TRINKET_FREQUENCY_THRESHOLD = 0.30
local DUMMY_HIGH_VARIANCE_THRESHOLD = 0.25
local DEFENSIVE_DRIFT_FREQUENCY_THRESHOLD = 0.25
local MAX_PRACTICE_SUGGESTIONS = 10
local RECENT_SESSION_COUNT = 30
-- ---------------------------------------------------------------------------
-- Severity scoring
-- ---------------------------------------------------------------------------
local SEVERITY_SCORES = {
high = 3,
medium = 2,
low = 1,
}
local function severityScore(severity)
return SEVERITY_SCORES[severity] or 1
end
-- ---------------------------------------------------------------------------
-- Internal: Weak area detectors
-- ---------------------------------------------------------------------------
--- Detect specs with low win rate.
local function detectWeakMatchups(aggregates)
local weakAreas = {}
local specs = aggregates and aggregates.specs or {}
for specId, bucket in pairs(specs) do
if (bucket.fights or 0) >= MIN_SPEC_SESSIONS then
local wr = bucket.fights > 0 and ((bucket.wins or 0) / bucket.fights) or 0
if wr < WEAK_WR_THRESHOLD then
weakAreas[#weakAreas + 1] = {
category = "matchup",
severity = wr < 0.25 and "high" or "medium",
title = string.format("Improve vs %s", bucket.specName or ("Spec " .. tostring(specId))),
action = string.format(
"Review your last %d losses vs %s. Focus on death patterns and pressure timing.",
math.min(3, bucket.losses or 0),
bucket.specName or "this spec"
),
evidence = {
specId = specId,
specName = bucket.specName,
wins = bucket.wins or 0,
losses = bucket.losses or 0,
wrPct = Helpers.Round(wr * 100, 1),
fights = bucket.fights,
},
linkedMatchup = "spec:" .. tostring(specId),
}
end
end
end
return weakAreas
end
--- Detect weak openers from opener lab aggregates.
local function detectWeakOpeners(aggregates)
local weakAreas = {}
local openers = aggregates and aggregates.openers or {}
for matchupKey, openerData in pairs(openers) do
if type(openerData) == "table" and (openerData.totalSessions or 0) >= MIN_SPEC_SESSIONS then
local wr = openerData.totalSessions > 0
and ((openerData.wins or 0) / openerData.totalSessions) or 0
if wr < OPENER_WEAK_WR then
weakAreas[#weakAreas + 1] = {
category = "opener",
severity = "medium",
title = string.format("Improve opener vs %s", matchupKey),
action = "Practice 10 opener reps on a target dummy using your current build. Focus on consistent burst.",
evidence = {
matchupKey = matchupKey,
wins = openerData.wins or 0,
totalSessions = openerData.totalSessions or 0,
wrPct = Helpers.Round(wr * 100, 1),
},
linkedMatchup = matchupKey,
}
end
end
end
return weakAreas
end
--- Detect bad trinket timing from recent session suggestions.
local function detectBadTrinketTiming(recentSessions)
local weakAreas = {}
local lateTrinketCount = 0
local totalWithCC = 0
for _, session in ipairs(recentSessions or {}) do
local hasCCEvents = false
for _, evt in ipairs(session.timelineEvents or {}) do
if evt.lane == Constants.TIMELINE_LANE.CC_RECEIVED then
hasCCEvents = true
break
end
end
if hasCCEvents then
totalWithCC = totalWithCC + 1
for _, sug in ipairs(session.suggestions or {}) do
if sug.reasonCode == "TRINKET_TIMING_POOR"
or sug.reasonCode == "CC_LATE_TRINKET" then
lateTrinketCount = lateTrinketCount + 1
break
end
end
end
end
if totalWithCC >= 5 then
local frequency = lateTrinketCount / totalWithCC
if frequency > LATE_TRINKET_FREQUENCY_THRESHOLD then
weakAreas[#weakAreas + 1] = {
category = "cc",
severity = frequency > 0.50 and "high" or "medium",
title = "Improve trinket timing in CC chains",
action = string.format(
"In your next 5 arena matches, save trinket for fresh CC (not diminished). Late trinkets in %.0f%% of recent matches.",
frequency * 100
),
evidence = {
lateTrinketCount = lateTrinketCount,
totalWithCC = totalWithCC,
frequency = Helpers.Round(frequency * 100, 1),
},
}
end
end
return weakAreas
end
--- Detect inconsistent dummy DPS from benchmark aggregates.
local function detectInconsistentDummy(aggregates)
local weakAreas = {}
local benchmarks = aggregates and aggregates.dummyBenchmarks or {}
for buildHash, bench in pairs(benchmarks) do
if (bench.sessions or 0) >= 5 and bench.bestDps and bench.worstDps and bench.bestDps > 0 then
local variance = (bench.bestDps - bench.worstDps) / bench.bestDps
if variance > DUMMY_HIGH_VARIANCE_THRESHOLD then
weakAreas[#weakAreas + 1] = {
category = "rotation",
severity = variance > 0.40 and "high" or "medium",
title = "Reduce rotation variance on dummies",
action = string.format(
"Do 5 dummy pulls. Focus on minimizing gaps between casts. Variance is %.0f%% (best %.0f vs worst %.0f DPS).",
variance * 100,
bench.bestDps,
bench.worstDps
),
evidence = {
buildHash = buildHash,
bestDps = bench.bestDps,
worstDps = bench.worstDps,
variance = Helpers.Round(variance * 100, 1),
sessions = bench.sessions,
},
}
end
end
end
return weakAreas
end
--- Detect defensive drift from recent session suggestions.
local function detectDefensiveDrift(recentSessions)
local weakAreas = {}
local driftCount = 0
local totalArena = 0
for _, session in ipairs(recentSessions or {}) do
if session.context == Constants.CONTEXT.ARENA then
totalArena = totalArena + 1
for _, sug in ipairs(session.suggestions or {}) do
if sug.reasonCode == "DEFENSIVE_DRIFT"
or sug.reasonCode == "REACTIVE_DEFENSIVE_LATE" then
driftCount = driftCount + 1
break
end
end
end
end
if totalArena >= 5 then
local frequency = driftCount / totalArena
if frequency > DEFENSIVE_DRIFT_FREQUENCY_THRESHOLD then
weakAreas[#weakAreas + 1] = {
category = "defensive",
severity = "medium",
title = "Improve defensive cooldown timing",
action = string.format(
"In your next 5 arena matches, use defensives within 1s of CC break. Late defensives in %.0f%% of recent arena matches.",
frequency * 100
),
evidence = {
driftCount = driftCount,
totalArena = totalArena,
frequency = Helpers.Round(frequency * 100, 1),
},
}
end
end
return weakAreas
end
-- ---------------------------------------------------------------------------
-- T110: GetWeakAreas — raw weak area list
-- ---------------------------------------------------------------------------
--- Identify all weak areas from aggregates and recent sessions.
--- @param aggregates table The db.aggregates table.
--- @param recentSessions table Array of recent sessions (last 20-50).
--- @return table Array of raw weak area entries.
function PracticePlannerService:GetWeakAreas(aggregates, recentSessions)
local areas = {}
local matchupAreas = detectWeakMatchups(aggregates)
for _, a in ipairs(matchupAreas) do areas[#areas + 1] = a end
local openerAreas = detectWeakOpeners(aggregates)
for _, a in ipairs(openerAreas) do areas[#areas + 1] = a end
local trinketAreas = detectBadTrinketTiming(recentSessions)
for _, a in ipairs(trinketAreas) do areas[#areas + 1] = a end
local dummyAreas = detectInconsistentDummy(aggregates)
for _, a in ipairs(dummyAreas) do areas[#areas + 1] = a end
local defensiveAreas = detectDefensiveDrift(recentSessions)
for _, a in ipairs(defensiveAreas) do areas[#areas + 1] = a end
return areas
end
-- ---------------------------------------------------------------------------
-- T111: GeneratePracticePlan — sorted, capped suggestions
-- ---------------------------------------------------------------------------
--- Generate a practice plan from weak areas analysis.
--- @param aggregates table The db.aggregates table.
--- @param recentSessions table Array of recent sessions.
--- @return table Array of PracticeSuggestion, sorted by severity (highest first).
function PracticePlannerService:GeneratePracticePlan(aggregates, recentSessions)
local areas = self:GetWeakAreas(aggregates or {}, recentSessions or {})
-- Sort by severity descending
table.sort(areas, function(a, b)
return severityScore(a.severity) > severityScore(b.severity)
end)
-- Cap at MAX_PRACTICE_SUGGESTIONS
local plan = {}
for i = 1, math.min(#areas, MAX_PRACTICE_SUGGESTIONS) do
local area = areas[i]
plan[#plan + 1] = {
category = area.category,
severity = area.severity,
title = area.title,
action = area.action,
evidence = area.evidence,
linkedMatchup = area.linkedMatchup,
linkedSessions = area.linkedSessions,
}
end
return plan
end
-- ---------------------------------------------------------------------------
-- Insights v2: GetRecurringDrills — short-window drill list for Insights tab
-- ---------------------------------------------------------------------------
--
-- Returns the list of "drill these next" items derived from reason codes that
-- recurred at least N times across the player's last weekDays of sessions.
-- Delegates the math to ns.InsightsRecurringDrills so it stays unit-testable
-- without a CombatStore round-trip.
local DEFAULT_RECURRING_WEEK_DAYS = 7
--- @param characterKey string? optional character scope; defaults to active
--- @param weekDays number? window in days (defaults to 7)
--- @return table ordered drill list (each: { reasonCode, count, severity, title, action })
function PracticePlannerService:GetRecurringDrills(characterKey, weekDays)
weekDays = weekDays or DEFAULT_RECURRING_WEEK_DAYS
local Drills = ns.InsightsRecurringDrills
if not Drills then return {} end
local store = ns.Addon and ns.Addon.GetModule and ns.Addon:GetModule("CombatStore", true) or nil
local sessions = {}
if store and store.GetRecentSessionStreak then
local ok, list = pcall(store.GetRecentSessionStreak, store, 50)
if ok and type(list) == "table" then sessions = list end
end
if characterKey then
local filtered = {}
for _, s in ipairs(sessions) do
if (s.characterKey or s.character) == characterKey then
filtered[#filtered + 1] = s
end
end
sessions = filtered
end
return Drills.Build(sessions, { windowDays = weekDays })
end
ns.Addon:RegisterModule("PracticePlannerService", PracticePlannerService)