Repository navigation
Expand file tree
/
Copy pathpractice.lua
More file actions
4064 lines (3798 loc) · 155 KB
/
Copy pathpractice.lua
File metadata and controls
4064 lines (3798 loc) · 155 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
989
990
991
992
993
994
995
996
997
998
999
1000
--=========================================================================================
-- Twist Practice minigame: pure simulation engine (port phase 1 - no WoW API, no frames).
--
-- Ported from the archived builds under docs/swedge/SwedgeTimer_Forever/practice.lua
-- (byte-identical to the *_TBC copy); reference lines:
-- * 72-127 sim state fields + rotation presets
-- * 132-146 ability definitions / order (stop included)
-- * 1342-1426 recalc_speed: rating + Bloodlust haste math, locked presets, swing rescale
-- * 1431-1458 determine_rotation thresholds (delegated to nextaction.lua)
-- * 3026-3094 sim_cast_seal (apply / dual-seal twist window / same-seal collapse)
-- * 3096-3174 sim_cast_ability (cooldowns; judgement requires and consumes all seals)
-- * 3176-3213 sim_cast_buff (Avenging Wrath only; the AW combos are deferred)
-- * 3218-3247 start_sim_gcd
-- * 3252-3279 process_spell_queue (a queued ability fires when the GCD ends)
-- * 3284-3425 sim_update (tick: seal fade, buff expiries, BL auto-fire, swing completion)
-- * 3430-3531 sim_on_swing (twist grading, older-seal consume)
-- * 4201-4227 sim_stopattack / sim_startattack (pause / resume)
-- * 1669-1878 swing grading: classify / CS segment patterns / evaluate / analyze
--
-- The module is deliberately PURE (no WoW API, no addon-owned live state) so the whole
-- simulation can be unit-tested offline with tests/practice_state.lua. Every entry point
-- takes `now` explicitly; the caller owns the clock (in game: st.api.get_time()).
--
-- Caller contract (phase 2 builds against this):
-- * sim = st.practice.new_sim(). Call apply_haste(sim, base_speed, now) at fight start,
-- after begin_fight/end_fight, and whenever tick reports out.buffs_changed or
-- out.bl_fired (the engine cannot re-derive speed without the base weapon speed).
-- * begin_fight(sim, now, { bl_delay = d, locked_rotation = key }) starts the fight and
-- the first swing at the CURRENT effective_speed (like the archive's start_fight);
-- call apply_haste after it so an immediate Bloodlust rescales the swing. A delay of
-- 0 Bloodlusts immediately (unless locked); a positive delay fires from tick().
-- * per-frame: tick(sim, now, elapsed, out). On out.swing_ready the caller evaluates
-- on_swing(sim, now, out2), then calls start_swing(sim, now) to begin the next swing;
-- restarting the swing after a swing is deliberately the caller's job in phase 1.
-- * stopattack: cast(sim, "stop", now, out) or pause_attack(sim, now). Resume happens
-- either through cast() of a resume ability (cs/judge/sob/aw; out.resumed_attack) or
-- through resume_attack(sim, now, out). A held swing (out.held_swing) is immediately
-- due and resolves through the normal tick swing_ready path.
-- * the DST proc is caller-driven: gate it on your own enable setting and the rotation
-- lock, then call maybe_dst_proc(sim, now, math.random()) after on_swing.
-- * timeline: mirror out.timeline_event from on_swing into the caller's event array
-- (add the fight-relative time), then feed the whole array to analyze(events, count,
-- bands, band_count) at fight end. analyze() consumes events with key `seal_active`;
-- the on_swing payload carries both `seal` and `seal_active` so it can be passed
-- through directly once `time` has been added.
-- * moving cue strip (NEW, not an archive port): timeline_playhead_x / timeline_pps /
-- timeline_event_x / timeline_window / timeline_slice / timeline_cadence are the
-- pure geometry + schedule for the practice UI's playhead strip (fixed playhead,
-- upcoming actions approaching it). They are additive: the LOG path above is
-- unchanged and still feeds analyze().
--
-- Phase-1 sim additions beyond the archive's st.practice.sim fields (needed because the
-- archive kept this state in st.player / st.practice):
-- * swing_active / swing_ends_at - the current swing (absolute end time in the
-- caller's clock domain); archive st.player.swing_timer.
-- * gcd_active / gcd_expires - the simulated GCD window; archive st.player.
-- gcd_lockout + active_gcd_remaining.
-- * queue_window - spell-queue window seconds (archive db default 0.4).
-- * locked_rotation - locked preset key set by begin_fight, read by
-- apply_haste (locked presets bypass all haste) and by cast() (locked blocks hpot and
-- abacus). NOT cleared by reset(): the archive keeps the lock as a practice setting.
--
-- Deferred to later phases (deliberately NOT simulated here):
-- * the AW combos practice_aw_includes_pot / practice_aw_includes_sob (archive 3198-3208;
-- needs new options, out of the practice lane's scope)
-- * UI side effects (sounds, glow, result text, timeline frame rendering, bar overlays)
-- * the in-real-combat scoring pause (archive sim_on_swing 3434-3441): the caller decides
-- whether to call on_swing while in real combat.
--
-- cast() out flags, all reset on every call: gcd_started, gcd_blocked (true whenever a
-- GCD rejected the cast, including when it was queued), queued, seal_changed (seal
-- applied, or seals consumed by judgement), twist_armed (twist_attempted set),
-- buff_changed, cooldown_set, resumed_attack, ignored (no-op cast), plus the phase-1
-- additions out.judged_seal ("command"|"blood"|nil; command wins) and out.held_swing.
--
-- tick() out flags: seal_faded, buffs_changed, bl_fired, swing_ready, attack_paused,
-- attack_resumed, fight_elapsed (fight clock running this tick), queue_ready (a queued
-- ability fired); when a queued ability fires, its cast() flags are merged into the same
-- out table.
--=========================================================================================
local addon_name, st = ...
st.practice = st.practice or {}
-- Seconds the older seal keeps twisting after a new seal is cast (archive sim_cast_seal).
st.practice.SEAL_FADE_SECONDS = 0.4
-- Ability definitions (archive 132-143, verbatim). "stop" is the stopattack action;
-- cast("stop") routes to pause_attack(). Seals have no cooldown; hpot/abacus have their
-- own activation rules; aw carries its buff duration.
st.practice.ABILITIES = {
soc = { name = "Seal of Command", spell_id = 20375, type = "seal", cooldown = 0, seal_label = "command" },
sob = { name = "Seal of Blood", spell_id = 31892, type = "seal", cooldown = 0, seal_label = "blood" },
sotc = { name = "Seal of the Crusader", spell_id = 21082, type = "seal", cooldown = 0, seal_label = "crusader" },
cs = { name = "Crusader Strike", spell_id = 35395, type = "ability", cooldown = 6 },
judge = { name = "Judgement", spell_id = 20271, type = "ability", cooldown = 8 },
consec = { name = "Consecration", spell_id = 26573, type = "ability", cooldown = 8 },
aw = { name = "Avenging Wrath", spell_id = 31884, type = "buff", cooldown = 180, duration = 20 },
hpot = { name = "Haste Potion", spell_id = 28507, type = "haste_consumable", cooldown = 0, duration = 15 },
abacus = { name = "Abacus of Violent Odds", spell_id = 33807, type = "haste_trinket", cooldown = 120, duration = 10 },
stop = { name = "Stop Attack", texture = "Interface\\Icons\\Ability_DualWield", type = "stopattack", cooldown = 0 },
}
-- UI/keybind order (archive 146).
st.practice.ABILITY_ORDER = { "soc", "sob", "cs", "judge", "sotc", "consec", "aw", "hpot", "abacus", "stop" }
-- Locked-rotation presets (archive 122-127): speed/gcd pairs that force a rotation via
-- determine_rotation(). While locked, apply_haste() uses these and ignores all haste.
st.practice.PRESETS = {
["1/2"] = { speed = 3.5, gcd = 1.5 },
["2/3"] = { speed = 2.3, gcd = 1.15 },
["2/4"] = { speed = 1.8, gcd = 1.15 },
}
-- Session-local default keybinds (archive reset_all_mappings 3952-3956): the practice
-- UI copies this into its own table at session start. Persistence (the archived
-- practice_key_* db keys) needs new options and is deliberately not wired here.
st.practice.DEFAULT_KEYBINDS = {
soc = "1",
sob = "2",
cs = "3",
judge = "4",
sotc = "5",
consec = "6",
aw = "7",
hpot = "8",
abacus = "9",
stop = "",
}
-- Timeline lane definitions (archive 148-160, minus the spell-id/texture lookups the
-- caller resolves: the pure engine only maps ability keys to lane keys).
st.practice.TIMELINE_LANES = {
{ key = "sob", name = "SoB" },
{ key = "melee", name = "Melee" },
{ key = "cs", name = "CS" },
{ key = "consec", name = "Consec" },
{ key = "soc", name = "SoC" },
{ key = "judge", name = "Judge" },
}
st.practice.TIMELINE_LANE_INDEX = {
sob = 1, melee = 2, cs = 3, consec = 4, soc = 5, judge = 6,
}
-- Seal label -> timeline lane (archive 160); crusader has no lane (never logged).
st.practice.SEAL_LABEL_TO_LANE = { blood = "sob", command = "soc" }
-- Melee swing icon (archive sim_on_swing 3457).
st.practice.MELEE_TEXTURE = "Interface\\Icons\\INV_Sword_04"
-- Human-readable labels for grading tooltips (archive 1682-1690).
st.practice.SWING_ACTION_LABELS = {
blood_auto = "Seal of Blood auto",
cmd_auto = "Seal of Command auto",
twist = "Twist",
no_seal = "Auto (no seal)",
cs = "Crusader Strike + SoB auto",
judge_blood = "Judge (Seal of Blood)",
judge_command = "Judge (Seal of Command)",
}
-- Abbreviate a keybind string to max 3 characters for icon labels (archive
-- shorten_keybind 183-225, verbatim logic). Pure: no WoW API.
function st.practice.shorten_keybind(key)
if not key or key == "" then return "" end
local mods = ""
local base = key
if base:find("^CTRL%-") then
mods = "C"; base = base:sub(6)
end
if base:find("^SHIFT%-") then
mods = "S"; base = base:sub(7)
end
if base:find("^ALT%-") then
mods = "A"; base = base:sub(5)
end
local np = base:match("^NUMPAD(.+)")
if np then
local np_map = {
DECIMAL = ".", PLUS = "+", MINUS = "-", MULTIPLY = "*", DIVIDE = "/",
}
base = "N" .. (np_map[np] or np)
end
if base == "MOUSEWHEELUP" then
base = "MU"
elseif base == "MOUSEWHEELDOWN" then
base = "MD"
elseif base:match("^BUTTON(%d)") then
base = "M" .. base:match("^BUTTON(%d)")
end
local pad_map = {
PAD1 = "P1",
PAD2 = "P2",
PAD3 = "P3",
PAD4 = "P4",
PADLSHOULDER = "LB",
PADRSHOULDER = "RB",
PADLTRIGGER = "LT",
PADRTRIGGER = "RT",
PADLSTICK = "LS",
PADRSTICK = "RS",
PADDUP = "DU",
PADDDOWN = "DD",
PADDLEFT = "DL",
PADDRIGHT = "DR",
PADFORWARD = "FW",
PADBACK = "BK",
PADLSTICKUP = "LU",
PADLSTICKDOWN = "LD",
PADLSTICKLEFT = "LL",
PADLSTICKRIGHT = "LR",
PADRSTICKUP = "RU",
PADRSTICKDOWN = "RD",
PADRSTICKLEFT = "RL",
PADRSTICKRIGHT = "RR",
}
if pad_map[base] then base = pad_map[base] end
local result = mods .. base
return result:sub(1, 3)
end
-- Fight clock label for the panel (archive sim_update 3393: "Fight: m:ss").
-- Negative inputs clamp to 0.
function st.practice.format_fight_time(elapsed)
if type(elapsed) ~= "number" or elapsed < 0 then
elapsed = 0
end
local mins = math.floor(elapsed / 60)
local secs = math.floor(elapsed % 60)
return string.format("Fight: %d:%02d", mins, secs)
end
-- Timeline lane for a cast ability (archive sim_cast_seal 3087-3093 +
-- sim_cast_ability 3165-3168): seals log on their seal lane, cs/judge/consec on
-- their own lane, everything else (aw/hpot/abacus/stop/sotc) logs nothing.
function st.practice.cast_timeline_lane(ability_key)
if ability_key == "cs" or ability_key == "judge" or ability_key == "consec" then
return ability_key
end
local ab = st.practice.ABILITIES[ability_key]
if ab and ab.type == "seal" and st.practice.SEAL_LABEL_TO_LANE[ab.seal_label] then
return st.practice.SEAL_LABEL_TO_LANE[ab.seal_label]
end
return nil
end
--=========================================================================================
-- Sim state
--=========================================================================================
-- Fresh simulation state (archive 72-117 field-for-field, plus the phase-1 additions
-- documented in the header). All times are in the caller's clock domain.
function st.practice.new_sim()
return {
active_seals = {}, -- [seal_label] = true
n_active_seals = 0,
seal_1_label = nil, -- oldest seal (or only seal)
seal_2_label = nil, -- newest seal (during the twist window)
fading_seal_label = nil, -- seal fading after a new seal was cast
fading_seal_expires = 0,
effective_speed = 3.5, -- weapon speed after haste
effective_gcd = 1.5, -- GCD after haste
has_bloodlust = false,
has_haste_pot = false,
has_abacus = false,
has_dst = false,
bl_buff_expires = 0,
haste_pot_buff_expires = 0,
abacus_buff_expires = 0,
abacus_cooldown_expires = 0,
dst_buff_expires = 0,
dst_icd_expires = 0,
cs_cooldown_expires = 0,
judge_cooldown_expires = 0,
consec_cooldown_expires = 0,
aw_cooldown_expires = 0,
aw_buff_expires = 0,
fight_active = false,
fight_start_time = 0,
fight_bl_delay = 0,
fight_bl_fired = false,
attack_paused = false,
attack_pause_pending = false,
swing_held = false, -- swing completed during stopattack, held until resume
twist_attempted = false, -- a seal swap armed a twist this swing cycle
queued_ability = nil,
queue_timestamp = 0,
-- Phase-1 additions (see header).
swing_active = false,
swing_ends_at = 0,
gcd_active = false,
gcd_expires = 0,
queue_window = 0.4,
locked_rotation = nil,
}
end
-- Reset the sim for a new practice session (archive reset_sim_state 1170-1229, which is
-- called when practice mode starts). Deliberate archive quirks preserved:
-- * effective_speed/effective_gcd and locked_rotation are NOT reset (practice-level
-- state; the caller re-runs apply_haste() / begin_fight()).
-- * has_abacus/abacus_* and has_dst/dst_* are NOT reset either (the archive clears them
-- through stop_fight -> deactivate_* instead; end_fight() mirrors that here).
function st.practice.reset(sim)
sim.active_seals = {}
sim.n_active_seals = 0
sim.seal_1_label = nil
sim.seal_2_label = nil
sim.fading_seal_label = nil
sim.fading_seal_expires = 0
sim.has_bloodlust = false
sim.has_haste_pot = false
sim.bl_buff_expires = 0
sim.haste_pot_buff_expires = 0
sim.cs_cooldown_expires = 0
sim.judge_cooldown_expires = 0
sim.consec_cooldown_expires = 0
sim.aw_cooldown_expires = 0
sim.aw_buff_expires = 0
sim.fight_active = false
sim.fight_start_time = 0
sim.fight_bl_delay = 0
sim.fight_bl_fired = false
sim.attack_paused = false
sim.attack_pause_pending = false
sim.swing_held = false
sim.twist_attempted = false
sim.queued_ability = nil
sim.queue_timestamp = 0
sim.swing_active = false
sim.swing_ends_at = 0
sim.gcd_active = false
sim.gcd_expires = 0
end
--=========================================================================================
-- Haste / rotation
--=========================================================================================
-- Recalculate effective_speed / effective_gcd (archive recalc_speed 1342-1426).
-- * Locked rotation: the preset's speed/gcd win and every haste source is ignored.
-- * Otherwise: haste rating is additive (pot 400 + DST 325 + Abacus 260; 15.77 rating
-- per 1% at level 70); Bloodlust is a separate multiplicative category (/1.3); the
-- GCD floors at 1.0; both values round to 3 dp.
-- * A swing already in flight is capped to the new speed (remaining time may only
-- shrink), mirroring the archive's "swing_timer > speed -> swing_timer = speed".
-- base_speed nil or <= 0 falls back to 3.5 (the archive's UnitAttackSpeed fallback).
function st.practice.apply_haste(sim, base_speed, now)
if sim.locked_rotation then
local preset = st.practice.PRESETS[sim.locked_rotation]
if preset then
sim.effective_speed = preset.speed
sim.effective_gcd = preset.gcd
if sim.swing_active and (sim.swing_ends_at - now) > preset.speed then
sim.swing_ends_at = now + preset.speed
end
return
end
end
if type(base_speed) ~= "number" or base_speed <= 0 then
base_speed = 3.5
end
local speed = base_speed
local gcd = 1.5
local total_haste_rating = 0
if sim.has_haste_pot then total_haste_rating = total_haste_rating + 400 end
if sim.has_dst then total_haste_rating = total_haste_rating + 325 end
if sim.has_abacus then total_haste_rating = total_haste_rating + 260 end
local haste_from_rating = total_haste_rating / 15.77 / 100
speed = speed / (1 + haste_from_rating)
gcd = gcd / (1 + haste_from_rating)
if sim.has_bloodlust then
speed = speed / 1.3
gcd = gcd / 1.3
end
gcd = math.max(1.0, gcd)
speed = math.floor(speed * 1000 + 0.5) / 1000
gcd = math.floor(gcd * 1000 + 0.5) / 1000
sim.effective_speed = speed
sim.effective_gcd = gcd
if sim.swing_active and (sim.swing_ends_at - now) > speed then
sim.swing_ends_at = now + speed
end
end
-- Rotation key for the current speed/GCD (archive determine_rotation 1431-1458).
-- The locked key wins; otherwise the thresholds live in nextaction.lua
-- (st.nextaction.calculate_rotation, the archived rotation.lua:140-172) so they are not
-- duplicated here. TOCs load nextaction.lua before practice.lua; the guard returns nil
-- only if the module is loaded without it.
function st.practice.determine_rotation(sim, locked_rotation)
local locked = locked_rotation or sim.locked_rotation
if locked then
return locked
end
local na = st.nextaction
if na and na.calculate_rotation then
return na.calculate_rotation(sim.effective_speed, sim.effective_gcd)
end
return nil
end
-- Direct haste setters for the panel toggle buttons (archive activate/deactivate
-- 2592-2690, minus UI/sound). Unlike cast("hpot") these start no GCD, matching the
-- archive's button paths. Guarded against redundant activation (the original expiry
-- is kept); clearing is unconditional. The caller re-runs apply_haste() afterwards.
function st.practice.set_bloodlust(sim, now, on)
if on then
if sim.has_bloodlust then return false end
if sim.locked_rotation then return false end
sim.has_bloodlust = true
sim.bl_buff_expires = now + 40
return true
end
sim.has_bloodlust = false
sim.bl_buff_expires = 0
return true
end
function st.practice.set_haste_pot(sim, now, on)
if on then
if sim.has_haste_pot then return false end
if sim.locked_rotation then return false end
sim.has_haste_pot = true
sim.haste_pot_buff_expires = now + 15
return true
end
sim.has_haste_pot = false
sim.haste_pot_buff_expires = 0
return true
end
--=========================================================================================
-- Fight timeline data (pure; the frame rendering lives in practice_ui.lua)
--=========================================================================================
-- Append a timeline event (archive log_timeline_event 1881-1898): no-op while no
-- fight runs. `time` is fight-relative. Returns the new count.
function st.practice.push_timeline_event(events, count, sim, now, lane, texture, result, seal_active)
if not sim.fight_active then
return count
end
local next_count = (count or 0) + 1
events[next_count] = {
time = now - sim.fight_start_time,
lane = lane,
texture = texture,
result = result,
seal_active = seal_active,
}
return next_count
end
-- Latest event time (archive timeline_max_time, computed on demand).
function st.practice.timeline_max_time(events, count)
local max_time = 0
for i = 1, (count or 0) do
local event = events[i]
if event and event.time and event.time > max_time then
max_time = event.time
end
end
return max_time
end
--=========================================================================================
-- Moving practice timeline (pure geometry + schedule; the frames live in practice_ui.lua)
--=========================================================================================
-- The cue strip the operator asked for: a FIXED vertical playhead a quarter of the way
-- into the visible strip, and the upcoming actions scrolling right-to-left toward it.
-- An action's icon crosses the playhead at its `at` clock, which is the second the
-- drill wants it pressed. Everything here is arithmetic - no frames, no WoW API - so
-- the suites pin the maths without a client.
-- Where the playhead sits: a fraction of the strip's VISIBLE width.
st.practice.TL_PLAYHEAD_FRACTION = 0.25
-- How far ahead the strip looks, in seconds. Ten, not six: the operator's
-- reference strip shows ~13 s of clock with the playhead a quarter in (10 s to the
-- right), and the pixels-per-second follows the strip's screen width, so ten keeps
-- the cue speed of the reference at any width.
st.practice.TL_HORIZON_SECONDS = 10
-- The neutral cadence's fallback spacing: a class that publishes none of its own
-- PRACTICE_GCD_SECONDS / PRACTICE_CYCLE_SECONDS previews at these.
st.practice.TL_CADENCE_GCD = 1.5
st.practice.TL_CADENCE_CYCLE = 3.5
-- A fraction clamped to [0, 1]; a non-number reads the shipped default.
local function tl_fraction(value)
value = tonumber(value) or st.practice.TL_PLAYHEAD_FRACTION
if value < 0 then
return 0
elseif value > 1 then
return 1
end
return value
end
-- The playhead's x inside the strip: `fraction` of the VISIBLE width. A zero/absent
-- width answers 0 (nothing to place against), never a guess.
function st.practice.timeline_playhead_x(width, fraction)
width = tonumber(width) or 0
if width < 0 then
width = 0
end
return width * tl_fraction(fraction)
end
-- Pixels per second for a strip that shows `horizon` seconds to the RIGHT of the
-- playhead: the playhead is fixed, so the right part of the strip IS the horizon.
function st.practice.timeline_pps(width, fraction, horizon)
width = tonumber(width) or 0
horizon = tonumber(horizon) or st.practice.TL_HORIZON_SECONDS
if width <= 0 or horizon <= 0 then
return 0
end
local span = width * (1 - tl_fraction(fraction))
if span <= 0 then
return 0
end
return span / horizon
end
-- x of an event at the absolute clock `at`, seen at `now`: ahead of now sits to the
-- RIGHT of the playhead, a past event to the LEFT. Nil when either clock is unknown.
function st.practice.timeline_event_x(playhead_x, at, now, pps)
at = tonumber(at)
now = tonumber(now)
if not at or not now then
return nil
end
return (tonumber(playhead_x) or 0) + (at - now) * (tonumber(pps) or 0)
end
-- The absolute-clock window on screen, left edge .. right edge: `fraction` of the
-- width behind the playhead, the rest of the horizon ahead. A dead clock (pps <= 0)
-- collapses the window onto `now` (nothing can be placed).
function st.practice.timeline_window(now, width, pps, fraction)
now = tonumber(now) or 0
width = tonumber(width) or 0
pps = tonumber(pps) or 0
if pps <= 0 or width <= 0 then
return now, now
end
fraction = tl_fraction(fraction)
return now - (width * fraction) / pps, now + (width * (1 - fraction)) / pps
end
-- Index range of the ascending `events` that fall inside [from_at, to_at]; nil when
-- the window is empty. A pure read: no allocation, no re-order.
function st.practice.timeline_slice(events, count, from_at, to_at)
if type(events) ~= "table" or type(from_at) ~= "number" or type(to_at) ~= "number" then
return nil
end
local first, last
for i = 1, (count or 0) do
local event = events[i]
local at = type(event) == "table" and event.at or nil
if type(at) == "number" and at == at and at >= from_at and at <= to_at then
first = first or i
last = i
end
end
return first, last
end
-- Is this rotation row a cue the preview may show? The class's OWN published fact:
-- a row the tree cannot read today (`live == false`) or the engine cannot ask for
-- (`engine == false`) is not a cue.
function st.practice.timeline_row_is_cue(row)
return type(row) == "table" and row.live == true and row.engine == true
end
-- The neutral "rotation cadence (preview)" schedule: the class's own live+engine
-- rotation rows, one cue every `gcd`-ish second, walking the published list one
-- action per cue. `cycle` is the repeat length: a cycle carries round(cycle/gcd)
-- cues spread EVENLY over it (1.5 s GCD inside a 3.5 s cycle = two cues 1.75 s
-- apart), so the preview never bursts two cues half a GCD apart at a cycle
-- boundary and the cycle itself stays exact. Absolute times are anchored to
-- `anchor` (the fight/session start), so the cues MOVE as the clock advances
-- instead of being re-based on `now` every rebuild. `out` is reused slot-for-slot
-- (no per-tick allocation); the returned count is the truth, slots past it are
-- stale and must be ignored.
function st.practice.timeline_cadence(out, rotation, anchor, now, horizon, gcd, cycle)
out = type(out) == "table" and out or {}
local rows = type(rotation) == "table" and #rotation or 0
if rows == 0 then
return out, 0
end
now = tonumber(now) or 0
horizon = tonumber(horizon) or st.practice.TL_HORIZON_SECONDS
if horizon < 0 then
horizon = 0
end
gcd = tonumber(gcd) or st.practice.TL_CADENCE_GCD
if gcd <= 0 then
gcd = st.practice.TL_CADENCE_GCD
end
cycle = tonumber(cycle) or st.practice.TL_CADENCE_CYCLE
if cycle <= 0 then
cycle = st.practice.TL_CADENCE_CYCLE
end
local limit = now + horizon
local origin
anchor = tonumber(anchor)
if anchor and anchor == anchor then
if anchor >= now then
origin = anchor
else
-- the cycle containing `now`, so a long fight never walks every cycle
origin = anchor + math.floor((now - anchor) / cycle) * cycle
end
else
origin = now
end
local count = 0
local index = 0
local steps = math.floor(cycle / gcd + 0.5)
if steps < 1 then
steps = 1
elseif steps > 64 then
-- A pathological GCD/cycle pair (a class passing 0.01 s) cannot flood the
-- schedule: at most 64 cues per cycle, which is already denser than the
-- strip can draw.
steps = 64
end
local step_gap = cycle / steps
local cycle_start = origin
local guard = 0
while cycle_start <= limit and guard < 256 do
guard = guard + 1
for k = 0, steps - 1 do
local at = cycle_start + k * step_gap
if at > limit then
break
end
-- advance to the next cue row (skipping rows the class does not offer)
local row
for step = 1, rows do
local i = (index + step - 1) % rows + 1
local candidate = rotation[i]
if st.practice.timeline_row_is_cue(candidate) then
row = candidate
index = i
break
end
end
if not row then
break
end
if at >= now then
count = count + 1
local slot = out[count]
if type(slot) ~= "table" then
slot = {}
out[count] = slot
end
slot.at = at
slot.key = row.action
slot.label = row.label or row.action
slot.spell_id = row.spell_id
-- A row may name its own lane; otherwise the lane is the one the
-- sim's own event log uses for that action (cast_timeline_lane),
-- so the preview lands on the same rail as the recorded fight.
slot.lane = row.lane or st.practice.cast_timeline_lane(row.action)
slot.texture = nil
end
end
cycle_start = cycle_start + cycle
end
return out, count
end
-- Opening rotation band for a fight (archive start_fight 2866-2876): one band at
-- time 0 when the rotation resolves, none when it does not.
function st.practice.bands_for_fight(rotation)
if not rotation then
return {}, 0
end
return { { start_time = 0, rotation = rotation } }, 1
end
-- Record a rotation change as a timeline band (archive update_rotation_tracking
-- 1557-1579, minus the display/HUD half which lives in practice_ui.lua). Appends
-- only when the rotation differs from the last band. Returns the new band count.
function st.practice.track_rotation_band(bands, band_count, rotation, fight_time)
if not rotation then
return band_count
end
band_count = band_count or 0
local last = bands[band_count]
if last and last.rotation == rotation then
return band_count
end
local next_count = band_count + 1
bands[next_count] = { start_time = fight_time, rotation = rotation }
return next_count
end
--=========================================================================================
-- Fight lifecycle
--=========================================================================================
-- Start a fresh swing: it completes after the current effective_speed (the archive's
-- st.player.reset_swing_timer is called by start_fight and sim_startattack).
function st.practice.start_swing(sim, now)
sim.swing_active = true
sim.swing_ends_at = now + sim.effective_speed
end
-- Begin a boss fight (archive start_fight 2807-2889). Starts with Seal of Blood, starts
-- the swing, and fires Bloodlust immediately when bl_delay is 0 and the rotation is not
-- locked. The caller should apply_haste() afterwards (Bloodlust changes the speed), and
-- must call end_fight() before starting another fight.
function st.practice.begin_fight(sim, now, opts)
if sim.fight_active then return end
opts = opts or {}
sim.fight_active = true
sim.fight_start_time = now
sim.fight_bl_delay = opts.bl_delay or 0
-- A stop-and-restart inside a swap fade window must not leak the pending twist into
-- the next fight (the first swing would grade a spurious MISS).
sim.twist_attempted = false
-- A locked rotation survives across fights (archive keeps the lock): only an explicit
-- non-nil argument changes it, so an empty opts table cannot silently clear it.
if opts.locked_rotation ~= nil then
sim.locked_rotation = opts.locked_rotation
end
if sim.locked_rotation then
sim.fight_bl_fired = true -- locked rotations never auto-Bloodlust
else
sim.fight_bl_fired = false
end
sim.attack_paused = false
sim.attack_pause_pending = false
sim.queued_ability = nil
sim.queue_timestamp = 0
sim.active_seals = {}
sim.active_seals["blood"] = true
sim.n_active_seals = 1
sim.seal_1_label = "blood"
sim.seal_2_label = nil
sim.fading_seal_label = nil
sim.fading_seal_expires = 0
st.practice.start_swing(sim, now)
if not sim.locked_rotation and sim.fight_bl_delay == 0 then
sim.fight_bl_fired = true
sim.has_bloodlust = true
sim.bl_buff_expires = now + 40
end
end
-- End the fight (archive stop_fight 2891-2953): clears the queue, attack/pause state,
-- timed buffs (Bloodlust/pot/Abacus/DST) and all cooldowns. locked_rotation persists.
-- The caller must apply_haste() afterwards to re-derive speed/GCD.
function st.practice.end_fight(sim)
if not sim.fight_active then return end
sim.queued_ability = nil
sim.queue_timestamp = 0
sim.fight_active = false
sim.fight_start_time = 0
sim.fight_bl_delay = 0
sim.fight_bl_fired = false
sim.attack_paused = false
sim.attack_pause_pending = false
sim.swing_held = false
sim.swing_active = false
sim.swing_ends_at = 0
sim.has_bloodlust = false
sim.bl_buff_expires = 0
sim.has_haste_pot = false
sim.haste_pot_buff_expires = 0
sim.has_abacus = false
sim.abacus_buff_expires = 0
sim.has_dst = false
sim.dst_buff_expires = 0
sim.cs_cooldown_expires = 0
sim.judge_cooldown_expires = 0
sim.consec_cooldown_expires = 0
sim.aw_cooldown_expires = 0
sim.aw_buff_expires = 0
sim.abacus_cooldown_expires = 0
sim.dst_icd_expires = 0
end
--=========================================================================================
-- Simulated cast
--=========================================================================================
-- Abilities that resume the attack out of a stopattack pause (archive 335).
local RESUME_ABILITIES = { cs = true, judge = true, sob = true, aw = true }
-- Cast a simulated ability (archive sim_cast 2958-3024 + the per-type bodies below).
-- See the header for the full out-flag contract. "stop" routes to pause_attack().
function st.practice.cast(sim, ability_key, now, out)
out.gcd_started = false
out.gcd_blocked = false
out.queued = false
out.seal_changed = false
out.twist_armed = false
out.buff_changed = false
out.cooldown_set = false
out.resumed_attack = false
out.ignored = false
out.judged_seal = nil
out.held_swing = nil
local ab = st.practice.ABILITIES[ability_key]
if not ab then
out.ignored = true
return out
end
if ability_key == "stop" then
st.practice.pause_attack(sim, now)
return out
end
-- GCD gate (archive 2972-2983): Judgement, AW and the trinket are off-GCD.
if ability_key ~= "judge" and ab.type ~= "buff" and ab.type ~= "haste_trinket" then
local remaining = sim.gcd_expires - now
if sim.gcd_active and remaining > 0 then
local queue_window = sim.queue_window or 0.4
if queue_window > 0 and remaining <= queue_window then
sim.queued_ability = ability_key
sim.queue_timestamp = now
out.queued = true
end
out.gcd_blocked = true
return out
end
end
-- Resume from stopattack (archive 2985-2996). A same-seal press swaps to the opposite
-- seal first so the cast below arms a twist.
if (sim.attack_paused or sim.attack_pause_pending) and RESUME_ABILITIES[ability_key] then
if ab.type == "seal" and sim.n_active_seals == 1 and sim.seal_1_label == ab.seal_label then
local opposite = ab.seal_label == "blood" and "command" or "blood"
sim.active_seals = {}
sim.active_seals[opposite] = true
sim.seal_1_label = opposite
end
st.practice.resume_attack(sim, now, out)
end
if ab.type == "seal" then
-- Seal apply / swap (archive sim_cast_seal 3026-3094).
local seal_label = ab.seal_label
if sim.n_active_seals > 0 then
local old_label = sim.seal_1_label
if old_label and old_label ~= seal_label then
-- Dual-seal twist window: the older seal fades after SEAL_FADE_SECONDS.
sim.fading_seal_label = old_label
sim.fading_seal_expires = now + st.practice.SEAL_FADE_SECONDS
sim.twist_attempted = true
sim.active_seals = {}
sim.active_seals[old_label] = true
sim.active_seals[seal_label] = true
sim.n_active_seals = 2
sim.seal_1_label = old_label
sim.seal_2_label = seal_label
out.twist_armed = true
else
-- Same seal recast: collapse to the single seal.
sim.active_seals = {}
sim.active_seals[seal_label] = true
sim.n_active_seals = 1
sim.seal_1_label = seal_label
sim.seal_2_label = nil
sim.fading_seal_label = nil
sim.fading_seal_expires = 0
end
else
sim.active_seals = {}
sim.active_seals[seal_label] = true
sim.n_active_seals = 1
sim.seal_1_label = seal_label
sim.seal_2_label = nil
end
out.seal_changed = true
sim.gcd_active = true
sim.gcd_expires = now + sim.effective_gcd
out.gcd_started = true
return out
end
if ab.type == "ability" then
-- Cooldown-gated abilities (archive sim_cast_ability 3096-3174).
local cd_key = ability_key .. "_cooldown_expires"
if sim[cd_key] and sim[cd_key] > now then
out.ignored = true
return out
end
if ability_key == "judge" then
if sim.n_active_seals == 0 then
out.ignored = true
return out
end
-- Capture the judged seal before consuming (command wins), then consume all.
if sim.active_seals["command"] then
out.judged_seal = "command"
elseif sim.active_seals["blood"] then
out.judged_seal = "blood"
end
sim.active_seals = {}
sim.n_active_seals = 0
sim.seal_1_label = nil
sim.seal_2_label = nil
sim.fading_seal_label = nil
sim.fading_seal_expires = 0
out.seal_changed = true
end
if ab.cooldown > 0 then
sim[cd_key] = now + ab.cooldown
out.cooldown_set = true
end
-- Judgement does not trigger the GCD; cs/consec do.
if ability_key ~= "judge" then
sim.gcd_active = true
sim.gcd_expires = now + sim.effective_gcd
out.gcd_started = true
end
return out
end
if ab.type == "buff" then
-- Off-GCD buffs (archive sim_cast_buff 3176-3213); only AW exists.
local cd_key = ability_key .. "_cooldown_expires"
if sim[cd_key] and sim[cd_key] > now then
out.ignored = true
return out
end
if ab.cooldown > 0 then
sim[cd_key] = now + ab.cooldown
out.cooldown_set = true
end
if ab.duration then
sim.aw_buff_expires = now + ab.duration
out.buff_changed = true
end
-- Deferred (archive 3198-3208): practice_aw_includes_pot and
-- practice_aw_includes_sob combos are not simulated in phase 1.
return out
end
if ab.type == "haste_consumable" then
-- Haste potion: blocked while the rotation is locked; always starts a GCD, even
-- on a redundant press while the buff is already up (archive 3004-3009 + 2654).
if sim.locked_rotation then
out.ignored = true
return out
end
if not sim.has_haste_pot then
sim.has_haste_pot = true
sim.haste_pot_buff_expires = now + ab.duration
out.buff_changed = true
end
sim.gcd_active = true
sim.gcd_expires = now + sim.effective_gcd