Repository navigation
Expand file tree
/
Copy pathtest.lua
More file actions
541 lines (507 loc) · 31.4 KB
/
Copy pathtest.lua
File metadata and controls
541 lines (507 loc) · 31.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
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
-- Standalone check: lua test.lua (no WoW, no framework)
dofile("Data.lua")
dofile("Priority.lua")
dofile("Traits.lua")
dofile("Match.lua")
local M = CastAheadMatch
local failures = 0
local function check(ok, msg)
if not ok then
failures = failures + 1
print("FAIL: " .. msg)
end
end
local rows = {
{ spell = 1, npc = 10, cast = 2.5, cd = { 8.5 }, first = 3.9, firstN = 12 },
{ spell = 2, npc = 20, cast = 2.5, cd = { 29.1 }, first = 20.6, firstN = 12 },
{ spell = 3, npc = 20, cast = 4.5, cd = { 21.9, 27.9, 19.5 }, first = 6.1, firstN = 12 },
}
-- Cast time picks the field of candidates.
check(#M.ByCastTime(rows, 2.5) == 2, "cast time 2.5 should match two rows")
check(#M.ByCastTime(rows, 2.6) == 2, "cast time within tolerance should still match")
check(#M.ByCastTime(rows, 3.5) == 0, "cast time outside tolerance should match nothing")
-- A channel and a cast of the same length are different things; the caller
-- says which kind it measured and only that kind may match.
local mixed = {
{ spell = 10, npc = 1, cast = 6.0, cd = { 20 } },
{ spell = 11, npc = 1, cast = 6.0, cd = { 20 }, channel = true },
}
check(#M.ByCastTime(mixed, 6.0) == 1 and M.ByCastTime(mixed, 6.0)[1].spell == 10,
"a measured cast matches only cast rows")
check(#M.ByCastTime(mixed, 6.0, true) == 1 and M.ByCastTime(mixed, 6.0, true)[1].spell == 11,
"a measured channel matches only channel rows")
-- An observed interval narrows it and pins the rotation slot.
local both = M.ByCastTime(rows, 2.5)
check(#M.NarrowByInterval(both, 8.4) == 1, "an observed 8.4s interval should pick one spell")
check(M.NarrowByInterval(both, 8.4)[1].spell == 1, "and it should be the 8.5s one")
check(#M.NarrowByInterval(both, 60) == 2, "an interval matching nothing must not erase candidates")
local golem = {
{ spell = 20, npc = 3, cast = 2.0, cd = { 30 }, follow = 4.0 },
{ spell = 21, npc = 3, cast = 2.0, cd = { 30 }, follow = 8.0 },
{ spell = 22, npc = 4, cast = 2.0, cd = { 15 } },
}
check(#M.NarrowByFollow(golem, 8.0) == 1 and M.NarrowByFollow(golem, 8.0)[1].spell == 21,
"the channel that followed a cast picks the spell that turns into it")
check(#M.NarrowByFollow(golem, 20.0) == 3, "a follow-up channel matching nothing must not erase candidates")
check(#M.NarrowByFollow(golem, 4.0) == 1 and M.NarrowByFollow(golem, 4.0)[1].spell == 20,
"a channel of exactly one spell's length picks it over a longer one it could be cut from")
check(#M.NarrowByFollow(golem, 6.0) == 1 and M.NarrowByFollow(golem, 6.0)[1].spell == 21,
"a channel cut short still belongs to a spell whose channel is at least that long")
local unknown = { golem[1], golem[2], { spell = 23, npc = 5, cast = 2.0, cd = { 20 }, followUnknown = true } }
unknown[#unknown + 1] = { spell = 24, npc = 6, cast = 2.0, cd = { 20 } }
check(#M.NarrowByFollow(unknown, 4.0) == 2 and #M.NarrowByFollow(unknown, false) == 2,
"a spell said to channel for an unconfirmed length is never ruled out by a channel or by its absence")
check(M.NarrowByFollow(unknown, false)[1].spell == 23 and M.NarrowByFollow(unknown, false)[2].spell == 24,
"and it does not crowd out a spell that never channels when the cast ended without one")
check(#M.NarrowByFollow(unknown, 20.0) == 1 and M.NarrowByFollow(unknown, 20.0)[1].spell == 23,
"a channel longer than every known one is left to the spell whose length is unconfirmed")
local six = M.NarrowByFollow(unknown, 6.0)
check(#six == 2 and six[1].spell == 21 and six[2].spell == 23,
"a cut-short channel keeps the known spell it fits beside the unconfirmed one")
check(#M.NarrowByFollow(golem, nil) == 3, "an unknown follow-up narrows nothing")
check(#M.NarrowByFollow(golem, false) == 1 and M.NarrowByFollow(golem, false)[1].spell == 22,
"a cast that ended without a channel is not one that always turns into one")
check(M.SlotForInterval(rows[3], 27.5) == 2, "27.5s belongs to the second rotation slot")
check(M.SlotForInterval(rows[3], 40) == nil, "an interval in no slot returns nil")
-- The rotation cycles, so slot 4 is slot 1 again.
check(M.CDAt(rows[3], 1) == 21.9, "rotation starts at its first value")
check(M.CDAt(rows[3], 4) == 21.9, "rotation wraps around")
check(M.CDAt(rows[1], 7) == 8.5, "a single-value rotation is constant")
check(M.ConsensusCD(both, 1) == 8.5, "consensus cooldown is the shortest one")
check(M.HasOpening({ first = 5, firstN = 8 }), "a well-sampled opening delay is usable")
check(not M.HasOpening({ first = 2.7, firstN = 7 }), "one seen seven times is still too thin to claim openers")
check(not M.HasOpening({ first = 17.8, firstN = 1 }), "a single-sample one is not")
check(not M.HasOpening({ firstN = 9 }), "and neither is a missing one")
do
local aimed, spread, unknown = { targeted = true }, { targeted = false }, {}
local kept = M.NarrowByTarget({ aimed, spread, unknown }, true)
check(#kept == 2 and kept[1] == aimed and kept[2] == unknown,
"a cast with a target keeps targeted rows and rows never measured")
check(#M.NarrowByTarget({ aimed, spread }, nil) == 2, "an unreadable target narrows nothing")
check(#M.NarrowByTarget({ spread }, true) == 1, "and narrowing never empties the list")
end
-- Time to the opening cast separates candidates before any interval exists.
check(#M.NarrowByFirst(both, 3.6) == 1, "a 3.6s opening should pick the 3.9s spell")
check(M.NarrowByFirst(both, 3.6)[1].spell == 1, "and it should be that one")
check(#M.NarrowByFirst(both, 90) == 2, "an opening matching nothing must not erase candidates")
check(#M.NarrowByFirst(both, nil) == 2, "no engage time means no narrowing")
-- A spell switched off in the UI disappears, with no fallback.
check(#M.NarrowByEnabled(both, function(id) return id == 1 end) == 1, "a disabled spell drops out")
check(#M.NarrowByEnabled(both, function() return true end) == 0, "disabling all leaves nothing")
check(#M.NarrowByEnabled(both, nil) == 2, "no filter means no narrowing")
-- A cast already identified on the plate rules out other creatures' spells.
check(#M.NarrowByMob(both, { [20] = true }) == 1, "a known mob should drop the other candidate")
check(M.NarrowByMob(both, { [20] = true })[1].spell == 2, "and keep its own spell")
check(#M.NarrowByMob(both, { [99] = true }) == 0, "a known mob rules out every other creature's spell")
check(#M.NarrowByMob(both, nil) == 2, "nothing identified yet means no narrowing")
-- What to do about a cast: kicked often -> kick it, stopped some other way ->
-- control it, big or wide damage -> defensive. The game's own interruptible
-- flag overrides the table.
local kicked = { spell = 4, cast = 2.5, cd = { 3.6 }, kickable = true, kick = 0.5, cc = 0.2,
dmg = 0.3, filler = true }
local heavy = { spell = 5, cast = 4.0, cd = { 30.4 }, kick = 0, cc = 0.06, hits = 5, dmg = 0.5 }
local stunned = { spell = 6, cast = 5.0, cd = {}, kick = 0.05, cc = 0.34, filler = true }
check(M.Advice(kicked).label == "KICK", "an interruptible spell says KICK")
-- A spell people often kicked but MDT says is not interruptible must never say
-- KICK: that is how Primal Juggernaut got a KICK label it could not obey.
check(M.Advice({ spell = 8, cast = 2.5, cd = { 20 }, kick = 0.9, cc = 0.05, dmg = 0.05 }) == nil,
"kick statistics alone must not produce KICK")
check(M.Advice(kicked, false).label ~= "KICK", "an immune cast must not say KICK")
check(M.Advice(heavy).label == "AOE", "damage across the group says AOE")
-- One target taking a big hit is a different call from the group taking one.
check(M.Advice({ spell = 9, cast = 3, cd = { 20 }, hits = 1, dmg = 0.45 }).key == "TANK",
"one target taking a big hit says TANK")
-- Crushing Smash lands for 17.7%% and was silently unlabelled under the old bar.
check(M.Advice({ spell = 10, cast = 2.5, cd = { 20 }, hits = 1, dmg = 0.177 }).key == "TANK",
"a mitigated tank hit still says TANK")
check(M.Advice(heavy, true).label == "KICK", "but if the game says kickable, kick it")
check(M.Advice(stunned).label == "STUN", "casts stopped without kicks say STUN")
-- Advice is only shown when every remaining candidate agrees.
check(M.ConsensusAdvice({ kicked, kicked }).label == "KICK", "agreeing candidates keep their advice")
check(M.ConsensusAdvice({ kicked, heavy }) == nil, "disagreeing candidates give no advice")
check(M.ConsensusAdvice({}) == nil, "no candidates, no advice")
check(M.BestGuess({ { n = 3, spell = 1 }, { n = 40, spell = 2 } }).spell == 2,
"the best guess is the most-seen candidate")
check(M.Advice({ spell = 7, cast = 2, cd = { 20 }, kick = 0, cc = 0, dmg = 0.05 }) == nil,
"a harmless cast gets no label")
check(M.Advice(nil) == nil, "no row, no advice")
-- Short-cycle and one-off casts get named, never counted down to.
check(M.HasSchedule(heavy) == true, "a real rotation schedules")
check(not M.HasSchedule(kicked), "a filler does not schedule")
check(not M.HasSchedule(stunned), "a spell with no rotation does not schedule")
check(M.CDAt(stunned, 1) == nil, "no rotation means no cooldown value")
check(M.ConsensusCD({ kicked, stunned }, 1) == nil, "fillers contribute no timer")
-- The shipped data has to be sane, and resolvable in at most two casts.
local dungeons, spells, stuck = 0, 0, 0
for instanceID, rowsForMap in pairs(CastAheadData) do
dungeons = dungeons + 1
for i = 1, #rowsForMap do
local row = rowsForMap[i]
spells = spells + 1
check(row.cast >= 1.0, string.format("spell %d has no real cast bar", row.spell))
check(type(row.cd) == "table", string.format("spell %d has no cd table", row.spell))
check(type(row.kick) == "number" and type(row.cc) == "number",
string.format("spell %d is missing its threat numbers", row.spell))
if M.HasSchedule(row) then
for slot = 1, #row.cd do
check(row.cd[slot] >= 8.0,
string.format("spell %d slot %d would count down a filler (%.1f)",
row.spell, slot, row.cd[slot]))
end
else
-- Anything without a timer must justify its place some other way.
check(M.Advice(row) ~= nil,
string.format("spell %d has neither a timer nor a reason to show", row.spell))
end
-- After cast time and interval, candidates may still share a slot. That
-- only hurts when their cooldowns differ - then the bar shows the wrong
-- time, not merely the wrong name.
local same = M.HasSchedule(row)
and M.NarrowByInterval(M.ByCastTime(rowsForMap, row.cast, row.channel), row.cd[1]) or {}
for j = 1, #same do
if same[j].spell ~= row.spell and same[j].npc ~= row.npc and M.HasSchedule(same[j])
and math.abs(M.CDAt(same[j], 1) - row.cd[1]) > 2.0 then
stuck = stuck + 1
print(string.format(" wrong-timer risk in %d: spell %d vs %d (cast %.1f, cd %.1f/%.1f)",
instanceID, row.spell, same[j].spell, row.cast, row.cd[1], M.CDAt(same[j], 1)))
break
end
end
end
end
-- Where cast time and interval both collide, only the mob check can save it.
local rescued = 0
for _, rowsForMap in pairs(CastAheadData) do
for i = 1, #rowsForMap do
local row = rowsForMap[i]
local same = M.HasSchedule(row)
and M.NarrowByInterval(M.ByCastTime(rowsForMap, row.cast, row.channel), row.cd[1]) or {}
if #same > 1 and #M.NarrowByMob(same, { [row.npc] = true }) < #same then
rescued = rescued + 1
end
end
end
-- Creature traits (Traits.lua) ---------------------------------------------
-- Every trait row must be usable by the matcher as written: a dungeon we have
-- data for, a level to match on, and well-formed optional lists.
local traitRows, traitDungeons = 0, 0
for instanceID, rows in pairs(CastAheadTraits) do
traitDungeons = traitDungeons + 1
check(CastAheadData[instanceID] ~= nil,
string.format("traits reference unknown instance %s", tostring(instanceID)))
for i = 1, #rows do
local row = rows[i]
traitRows = traitRows + 1
check(type(row.npc) == "number" and type(row.level) == "number",
string.format("trait row %d in %s lacks npc or level", i, tostring(instanceID)))
check(type(row.elite) == "boolean", string.format("trait row for npc %s has no elite flag", tostring(row.npc)))
for _, key in ipairs({ "stage", "co", "casts", "channels" }) do
check(row[key] == nil or (type(row[key]) == "table" and #row[key] > 0),
string.format("trait row for npc %s has a malformed %s list", tostring(row.npc), key))
end
-- The generator drops the source's 604800 (one week) "unknown length"
-- sentinel; anything past a real channel's ceiling means it leaked in.
for j = 1, #(row.channels or {}) do
check(row.channels[j] > 0 and row.channels[j] <= 60,
string.format("npc %s has a channel length %.1fs outside (0, 60]", tostring(row.npc), row.channels[j]))
end
end
end
check(traitRows > 0, "no creature traits at all")
-- The matcher rejects a creature over any measured cast length its trait row
-- does not list, so every length Data.lua knows for the creature must be there.
for instanceID, rows in pairs(CastAheadTraits) do
for i = 1, #rows do
local row = rows[i]
if row.casts then
for _, spell in ipairs(CastAheadData[instanceID] or {}) do
-- Channel rows are checked against `channels` below.
if spell.npc == row.npc and not spell.channel then
local listed = false
for j = 1, #row.casts do
if math.abs(row.casts[j] - spell.cast) <= M.CAST_TOLERANCE then listed = true end
end
check(listed, string.format("npc %d: cast %.1fs (%s) is missing from its trait row",
row.npc, spell.cast, spell.name or "?"))
end
end
end
end
end
-- Channel lengths get the same guarantee as cast lengths, against `channels`.
for instanceID, rows in pairs(CastAheadTraits) do
for i = 1, #rows do
local row = rows[i]
for _, spell in ipairs(CastAheadData[instanceID] or {}) do
if spell.npc == row.npc and spell.channel then
local listed = false
for j = 1, #(row.channels or {}) do
if math.abs(row.channels[j] - spell.cast) <= M.CAST_TOLERANCE then listed = true end
end
check(listed, string.format("npc %d: channel %.1fs (%s) is missing from its trait row",
row.npc, spell.cast, spell.name or "?"))
end
end
end
end
-- Channels reach the game either harvested into Data.lua (channels.json gives
-- the length) or as provisional Extra rows; either way some must exist.
local channelRows = 0
for _, extras in pairs(CastAheadExtra) do
for i = 1, #extras do
if extras[i].channel then channelRows = channelRows + 1 end
end
end
for _, rowsForMap in pairs(CastAheadData) do
for i = 1, #rowsForMap do
if rowsForMap[i].channel then channelRows = channelRows + 1 end
end
end
check(channelRows > 0, "no channel rows were generated at all")
-- Curated priorities (Priority.lua) ----------------------------------------
-- Every category written by the generator must resolve to an advice entry, or
-- a prioritised cast would show an icon with no label and no voice line.
for spellID, category in pairs(CastAheadPriority) do
check(M.ADVICE[category] ~= nil,
string.format("spell %d has unknown priority category %s", spellID, tostring(category)))
end
-- The curation must actually cut the noise: a fair share of measured spells
-- stays unmarked, or "important only" filters nothing.
local marked, total = 0, 0
for _, rowsForMap in pairs(CastAheadData) do
for i = 1, #rowsForMap do
total = total + 1
if CastAheadPriority[rowsForMap[i].spell] then marked = marked + 1 end
end
end
check(marked > 0, "no measured spell carries a priority mark")
check(marked < total, "every measured spell is marked - the filter would be a no-op")
-- Extra rows carry provisional timings and must be usable by the same
-- matching pipeline as our own rows.
for instanceID, extras in pairs(CastAheadExtra) do
check(CastAheadData[instanceID] ~= nil,
string.format("extras reference unknown instance %d", instanceID))
for i = 1, #extras do
local row = extras[i]
check(type(row.cast) == "number" and row.cast > 0,
string.format("extra spell %d has no cast time", row.spell))
check(CastAheadPriority[row.spell] ~= nil,
string.format("extra spell %d is not in the priority set", row.spell))
check(row.approx == true,
string.format("extra spell %d must be marked approximate", row.spell))
for j = 1, #(CastAheadData[instanceID] or {}) do
local ours = CastAheadData[instanceID][j]
check(not (ours.spell == row.spell and ours.npc == row.npc),
string.format("extra spell %d duplicates a measured row", row.spell))
end
check(row.channel == nil or row.channel == true,
string.format("extra spell %d has a malformed channel flag", row.spell))
end
end
-- Advice: the curated category wins over statistics, except a KICK the game
-- says is uninterruptible right now.
check(M.Advice({ prio = "TANK", kickable = true }) == M.ADVICE.TANK,
"curated TANK must beat the kickable flag")
check(M.Advice({ prio = "KICK" }, false) ~= M.ADVICE.KICK,
"curated KICK must yield when the game says uninterruptible")
check(M.Advice({ prio = "KICK" }, nil) == M.ADVICE.KICK,
"curated KICK holds when interruptibility is unknown")
check(M.Advice({ kickable = true }) == M.ADVICE.KICK,
"rows without a curated category keep the statistical verdict")
-- Importance helpers drive the display filter and the sound gate.
check(M.AnyImportant({ { prio = "AOE" }, {} }), "one marked candidate makes the set important")
check(not M.AnyImportant({ {}, {} }), "no marked candidate, not important")
check(not M.AnyImportant(nil), "nil candidate set is not important")
check(#M.OnlyImportant({ { prio = "AOE" }, {} }) == 1, "OnlyImportant keeps just marked rows")
check(M.ADVICE.DEFENSIVE and M.ADVICE.DEFENSIVE.say == "defensive" and M.ADVICE.DEFENSIVE.file == "DEFENSIVE",
"one defensive call, spoken as defensive")
check(M.ADVICE.SMALL == nil and M.ADVICE.BIG == nil, "no small or big defensive call")
-- Every verdict has both voice clips on disk (tools/voice.py renders them);
-- a missing one would silently drop to the game voice or the beep.
for key, advice in pairs(M.ADVICE) do
if key ~= "SAVE" then
check(advice.key == key and advice.file, "ADVICE." .. key .. " lacks key/file")
for _, suffix in ipairs({ "", "_soon" }) do
local path = "Sounds/en/" .. advice.file .. suffix .. ".ogg"
local f = io.open(path, "rb")
check(f ~= nil, "voice clip missing: " .. path)
if f then f:close() end
end
end
end
local icon = io.open("Media/icon.tga", "rb")
check(icon ~= nil, "Media/icon.tga missing")
if icon then
local header = icon:read(18)
icon:close()
local w1, w2, h1, h2 = header:byte(13, 16)
check(w1 + w2 * 256 == 64 and h1 + h2 * 256 == 64, "Media/icon.tga must be 64x64")
end
-- The filter hides what the player cannot act on: TANK by role, dispel
-- schools by whether a matching ability is known. Unknown role/permissive
-- capability hides nothing.
for role, hides in pairs(M.ROLE_HIDES) do
for category in pairs(hides) do
check(M.ADVICE[category], "ROLE_HIDES." .. role .. " names unknown category " .. category)
end
end
for category in pairs(M.CAPABILITY_GATED) do
check(M.ADVICE[category], "CAPABILITY_GATED names unknown category " .. category)
end
local savedRole, savedCan = M.PlayerRole, M.PlayerCanHandle
M.PlayerRole = function() return "DAMAGER" end
check(not M.Important({ prio = "TANK" }), "a dps is not told about tank busters")
check(M.Important({ prio = "KICK" }), "a dps still gets kicks")
check(#M.OnlyImportant({ { prio = "TANK" }, { prio = "DODGE" }, {} }) == 1, "OnlyImportant applies the role")
M.PlayerRole = function() return "TANK" end
check(M.Important({ prio = "TANK" }), "a tank sees tank busters")
M.PlayerCanHandle = function(category) return category ~= "POISON" end
check(not M.AnyImportant({ { prio = "POISON" } }), "no poison dispel known, no poison call")
check(M.Important({ prio = "CURSE" }), "other schools stay while known")
check(M.Important({ prio = "DODGE" }), "role-blind categories are never capability-gated")
M.PlayerCanHandle = function() return false end
check(M.Important({ prio = "AOE" }), "AOE is not capability-gated")
check(not M.Important({ prio = "SOOTHE" }), "soothe needs a soothe ability")
check(not M.Important({ prio = "PURGE" }), "purge needs a purge ability")
check(not M.Important({ prio = "DISPEL" }), "generic dispel needs any friendly dispel")
M.PlayerRole = function() return nil end
M.PlayerCanHandle = function() return true end
check(M.Important({ prio = "POISON" }), "outside the game nothing is hidden")
M.PlayerRole, M.PlayerCanHandle = savedRole, savedCan
-- Save verdicts: the player's real role picks the verdict, a kick still wins.
M.SpecRole = function() return "DAMAGER" end
local saveRow = { spell = 900, cast = 2.0, save = { DAMAGER = true, TANK = true, lead = 2.5 }, hits = 5, dmg = 0.6 }
check(M.Advice(saveRow) == M.ADVICE.DEFENSIVE, "a dps gets the defensive call where the table marks dps")
M.SpecRole = function() return "HEALER" end
check(M.Advice(saveRow) == M.ADVICE.AOE, "a healer with no verdict keeps the old call")
M.SpecRole = function() return "TANK" end
check(M.Advice(saveRow) == M.ADVICE.AOE, "a hit that also lands on dps is no tank save, the old call stays")
check(M.Advice({ spell = 908, save = { TANK = true } }) == M.ADVICE.BUSTER, "a hit only the tank takes is a tank buster save")
check(M.ADVICE.BUSTER.say == "tank buster" and M.ADVICE.BUSTER.file == "TANK", "the tank's save is spoken as a tank buster")
M.SpecRole = function() return "DAMAGER" end
check(M.SaveAdvice({ save = { DAMAGER = true } }) == M.ADVICE.DEFENSIVE, "a dps row calls the defensive")
M.HasButton = function() return false end
check(M.SaveAdvice({ save = { DAMAGER = true } }) == nil, "no button in the list, no defensive call")
M.HasButton = function() return true end
M.SpecRole = function() return "TANK" end
check(M.Advice({ spell = 909, prio = "TANK", save = { DAMAGER = true, TANK = true } }) == M.ADVICE.BUSTER,
"a curated tank buster keeps the tank save even when dps were hit too")
check(M.SaveKey({ DAMAGER = true, TANK = true }, "TANK") == nil and M.SaveKey({ DAMAGER = true, TANK = true }, "DAMAGER") == "DEFENSIVE"
and M.SaveKey({ TANK = true }, "TANK") == "BUSTER",
"SaveKey applies the tank buster rule to tanks only")
local kickRow = { spell = 901, cast = 2.0, kickable = true, save = { TANK = true } }
check(M.Advice(kickRow) == M.ADVICE.KICK, "a kickable cast still says interrupt")
check(M.Advice({ spell = 902, prio = "KICK", save = { TANK = true } }) == M.ADVICE.KICK, "a curated kick still wins")
check(M.Advice({ spell = 903, prio = "DODGE", save = { TANK = true } }) == M.ADVICE.DODGE, "a curated dodge outranks the defensive call")
check(M.Advice({ spell = 903, prio = "TARGET", save = { TANK = true } }) == M.ADVICE.TARGET, "a curated target keeps its call, the save may be someone else's")
check(M.Advice({ spell = 910, targeted = true, save = { TANK = true } }) == M.ADVICE.BUSTER, "a targeted hit that only kills tanks is a tank buster to the tank")
check(M.Advice({ spell = 911, targeted = false, save = { TANK = true } }) == M.ADVICE.BUSTER, "an untargeted cast keeps its save")
check(M.Advice({ spell = 912, prio = "TANK", targeted = true, save = { TANK = true } }) == M.ADVICE.BUSTER, "a curated tank buster with a target is still a tank buster to the tank")
M.HasButton = function() return false end
check(M.Advice({ spell = 912, prio = "TANK", targeted = true, save = { TANK = true } }) == M.ADVICE.BUSTER, "no button in the list, the tank still hears the tank buster")
check(M.Advice({ spell = 913, prio = "AOE", save = { TANK = true } }) == M.ADVICE.BUSTER, "no button in the list, what only kills the tank is still a tank buster")
check(M.Important({ spell = 914, save = { TANK = true } }), "an uncurated tank-only hit with no button is still announced")
M.SpecRole = function() return "HEALER" end
check(M.Advice({ spell = 913, prio = "AOE", save = { TANK = true } }) == M.ADVICE.AOE, "the healer keeps the curated aoe call")
M.SpecRole = function() return "DAMAGER" end
M.HasButton = function() return true end
check(M.Advice({ spell = 915, targeted = true, save = { DAMAGER = true } }) == M.ADVICE.TARGET, "a cast the game shows a target for says targeted instead of a dps save")
check(M.Advice({ spell = 916, prio = "TANK", targeted = true, save = { TANK = true, DAMAGER = true } }) == M.ADVICE.TARGET,
"a dps still hears targeted on a curated tank buster that also kills dps")
M.SpecRole = function() return "TANK" end
local busterPair = { { spell = 917, prio = "TANK", save = { TANK = true } }, { spell = 918, prio = "TANK" } }
check(M.ConsensusAdvice(busterPair) == M.ADVICE.BUSTER and M.ConsensusAdvice({ busterPair[2], busterPair[1] }) == M.ADVICE.BUSTER,
"the tank's save and a plain tank buster agree, and the save is kept")
check(M.SplitAdvice({ busterPair[1], busterPair[2], { spell = 919, kickable = true } }) and #M.SplitAdvice({ busterPair[1], busterPair[2], { spell = 919, kickable = true } }) == 2,
"a split never lists tank buster twice")
check(M.Advice({ spell = 903, prio = "TARGET", dispel = "Poison", save = { TANK = true } }) == M.ADVICE.POISON, "a dispel outranks the defensive call")
check(M.Advice({ spell = 903, prio = "BLEED", save = { TANK = true } }) == M.ADVICE.BUSTER, "the defensive call outranks a curated bleed")
check(M.IsSave(M.ADVICE.DEFENSIVE) and M.IsSave(M.ADVICE.BUSTER) and not M.IsSave(M.ADVICE.AOE) and not M.IsSave(M.ADVICE.TANK),
"IsSave names the defensive call")
local savedPlayerRole = M.PlayerRole
M.PlayerRole = function() return nil end
check(M.Important({ spell = 904, save = { TANK = true } }), "a save row is important even with the role filter off")
check(not M.Important({ spell = 905, kickable = true, save = { TANK = true } }), "a kickable save row is not important by its verdict")
local savedCanHandle = M.PlayerCanHandle
M.PlayerCanHandle = function() return false end
check(not M.Important({ spell = 906, prio = "MAGIC", save = { TANK = true } }), "a dispel row the player cannot handle is not made important by a save it does not call")
check(M.Important({ spell = 907, prio = "TARGET", save = { TANK = true } }), "a curated target with a save verdict stays important")
M.PlayerCanHandle = savedCanHandle
M.PlayerRole = savedPlayerRole
M.SpecRole = function() return nil end
check(M.Advice(saveRow) == M.ADVICE.AOE, "outside the game no role means no save call")
dofile("SaveButtons.lua")
dofile("Defensives.lua")
dofile("BossCadence.lua")
for id, c in pairs(CastAheadBossCadence) do
local row = CastAheadDefensives.spells[id]
check(row and row.boss ~= "", "boss cadence " .. id .. " belongs to a boss row")
check(type(c.first) == "number" and type(c.cd) == "table" and type(c.steady) == "boolean", "boss cadence " .. id .. " has first, cd and steady")
for _, gap in ipairs(c.cd) do check(type(gap) == "number" and gap > 0, "boss cadence " .. id .. " gaps are positive numbers") end
end
dofile("BurstWindows.lua")
check(M.ADVICE.SOON and M.ADVICE.SOON.say == "burst soon", "SOON advice says burst soon")
check(M.ADVICE.BURST and M.ADVICE.BURST.say == "burst now", "BURST advice says burst now")
for enc, byDifficulty in pairs(CastAheadBurstWindows) do
check(type(enc) == "number" and enc > 0, "burst windows key " .. tostring(enc) .. " is an encounter id")
for difficulty, list in pairs(byDifficulty) do
check(difficulty == 8 or difficulty == 14 or difficulty == 15 or difficulty == 16,
string.format("burst windows %s use a game difficulty id, got %s", tostring(enc), tostring(difficulty)))
check(type(list.name) == "string" and list.name ~= "",
string.format("burst windows %d/%s carry a non-empty name", enc, tostring(difficulty)))
local last = {}
local sorted = {}
for i, w in ipairs(list) do sorted[i] = w end
table.sort(sorted, function(a, b) return (a.release or -1) < (b.release or -1) end)
for i, w in ipairs(sorted) do
check(w.event == nil or type(w.event) == "number" and w.event > 0 and w.event % 1 == 0,
string.format("burst window %d/%d #%d event is nil or an encounter event id", enc, difficulty, i))
check(w.event ~= nil or type(w.release) == "number" and w.release >= 0,
string.format("burst window %d/%d #%d has a release time from the pull", enc, difficulty, i))
check(w.role == nil or w.role == "HEALER",
string.format("burst window %d/%d #%d role is nil or HEALER", enc, difficulty, i))
check(w.target == nil or type(w.target) == "string",
string.format("burst window %d/%d #%d target is nil or a string", enc, difficulty, i))
if w.release then
local role = w.role or "damage"
check(last[role] == nil or w.release - last[role] >= 10,
string.format("burst %s windows %d/%d are at least 10 s apart", role, enc, difficulty))
last[role] = w.release
end
end
end
end
local shippedTables = {}
for spec, lists in pairs(CastAheadSaveButtons) do
check(type(lists.def) == "table" and #lists.def > 0 and lists.small == nil and lists.big == nil and lists.heal == nil,
"spec " .. spec .. " ships one def list and no small, big or heal list")
for _, e in ipairs(lists.def or {}) do
check(type(e) == "number" or type(e) == "table" and type(e.use) == "number",
"spec " .. spec .. " def entry is a spell id or an item use")
if type(e) == "table" then
check(not shippedTables[e], "spec " .. spec .. " def item entry is its own table")
shippedTables[e] = true
end
end
end
for id, row in pairs(CastAheadDefensives.spells) do
for _, role in ipairs({ "DAMAGER", "HEALER", "TANK" }) do
check(row[role] == nil or row[role] == true, "spell " .. id .. " has a valid " .. role .. " verdict")
check(row[role] == nil or type(row.ep) == "table" and type(row.ep[role]) == "number",
"spell " .. id .. " has an episode size for " .. role)
end
check(type(row.name) == "string" and type(row.mob) == "string" and type(row.boss) == "string", "spell " .. id .. " carries name, mob and boss strings")
check(row.name ~= "" and row.mob ~= "", "spell " .. id .. " has a non-empty name and mob")
check(type(row.dungeon) == "number" and CastAheadData[row.dungeon] ~= nil, "spell " .. id .. " names a known dungeon")
check(type(row.bar) == "boolean", "spell " .. id .. " has a boolean bar flag")
check(type(row.school) == "number" and row.school > 0 and row.school < 128,
"spell " .. id .. " has a damage school mask")
check(row.aoe == nil or type(row.aoe) == "boolean", "spell " .. id .. " has no or a boolean aoe flag")
end
for id, limit in pairs(CastAheadButtonLimits) do
check(limit == "magic" or limit == "physical" or limit == "aoe", "button " .. id .. " has a known limit")
end
print(string.format("%d dungeons, %d spells, %d unresolvable after two casts, %d needing the mob check",
dungeons, spells, stuck, rescued))
print(failures == 0 and "OK" or (failures .. " FAILURES"))
os.exit(failures == 0 and 0 or 1)