-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathScheduler.lua
More file actions
988 lines (936 loc) · 48.9 KB
/
Copy pathScheduler.lua
File metadata and controls
988 lines (936 loc) · 48.9 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
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
-- Scheduling: on ENCOUNTER_START, build a sorted reminder queue and drive it
-- with a single per-frame ticker. Each tick computes the imminent alert (within
-- its lead window) + the upcoming queue, and renders an anticipation countdown.
--
-- Phase-gated bosses: reminders carry a phaseIndex (offset from a phase that
-- begins on a variable trigger — a boss cast, a shield removal, or an HP%). Those
-- cues are deferred until we DETECT the phase live (via BigWigs/DBM boss-mod
-- callbacks — Midnight blocks addons from reading the combat log / enemy spellIds
-- directly), then scheduled relative to the moment the phase began for THIS pull.
-- Absolute cues are unchanged.
local _, ns = ...
local Scheduler = {}
ns.Scheduler = Scheduler
local LINGER = 1.0
-- 3-2-1 countdown window. A cue whose cast lands within this many seconds of the
-- PREVIOUS cast gets no spoken countdown (the lead spell's countdown already
-- covers the window — a back-to-back cue would only stutter a clipped "2..1").
local COUNTDOWN_LEAD = 3
local driver, pullTime, queue, activeId
-- phase state (live, phase-gated bosses only):
-- pendingPhase[idx] = { cue, ... } cues waiting for phase idx to begin
-- phaseTriggers = { {index, kind, spellId, occurrence, pct, seen, fired}, ... }
-- hasHealthTrigger = true if any trigger polls UnitHealth
local pendingPhase, phaseTriggers, hasHealthTrigger
-- C_EncounterTimeline sub-phase tracking (drives firePhase on BW-less bosses):
-- tlAddedAt[id] = elapsed when that timeline event was ADDED (survival fallback)
-- durById[id] = the event's info.duration (for dur-signature matching)
-- tlLastAdvance = elapsed of the last advance (debounce so one signal fires once)
-- tlLastSignal = 'entry' | 'exit' — alternation guard for 5-segment advance
-- tlCursor = index into phaseTriggers; the next phase a TL signal fires
local tlAddedAt, tlLastAdvance, durById, tlLastSignal, tlCursor
-- HP / boss-count diagnostic state. logBossHealth is defined AFTER recordCapture
-- (it calls it), so forward-declare here for tick() and run() to reference.
local logBossHealth, lastHpLog, lastBossCount, recordCapture
local TL_DEBOUNCE = 8 -- s: ignore further signals within this window. Its only
-- job is to collapse a boundary's burst of simultaneous
-- events (<1s apart) into one advance — the entry/exit
-- alternation already prevents same-type repeats. Kept
-- BELOW the shortest real sub-phase (Crawth fight 2564 sub-
-- phase 1 was ~14s entry→exit; 15s would have eaten its
-- exit and mis-anchored to the next rotation cycle).
-- NSRT-style dur signatures (from /coolplan capture): a boss's MAIN rotation event
-- durations. A main-dur event CANCELED (state=3) = sub-phase ENTRY; the main-dur set
-- re-ADDED = sub-phase EXIT (= next main phase). More robust than survival+occurrence
-- (no occ-skew). Gemellus uses unitCount → not listed here. Crawth (2564) & Lothraxion
-- (3333) reuse their rotation dur during the sub-phase, so their ENTRY comes from
-- PHASE_ENTRY_WARN instead (the dur sig here still drives their exit); L'ura (2068) is
-- likewise EXIT-ONLY here (entry = PHASE_ENTRY_ANYCANCEL, its cancel dur is laser-variable).
-- ✓ ALL FOUR VERIFIED in-game (2026-06-11/12): 3058 Kroluk &
-- 3213 Vordaza use a signature dur going state=3 for ENTRY (Vordaza's dur=70 cancels at
-- the Deathshroud entry); 2564 Crawth & 3333 Lothraxion are warning-gated (PHASE_ENTRY_
-- WARN) so their signature is EXIT-ONLY (the rotation set re-ADDED ends the sub-phase).
local PHASE_DUR_SIGNATURES = {
[2564] = { 20, 5, 14 }, -- Crawth (Gust/Peck/Screech)
[3213] = { 25.333, 3, 70, 14.166, 33.5 }, -- Vordaza
[3058] = { 1001, 999, 1004 }, -- Kroluk: long "main rotation" bars.
-- VERIFIED 2026-06-11 (in-game capture, fight 3058): the main-phase bars are
-- always freshly ADDED at exactly {1001, 999, 1004}; they go state=3 at each
-- sub-phase ENTRY and re-ADD at the EXIT. The 952-998 values seen in the capture
-- are transient "remaining-time" snapshots at the boundary — deliberately NOT
-- listed (an exact match avoids false EXIT fires right after an entry).
[3333] = { 2, 11, 52, 24 }, -- Lothraxion
[2068] = { 24, 12, 35 }, -- L'ura: main rotation SET (Discordant Beam 24 /
-- Disintegrate 12 / Grim Chorus 35). EXIT-ONLY (entry is PHASE_ENTRY_ANYCANCEL, below).
-- VERIFIED 2026-06-12 (capture fight 2068): the main set {1.5,24,12,35} re-ADDs together at
-- each sub-phase exit. 1.5 is DELIBERATELY EXCLUDED — it collides with the Symphony cast bar
-- (dur 1.5) that ADDs AT the entry, which (alternation just set "entry") would fire a false
-- EXIT one tick after the entry. 24/12/35 appear ONLY at the main-set re-ADD — never during
-- the intermission (which shows just 1.5/20) nor in the b-set (5/17/28) — so the first of them
-- after an entry = the rotation resumed = exit. Multi-value (like Kroluk/Crawth) is more
-- robust than a single dur if one value drifts run-to-run (sub-phases 2+ not yet captured).
}
local DUR_EPSILON = 0.3 -- s: float dur match tolerance (25.333, 14.166 …)
-- Warning-gated ENTRY. Some bosses don't surface a clean sub-phase CANCEL on a signature
-- duration, but DO herald the sub-phase with an ENCOUNTER_WARNING. `sev` = minimum
-- severity; optional `dur` pins the warning's own duration when severity alone doesn't
-- disambiguate. For these bosses the warning is the SOLE entry signal — the state=3 entry
-- path is suppressed (their signature durations also CANCEL as routine/exit churn, which
-- must not count as an entry). The signature is kept for the EXIT (rotation set re-ADDED).
-- Crawth (2564): Ruinous Winds = sev 2; routine telegraphs are sev 1, so sev alone
-- suffices. VERIFIED 2026-06-11: sub-phase warnings at 50.6s / 112.6s (both subs).
-- Lothraxion (3333): Divine Guile = a sev-2 warning of dur 3.5, but ROUTINE telegraphs
-- are ALSO sev 2 (dur 3) — so the 3.5 duration is the discriminator. VERIFIED
-- 2026-06-12 (two captures): dur=3.5 sev=2 precedes each sub-phase (player: ~52-53s
-- cycle, the actual mechanic begins ~9.5s later = cast 7.5s + ~2s); routine dur=3
-- sev=2 warnings are correctly excluded; exit = rotation set {2,11,52,24} re-ADDED.
local PHASE_ENTRY_WARN = {
[2564] = { sev = 2 }, -- Crawth: severity alone (routine = sev 1)
[3333] = { sev = 2, dur = 3.5 }, -- Lothraxion: routine warnings are also sev 2 (dur 3)
}
-- ANY-CANCEL ENTRY. A few bosses signal the sub-phase by interrupting their rotation
-- but the CANCELED event's duration isn't fixed (so it can't be dur-gated like
-- Kroluk/Vordaza). For these, ANY state=3 (cancel) during combat = sub-phase entry; the
-- exit still comes from the dur signature (rotation set re-ADD). The entry/exit
-- alternation + debounce keep one signal per boundary.
-- L'ura (2068): L'ura casts Symphony of the Eternal Night (annihilation) until Alleria
-- interrupts it; the cast START cancels whatever rotation event is currently active
-- (laser-variable: dur 28 in two captures, but which event is active shifts with the
-- laser-objective timing), so the entry can't key on a fixed dur. VERIFIED 2026-06-12
-- (two captures): during combat the ONLY state=3 was the intermission entry (~64s);
-- all other transitions were state=2. Combat-end cancels arrive with pullTime nil
-- (guarded below).
local PHASE_ENTRY_ANYCANCEL = {
[2068] = true, -- L'ura: Symphony interrupt cancels a laser-variable rotation event
}
-- preview mode: a virtual clock advanced by `speed`x each real tick, with an
-- optional onTick(elapsed, total) callback (used by the Timeline playhead).
local preview = false
local previewClock = 0
local previewSpeed = 1
local previewTotal = 0
local previewOnTick = nil
local previewPhaseStart = nil -- {[phaseIndex]=startMs}: synthetic phase windows (preview)
-- Preview has no live phase timing. The Timeline view passes its own per-phase start
-- offsets (opts.phaseStart) so the playhead/markers/HUD share one layout; this constant
-- is only a FALLBACK (seconds per phase) when a caller arms a preview without one.
local PREVIEW_PHASE_GAP = 30
local function nameMatchesMe(player)
if not player or player == "" then return true end
local me = UnitName("player")
if not me then return true end
return strlower(player) == strlower(me)
end
local function nowElapsed()
if preview then return previewClock end
if not pullTime then return 0 end
return GetTime() - pullTime
end
-- Push a cue onto the live queue at an absolute elapsed-seconds castAt, computing
-- its on-screen (lead) and audible (soundLead) windows. Shared by the initial
-- build and dynamic phase insertion.
local function enqueue(cue, castAt)
local o = ns.DB.Options()
local lead = o.leadSeconds or 4
local soundLead = o.soundLeadSeconds or 0
queue[#queue + 1] = {
cue = cue,
castAt = castAt,
showAt = math.max(0, castAt - lead),
soundAt = math.max(0, castAt - soundLead),
soundCued = false,
}
end
-- Sort the queue by castAt and (re)flag cues that follow the previous cast within
-- the countdown window so they skip their spoken countdown. Recomputed whenever
-- the queue changes (initial build + each phase insertion).
local function finalizeQueue()
table.sort(queue, function(a, b) return a.castAt < b.castAt end)
local prevCast
for _, item in ipairs(queue) do
item.silentCountdown = nil
if prevCast and (item.castAt - prevCast) < COUNTDOWN_LEAD then
item.silentCountdown = true
end
prevCast = item.castAt
end
end
-- A phase began (its trigger fired): anchor here and schedule its deferred cues
-- relative to this instant. One-shot per phase.
local function firePhase(index)
local pend = pendingPhase and pendingPhase[index]
if phaseTriggers then
for _, tr in ipairs(phaseTriggers) do
if tr.index == index then tr.fired = true end
end
end
-- A new phase began: any still-pending cue scheduled for an EARLIER phase is now
-- stale (that phase is over) and must NOT fire during this one. Drop un-cast queued
-- cues from every earlier phase. In a phased plan, absolute (no-pN) cues ARE phase 1
-- — `phaseIndex or 1` — so they're dropped too once we leave phase 1 (e.g. the boss
-- hit the sub-phase early, before all of phase 1's cooldowns came up).
if index and index > 1 and queue then
local now = nowElapsed()
for i = #queue, 1, -1 do
local q = queue[i].cue
if q and (q.phaseIndex or 1) < index and queue[i].castAt > now then
table.remove(queue, i)
end
end
end
if ns.debug then ns.Print("|cff66ff66CoolPlan phase " .. index .. " fired @ " .. string.format("%.1f", nowElapsed()) .. "s — scheduling " .. (pend and #pend or 0) .. " cue(s)|r") end
if not pend then return end
pendingPhase[index] = nil
local anchorElapsed = nowElapsed()
for _, cue in ipairs(pend) do
enqueue(cue, anchorElapsed + cue.timeMs / 1000)
end
finalizeQueue()
end
-- C_EncounterTimeline sub-phase advance (for BW-less bosses). The official timeline
-- has no usable info.phase; instead we read the entry/exit signals (a main rotation
-- event CANCELED / re-ADDED, or a high-severity warning) and walk the phase triggers
-- in order. We can't read which spellId each signal is, so the Nth signal fires the
-- Nth phase trigger (see timelineAdvancePhase's cursor).
-- Is this duration one of the active boss's MAIN rotation durations?
local function isMainDur(dur)
local sig = activeId and PHASE_DUR_SIGNATURES[activeId]
-- A Midnight-secret number still reports type=="number" but THROWS on arithmetic, so
-- guard it like the ENCOUNTER_WARNING path does before doing math.abs (else the first
-- ADDED/state=3 event on a boss whose duration is secret-guarded errors every frame).
if not sig or type(dur) ~= "number" or (issecretvalue and issecretvalue(dur)) then return false end
for _, d in ipairs(sig) do if math.abs(dur - d) < DUR_EPSILON then return true end end
return false
end
-- Advance to the NEXT phase in plan order. The TL signals give the ORDER of phase
-- transitions (sub-phase entry / exit alternating); we walk phaseTriggers 2→3→4→5
-- in turn via an independent cursor — NOT "lowest unfired". That distinction matters
-- when BigWigs is also running: BW fires the entry phases (e.g. Crawth p2/p4) directly
-- via bossModSpell, marking them fired; a "lowest unfired" walk would then make the
-- NEXT TL signal skip ahead to the exit phase at the entry's time. The cursor is immune
-- — firePhase is idempotent, so BW's pre-fire of a phase the cursor also reaches is
-- harmless. Gemellus (HP-gated) is handled by unitCount, not this.
-- Trade-off (code review #3, judged a NON-ISSUE in practice): a positional cursor can't
-- self-correct if a TL signal is ever spurious/missed (it would offset every later phase
-- by one). Accepted because the happy path is verified live (Crawth w/ BigWigs) and no
-- spurious/missed signal has been observed on any boss — each boundary emits exactly one
-- entry + one exit, and the debounce/alternation collapse simultaneous-cancel bursts. If
-- "cooldowns shifted by one phase / earlier-phase cues vanished" is ever reported, revisit
-- with a fire-highest-fired-index resync. (Not structurally impossible, just unobserved.)
local function timelineAdvancePhase()
if not phaseTriggers then return end
tlCursor = (tlCursor or 0) + 1
local tr = phaseTriggers[tlCursor]
if tr then firePhase(tr.index) end
end
-- Sub-phase ENTRY / EXIT signals, alternated (tlLastSignal) so an entry can't fire
-- twice in a row, and debounced so a boundary's burst of simultaneous events (a whole
-- rotation set canceled / re-added at once) advances the phase only once.
local function tlEntry(now)
local ok = tlLastSignal ~= "entry" and (now - (tlLastAdvance or -999)) >= TL_DEBOUNCE
if ns.debug then
local msg = "id=" .. tostring(activeId) .. " sig=" .. tostring(tlLastSignal) .. " cursor=" .. tostring(tlCursor) .. " → " .. (ok and "FIRE" or "skip")
if recordCapture then recordCapture("tl:ENTRY?", msg, "") end
ns.Print("|cffaaaaaa[tl] ENTRY @" .. string.format("%.1f", now) .. "s " .. msg .. "|r")
end
if ok then
tlLastAdvance, tlLastSignal = now, "entry"
timelineAdvancePhase()
end
end
local function tlExit(now)
local ok = tlLastSignal == "entry" and (now - (tlLastAdvance or -999)) >= TL_DEBOUNCE
if ns.debug then
local msg = "id=" .. tostring(activeId) .. " sig=" .. tostring(tlLastSignal) .. " cursor=" .. tostring(tlCursor) .. " → " .. (ok and "FIRE" or "skip")
if recordCapture then recordCapture("tl:EXIT?", msg, "") end
ns.Print("|cffaaaaaa[tl] EXIT @" .. string.format("%.1f", now) .. "s " .. msg .. "|r")
end
if ok then
tlLastAdvance, tlLastSignal = now, "exit"
timelineAdvancePhase()
end
end
-- Health-gated phases: poll boss unit frames; fire when any boss drops to/below
-- the threshold. Cheap, and only called when a health trigger is pending.
local function pollHealth()
if not phaseTriggers then return end
for _, tr in ipairs(phaseTriggers) do
if (not tr.fired) and tr.kind == "health" and tr.pct then
for u = 1, 5 do
local unit = "boss" .. u
if UnitExists(unit) then
local hp = UnitHealth(unit)
local mx = UnitHealthMax(unit)
-- Midnight: boss HP is a SECRET value inside instances — any compare/
-- arithmetic on it throws a Lua error. Skip when secret (health gating is
-- unsupported there; the phase slot still advances via the TL cursor, or
-- its cues suppress). This is why HP-gated bosses are modeled as `cast`/
-- `unitCount`, not `health` — but guard here so a stray health trigger
-- can never crash the player mid-pull.
local secret = issecretvalue and (issecretvalue(hp) or issecretvalue(mx))
if (not secret) and mx and mx > 0 then
local pc = hp / mx * 100
if pc > 0 and pc <= tr.pct then
firePhase(tr.index)
break
end
end
end
end
end
end
end
-- Driven every frame (OnUpdate) so bars deplete smoothly instead of stepping at
-- a tick. `dt` is the real frame delta; preview advances its virtual clock by
-- dt*speed.
local function tick(dt)
if not queue then return end
if (not preview) and not pullTime then return end
local o = ns.DB.Options()
if preview then previewClock = previewClock + (dt or 0) * previewSpeed end
if (not preview) and hasHealthTrigger then pollHealth() end
if (not preview) and pullTime then logBossHealth() end
local elapsed = nowElapsed()
local lead = o.leadSeconds or 4
local active = nil -- {reminder, remaining, total}
local upcoming = {}
local soonest, soonestItem = nil, nil -- nearest still-upcoming cast (for the countdown)
for _, item in ipairs(queue) do
-- preview: a cue is only "live" during its OWN synthetic phase window. Before its
-- phase starts it isn't visible; once the NEXT phase starts the live addon would
-- have dropped it (firePhase). Skip otherwise so the Test mirrors the real per-phase
-- visibility instead of showing every phase's cues at once.
local visible = true
if preview and previewPhaseStart then
local pidx = (item.cue and item.cue.phaseIndex) or 1
local startN = (previewPhaseStart[pidx] or 0) / 1000
local nextMs = previewPhaseStart[pidx + 1]
if previewClock < startN or (nextMs and previewClock >= nextMs / 1000) then
visible = false
end
end
if visible then
local remaining = item.castAt - elapsed
-- audible cue (sound / TTS) fires once when the SOUND lead window opens —
-- independent of the on-screen lead so audio can lead/trail the visuals.
if (not item.soundCued) and elapsed >= item.soundAt then
item.soundCued = true
ns.Reminders.Cue(item.cue, o)
end
-- track the single nearest upcoming cast in this same pass (the countdown
-- below speaks only that one, so it doesn't need its own queue scan).
if remaining > 0 and ((not soonest) or remaining < soonest) then
soonest, soonestItem = remaining, item
end
if elapsed >= item.showAt and elapsed <= item.castAt + LINGER then
-- in the anticipation window: nearest cast becomes the big alert,
-- any others fall into the queue
if (not active) or remaining < active.remaining then
if active then upcoming[#upcoming + 1] = { cue = active.cue, remaining = active.remaining } end
active = { cue = item.cue, remaining = remaining, total = lead }
else
upcoming[#upcoming + 1] = { cue = item.cue, remaining = remaining }
end
elseif remaining > 0 then
upcoming[#upcoming + 1] = { cue = item.cue, remaining = remaining }
end
end
end
-- spoken 3-2-1 countdown (TTS) over the final 3s before a cast — once per
-- whole second. Fired for ONLY the single soonest upcoming cast so overlapping
-- cues (a queued "next" spell landing inside the active spell's countdown
-- window) never stack two simultaneous countdowns. Independent of the alert
-- mode and the spell-name TTS.
if o.countdownVoice and soonestItem and not soonestItem.silentCountdown then
local sec = math.ceil(soonest)
if sec <= 3 and sec ~= soonestItem.cdLast then
soonestItem.cdLast = sec
ns.Reminders.SpeakCountdown(sec, o)
end
end
-- only preview cues casting within the lookahead window (default 10s), so the
-- queue doesn't list things still half a minute out.
local window = o.queueWindow or 10
for i = #upcoming, 1, -1 do
if upcoming[i].remaining > window then table.remove(upcoming, i) end
end
table.sort(upcoming, function(a, b) return a.remaining < b.remaining end)
local cap = o.queueCount or 3
while #upcoming > cap do table.remove(upcoming) end
ns.Reminders.RenderTick(active, upcoming, o)
if preview then
if previewOnTick then ns.safecall(previewOnTick, elapsed, previewTotal) end
-- auto-stop a short while past the last cast
if elapsed > previewTotal + LINGER + 1 then
Scheduler.Stop()
end
end
end
-- Per-frame driver (only runs OnUpdate while shown → while a schedule is active).
driver = CreateFrame("Frame")
driver:Hide()
driver:SetScript("OnUpdate", function(_, e) ns.safecall(tick, e) end)
-- ── live phase detection via boss mods (BigWigs / DBM) ───────────────────────
-- Midnight blocks addons from reading enemy spellIds / the combat log inside
-- instances (RegisterEvent on COMBAT_LOG_EVENT_UNFILTERED is forbidden, and
-- UNIT_SPELLCAST spellIds are "secret"). Boss mods have sanctioned access and
-- expose callbacks, so we consume THEM: the catalog trigger spellId is matched
-- against the spellId carried by BigWigs/DBM bar/message/timer events. The rest
-- (occurrence count → firePhase, scheduling) is unchanged. Needs BigWigs
-- (+LittleWigs for M+) or DBM; without either, phase-gated bosses stay absolute.
local BOSSMOD_DEBOUNCE = 3 -- seconds: collapse the several bar/message/timer
-- events a boss mod fires for ONE ability instance into a single occurrence.
-- Capture buffer: every boss-mod event seen during the current armed encounter,
-- with its elapsed time — so the trigger spellId can be read exactly (WoW chat
-- isn't copyable). `/coolplan capture` dumps it to a copyable editbox. Reset per
-- pull in run().
local capture = {}
local lastTimelinePhase -- last Encounter-Timeline info.phase seen (diagnostic)
function recordCapture(label, v, name)
if #capture >= 400 then return end
capture[#capture + 1] = { t = nowElapsed(), label = label, v = v, name = name or "?" }
end
-- HP / boss-count diagnostic (assigns the forward-declared upvalue above). Logs
-- boss1-5 health (+ whether it's secret) every ~2s, and the boss UNIT COUNT the
-- instant it changes. Midnight 12.0.5 may mark HP secret/forbidden in instances
-- (project_midnight_addon_restrictions); the count (1→3→5 on Gemellus) uses only
-- UnitExists (a basic API) so it may be a more robust split signal than HP or TL.
function logBossHealth()
-- Boss UNIT COUNT change (no throttle — catches the split instant). Gemellus:
-- 1 → 3 (occ1) → 5 (occ2 / 50%); boss5 appearing == the 50% split.
local count = 0
for u = 1, 5 do if UnitExists("boss" .. u) then count = count + 1 end end
if count ~= lastBossCount then
lastBossCount = count
recordCapture("BOSSCOUNT", tostring(count), "(boss unit count)")
-- Fire any unitCount-gated phase whose threshold the count just reached
-- (Gemellus 50% split = 5 units). UnitExists is readable in Midnight instances
-- (HP/spellID are secret), so this is the robust path where HP and TL-occurrence
-- both fail. firePhase is idempotent, so re-reaching the count is harmless.
if phaseTriggers then
for _, tr in ipairs(phaseTriggers) do
if (not tr.fired) and tr.unitCount and count >= tr.unitCount then
firePhase(tr.index)
end
end
end
end
local now = nowElapsed()
if lastHpLog and (now - lastHpLog) < 2 then return end
lastHpLog = now
for u = 1, 5 do
local unit = "boss" .. u
if UnitExists(unit) then
local hp = UnitHealth(unit)
local mx = UnitHealthMax(unit)
local val
if issecretvalue and (issecretvalue(hp) or issecretvalue(mx)) then
val = "<secret>"
elseif type(hp) == "number" and type(mx) == "number" and mx > 0 then
val = string.format("%.0f%% (%d/%d)", hp / mx * 100, hp, mx)
else
val = "hp=" .. tostring(hp) .. " max=" .. tostring(mx)
end
recordCapture("HP:boss" .. u, val, "(health probe)")
end
end
end
-- Feed a spellId (from a boss-mod callback) into phase detection. `fire` acts on
-- it (an at-the-moment signal); otherwise it's logged only (diagnostics).
-- Secret / non-number keys (some BigWigs bars, or 12.0 secret values) are
-- skipped for matching — they can't be compared to a catalog spellId.
local function bossModSpell(spellID, label, fire)
if not phaseTriggers then return end
if type(spellID) ~= "number" then
recordCapture(label, "key:" .. type(spellID), "(non-number)")
if ns.debug then ns.Print("|cff888888[bossmod " .. label .. "] non-number key (" .. type(spellID) .. ")|r") end
return
end
local nm = (C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(spellID)) or "?"
recordCapture(label, spellID, nm)
if ns.debug then
local match = ""
for _, tr in ipairs(phaseTriggers) do
if tr.spellId == spellID then match = " |cff66ff66<<< matches p" .. tr.index .. (fire and "" or " (log only)") .. "|r" end
end
ns.Print("|cffffcc44[bossmod " .. label .. "] " .. spellID .. " (" .. nm .. ")|r" .. match)
end
if not fire then return end
local now = GetTime()
for _, tr in ipairs(phaseTriggers) do
-- EXIT phases (removebuff / rotation-resume / interrupt) advance ONLY via the
-- Encounter-Timeline cursor, never via a boss-mod spellId match. They often reuse the
-- ENTRY's spellId (Kroluk/Vordaza: cast p2 and removebuff p3 share one id), so matching
-- them here would fire the exit at the ENTRY's time on one boss-mod message.
local exitKind = tr.kind == "removebuff" or tr.kind == "rotation-resume" or tr.kind == "interrupt"
if (not tr.fired) and (not exitKind) and tr.spellId == spellID then
if not (tr.lastSeen and (now - tr.lastSeen) < BOSSMOD_DEBOUNCE) then
tr.lastSeen = now
tr.seen = tr.seen + 1
if tr.seen >= (tr.occurrence or 1) then firePhase(tr.index) end
end
end
end
end
-- ── Encounter Timeline (official C_EncounterTimeline API) ─────────────────────
-- Midnight's SANCTIONED encounter-event feed — the same one NSRT uses (NOT
-- BigWigs/DBM). Events ENCOUNTER_TIMELINE_EVENT_ADDED/REMOVED/STATE_CHANGED +
-- ENCOUNTER_WARNING carry an `info` table (id, phase, duration, time, text, …).
-- CONFIRMED to fire in M+ dungeons (user test 2026-06-10). For now this only
-- DIAGNOSES: it logs every field (secret-guarded) into the capture buffer so one
-- pull reveals whether info.phase tracks our sub-phases — then we can drive
-- firePhase from it (cleaner than the BigWigs bridge: official, no BigWigs
-- dependency, no per-boss spellId curation). Fields may be `secret` inside
-- instances, so guard EVERY access.
local function safeVal(v)
if v == nil then return "nil" end
if issecretvalue and issecretvalue(v) then return "<secret>" end
return tostring(v)
end
local function onTimelineEvent(e, a1)
local line
if e == "ENCOUNTER_TIMELINE_EVENT_ADDED" then
local info = a1
local phase = info and info.phase
line = "id=" .. safeVal(info and info.id)
.. " phase=" .. safeVal(phase)
.. " dur=" .. safeVal(info and info.duration)
.. " text=" .. safeVal(info and info.text)
-- Track phase changes when `phase` is a real (non-secret) number — the signal
-- we hope to drive firePhase from once the info.phase→our-index mapping is known.
if type(phase) == "number" and not (issecretvalue and issecretvalue(phase)) then
if phase ~= lastTimelinePhase then
lastTimelinePhase = phase
recordCapture("TL:PHASE", "phase:" .. phase, "(phase change)")
if ns.debug then ns.Print("|cff00ff00[timeline] PHASE → " .. phase .. "|r") end
end
end
-- Record the ADDED time (survival fallback) and the duration (dur signature).
if phaseTriggers and type(info and info.id) == "number" then
tlAddedAt = tlAddedAt or {}
tlAddedAt[info.id] = nowElapsed()
if type(info.duration) == "number" and not (issecretvalue and issecretvalue(info.duration)) then
durById = durById or {}
durById[info.id] = info.duration
end
end
-- dur-signature EXIT: the MAIN rotation set re-ADDED after a sub-phase means the
-- sub-phase ended → next MAIN phase. tlExit only fires while the previous signal
-- was an entry, so the pull's initial main-set ADDED (and every normal rotation
-- cycle after an exit) is ignored.
if phaseTriggers and isMainDur(info and info.duration) then
tlExit(nowElapsed())
end
elseif e == "ENCOUNTER_TIMELINE_EVENT_STATE_CHANGED" then
local id = a1
local st = C_EncounterTimeline and C_EncounterTimeline.GetEventState and C_EncounterTimeline.GetEventState(id)
line = "id=" .. safeVal(id) .. " state=" .. safeVal(st)
-- state 3 = Canceled. A boss's MAIN rotation event canceled = sub-phase ENTRY, matched
-- by its dur SIGNATURE (Kroluk/Vordaza). SUPPRESSED for warning-gated bosses
-- (PHASE_ENTRY_WARN: Crawth/Lothraxion — their signature durations also cancel as
-- routine/exit churn, so state=3 here would mis-fire; entry is the warning). L'ura
-- (PHASE_ENTRY_ANYCANCEL) fires on ANY cancel (its entry-cancel dur is laser-variable),
-- but ONLY while pullTime is live — a fight's END cancels the whole rotation set at
-- once with pullTime nil (verified: combat-end state=3 burst at elapsed 0), which the
-- guard drops. Bosses with NO signature/anycancel use their OWN gate (Gemellus =
-- unitCount), so there is deliberately NO generic survival fallback — a "lived >= Ns
-- then canceled = entry" guess would fire Gemellus's p2 on any early bar cancel, before
-- the 5-unit split, and firePhase would consume its cues unrecoverably.
if st == 3 and phaseTriggers and activeId and not PHASE_ENTRY_WARN[activeId] then
if PHASE_ENTRY_ANYCANCEL[activeId] then
if pullTime then tlEntry(nowElapsed()) end -- L'ura: any cancel = entry (combat only)
elseif PHASE_DUR_SIGNATURES[activeId] and isMainDur(durById and durById[id]) then
tlEntry(nowElapsed()) -- Kroluk/Vordaza: signature cancel = entry
end
end
elseif e == "ENCOUNTER_TIMELINE_EVENT_REMOVED" then
line = "id=" .. safeVal(a1)
if tlAddedAt and type(a1) == "number" then tlAddedAt[a1] = nil end
if durById and type(a1) == "number" then durById[a1] = nil end
elseif e == "ENCOUNTER_WARNING" then
local info = a1
local sev = info and info.severity
local wdur = info and info.duration
line = "dur=" .. safeVal(wdur) .. " sev=" .. safeVal(sev)
-- warning-gated ENTRY: the sub-phase telegraph. Match severity, and (when the boss
-- needs it) the warning's own duration to tell the sub-phase cast apart from routine
-- telegraphs of the same severity (Crawth = sev only; Lothraxion = sev 2 + dur 3.5).
local cfg = activeId and PHASE_ENTRY_WARN[activeId]
if phaseTriggers and cfg and type(sev) == "number"
and not (issecretvalue and issecretvalue(sev)) and sev >= cfg.sev then
local durOk = true
if cfg.dur then
durOk = type(wdur) == "number" and not (issecretvalue and issecretvalue(wdur))
and math.abs(wdur - cfg.dur) < DUR_EPSILON
end
if durOk then tlEntry(nowElapsed()) end
end
else
line = safeVal(a1)
end
local short = e:gsub("ENCOUNTER_TIMELINE_EVENT_", ""):gsub("ENCOUNTER_", "")
recordCapture("TL:" .. short, line, "")
if ns.debug then ns.Print("|cff66ccff[timeline " .. short .. "] " .. line .. "|r") end
end
-- Registered once on PLAYER_LOGIN, after BigWigs/DBM have loaded (see Core.lua).
function Scheduler.InitBossMods()
local sources = {}
local BW = _G.BigWigsLoader
if BW and BW.RegisterMessage then
local proxy = {} -- any table works as the CallbackHandler registrant
-- key (3rd arg) is the spellId for spell bars/messages. We act on the
-- at-event Message AND StartBar: for the HP/objective-gated abilities we gate
-- on, BigWigs draws the cast bar AT the cast, so timing matches; the debounce
-- collapses the Message+Bar pair into one occurrence.
BW.RegisterMessage(proxy, "BigWigs_Message", function(_, _, key) bossModSpell(key, "BW:Msg", true) end)
BW.RegisterMessage(proxy, "BigWigs_StartBar", function(_, _, key) bossModSpell(key, "BW:Bar", true) end)
BW.RegisterMessage(proxy, "BigWigs_SetStage", function(_, _, stage)
if phaseTriggers then recordCapture("BW:SetStage", "stage:" .. tostring(stage), "(stage)") end
if ns.debug then ns.Print("|cffffcc44[bossmod BW:SetStage] " .. tostring(stage) .. "|r") end
end)
sources[#sources + 1] = "BigWigs"
end
local DBM = _G.DBM
if DBM and DBM.RegisterCallback then
DBM:RegisterCallback("DBM_Announce", function(_, _, _, _, spellId) bossModSpell(spellId, "DBM:Ann", true) end)
DBM:RegisterCallback("DBM_TimerStart", function(_, _, _, _, _, _, spellId) bossModSpell(spellId, "DBM:Timer", false) end)
sources[#sources + 1] = "DBM"
end
-- Official Encounter Timeline feed (sanctioned in Midnight instances incl. M+).
-- Always on (diagnostic) when the client exposes it; independent of BigWigs/DBM.
if C_EncounterTimeline then
local tf = CreateFrame("Frame")
for _, ev in ipairs({
"ENCOUNTER_TIMELINE_EVENT_ADDED",
"ENCOUNTER_TIMELINE_EVENT_REMOVED",
"ENCOUNTER_TIMELINE_EVENT_STATE_CHANGED",
"ENCOUNTER_WARNING",
}) do
pcall(tf.RegisterEvent, tf, ev) -- pcall: older clients may not have the event
end
tf:SetScript("OnEvent", function(_, e, a1) onTimelineEvent(e, a1) end)
sources[#sources + 1] = "EncounterTimeline"
end
ns.bossModSources = sources
if ns.debug then
ns.Print("|cff88ccffCoolPlan boss-mod bridge: " .. (#sources > 0 and table.concat(sources, "+") or "NONE — install BigWigs+LittleWigs or DBM for live phases") .. "|r")
end
end
-- Build a copyable dump of the capture buffer (every boss-mod event this pull,
-- with elapsed time) — WoW chat isn't copyable, so /coolplan capture shows this
-- in the editor box. Used to curate each phase boss's live trigger spellId.
function Scheduler.GetCaptureText()
if #capture == 0 then
return "COOLPLAN CAPTURE: nothing recorded.\nArm a plan (or /coolplan testenc <id>) then pull a boss — captures BigWigs (BW:*) AND official Encounter Timeline (TL:*) events. Then /coolplan capture."
end
local lines = {
"COOLPLAN CAPTURE (elapsed | source | value | name)",
" BW:* = BigWigs/DBM callbacks (id = spellId). TL:* = official C_EncounterTimeline.",
" Look for: TL:PHASE lines (does info.phase track the sub-phases?), and which",
" TL:ADDED / BW line lines up with each sub-phase entry/exit.",
}
for _, c in ipairs(capture) do
lines[#lines + 1] = string.format("%6.1fs | %-11s | %s | %s", c.t, c.label, tostring(c.v), c.name)
end
return table.concat(lines, "\n")
end
-- Run an arbitrary cue list (already filtered). Returns true if armed.
-- opts (optional): { preview=true, speed=1, onTick=fn } for the live preview,
-- and { phases = {...} } (live only) to enable phase re-anchoring.
local function run(cues, opts)
-- Callers set `activeId` just before calling run(); Stop() clears it, so save and
-- restore it across the cleanup. Without this, activeId is nil for the whole pull and
-- every per-encounter table lookup (PHASE_DUR_SIGNATURES / PHASE_ENTRY_WARN) misses.
local keepActiveId = activeId
Scheduler.Stop()
activeId = keepActiveId
local previewMode = opts and opts.preview
-- Capture-only arm: a phase boss with no saved plan still sets pullTime so
-- /coolplan capture timestamps are real (for trigger curation). No reminders.
local captureOnly = opts and opts.captureOnly
wipe(capture) -- fresh capture buffer per pull (for /coolplan capture)
lastTimelinePhase = nil
tlAddedAt = {}
durById = {}
tlLastAdvance = nil
tlLastSignal = nil
tlCursor = nil
lastHpLog = nil
lastBossCount = nil
queue = {}
pendingPhase = nil
phaseTriggers = nil
hasHealthTrigger = false
-- Build the live trigger table from the plan's phases. In preview there are no
-- real triggers, so phase cues are flattened to absolute (anchored at the pull)
-- just so the whole plan is visible in the Timeline test.
local phases = opts and opts.phases
if phases and not previewMode then
local triggers = {}
for _, p in ipairs(phases) do
local t = p.trigger
-- Register EVERY index>1 phase that carries a (recognized) trigger kind — even
-- when it has no spellId/pct (rotation-resume / TL-only exits). Such phases have
-- no BigWigs/CLEU match; they advance purely from the Encounter Timeline via
-- timelineAdvancePhase, so the index SLOT must exist or 2→3→4→5 breaks.
if p.index and p.index > 1 and t and t.kind then
triggers[#triggers + 1] = {
index = p.index, kind = t.kind, spellId = t.spellId,
occurrence = t.occurrence or 1, pct = t.pct, unitCount = t.unitCount,
seen = 0, fired = false,
}
if t.kind == "health" then hasHealthTrigger = true end
end
end
if #triggers > 0 then phaseTriggers = triggers end
if ns.debug and phaseTriggers then
recordCapture("tl:ARMED", "activeId=" .. tostring(activeId)
.. " warnEntry=" .. tostring(PHASE_ENTRY_WARN[activeId] ~= nil)
.. " durSig=" .. tostring(PHASE_DUR_SIGNATURES[activeId] ~= nil)
.. " triggers=" .. #phaseTriggers, "")
local ids = {}
for _, tr in ipairs(phaseTriggers) do
ids[#ids + 1] = "p" .. tr.index .. "=" .. tostring(tr.kind) .. ":" .. tostring(tr.spellId or tr.pct) .. "x" .. tostring(tr.occurrence or 1)
end
ns.Print("|cff88ccffCoolPlan armed " .. #phaseTriggers .. " phase trigger(s): " .. table.concat(ids, ", ") .. "|r")
end
end
local function phaseHasTrigger(idx)
if not phaseTriggers then return false end
for _, tr in ipairs(phaseTriggers) do
if tr.index == idx then return true end
end
return false
end
local maxCast = 0
for _, c in ipairs(cues) do
local pidx = c.phaseIndex
if pidx and pidx > 1 and phaseHasTrigger(pidx) then
-- defer until that phase's trigger fires
pendingPhase = pendingPhase or {}
pendingPhase[pidx] = pendingPhase[pidx] or {}
local list = pendingPhase[pidx]
list[#list + 1] = c
elseif (not previewMode) and phaseTriggers and pidx and pidx > 1 then
-- Phase-gated cue whose phase has NO live trigger to anchor it (plan/format
-- mismatch, a repeat-expansion gap, a future boss). For a phase boss an
-- offset-from-pull "absolute" time is meaningless, so firing it would show
-- WRONG timing — worse than nothing. Suppress it. If a boss's phase signature
-- breaks (e.g. a Blizzard hotfix shifts a dur), recovery is a fast addon
-- release (auto-updated via WoWUp/CurseForge), NOT a misleading fallback.
-- (Preview keeps the cue below so the Timeline test still renders the plan.)
-- GATED on `phaseTriggers`: only a phase-gated PLAN suppresses. A non-phase
-- plan (no @phase table) with a hand-edited pN+ row keeps its absolute time
-- below — for a non-phase boss the pull-relative time IS meaningful.
if ns.debug then
recordCapture("tl:SUPPRESS", "pidx=" .. tostring(pidx) .. " spell=" .. tostring(c.spellId), "")
end
else
-- genuinely absolute: pidx nil / phase 1, or the preview sequential layout
local castAt = c.timeMs / 1000
if previewMode and pidx and pidx > 1 then
-- No real phase timing in preview. Use the SAME sequential layout the Timeline
-- canvas draws its markers with (opts.phaseStart, ms), so the playhead, the
-- icons, and the HUD line up. Fall back to a fixed gap if none was passed.
local ps = opts and opts.phaseStart
local baseMs = (ps and ps[pidx]) or ((pidx - 1) * PREVIEW_PHASE_GAP * 1000)
castAt = (baseMs + c.timeMs) / 1000
end
enqueue(c, castAt)
if castAt > maxCast then maxCast = castAt end
end
end
if #queue == 0 and not pendingPhase and not captureOnly then return false end
finalizeQueue()
if previewMode then
preview = true
previewClock = 0
previewSpeed = opts.speed or 1
previewTotal = maxCast
previewOnTick = opts.onTick
previewPhaseStart = opts.phaseStart
pullTime = nil
else
pullTime = GetTime()
-- Phase triggers fire via the boss-mod bridge (registered once on login);
-- bossModSpell gates on phaseTriggers, so nothing extra is needed here.
end
driver:Show()
return true
end
-- Categories shown to EVERYONE regardless of "only me": group-relevant cues a
-- player should see even when they belong to someone else — raid-wide defensives
-- (공생기 / raid_defensive) and Bloodlust/Heroism (bloodlust), since the whole
-- party plans around the lust window. Healer cooldowns (healer_cd / 힐러 쿨기)
-- are NOT here — they are self-only and filtered to the local character when
-- filterToMe is on, like every other non-raid-wide category.
local ALWAYS_SHOWN = { raid_defensive = true, bloodlust = true }
-- Categories that don't identify a class/spec (everyone could share or item-grant
-- them), so they're ignored when guessing which slot is "me" by known spells.
local NONSPEC_CATEGORY = { trinket = true, potion = true, racial = true }
-- Resolve which plan slot is the LOCAL character, for the live "only me" filter.
-- 1) exact character-name match — how plans authored with your own toon name
-- already work (unchanged path).
-- 2) fallback when no name matches (site exports carry log/team names, not your
-- toon name): the slot whose cooldowns your CURRENT spec/talents actually
-- know, via IsPlayerSpell. Returns the SINGLE confident winner (strictly the
-- most known class/spec CDs, ≥1) or nil → no guess (caller then shows only
-- raid-wide cues, i.e. today's behavior). A same-spec slot outscores a
-- same-class-other-spec one (it knows more of the row's spells), so when your
-- spec is present it wins; ties / no match resolve to nil rather than guess.
local function resolveOwner(reminders)
local me = UnitName and UnitName("player")
if me then
for _, r in ipairs(reminders or {}) do
if r.player and r.player ~= "" and strlower(r.player) == strlower(me) then
return r.player
end
end
end
local known = IsPlayerSpell or IsSpellKnown
if not known then return nil end
local score, seen = {}, {}
for _, r in ipairs(reminders or {}) do
local pl, sid, cat = r.player, r.spellId, r.category or ""
if pl and pl ~= "" and sid and not ALWAYS_SHOWN[cat] and not NONSPEC_CATEGORY[cat] then
local key = pl .. "|" .. tostring(sid)
if not seen[key] then -- count each distinct (player, spell) once
seen[key] = true
local ok, res = pcall(known, sid)
if ok and res then score[pl] = (score[pl] or 0) + 1 end
end
end
end
local best, bestN, tie = nil, 0, false
for pl, n in pairs(score) do
if n > bestN then best, bestN, tie = pl, n, false
elseif n == bestN then tie = true end
end
if best and bestN >= 1 and not tie then return best end
return nil
end
-- Build the cue list of cooldowns (kind="cd"): raid_defensive + bloodlust shown
-- to all; others "only me" + enabled categories. Boss mechanics are NOT cued
-- here — boss timelines are display-only (the Timeline view's red boss track),
-- never on-screen/sound/TTS alerts (BigWigs/DBM already cover boss mechanics).
-- previewAll: the Timeline "Test" is a preview of the whole plan, so it must NOT
-- filter to the logged-in character's name or hide categories — otherwise testing
-- on a different char (or with log/team names that don't match) shows nothing.
-- forPlayer (preview only): show that exact player's casts (+ always-shown
-- categories), ignoring the live "only me"/category filters.
-- `_boss` is accepted (call-site symmetry) but unused: boss cues are not fired.
local function buildCues(reminders, _boss, previewAll, forPlayer)
local o = ns.DB.Options()
-- Live "only me": resolve my plan slot up front (exact name → else spec guess).
-- nil → no confident slot, so only raid-wide cues show (today's behavior).
local liveFilter = (not previewAll) and (not forPlayer) and o.filterToMe
local resolvedMe = liveFilter and resolveOwner(reminders) or nil
local cues = {}
for _, r in ipairs(reminders or {}) do
local common = ALWAYS_SHOWN[r.category or ""]
local meOk
if forPlayer then
meOk = common or (strlower(r.player or "") == strlower(forPlayer))
elseif liveFilter then
local pl = r.player
meOk = common or (not pl) or pl == ""
or (resolvedMe ~= nil and strlower(pl) == strlower(resolvedMe))
else
meOk = previewAll or common or (not o.filterToMe) or nameMatchesMe(r.player)
end
if meOk and (previewAll or forPlayer or ns.DB.CategoryEnabled(r.category)) then
cues[#cues + 1] = {
kind = "cd", timeMs = r.timeMs, spellId = r.spellId, phaseIndex = r.phaseIndex,
player = r.player, category = r.category, spellName = r.spellName, alert = r.alert,
}
end
end
return cues
end
-- Start from the encounter's ACTIVE plan (boss timeline + phases live on the plan).
function Scheduler.Start(encounterID)
local e = ns.DB.GetEncounter(encounterID)
if not e then return false end
local plan = e.plans[e.active]
local cues = buildCues(plan and plan.reminders or {}, plan and plan.boss)
if #cues == 0 then return false end
activeId = encounterID
return run(cues, { phases = plan and plan.phases })
end
-- Arm WITHOUT a plan, purely to capture boss-mod / Encounter-Timeline events with
-- real timestamps (trigger curation). Sets pullTime; shows no reminders. Used as
-- the testenc fallback when a boss has no saved plan yet. Arms BARE phase slots
-- (p2..p7, no cues) so the live TL/warning detection can still advance and LOG
-- "phase N fired" — letting you verify 2→3→4→5 detection without importing a plan.
-- Bare phase slots for plan-less capture. Must cover the MOST phases any boss can
-- export: timer-recurring bosses (Lothraxion/Vordaza) expand to 1 + maxOccurrences*2
-- phases (boss-phases.json repeat.maxOccurrences = 8 → up to index 17). Too few slots
-- and a long pull's later TL signals advance the cursor past the end (no "phase N fired"
-- log), making detection look truncated. Keep this ≥ that ceiling.
local CAPTURE_PHASE_SLOTS = 17
function Scheduler.StartCapture(encounterID)
activeId = encounterID
local phases = {}
for i = 2, CAPTURE_PHASE_SLOTS do phases[#phases + 1] = { index = i, trigger = { kind = "cast" } } end
return run({}, { captureOnly = true, phases = phases })
end
-- Manually fire a phase NOW (testing): isolates the cue display/scheduling path
-- from live CLEU detection. Arm with `/coolplan testenc <id>` first, then
-- `/coolplan firephase <n>` — the phase-N cues should schedule at now + offset.
function Scheduler.FirePhase(index)
if not pullTime then ns.Print("CoolPlan: arm a schedule first (/coolplan testenc <id>).") return false end
if ns.debug then ns.Print("|cff88ccffCoolPlan: manually firing phase " .. tostring(index) .. "|r") end
firePhase(index)
return true
end
-- A short synthetic schedule so the user can preview the countdown in town.
function Scheduler.StartDemo()
local me = UnitName("player")
local demo = {
{ kind = "cd", timeMs = 3000, spellId = 740, player = me, spellName = "Tranquility" },
{ kind = "cd", timeMs = 8000, spellId = 48707, player = me, spellName = "Anti-Magic Shell" },
{ kind = "cd", timeMs = 13000, spellId = 31884, player = me, spellName = "Avenging Wrath" },
}
activeId = -1
return run(demo)
end
function Scheduler.Stop()
if driver then driver:Hide() end
-- Boss-mod callbacks stay registered (once, on login); bossModSpell no-ops once
-- phaseTriggers is cleared below.
queue, pullTime, activeId = nil, nil, nil
pendingPhase, phaseTriggers, hasHealthTrigger = nil, nil, false
preview, previewClock, previewSpeed, previewTotal, previewOnTick = false, 0, 1, 0, nil
previewPhaseStart = nil
if ns.Reminders then ns.Reminders.Clear() end
end
-- Live preview of a plan, with no real encounter: virtual clock from 0.
-- `reminders`/`boss` come from a saved note. `speed` (default 1) accelerates
-- the virtual clock. `onTick(elapsed, total)` drives the Timeline playhead.
-- Honors the same filterToMe / category options as a real pull. Phase-anchored
-- cues are flattened to absolute (no live triggers exist in preview).
-- forPlayer: "__all__" = everyone, "<name>" = that player, nil/"" = the live
-- "only my character" filter (with a whole-plan fallback if it leaves nothing).
function Scheduler.StartPreview(reminders, boss, speed, onTick, forPlayer, phaseStart)
local cues
if forPlayer == "__all__" then
cues = buildCues(reminders or {}, boss, true)
elseif forPlayer and forPlayer ~= "" then
cues = buildCues(reminders or {}, boss, false, forPlayer)
else
cues = buildCues(reminders or {}, boss, false)
if #cues == 0 then cues = buildCues(reminders or {}, boss, true) end
end
if #cues == 0 then return false end
activeId = -2
return run(cues, { preview = true, speed = speed or 1, onTick = onTick, phaseStart = phaseStart })
end
function Scheduler.IsPreview()
return preview
end
function Scheduler.IsActive()
return (driver and driver:IsShown()) or false
end
function Scheduler.ActiveEncounter()
return activeId
end