Repository navigation
Expand file tree
/
Copy pathMatch.lua
More file actions
640 lines (586 loc) · 26.2 KB
/
Copy pathMatch.lua
File metadata and controls
640 lines (586 loc) · 26.2 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
-- Spell identification, kept free of WoW API so it can be tested standalone.
--
-- Cooldowns are rotations: cd = { 4.8, 4.8, 8.7 } means the gaps between casts
-- cycle through those values. Which slot a mob is on is only known once it has
-- shown us an interval, so before that every slot is a possible match.
CastAheadMatch = {
CAST_TOLERANCE = 0.25, -- how far a measured cast time may drift
-- Event delivery jitter is a few tenths of a second, so the interval match
-- is mostly absolute; the relative term only covers long cooldowns, where
-- the table's own median is less exact.
CD_TOLERANCE_FLAT = 1.0,
CD_TOLERANCE_REL = 0.05,
-- Time from engage to first cast varies with how fast the pull is walked
-- into, so this is the loosest of the three. 4.0 was measured on 115 real
-- runs (ours and public): against 2.5 it resolved 2-3 points more casts
-- for a fraction of a point of wrong calls, while 6.0 doubled the wrong
-- calls on public logs.
FIRST_TOLERANCE = 4.0,
}
local M = CastAheadMatch
-- What the group is expected to do about a cast, read off what groups actually
-- did in the logs: how often it got interrupted, how often it died to something
-- else (a stun, a knock), how many people it hit and for what share of health.
M.KICK_SHARE = 0.25
M.CC_SHARE = 0.25
M.AOE_TARGETS = 3
M.HEAVY_DAMAGE = 0.20 -- share of health that makes group damage notable
M.HEAVY_SINGLE = 0.15 -- single-target hits are mitigated harder
-- `say` is spoken aloud by the addon; it has to name the response, not the
-- spell, because that is what the player has to decide in the moment.
M.ADVICE = {
KICK = { label = "KICK", say = "kick", r = 1.00, g = 0.20, b = 0.20 },
CC = { label = "STUN", say = "stun", r = 0.70, g = 0.40, b = 1.00 },
-- One target taking a large hit and the whole group taking one call for
-- different answers - a personal cooldown versus everyone reacting - so
-- they are separate verdicts rather than one "defensive".
-- `short` is the narrower spelling used under a nameplate icon when the
-- player asks for short labels: the icons step sideways by the width of
-- the words under them, so the longest verdict pushes every neighbour it
-- has away. Off by default - the full word teaches, the short one only
-- reminds - and the table and the voice always keep the whole word.
--
-- "BUSTER" rather than "TANK": the same four characters saved, and it
-- still reads as the threat rather than as a role assignment. Stacking it
-- over two lines was tried and is strictly worse - a two-line label is as
-- wide as its longest line, which is "BUSTER" either way, for twice the
-- height.
TANK = { label = "TANKBUSTER", short = "BUSTER", say = "tank buster", r = 1.00, g = 0.45, b = 0.10 },
AOE = { label = "AOE", say = "aoe damage", r = 1.00, g = 0.75, b = 0.15 },
DEFENSIVE = { label = "DEFENSIVE", short = "DEF", say = "defensive", r = 1.00, g = 0.35, b = 0.85 },
-- The tank's own save: whatever kills the tank is a tank buster to the tank,
-- so it is said and shown as one, and works as a save everywhere else.
BUSTER = { label = "TANKBUSTER", short = "BUSTER", say = "tank buster", r = 1.00, g = 0.45, b = 0.10, file = "TANK", sound = "TANK" },
SOON = { label = "BURST SOON", short = "SOON", say = "burst soon", r = 1.00, g = 0.65, b = 0.20 },
BURST = { label = "BURST", say = "burst now", r = 1.00, g = 0.30, b = 0.10 },
-- Categories assigned by the curated priority set (row.prio).
DODGE = { label = "DODGE", say = "dodge", r = 0.30, g = 0.90, b = 0.40 },
FRONTAL = { label = "FRONT", say = "frontal", r = 0.95, g = 0.90, b = 0.30 },
-- "Targets a player" - not necessarily you: the cast target is Secret in
-- 12.1, so nobody (the source curation included) can tell whose it is.
TARGET = { label = "TARGETED", say = "targeted", r = 0.95, g = 0.35, b = 0.95 },
DISPEL = { label = "DISPEL", say = "dispel", r = 0.40, g = 0.80, b = 1.00 },
-- Dispels by school: the voice line has to say whose job it is.
POISON = { label = "POISON", say = "dispel poison", r = 0.45, g = 0.90, b = 0.30 },
CURSE = { label = "CURSE", say = "dispel curse", r = 0.70, g = 0.35, b = 0.95 },
MAGIC = { label = "MAGIC", say = "dispel magic", r = 0.35, g = 0.65, b = 1.00 },
SOOTHE = { label = "SOOTHE", say = "soothe enrage", r = 1.00, g = 0.60, b = 0.60 },
PURGE = { label = "PURGE", say = "purge buff", r = 0.55, g = 0.85, b = 1.00 },
DISEASE = { label = "DISEASE", say = "dispel disease", r = 0.75, g = 0.65, b = 0.30 },
-- Nothing removes a bleed; the call is the defensive, not the dispel.
BLEED = { label = "BLEED", say = "bleed, defensive", r = 0.90, g = 0.25, b = 0.25 },
-- "SWAP" everywhere, not only when short labels are on: four characters
-- fit the icon's own width at the default size where six did not, and
-- nobody has to learn what it means. The voice still says the whole
-- instruction, which is what a spoken line has room for.
SWITCH = { label = "SWAP", say = "switch target", r = 0.85, g = 0.85, b = 0.85 },
ADDS ={ label = "ADDS", say = "adds incoming", r = 0.95, g = 0.70, b = 0.45 },
ALERT = { label = "WATCH", say = "danger", r = 1.00, g = 1.00, b = 1.00 },
}
-- Each verdict knows its own key: the voice clip on disk is named after it
-- (Sounds/<lang>/<key>.ogg), and labels with spaces ("ON YOU") are not file
-- names.
for key, advice in pairs(M.ADVICE) do
advice.key = key
advice.file = advice.file or key:gsub(" ", "_")
-- Only the verdicts long enough to crowd their neighbours carry a short
-- form; the rest are already short and read the same either way.
advice.short = advice.short or advice.label
end
M.ADVICE.SAVE = M.ADVICE.TANK -- old name, kept so saved settings still resolve
-- A curated category (row.prio) wins outright. Below that, `interruptible` is
-- what the game said about the cast happening right now and overrides the
-- statistics, since a mob can be immune this pull.
--
-- Otherwise `row.kickable` decides, and that comes from MDT - a fact about the
-- spell, not a tally of how often a group managed to kick it. The tally used to
-- decide, and it labelled an uninterruptible cast KICK because a boss spell with
-- the same cast time had been kicked a lot.
-- What the game told us a cast leaves on the player, learned in play: the
-- player's own auras are not Secret, so the first time a curated "targeted"
-- cast lands a Poison the call becomes "dispel poison" from then on.
-- "Bleed" is a dispel type of its own since 11.0, so the game does name it.
M.DISPEL_CATEGORY = { Magic = "MAGIC", Poison = "POISON", Curse = "CURSE", Disease = "DISEASE", Bleed = "BLEED" }
local REFINABLE = { TARGET = true, ALERT = true, DISPEL = true }
local function BaseAdvice(row, interruptible)
if not row then return nil end
if row.prio and REFINABLE[row.prio] and row.dispel and M.DISPEL_CATEGORY[row.dispel] then
return M.ADVICE[M.DISPEL_CATEGORY[row.dispel]]
end
-- A curated category beats anything derived from statistics: the priority
-- set is a human saying what this cast is, ours is a guess from tallies.
-- The one exception is a KICK the game just declared uninterruptible -
-- this pull it cannot be kicked, whatever the preset says.
if row.prio and M.ADVICE[row.prio]
and not (row.prio == "KICK" and interruptible == false) then
return M.ADVICE[row.prio]
end
if interruptible == true then
return M.ADVICE.KICK
end
if interruptible ~= false and row.kickable then
return M.ADVICE.KICK
end
-- Not kickable: a stun or other control is the way to stop it, if the logs
-- show the group stopping it at all.
if not row.kickable and (row.cc or 0) >= M.CC_SHARE then
return M.ADVICE.CC
end
if (row.hits or 0) >= M.AOE_TARGETS and (row.dmg or 0) >= M.HEAVY_DAMAGE then
return M.ADVICE.AOE
end
if (row.dmg or 0) >= M.HEAVY_SINGLE then
return M.ADVICE.TANK
end
return nil
end
M.SpecRole = function() return nil end
function M.SaveRow(id)
local data = CastAheadDefensives
local spells, alias = data and data.spells, data and data.alias
if not spells then return nil end
if spells[id] then return spells[id], id end
local to = alias and alias[id]
if to and spells[to] then return spells[to], to end
return nil
end
function M.SaveKey(save, role, prio)
if role == "TANK" and not save.raid and prio ~= "TANK" and (save.DAMAGER or save.HEALER) then return nil end
if not save[role] then return nil end
return role == "TANK" and "BUSTER" or "DEFENSIVE"
end
M.HasButton = function() return true end
function M.SaveAdvice(row)
local save = row and row.save
local role = save and M.SpecRole()
local key = role and M.SaveKey(save, role, row.prio)
-- "tank buster" names the threat, so the tank hears it with no button to press.
return key and (key == "BUSTER" or M.HasButton()) and M.ADVICE[key] or nil
end
-- A debuff landing on the player. A healer who can remove it is told to, and
-- does the defensive only when the dispel is not there; everyone else, and
-- the second of two bombs, still needs the defensive.
function M.AuraAdvice(row, prio)
local category = row.dispel and M.DISPEL_CATEGORY[row.dispel]
if category and category ~= "BLEED" and row.HEALER and M.SpecRole() == "HEALER"
and M.PlayerCanHandle and M.PlayerCanHandle(category) then
return M.ADVICE[category]
end
return M.SaveAdvice({ save = row, prio = prio })
end
function M.IsSave(advice)
return advice == M.ADVICE.DEFENSIVE or advice == M.ADVICE.BUSTER
end
local SAVE_REPLACES = {
[M.ADVICE.AOE] = true, [M.ADVICE.TANK] = true,
[M.ADVICE.BLEED] = true, [M.ADVICE.ALERT] = true,
}
function M.Beats(row, interruptible)
local base = BaseAdvice(row, interruptible)
if base and not SAVE_REPLACES[base] then return base end
local save = row and row.targeted and M.SaveAdvice(row)
if save and save ~= M.ADVICE.BUSTER then return M.ADVICE.TARGET end
return nil
end
function M.Advice(row, interruptible)
if not row then return nil end
return M.Beats(row, interruptible) or M.SaveAdvice(row) or BaseAdvice(row, interruptible)
end
M.MIN_OPENING_SAMPLES = 8
-- The opening delay is the loosest number in the table, so a thin one is worse
-- than none: Primal Juggernaut's rested on a single sample and claimed 17.8s
-- for a cast that lands in a couple of seconds.
function M.HasOpening(row)
return row and row.first ~= nil and (row.firstN or 0) >= M.MIN_OPENING_SAMPLES
end
-- Spells on a short cycle, or seen only once per pull, get no countdown: the
-- bar only names them while they are being cast.
function M.HasSchedule(row)
return row and not row.filler and row.cd and #row.cd > 0
end
-- Advice is only safe to show when every remaining candidate agrees. Six spells
-- share a 2.5s cast in Ruby Life Pools; taking the first one's verdict once told
-- the player to kick Primal Juggernaut's uninterruptible Crushing Smash.
local function SameCall(a, b)
return a == b or (a ~= nil and b ~= nil and a.file == b.file)
end
function M.ConsensusAdvice(candidates, interruptible)
if not candidates or #candidates == 0 then return nil end
local first = M.Advice(candidates[1], interruptible)
for i = 2, #candidates do
local advice = M.Advice(candidates[i], interruptible)
if not SameCall(advice, first) then return nil end
-- The tank's save and a plain tank buster say the same; the save carries the buttons.
if M.IsSave(advice) then first = advice end
end
return first
end
-- When the candidates disagree, the distinct calls they would make, in a
-- stable order - "KICK / DODGE" is still worth more to the player than a
-- blank: they know something is coming and which two answers are on the
-- table. Nil when there is agreement (ConsensusAdvice covers that) or when
-- nothing here has a call at all.
function M.SplitAdvice(candidates, interruptible)
if not candidates or #candidates < 2 then return nil end
local seen, out = {}, {}
for i = 1, #candidates do
local advice = M.Advice(candidates[i], interruptible)
if advice and not seen[advice.file] then
seen[advice.file] = true
out[#out + 1] = advice
end
end
if #out < 2 then return nil end
table.sort(out, function(a, b) return a.label < b.label end)
return out
end
-- Which candidate to name while several are still possible: the one seen most
-- often, rather than whichever happened to sort first.
function M.BestGuess(candidates)
local best
for i = 1, #candidates do
if not best or (candidates[i].n or 0) > (best.n or 0) then
best = candidates[i]
end
end
return best
end
local function IntervalMatches(cd, interval)
return math.abs(cd - interval) <= M.CD_TOLERANCE_FLAT + cd * M.CD_TOLERANCE_REL
end
-- Rotation slot to use for the cast after `index` casts, 1-based and cycling.
function M.CDAt(row, index)
local cd = row.cd
if not cd or #cd == 0 then return nil end
index = tonumber(index) or 1
return cd[(index - 1) % #cd + 1]
end
-- Which slot an observed interval fits, or nil. Used to line the rotation up
-- with where the mob actually is.
--
-- `expected` is the slot we thought was due, and it is tried first on purpose:
-- a rotation like {4.8, 4.8, 8.7} has two identical slots, and always taking
-- the first match would pin the mob to slot 1 forever and never predict the 8.7.
function M.SlotForInterval(row, interval, expected)
if not row.cd or #row.cd == 0 then return nil end
expected = tonumber(expected)
if expected then
local slot = (expected - 1) % #row.cd + 1
if IntervalMatches(row.cd[slot], interval) then
return slot
end
end
for i = 1, #row.cd do
if IntervalMatches(row.cd[i], interval) then
return i
end
end
return nil
end
-- Every spell in the dungeon whose length matches what we just measured.
-- `channel` says which kind was measured: a channel and a cast of the same
-- length are different abilities and never match each other.
function M.ByCastTime(rows, duration, channel)
local out = {}
if type(rows) ~= "table" then return out end
local wantChannel = channel == true
for i = 1, #rows do
local row = rows[i]
if (row.channel == true) == wantChannel
and math.abs(row.cast - duration) <= M.CAST_TOLERANCE then
out[#out + 1] = row
end
end
return out
end
function M.SoleChannel(rows, duration)
if type(rows) ~= "table" or #M.ByCastTime(rows, duration, true) > 0 then return nil end
for i = 1, #rows do
local row = rows[i]
if row.soleChannel and row.channel and duration <= row.cast + M.CAST_TOLERANCE then
return row
end
end
return nil
end
-- The interval a mob actually showed us separates candidates that share a cast
-- time. If nothing matches, the observation was off (interrupt, lag) - keep the
-- candidates rather than throwing the identification away.
function M.NarrowByInterval(candidates, interval)
local out = {}
for i = 1, #candidates do
if M.SlotForInterval(candidates[i], interval) then
out[#out + 1] = candidates[i]
end
end
return #out > 0 and out or candidates
end
function M.FollowFits(row, follow)
if row.followUnknown then return true end
if follow == false then return row.follow == nil end
return row.follow ~= nil and follow <= row.follow + M.CAST_TOLERANCE
end
function M.FollowExact(row, follow)
return type(follow) == "number" and not row.followUnknown and row.follow ~= nil
and math.abs(row.follow - follow) <= M.CAST_TOLERANCE
end
function M.NarrowByFollow(candidates, follow)
if follow == nil then return candidates end
local exact, fits, unknown = {}, {}, {}
for i = 1, #candidates do
local row = candidates[i]
if row.followUnknown then
unknown[#unknown + 1] = row
elseif M.FollowExact(row, follow) then
exact[#exact + 1] = row
end
if M.FollowFits(row, follow) then fits[#fits + 1] = row end
end
if #exact > 0 then
for i = 1, #unknown do exact[#exact + 1] = unknown[i] end
return exact
end
return #fits > 0 and fits or candidates
end
-- Time from the mob entering combat to this cast. Separates candidates on the
-- very first cast, before any interval exists - Sand-Sworn Rider opens at 20.6s
-- where Corrupted Guardian opens at 3.5s.
function M.NarrowByFirst(candidates, elapsed)
if not elapsed then return candidates end
-- A row with no opening data is dropped when a rival fits the window. That
-- reads harsh, and the gentler rule (keep the unknowns) was measured on 115
-- real runs: it resolved 1.5 points fewer casts and removed no wrong calls.
-- Rows without an opening are the rarely seen ones, and rarely seen is
-- itself evidence against them.
local out = {}
for i = 1, #candidates do
local first = M.HasOpening(candidates[i]) and candidates[i].first or nil
if first and math.abs(first - elapsed) <= M.FIRST_TOLERANCE then
out[#out + 1] = candidates[i]
end
end
return #out > 0 and out or candidates
end
-- A tank buster is nobody's call but the tank's and the healer's; the other
-- role-blind categories (kicks, dodges, AOE, frontals) are everyone's.
M.ROLE_HIDES = {
DAMAGER = { TANK = true },
}
-- Dispel-school categories are not a role question at all: a dps druid can
-- remove poison, a priest healer never can. Whether the character actually
-- knows a matching ability is the game's to answer, so Core supplies the
-- check; outside the game (or with the filter off) everything passes.
M.CAPABILITY_GATED = {
DISPEL = true, POISON = true, CURSE = true, MAGIC = true, DISEASE = true,
SOOTHE = true, PURGE = true,
}
-- Supplied by Core from the game (nil / permissive outside it or when the
-- filter is off).
M.PlayerRole = function() return nil end
M.PlayerCanHandle = function() return true end
-- The player's own star (true/false), nil when they left it to the curated set.
M.OwnImportant = function() return nil end
-- "Important casts only" from General.
M.ImportantOnly = function() return false end
-- The cast's call is one this character can act on: its role, and the
-- dispels it knows. Judged by the call the cast actually makes - a dispel
-- refined to the school it left, a cast the game just declared
-- interruptible - and by its curated category only when it makes none.
function M.FitsPlayer(row, interruptible)
local advice = M.Advice(row, interruptible)
local key = (advice and advice.key) or row.prio
if not key then return true end
local hides = M.ROLE_HIDES[M.PlayerRole()]
if hides and hides[key] then return false end
if M.CAPABILITY_GATED[key] and not M.PlayerCanHandle(key) then return false end
return true
end
-- "Important" means the player's own star when they set one, else a human
-- curated this cast into the priority set and the player can act on it. Kept
-- as functions here so the display filter, the sound gate and the Casts page
-- agree on the meaning.
function M.Curated(row)
if M.IsSave(M.Advice(row)) then return true end
return row.prio ~= nil and M.FitsPlayer(row)
end
function M.Important(row)
local own = M.OwnImportant(row.spell)
if own ~= nil then return own and M.Advice(row) ~= nil end
return M.Curated(row)
end
function M.OnlyImportant(rows)
local out = {}
for i = 1, #rows do
if M.Important(rows[i]) then out[#out + 1] = rows[i] end
end
return out
end
-- Whether a run shows and calls the cast at all. With "Important casts only"
-- that is the important set; without it, everything this character can act
-- on ("My role only"), plus whatever the player starred and every defensive
-- call.
function M.Shown(row, interruptible)
if M.ImportantOnly() then return M.Important(row) end
if M.OwnImportant(row.spell) == true then return M.Advice(row) ~= nil end
return M.IsSave(M.Advice(row, interruptible)) or M.FitsPlayer(row, interruptible)
end
-- `interruptible` is what the game says about the cast going out now, when
-- there is one; it can turn a statistical verdict into a kick.
function M.AnyShown(candidates, interruptible)
if not candidates then return false end
for i = 1, #candidates do
if M.Shown(candidates[i], interruptible) then return true end
end
return false
end
function M.OnlyShown(rows)
local out = {}
for i = 1, #rows do
if M.Shown(rows[i]) then out[#out + 1] = rows[i] end
end
return out
end
-- Spells switched off in the UI drop out entirely. Unlike the other filters
-- this one does not fall back to the full list: an empty result means the user
-- asked for no bar here.
function M.NarrowByEnabled(candidates, isDisabled)
if type(isDisabled) ~= "function" then return candidates end
local out = {}
for i = 1, #candidates do
if not isDisabled(candidates[i].spell) then
out[#out + 1] = candidates[i]
end
end
return out
end
-- UnitLevel is one of the few facts still readable off a hostile nameplate, so
-- it narrows candidates the moment a mob enters combat - before any cast. Rows
-- without a level (MDT did not list the creature) are kept rather than guessed
-- away.
function M.NarrowByLevel(candidates, level)
if not level or level <= 0 then return candidates end
local out = {}
for i = 1, #candidates do
local rowLevel = candidates[i].level
if not rowLevel or rowLevel == level then
out[#out + 1] = candidates[i]
end
end
return #out > 0 and out or candidates
end
-- UnitShouldDisplaySpellTargetName, read as the cast starts, says whether the
-- cast has a target - a property of the spell, not of the creature, so it
-- splits two spells of one mob that tie on everything else. Rows whose
-- `targeted` was never measured are kept.
function M.NarrowByTarget(candidates, targeted)
if targeted == nil then return candidates end
local out = {}
for i = 1, #candidates do
local rowTargeted = candidates[i].targeted
if rowTargeted == nil or rowTargeted == targeted then
out[#out + 1] = candidates[i]
end
end
return #out > 0 and out or candidates
end
function M.TargetRulesOut(candidates, targeted)
if targeted == nil or not candidates or #candidates == 0 then return false end
for i = 1, #candidates do
local rowTargeted = candidates[i].targeted
if rowTargeted == nil or rowTargeted == targeted then return false end
end
return true
end
-- Every spell in the dungeon a creature of this level could be about to cast.
-- If they all belong to one creature, the mob is identified before it acts.
-- `needFirst` restricts it to spells with a known opening delay, which is what
-- timing an opener requires; a preview wants the creature's whole kit.
-- `strict` demands the level actually match. Elsewhere a missing level counts
-- as compatible so nothing is lost, but for identifying a mob before it acts
-- that leniency lets every unlevelled row in and the answer is always ambiguous.
function M.OpeningCandidates(rows, level, needFirst, strict)
local out = {}
if type(rows) ~= "table" then return out end
for i = 1, #rows do
local row = rows[i]
local levelOk
if strict then
levelOk = level and row.level == level
else
levelOk = not level or not row.level or row.level == level
end
if levelOk and (M.HasOpening(row) or not needFirst) then
out[#out + 1] = row
end
end
return out
end
-- Every spell belonging to one creature, in the order it opens with them.
function M.SpellsOfNPC(rows, npc)
local out = {}
for i = 1, #rows do
if rows[i].npc == npc then
out[#out + 1] = rows[i]
end
end
table.sort(out, function(a, b) return (a.offset or a.first or 0) < (b.offset or b.first or 0) end)
return out
end
function M.SharedNPC(candidates)
local npc
for i = 1, #candidates do
if npc and candidates[i].npc ~= npc then return nil end
npc = candidates[i].npc
end
return npc
end
-- Soonest opening cast among candidates that all belong to one creature.
function M.SoonestOpening(candidates)
local best
for i = 1, #candidates do
if not best or candidates[i].first < best.first then
best = candidates[i]
end
end
return best
end
-- Casts already identified on this nameplate say which creature it is, so
-- candidates belonging to a different one are dropped - even if that leaves
-- nothing. The fallback that used to keep them is how a Shivan Punisher got
-- six other creatures' 2.0s spells for a cast of its own that is not in the
-- table. The caller only passes creatures it is sure about (track.sure). This is the cheap half of what
-- a shipped trait table would provide, using nothing the game keeps Secret: the
-- npc field is a grouping key inside our own data, never queried from a unit.
function M.NarrowByMob(candidates, knownNPCs)
if not knownNPCs or not next(knownNPCs) then return candidates end
local out = {}
for i = 1, #candidates do
if knownNPCs[candidates[i].npc] then
out[#out + 1] = candidates[i]
end
end
return out
end
-- Creatures that cannot be it any more: every one MDT placed in the dungeon
-- has already been seen dying. Unlike the other narrowings this one may
-- empty the list - a cast from a creature that no longer exists was never
-- that creature's, and an empty answer is the honest one.
function M.DropNPCs(candidates, exhausted)
if not exhausted or not next(exhausted) then return candidates end
local out = {}
for i = 1, #candidates do
if not exhausted[candidates[i].npc] then
out[#out + 1] = candidates[i]
end
end
return out
end
-- Shortest upcoming cooldown among candidates: warn early rather than late.
function M.ConsensusCD(candidates, index)
local best
for i = 1, #candidates do
local cd = M.HasSchedule(candidates[i]) and M.CDAt(candidates[i], index) or nil
if cd and (not best or cd < best) then best = cd end
end
return best
end